# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::Install::Filesystem;

# NOTE: This package may "use" any modules that it likes,
# and localconfig is available. However, all functions in this
# package should assume that:
#
# * Templates are not available.
# * Files do not have the correct permissions.
# * The database does not exist.

use 5.10.1;
use strict;
use warnings;

use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Install::Localconfig;
use Bugzilla::Install::Util qw(install_string);
use Bugzilla::Util;
use Bugzilla::Hook;

use File::Find;
use File::Path;
use File::Basename;
use File::Copy qw(move);
use File::Spec;
use IO::File;
use POSIX ();

use parent qw(Exporter);
our @EXPORT = qw(
    update_filesystem
    create_htaccess
    fix_all_file_permissions
    fix_dir_permissions
    fix_file_permissions
);

use constant HT_DEFAULT_DENY => <<EOT;
# nothing in this directory is retrievable unless overridden by an .htaccess
# in a subdirectory
<IfModule mod_version.c>
  <IfVersion < 2.4>
    Deny from all
  </IfVersion>
  <IfVersion >= 2.4>
    Require all denied
  </IfVersion>
</IfModule>
<IfModule !mod_version.c>
  Deny from all
</IfModule>
EOT

###############
# Permissions #
###############

# Used by the permissions "constants" below.
sub _suexec { Bugzilla->localconfig->{'use_suexec'}     };
sub _group  { Bugzilla->localconfig->{'webservergroup'} };

# Writeable by the owner only.
use constant OWNER_WRITE => 0600;
# Executable by the owner only.
use constant OWNER_EXECUTE => 0700;
# A directory which is only writeable by the owner.
use constant DIR_OWNER_WRITE => 0700;

# A cgi script that the webserver can execute.
sub WS_EXECUTE { _group() ? 0750 : 0755 };
# A file that is read by cgi scripts, but is not ever read
# directly by the webserver.
sub CGI_READ { _group() ? 0640 : 0644 };
# A file that is written to by cgi scripts, but is not ever
# read or written directly by the webserver.
sub CGI_WRITE { _group() ? 0660 : 0666 };
# A file that is served directly by the web server.
sub WS_SERVE { (_group() and !_suexec()) ? 0640 : 0644 };

# A directory whose contents can be read or served by the
# webserver (so even directories containing cgi scripts
# would have this permission).
sub DIR_WS_SERVE { (_group() and !_suexec()) ? 0750 : 0755 };
# A directory that is read by cgi scripts, but is never accessed
# directly by the webserver
sub DIR_CGI_READ { _group() ? 0750 : 0755 };
# A directory that is written to by cgi scripts, but where the
# scripts never needs to overwrite files created by other
# users.
sub DIR_CGI_WRITE { _group() ? 0770 : 01777 };
# A directory that is written to by cgi scripts, where the
# scripts need to overwrite files created by other users.
sub DIR_CGI_OVERWRITE { _group() ? 0770 : 0777 };

# This can be combined (using "|") with other permissions for 
# directories that, in addition to their normal permissions (such
# as DIR_CGI_WRITE) also have content served directly from them
# (or their subdirectories) to the user, via the webserver.
sub DIR_ALSO_WS_SERVE { _suexec() ? 0001 : 0 };

# This looks like a constant because it effectively is, but
# it has to call other subroutines and read the current filesystem,
# so it's defined as a sub. This is not exported, so it doesn't have
# a perldoc. However, look at the various hashes defined inside this 
# function to understand what it returns. (There are comments throughout.)
#
# The rationale for the file permissions is that there is a group the
# web server executes the scripts as, so the cgi scripts should not be writable
# by this group. Otherwise someone may find it possible to change the cgis
# when exploiting some security flaw somewhere (not necessarily in Bugzilla!)
sub FILESYSTEM {
    my $datadir       = bz_locations()->{'datadir'};
    my $attachdir     = bz_locations()->{'attachdir'};
    my $extensionsdir = bz_locations()->{'extensionsdir'};
    my $webdotdir     = bz_locations()->{'webdotdir'};
    my $templatedir   = bz_locations()->{'templatedir'};
    my $libdir        = bz_locations()->{'libpath'};
    my $extlib        = bz_locations()->{'ext_libpath'};
    my $skinsdir      = bz_locations()->{'skinsdir'};
    my $localconfig   = bz_locations()->{'localconfig'};
    my $template_cache = bz_locations()->{'template_cache'};
    my $graphsdir     = bz_locations()->{'graphsdir'};
    my $assetsdir     = bz_locations()->{'assetsdir'};

    # We want to set the permissions the same for all localconfig files
    # across all PROJECTs, so we do something special with $localconfig,
    # lower down in the permissions section.
    if ($ENV{PROJECT}) {
        $localconfig =~ s/\.\Q$ENV{PROJECT}\E$//;
    }

    # Note: When being processed by checksetup, these have their permissions
    # set in this order: %all_dirs, %recurse_dirs, %all_files.
    #
    # Each is processed in alphabetical order of keys, so shorter keys
    # will have their permissions set before longer keys (thus setting
    # the permissions on parent directories before setting permissions
    # on their children).

    # --- FILE PERMISSIONS (Non-created files) --- #
    my %files = (
        '*'               => { perms => OWNER_WRITE },
        # Some .pl files are WS_EXECUTE because we want
        # users to be able to cron them or otherwise run
        # them as a secure user, like the webserver owner.
        '*.cgi'           => { perms => WS_EXECUTE },
        'whineatnews.pl'  => { perms => WS_EXECUTE },
        'collectstats.pl' => { perms => WS_EXECUTE },
        'importxml.pl'    => { perms => WS_EXECUTE },
        'testserver.pl'   => { perms => WS_EXECUTE },
        'whine.pl'        => { perms => WS_EXECUTE },
        'email_in.pl'     => { perms => WS_EXECUTE },
        'sanitycheck.pl'  => { perms => WS_EXECUTE },
        'checksetup.pl'   => { perms => OWNER_EXECUTE },
        'runtests.pl'     => { perms => OWNER_EXECUTE },
        'jobqueue.pl'     => { perms => OWNER_EXECUTE },
        'migrate.pl'      => { perms => OWNER_EXECUTE },
        'install-module.pl' => { perms => OWNER_EXECUTE },
        'clean-bug-user-last-visit.pl' => { perms => WS_EXECUTE },

        'Bugzilla.pm'   => { perms => CGI_READ },
        "$localconfig*" => { perms => CGI_READ },
        'bugzilla.dtd'  => { perms => WS_SERVE },
        'mod_perl.pl'   => { perms => WS_SERVE },
        'robots.txt'    => { perms => WS_SERVE },
        '.htaccess'     => { perms => WS_SERVE },

        'contrib/README'       => { perms => OWNER_WRITE },
        'contrib/*/README'     => { perms => OWNER_WRITE },
        'contrib/Bugzilla.pm'  => { perms => OWNER_WRITE },
        'docs/bugzilla.ent'    => { perms => OWNER_WRITE },
        'docs/makedocs.pl'     => { perms => OWNER_EXECUTE },
        'docs/style.css'       => { perms => WS_SERVE },
        'docs/*/rel_notes.txt' => { perms => WS_SERVE },
        'docs/*/README.docs'   => { perms => OWNER_WRITE },
        "$datadir/params.json" => { perms => CGI_WRITE },
        "$datadir/old-params.txt"  => { perms => OWNER_WRITE },
        "$extensionsdir/create.pl" => { perms => OWNER_EXECUTE },
        "$extensionsdir/*/*.pl"    => { perms => WS_EXECUTE },
    );

    # Directories that we want to set the perms on, but not
    # recurse through. These are directories we didn't create
    # in checkesetup.pl.
    my %non_recurse_dirs = (
        '.'  => DIR_WS_SERVE,
        docs => DIR_WS_SERVE,
    );

    # This sets the permissions for each item inside each of these 
    # directories, including the directory itself. 
    # 'CVS' directories are special, though, and are never readable by 
    # the webserver.
    my %recurse_dirs = (
        # Writeable directories
         $template_cache    => { files => CGI_READ,
                                  dirs => DIR_CGI_OVERWRITE },
         $attachdir         => { files => CGI_WRITE,
                                  dirs => DIR_CGI_WRITE },
         $webdotdir         => { files => WS_SERVE,
                                  dirs => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE },
         $graphsdir         => { files => WS_SERVE,
                                  dirs => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE },
         "$datadir/db"      => { files => CGI_WRITE,
                                  dirs => DIR_CGI_WRITE },
         $assetsdir         => { files => WS_SERVE,
                                  dirs => DIR_CGI_OVERWRITE | DIR_ALSO_WS_SERVE },

         # Readable directories
         "$datadir/mining"     => { files => CGI_READ,
                                     dirs => DIR_CGI_READ },
         "$libdir/Bugzilla"    => { files => CGI_READ,
                                     dirs => DIR_CGI_READ },
         $extlib               => { files => CGI_READ,
                                     dirs => DIR_CGI_READ },
         $templatedir          => { files => CGI_READ,
                                     dirs => DIR_CGI_READ },
         # Directories in the extensions/ dir are WS_SERVE so that
         # the web/ directories can be served by the web server.
         # But, for extra security, we deny direct webserver access to
         # the lib/ and template/ directories of extensions.
         $extensionsdir        => { files => CGI_READ,
                                     dirs => DIR_WS_SERVE },
         "$extensionsdir/*/lib" => { files => CGI_READ,
                                      dirs => DIR_CGI_READ },
         "$extensionsdir/*/template" => { files => CGI_READ,
                                           dirs => DIR_CGI_READ },

         # Content served directly by the webserver
         images                => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         js                    => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         $skinsdir             => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         'docs/*/html'         => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         'docs/*/pdf'          => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         'docs/*/txt'          => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         'docs/*/images'       => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },
         "$extensionsdir/*/web" => { files => WS_SERVE,
                                     dirs => DIR_WS_SERVE },

         # Directories only for the owner, not for the webserver.
         '.bzr'                => { files => OWNER_WRITE,
                                    dirs  => DIR_OWNER_WRITE },
         t                     => { files => OWNER_WRITE,
                                     dirs => DIR_OWNER_WRITE },
         xt                    => { files => OWNER_WRITE,
                                     dirs => DIR_OWNER_WRITE },
         'docs/lib'            => { files => OWNER_WRITE,
                                     dirs => DIR_OWNER_WRITE },
         'docs/*/xml'          => { files => OWNER_WRITE,
                                     dirs => DIR_OWNER_WRITE },
         'contrib'             => { files => OWNER_EXECUTE,
                                     dirs => DIR_OWNER_WRITE, },
    );

    # --- FILES TO CREATE --- #

    # The name of each directory that we should actually *create*,
    # pointing at its default permissions.
    my %create_dirs = (
        # This is DIR_ALSO_WS_SERVE because it contains $webdotdir and
        # $assetsdir.
        $datadir                => DIR_CGI_OVERWRITE | DIR_ALSO_WS_SERVE,
        # Directories that are read-only for cgi scripts
        "$datadir/mining"       => DIR_CGI_READ,
        "$datadir/extensions"   => DIR_CGI_READ,
        $extensionsdir          => DIR_CGI_READ,
        # Directories that cgi scripts can write to.
        "$datadir/db"           => DIR_CGI_WRITE,
        $attachdir              => DIR_CGI_WRITE,
        $graphsdir              => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE,
        $webdotdir              => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE,
        $assetsdir              => DIR_CGI_WRITE | DIR_ALSO_WS_SERVE,
        # Directories that contain content served directly by the web server.
        "$skinsdir/custom"      => DIR_WS_SERVE,
        "$skinsdir/contrib"     => DIR_WS_SERVE,
    );

    # The name of each file, pointing at its default permissions and
    # default contents.
    my %create_files = (
        "$datadir/extensions/additional" => { perms    => CGI_READ, 
                                              contents => '' },
        # We create this file so that it always has the right owner
        # and permissions. Otherwise, the webserver creates it as
        # owned by itself, which can cause problems if jobqueue.pl
        # or something else is not running as the webserver or root.
        "$datadir/mailer.testfile" => { perms    => CGI_WRITE,
                                        contents => '' },
    );

    # Because checksetup controls the creation of index.html separately
    # from all other files, it gets its very own hash.
    my %index_html = (
        'index.html' => { perms => WS_SERVE, contents => <<EOT
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
  <meta http-equiv="Refresh" content="0; URL=index.cgi">
</head>
<body>
  <h1>I think you are looking for <a href="index.cgi">index.cgi</a></h1>
</body>
</html>
EOT
        }
    );

    # Because checksetup controls the .htaccess creation separately
    # by a localconfig variable, these go in a separate variable from
    # %create_files.
    #
    # Note that these get WS_SERVE as their permission
    # because they're *read* by the webserver, even though they're not
    # actually, themselves, served.
    my %htaccess = (
        "$attachdir/.htaccess"       => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        "$libdir/Bugzilla/.htaccess" => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        "$extlib/.htaccess"          => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        "$templatedir/.htaccess"     => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        'contrib/.htaccess'          => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        't/.htaccess'                => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        'xt/.htaccess'               => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },
        "$datadir/.htaccess"         => { perms    => WS_SERVE,
                                          contents => HT_DEFAULT_DENY },

        "$graphsdir/.htaccess" => { perms => WS_SERVE, contents => <<EOT
# Allow access to .png and .gif files.
<FilesMatch (\\.gif|\\.png)\$>
  <IfModule mod_version.c>
    <IfVersion < 2.4>
      Allow from all
    </IfVersion>
    <IfVersion >= 2.4>
      Require all granted
    </IfVersion>
  </IfModule>
  <IfModule !mod_version.c>
    Allow from all
  </IfModule>
</FilesMatch>

# And no directory listings, either.
<IfModule mod_version.c>
  <IfVersion < 2.4>
    Deny from all
  </IfVersion>
  <IfVersion >= 2.4>
    Require all denied
  </IfVersion>
</IfModule>
<IfModule !mod_version.c>
  Deny from all
</IfModule>
EOT
        },

        "$webdotdir/.htaccess" => { perms => WS_SERVE, contents => <<EOT
# Restrict access to .dot files to the public webdot server at research.att.com
# if research.att.com ever changes their IP, or if you use a different
# webdot server, you'll need to edit this
<FilesMatch \\.dot\$>
  <IfModule mod_version.c>
    <IfVersion < 2.4>
      Allow from 192.20.225.0/24
      Deny from all
    </IfVersion>
    <IfVersion >= 2.4>
      Require ip 192.20.225.0/24
      Require all denied
    </IfVersion>
  </IfModule>
  <IfModule !mod_version.c>
    Allow from 192.20.225.0/24
    Deny from all
  </IfModule>
</FilesMatch>

 # Allow access to .png files created by a local copy of 'dot'
 <FilesMatch \\.png\$>
  <IfModule mod_version.c>
    <IfVersion < 2.4>
      Allow from all
    </IfVersion>
    <IfVersion >= 2.4>
      Require all granted
    </IfVersion>
  </IfModule>
  <IfModule !mod_version.c>
    Allow from all
  </IfModule>
</FilesMatch>

# And no directory listings, either.
<IfModule mod_version.c>
  <IfVersion < 2.4>
    Deny from all
  </IfVersion>
  <IfVersion >= 2.4>
    Require all denied
  </IfVersion>
</IfModule>
<IfModule !mod_version.c>
  Deny from all
</IfModule>
EOT
        },

        "$assetsdir/.htaccess" => { perms => WS_SERVE, contents => <<EOT
# Allow access to .css files
<FilesMatch \\.(css|js)\$>
  <IfModule mod_version.c>
    <IfVersion < 2.4>
      Allow from all
    </IfVersion>
    <IfVersion >= 2.4>
      Require all granted
    </IfVersion>
  </IfModule>
  <IfModule !mod_version.c>
     Allow from all
  </IfModule>
</FilesMatch>

# And no directory listings, either.
<IfModule mod_version.c>
  <IfVersion < 2.4>
    Deny from all
  </IfVersion>
  <IfVersion >= 2.4>
    Require all denied
  </IfVersion>
</IfModule>
<IfModule !mod_version.c>
  Deny from all
</IfModule>
EOT
        },

    );

    Bugzilla::Hook::process('install_filesystem', {
        files            => \%files,
        create_dirs      => \%create_dirs,
        non_recurse_dirs => \%non_recurse_dirs,
        recurse_dirs     => \%recurse_dirs,
        create_files     => \%create_files,
        htaccess         => \%htaccess,
    });

    my %all_files = (%create_files, %htaccess, %index_html, %files);
    my %all_dirs  = (%create_dirs, %non_recurse_dirs);

    return {
        create_dirs  => \%create_dirs,
        recurse_dirs => \%recurse_dirs,
        all_dirs     => \%all_dirs,

        create_files => \%create_files,
        htaccess     => \%htaccess,
        index_html   => \%index_html,
        all_files    => \%all_files,
    };
}

sub update_filesystem {
    my ($params) = @_;
    my $fs = FILESYSTEM();
    my %dirs  = %{$fs->{create_dirs}};
    my %files = %{$fs->{create_files}};

    my $datadir = bz_locations->{'datadir'};
    my $graphsdir = bz_locations->{'graphsdir'};
    my $assetsdir = bz_locations->{'assetsdir'};
    # If the graphs/ directory doesn't exist, we're upgrading from
    # a version old enough that we need to update the $datadir/mining 
    # format.
    if (-d "$datadir/mining" && !-d $graphsdir) {
        _update_old_charts($datadir);
    }

    # If there is a file named '-All-' in $datadir/mining, then we're still
    # having mining files named by product name, and we need to convert them to
    # files named by product ID.
    if (-e File::Spec->catfile($datadir, 'mining', '-All-')) {
        _update_old_mining_filenames(File::Spec->catdir($datadir, 'mining'));
    }

    # By sorting the dirs, we assure that shorter-named directories
    # (meaning parent directories) are always created before their
    # child directories.
    foreach my $dir (sort keys %dirs) {
        unless (-d $dir) {
            print "Creating $dir directory...\n";
            mkdir $dir or die "mkdir $dir failed: $!";
            # For some reason, passing in the permissions to "mkdir"
            # doesn't work right, but doing a "chmod" does.
            chmod $dirs{$dir}, $dir or warn "Cannot chmod $dir: $!";
        }
    }

    # Move the testfile if we can't write to it, so that we can re-create
    # it with the correct permissions below.
    my $testfile = "$datadir/mailer.testfile";
    if (-e $testfile and !-w $testfile) {
        _rename_file($testfile, "$testfile.old");
    }

    # If old-params.txt exists in the root directory, move it to datadir.
    my $oldparamsfile = "old_params.txt";
    if (-e $oldparamsfile) {
        _rename_file($oldparamsfile, "$datadir/$oldparamsfile");
    }

    # Remove old assets htaccess file to force recreation with correct values.
    if (-e "$assetsdir/.htaccess") {
        if (read_text("$assetsdir/.htaccess") =~ /<FilesMatch \\\.css\$>/) {
            unlink("$assetsdir/.htaccess");
        }
    }

    _create_files(%files);
    if ($params->{index_html}) {
        _create_files(%{$fs->{index_html}});
    }
    elsif (-e 'index.html') {
        my $templatedir = bz_locations()->{'templatedir'};
        print <<EOT;

*** It appears that you still have an old index.html hanging around.
    Either the contents of this file should be moved into a template and 
    placed in the '$templatedir/en/custom' directory, or you should delete 
    the file.

EOT
    }

    # Delete old files that no longer need to exist

    # 2001-04-29 jake@bugzilla.org - Remove oldemailtech
    #   http://bugzilla.mozilla.org/show_bugs.cgi?id=71552
    if (-d 'shadow') {
        print "Removing shadow directory...\n";
        rmtree("shadow");
    }

    if (-e "$datadir/versioncache") {
        print "Removing versioncache...\n";
        unlink "$datadir/versioncache";
    }

    if (-e "$datadir/duplicates.rdf") {
        print "Removing duplicates.rdf...\n";
        unlink "$datadir/duplicates.rdf";
        unlink "$datadir/duplicates-old.rdf";
    }

    if (-e "$datadir/duplicates") {
        print "Removing duplicates directory...\n";
        rmtree("$datadir/duplicates");
    }

    _remove_empty_css_files();
    _convert_single_file_skins();
    _remove_dynamic_assets();
}

sub _remove_empty_css_files {
    my $skinsdir = bz_locations()->{'skinsdir'};
    foreach my $css_file (glob("$skinsdir/custom/*.css"),
                          glob("$skinsdir/contrib/*/*.css"))
    {
        _remove_empty_css($css_file);
    }
}

# A simple helper for the update code that removes "empty" CSS files.
sub _remove_empty_css {
    my ($file) = @_;
    my $basename = basename($file);
    my $empty_contents = <<EOT;
/*
 * Custom rules for $basename.
 * The rules you put here override rules in that stylesheet.
 */
EOT
    if (length($empty_contents) == -s $file) {
        open(my $fh, '<', $file) or warn "$file: $!";
        my $file_contents;
        { local $/; $file_contents = <$fh>; }
        if ($file_contents eq $empty_contents) {
            print install_string('file_remove', { name => $file }), "\n";
            unlink $file or warn "$file: $!";
        }
    };
}

# We used to allow a single css file in the skins/contrib/ directory
# to be a whole skin.
sub _convert_single_file_skins {
    my $skinsdir = bz_locations()->{'skinsdir'};
    foreach my $skin_file (glob "$skinsdir/contrib/*.css") {
        my $dir_name = $skin_file;
        $dir_name =~ s/\.css$//;
        mkdir $dir_name or warn "$dir_name: $!";
        _rename_file($skin_file, "$dir_name/global.css");
    }
}

# delete all automatically generated css/js files to force recreation at the
# next request.
sub _remove_dynamic_assets {
    my @files = (
        glob(bz_locations()->{assetsdir} . '/*.css'),
        glob(bz_locations()->{assetsdir} . '/*.js'),
    );
    foreach my $file (@files) {
        unlink($file);
    }

    # remove old skins/assets directory
    my $old_path = bz_locations()->{skinsdir} . '/assets';
    if (-d $old_path) {
        foreach my $file (glob("$old_path/*.css")) {
            unlink($file);
        }
        rmdir($old_path);
    }
}

sub create_htaccess {
    _create_files(%{FILESYSTEM()->{htaccess}});

    # Repair old .htaccess files

    my $webdot_dir = bz_locations()->{'webdotdir'};
    # The public webdot IP address changed.
    my $webdot = new IO::File("$webdot_dir/.htaccess", 'r')
        || die "$webdot_dir/.htaccess: $!";
    my $webdot_data;
    { local $/; $webdot_data = <$webdot>; }
    $webdot->close;
    if ($webdot_data =~ /192\.20\.225\.10/) {
        print "Repairing $webdot_dir/.htaccess...\n";
        $webdot_data =~ s/192\.20\.225\.10/192.20.225.0\/24/g;
        $webdot = new IO::File("$webdot_dir/.htaccess", 'w') || die $!;
        print $webdot $webdot_data;
        $webdot->close;
    }
}

sub _rename_file {
    my ($from, $to) = @_;
    print install_string('file_rename', { from => $from, to => $to }), "\n";
    if (-e $to) {
        warn "$to already exists, not moving\n";
    }
    else {
        move($from, $to) or warn $!;
    }
}

# A helper for the above functions.
sub _create_files {
    my (%files) = @_;

    # It's not necessary to sort these, but it does make the
    # output of checksetup.pl look a bit nicer.
    foreach my $file (sort keys %files) {
        unless (-e $file) {
            print "Creating $file...\n";
            my $info = $files{$file};
            my $fh = new IO::File($file, O_WRONLY | O_CREAT, $info->{perms})
                || die $!;
            print $fh $info->{contents} if $info->{contents};
            $fh->close;
        }
    }
}

# If you ran a REALLY old version of Bugzilla, your chart files are in the
# wrong format. This code is a little messy, because it's very old, and
# when moving it into this module, I couldn't test it so I left it almost 
# completely alone.
sub _update_old_charts {
    my ($datadir) = @_;
    print "Updating old chart storage format...\n";
    foreach my $in_file (glob("$datadir/mining/*")) {
        # Don't try and upgrade image or db files!
        next if (($in_file =~ /\.gif$/i) ||
                 ($in_file =~ /\.png$/i) ||
                 ($in_file =~ /\.db$/i) ||
                 ($in_file =~ /\.orig$/i));

        rename("$in_file", "$in_file.orig") or next;
        open(IN, "<", "$in_file.orig") or next;
        open(OUT, '>', $in_file) or next;

        # Fields in the header
        my @declared_fields;

        # Fields we changed to half way through by mistake
        # This list comes from an old version of collectstats.pl
        # This part is only for people who ran later versions of 2.11 (devel)
        my @intermediate_fields = qw(DATE UNCONFIRMED NEW ASSIGNED REOPENED
                                     RESOLVED VERIFIED CLOSED);

        # Fields we actually want (matches the current collectstats.pl)
        my @out_fields = qw(DATE NEW ASSIGNED REOPENED UNCONFIRMED RESOLVED
                            VERIFIED CLOSED FIXED INVALID WONTFIX LATER REMIND
                            DUPLICATE WORKSFORME MOVED);

         while (<IN>) {
            if (/^# fields?: (.*)\s$/) {
                @declared_fields = map uc, (split /\||\r/, $1);
                print OUT "# fields: ", join('|', @out_fields), "\n";
            }
            elsif (/^(\d+\|.*)/) {
                my @data = split(/\||\r/, $1);
                my %data;
                if (@data == @declared_fields) {
                    # old format
                    for my $i (0 .. $#declared_fields) {
                        $data{$declared_fields[$i]} = $data[$i];
                    }
                }
                elsif (@data == @intermediate_fields) {
                    # Must have changed over at this point
                    for my $i (0 .. $#intermediate_fields) {
                        $data{$intermediate_fields[$i]} = $data[$i];
                    }
                }
                elsif (@data == @out_fields) {
                    # This line's fine - it has the right number of entries
                    for my $i (0 .. $#out_fields) {
                        $data{$out_fields[$i]} = $data[$i];
                    }
                }
                else {
                    print "Oh dear, input line $. of $in_file had " .
                          scalar(@data) . " fields\nThis was unexpected.",
                          " You may want to check your data files.\n";
                }

                print OUT join('|', 
                    map { defined ($data{$_}) ? ($data{$_}) : "" } @out_fields),
                    "\n";
            }
            else {
                print OUT;
            }
        }

        close(IN);
        close(OUT);
    } 
}

# The old naming scheme has product names as mining file names; we rename them
# to product IDs.
sub _update_old_mining_filenames {
    my ($miningdir) = @_;
    my $dbh = Bugzilla->dbh;
    my @conversion_errors;

    # We use a dummy product instance with ID 0, representing all products
    my $product_all = {id => 0, name => '-All-'};

    print "Updating old charting data file names...";
    my @products = @{ $dbh->selectall_arrayref('SELECT id, name FROM products
                                                ORDER BY name', {Slice=>{}}) };
    push(@products, $product_all);
    foreach my $product (@products) {
        if (-e File::Spec->catfile($miningdir, $product->{id})) {
            push(@conversion_errors,
                 { product => $product,
                   message => 'A file named "' . $product->{id} .
                              '" already exists.' });
        }
    }

    if (! @conversion_errors) {
        # Renaming mining files should work now without a hitch.
        foreach my $product (@products) {
            if (! rename(File::Spec->catfile($miningdir, $product->{name}),
                         File::Spec->catfile($miningdir, $product->{id}))) {
                push(@conversion_errors,
                     { product => $product,
                       message => $! });
            }
        }
    }

    # Error reporting
    if (! @conversion_errors) {
        print " done.\n";
    }
    else {
        print " FAILED:\n";
        foreach my $error (@conversion_errors) {
            printf "Cannot rename charting data file for product %d (%s): %s\n",
                   $error->{product}->{id}, $error->{product}->{name},
                   $error->{message};
        }
        print "You need to empty the \"$miningdir\" directory, then run\n",
              "   collectstats.pl --regenerate\n",
              "in order to clean this up.\n";
    }
}

sub fix_dir_permissions {
    my ($dir) = @_;
    return if ON_WINDOWS;
    # Note that _get_owner_and_group is always silent here.
    my ($owner_id, $group_id) = _get_owner_and_group();

    my $perms;
    my $fs = FILESYSTEM();
    if ($perms = $fs->{recurse_dirs}->{$dir}) {
        _fix_perms_recursively($dir, $owner_id, $group_id, $perms);
    }
    elsif ($perms = $fs->{all_dirs}->{$dir}) {
        _fix_perms($dir, $owner_id, $group_id, $perms);
    }
    else {
        # Do nothing. We know nothing about this directory.
        warn "Unknown directory $dir";
    }
}

sub fix_file_permissions {
    my ($file) = @_;
    return if ON_WINDOWS;
    my $perms = FILESYSTEM()->{all_files}->{$file}->{perms};
    # Note that _get_owner_and_group is always silent here.
    my ($owner_id, $group_id) = _get_owner_and_group();
    _fix_perms($file, $owner_id, $group_id, $perms);
}

sub fix_all_file_permissions {
    my ($output) = @_;

    # _get_owner_and_group also checks that the webservergroup is valid.
    my ($owner_id, $group_id) = _get_owner_and_group($output);

    return if ON_WINDOWS;

    my $fs = FILESYSTEM();
    my %files = %{$fs->{all_files}};
    my %dirs  = %{$fs->{all_dirs}};
    my %recurse_dirs = %{$fs->{recurse_dirs}};

    print get_text('install_file_perms_fix') . "\n" if $output;

    foreach my $dir (sort keys %dirs) {
        next unless -d $dir;
        _fix_perms($dir, $owner_id, $group_id, $dirs{$dir});
    }

    foreach my $pattern (sort keys %recurse_dirs) {
        my $perms = $recurse_dirs{$pattern};
        # %recurse_dirs supports globs
        foreach my $dir (glob $pattern) {
            next unless -d $dir;
            _fix_perms_recursively($dir, $owner_id, $group_id, $perms);
        }
    }

    foreach my $file (sort keys %files) {
        # %files supports globs
        foreach my $filename (glob $file) {
            # Don't touch directories.
            next if -d $filename || !-e $filename;
            _fix_perms($filename, $owner_id, $group_id, 
                       $files{$file}->{perms});
        }
    }

    _fix_cvs_dirs($owner_id, '.');
}

sub _get_owner_and_group {
    my ($output) = @_;
    my $group_id = _check_web_server_group($output);
    return () if ON_WINDOWS;

    my $owner_id = POSIX::getuid();
    $group_id = POSIX::getgid() unless defined $group_id;
    return ($owner_id, $group_id);
}

# A helper for fix_all_file_permissions
sub _fix_cvs_dirs {
    my ($owner_id, $dir) = @_;
    my $owner_gid = POSIX::getgid();
    find({ no_chdir => 1, wanted => sub {
        my $name = $File::Find::name;
        if ($File::Find::dir =~ /\/CVS/ || $_ eq '.cvsignore'
            || (-d $name && $_ =~ /CVS$/)) 
        {
            my $perms = 0600;
            if (-d $name) {
                $perms = 0700;
            }
            _fix_perms($name, $owner_id, $owner_gid, $perms);
        }
    }}, $dir);
}

sub _fix_perms {
    my ($name, $owner, $group, $perms) = @_;
    #printf ("Changing $name to %o\n", $perms);

    # The webserver should never try to chown files.
    if (Bugzilla->usage_mode == USAGE_MODE_CMDLINE) {
        chown $owner, $group, $name
            or warn install_string('chown_failed', { path => $name, 
                                                     error => $! }) . "\n";
    }
    chmod $perms, $name
        or warn install_string('chmod_failed', { path => $name, 
                                                 error => $! }) . "\n";
}

sub _fix_perms_recursively {
    my ($dir, $owner_id, $group_id, $perms) = @_;
    # Set permissions on the directory itself.
    _fix_perms($dir, $owner_id, $group_id, $perms->{dirs});
    # Now recurse through the directory and set the correct permissions
    # on subdirectories and files.
    find({ no_chdir => 1, wanted => sub {
        my $name = $File::Find::name;
        if (-d $name) {
            _fix_perms($name, $owner_id, $group_id, $perms->{dirs});
        }
        else {
            _fix_perms($name, $owner_id, $group_id, $perms->{files});
        }
    }}, $dir);
}

sub _check_web_server_group {
    my ($output) = @_;

    my $group    = Bugzilla->localconfig->{'webservergroup'};
    my $filename = bz_locations()->{'localconfig'};
    my $group_id;

    # If we are on Windows, webservergroup does nothing
    if (ON_WINDOWS && $group && $output) {
        print "\n\n" . get_text('install_webservergroup_windows') . "\n\n";
    }

    # If we're not on Windows, make sure that webservergroup isn't
    # empty.
    elsif (!ON_WINDOWS && !$group && $output) {
        print "\n\n" . get_text('install_webservergroup_empty') . "\n\n";
    }

    # If we're not on Windows, make sure we are actually a member of
    # the webservergroup.
    elsif (!ON_WINDOWS && $group) {
        $group_id = getgrnam($group);
        ThrowCodeError('invalid_webservergroup', { group => $group }) 
            unless defined $group_id;

        # If on unix, see if we need to print a warning about a webservergroup
        # that we can't chgrp to
        if ($output && $< != 0 && !grep($_ eq $group_id, split(" ", $)))) {
            print "\n\n" . get_text('install_webservergroup_not_in') . "\n\n";
        }
    }

    return $group_id;
}

1;

__END__

=head1 NAME

Bugzilla::Install::Filesystem - Fix up the filesystem during
  installation.

=head1 DESCRIPTION

This module is used primarily by L<checksetup.pl> to modify the 
filesystem during installation, including creating the data/ directory.

=head1 SUBROUTINES

=over

=item C<update_filesystem({ index_html => 0 })>

Description: Creates all the directories and files that Bugzilla
             needs to function but doesn't ship with. Also does
             any updates to these files as necessary during an
             upgrade.

Params:      C<index_html> - Whether or not we should create
               the F<index.html> file.

Returns:     nothing

=item C<create_htaccess()>

Description: Creates all of the .htaccess files for Apache,
             in the various Bugzilla directories. Also updates
             the .htaccess files if they need updating.

Params:      none

Returns:     nothing

=item C<fix_all_file_permissions($output)>

Description: Sets all the file permissions on all of Bugzilla's files
             to what they should be. Note that permissions are different
             depending on whether or not C<$webservergroup> is set
             in F<localconfig>.

Params:      C<$output> - C<true> if you want this function to print
                 out information about what it's doing.

Returns:     nothing

=item C<fix_dir_permissions>

Given the name of a directory, its permissions will be fixed according to
how they are supposed to be set in Bugzilla's current configuration.
If it fails to set the permissions, a warning will be printed to STDERR.

=item C<fix_file_permissions>

Given the name of a file, its permissions will be fixed according to
how they are supposed to be set in Bugzilla's current configuration.
If it fails to set the permissions, a warning will be printed to STDERR.

=back

=head1 B<Methods in need of POD>

=over

=item CGI_WRITE

=item DIR_WS_SERVE

=item DIR_ALSO_WS_SERVE

=item WS_SERVE

=item FILESYSTEM

=item WS_EXECUTE

=item CGI_READ

=item DIR_CGI_READ

=item DIR_CGI_WRITE

=item DIR_CGI_OVERWRITE

=back
