#!/usr/bin/perl

use strict;
use warnings;
use Data::Dumper;
$Data::Dumper::Indent = 1;
$Data::Dumper::Sortkeys = 1;

# ----------------------------------------------------------------------------
#       find the rc file, then pull the libraries
# ----------------------------------------------------------------------------

BEGIN {
    die "ENV GL_RC not set\n" unless $ENV{GL_RC};
    die "ENV GL_BINDIR not set\n" unless $ENV{GL_BINDIR};
}

use lib $ENV{GL_BINDIR};
use gitolite_rc;
use gitolite qw(:DEFAULT %repos %groups %git_configs %split_conf);

# === add-auth-keys ===

# setup quiet mode if asked; please do not use this when running manually
open STDOUT, ">", "/dev/null" if (@ARGV and shift eq '-q');

# ----------------------------------------------------------------------------
#       definitions specific to this program
# ----------------------------------------------------------------------------

# groups can now represent user groups or repo groups.

# $groups{group}{member} = "master" (or name of fragment file in which the
# group is defined).
# our %groups = ();     # moved to gitolite.pm now

# %repos has two functions.

# $repos{repo}{R|W}{user} = 1 if user has R (or W) permissions for at least
# one branch in repo.  This is used by the "level 1 check" (see faq).  There's
# also the new "C" (create a repo) permission now

# $repos{repo}{user} is a list of {ref, perms} pairs.  This is used by the
# level 2 check.  In order to allow "exclude" rules, the order of rules now
# matters, so what used to be entirely "hash of hash of hash" now has a list
# in between :)
# copy above desc to lite.pm -- my %repos = ();

# names of repos whose ACLs don't make it into the main compiled config file
# copy above desc to lite.pm -- my %split_conf = ();

# rule sequence number
my $rule_seq = 0;

# <sigh>...  having been forced to use a list as described above, we lose some
# efficiency due to the possibility of the same {ref, perms} pair showing up
# multiple times for the same repo+user.  So...
my %rurp_seen = ();

# catch usernames<->pubkeys mismatches; search for "lint" below
my %user_list = ();

# repo specific 'git config' stuff
# our %git_configs = ();    # moved to gitolite.pm now

# gitweb descriptions and owners; plain text, keyed by "$repo.git"
my %desc = ();
my %owner = ();

# ----------------------------------------------------------------------------
#       subroutines
# ----------------------------------------------------------------------------

sub expand_list
{
    my @list = @_;
    my @new_list = ();

    for my $item (@list)
    {
        if ($item =~ /^@/ and $item ne '@all')      # nested group
        {
            die "$ABRT undefined group $item\n" unless $groups{$item};
            # add those names to the list
            push @new_list, sort keys %{ $groups{$item} };
        }
        else
        {
            push @new_list, $item;
        }
    }

    return @new_list;
}

sub device_inode {
    my $file = shift;
    return join("/", (stat $file)[0,1]);
}

# ----------------------------------------------------------------------------
#       "compile" GL conf
# ----------------------------------------------------------------------------

# detect recursion in include files; see processing of "include" statement later
our %included;
$included{device_inode("conf/gitolite.conf")}++;

sub check_fragment_repo_disallowed
{
    # trying to set access for $repo (='foo')...
    my ($fragment, $repo) = @_;

    # processing the master config, not a fragment
    return 0 if $fragment eq 'master';
    # fragment is also called 'foo' (you're allowed to have a
    # fragment that is only concerned with one repo)
    return 0 if $fragment eq $repo;
    # same thing in big-config-land; foo is just @foo now
    return 0 if $GL_BIG_CONFIG and ("\@$fragment" eq $repo);
    my @matched = grep { $repo =~ /^$_$/ }
                  grep { $groups{"\@$fragment"}{$_} eq 'master' }
                  sort keys %{ $groups{"\@$fragment"} };
    return 0 if @matched > 0;
    return 1;
}

sub parse_conf_line
{
    my ($line, $fragment, $repos_p, $ignored_p) = @_;

    # user or repo groups
    if ($line =~ /^(@\S+) = ?(.*)/)
    {
        die "$ABRT defining groups is not allowed inside fragments\n"
            if $GL_BIG_CONFIG and $fragment ne 'master';
        # store the members of each group as hash key.  Keep track of when
        # the group was *first* created by using $fragment as the *value*
        do { $groups{$1}{$_} ||= $fragment } for ( expand_list( split(' ', $2) ) );
        # create the group hash even if empty
        $groups{$1} = {} unless $groups{$1};
        die "$ABRT bad group $1\n" unless $1 =~ $REPONAME_PATT;
    }
    # repo(s)
    elsif ($line =~ /^repo (.*)/)
    {
        # grab the list...
        @{ $repos_p } = split ' ', $1;
        # ...expand groups in the default case
        @{ $repos_p } = expand_list ( @{ $repos_p } ) unless $GL_BIG_CONFIG;
        # ...sanity check
        for (@{ $repos_p }) {
            print STDERR "$WARN explicit '.git' extension ignored for $_.git\n" if s/\.git$//;
            die "$ABRT bad reponame $_\n"
                if ($GL_WILDREPOS and $_ !~ $REPOPATT_PATT);
            die "$ABRT bad reponame $_ or you forgot to set \$GL_WILDREPOS\n"
                if (not $GL_WILDREPOS and $_ !~ $REPONAME_PATT);
        }
        s/\bCREAT[EO]R\b/\$creator/g for @{ $repos_p };
    }
    # actual permission line
    elsif ($line =~ /^(-|C|R|RW\+?(?:C?D?|D?C?)) (.* )?= (.+)/)
    {
        my $perms = $1;
        my @refs; @refs = split( ' ', $2 ) if $2;
        @refs = expand_list ( @refs );
        my @users = split ' ', $3;
        die "$ABRT \$GL_WILDREPOS is not set, you cant use 'C' in config\n" if $perms eq 'C' and not $GL_WILDREPOS;

        # if no ref is given, this PERM applies to all refs
        @refs = qw(refs/.*) unless @refs;
        # deprecation warning
        map { print STDERR "WARNING: old syntax 'PATH/' found; please use new syntax 'NAME/'\n" if s(^PATH/)(NAME/) } @refs;
        # fully qualify refs that dont start with "refs/" or "NAME/";
        # prefix them with "refs/heads/"
        @refs = map { m(^(refs|NAME)/) or s(^)(refs/heads/); $_ } @refs;
        @refs = map { s(/USER/)(/\$gl_user/); $_ } @refs;

        # expand the user list, unless it is just "@all"
        @users = expand_list ( @users ) unless $GL_BIG_CONFIG;
        do  { die "$ABRT bad username $_\n" unless $_ =~ $USERNAME_PATT } for @users;

        s/\bCREAT[EO]R\b/~\$creator/g for @users;

        # ok, we can finally populate the %repos hash
        for my $repo (@{ $repos_p })       # each repo in the current stanza
        {
            # if we're processing a delegated config file (not the master
            # config), we need to prevent attempts by that admin to obtain
            # rights on stuff outside his domain

            # trying to set access for $repo (='foo')...
            if (check_fragment_repo_disallowed( $fragment, $repo ))
            {
                $ignored_p->{$fragment}{$repo} = 1;
                next;
            }
            for my $user (@users)
            {
                # lint check, to catch pubkey/username typos
                if ($user =~ /^@/ and $user ne '@all') {
                    # this is a usergroup, not a normal user; happens with GL_BIG_CONFIG
                    if (exists $groups{$user}) {
                        $user_list{$_}++ for keys %{ $groups{$user} };
                    }
                } else {
                    $user_list{$user}++;
                }

                # for 1st level check (see faq/tips doc)
                $repos{$repo}{C}{$user} = 1, next if $perms eq 'C';
                $repos{$repo}{R}{$user} = 1 if $perms =~ /R/;
                $repos{$repo}{W}{$user} = 1 if $perms =~ /W|D/;

                # if the user specified even a single 'D' anywhere, make
                # that fact easy to find; this changes the meaning of RW+
                # to no longer permit deletes (see update hook)
                $repos{$repo}{DELETE_IS_D} = 1 if $perms =~ /D/;
                $repos{$repo}{CREATE_IS_C} = 1 if $perms =~ /RW.*C/;

                # for 2nd level check, store each "ref, perms" pair in order
                for my $ref (@refs)
                {
                    # checking NAME based restrictions is expensive for
                    # the update hook (see the changes to src/hooks/update
                    # in this commit for why) so we would *very* much like
                    # to avoid doing it for the large majority of repos
                    # that do *not* use NAME limits.  Setting a flag that
                    # can be checked right away will help us do that
                    $repos{$repo}{NAME_LIMITS} = 1 if $ref =~ /^NAME\//;
                    my $p_user = $user; $p_user =~ s/creator$/creator - wild/;
                    push @{ $repos{$repo}{$p_user} }, [ $rule_seq++, $ref, $perms ]
                        unless $rurp_seen{$repo}{$p_user}{$ref}{$perms}++;
                }
            }
        }
    }
    # repo specific 'git config' stuff
    elsif ($line =~ /^config (.+) = ?(.*)/)
    {
        my ($key, $value) = ($1, $2);
        my @validkeys = split(' ', ($GL_GITCONFIG_KEYS || '') );
        my @matched = grep { $key =~ /^$_$/ } @validkeys;
        die "$ABRT git config $key not allowed\ncheck GL_GITCONFIG_KEYS in the rc file for how to allow it\n" if (@matched < 1);
        for my $repo (@{ $repos_p })       # each repo in the current stanza
        {
            $git_configs{$repo}{$key} = $value;
            # no problem if it's a plain repo (non-pattern, non-groupname)
            # OR wild configs are allowed
            unless ( ($repo =~ $REPONAME_PATT and $repo !~ /^@/) or $GL_GITCONFIG_WILD) {
                my @r = ($repo);                                        # single wildpatt
                @r = sort keys %{ $groups{$repo} } if $groups{$repo};   # or a group; get its members
                do {
                    warn "$WARN git config set for $_ but \$GL_GITCONFIG_WILD not set\n" unless $_ =~ $REPONAME_PATT
                } for @r;
            }
        }
    }
    # include
    elsif ($line =~ /^include "(.+)"/)
    {
        my $include_glob = $1;
        for my $file (glob($include_glob =~ m(^/) ? $include_glob : "conf/$include_glob")) {
            die "$ABRT $fragment attempting to include configuration\n" if $fragment ne 'master';
            die "$ABRT included file not found: '$file'\n" unless -f $file;

            my $file_id = device_inode($file);
            warn("$WARN $file already included\n"), next if ($included{$file_id}++);

            parse_conf_file( $file, $fragment );
        }
    }
    # very simple syntax for the gitweb description of repo; one of:
    #       reponame = "some description string"
    #       reponame "owner name" = "some description string"
    elsif ($line =~ /^(\S+)(?: "(.*?)")? = "(.*)"$/)
    {
        my ($repo, $owner, $desc) = ($1, $2, $3);
        die "$ABRT bad repo name $repo\n" unless $repo =~ $REPONAME_PATT;
        die "$ABRT $fragment attempting to set description for $repo\n" if check_fragment_repo_disallowed( $fragment, $repo );
        $desc{"$repo.git"} = $desc;
        $owner{"$repo.git"} = $owner || '';
    }
    else
    {
        die "$ABRT can't make head or tail of '$line'\n";
    }
}

sub cleanup_conf_line
{
    my ($line) = @_;

    # kill comments, but take care of "#" inside *simple* strings
    $line =~ s/^((".*?"|[^#"])*)#.*/$1/;
    # normalise whitespace; keeps later regexes very simple
    $line =~ s/=/ = /;
    $line =~ s/\s+/ /g;
    $line =~ s/^ //;
    $line =~ s/ $//;
    return $line;
}

sub parse_conf_file
{
    my ($conffile, $fragment) = @_;
    # the second arg, $fragment, is passed in as "master" when parsing the
    # main config, and the fragment name when parsing a fragment.  In the
    # latter case, the parser uses that information to ignore (and warn about)
    # any repos in the fragment that are not members of the "repo group" of
    # the same name.
    my %ignored = ();

    my $conf_fh = wrap_open( "<", $conffile );

    # the syntax is fairly simple, so we parse it inline

    my @repos;
    my $line;
    while (<$conf_fh>)
    {
        $line = cleanup_conf_line($_);
        # skip blank lines
        next unless $line =~ /\S/;

        parse_conf_line( $line, $fragment, \@repos, \%ignored );
    }
    for my $ig (sort keys %ignored)
    {
        warn "\n\t\t***** WARNING *****\n" .
             "\t$ig.conf attempting to set access for " .
             join (", ", sort keys %{ $ignored{$ig} }) . "\n";
    }
}

# parse the main config file
parse_conf_file($GL_CONF, 'master');

# parse any delegated fragments
wrap_chdir($GL_ADMINDIR);
for my $fragment_file (glob("conf/fragments/*.conf"))
{
    # we already check (elsewhere) that a fragment called "foo" will not try
    # to specify access control for a repo whose name is not "foo" or is not
    # part of a group called "foo" created by master

    # meanwhile, I found a possible attack where the admin for group B creates
    # a "convenience" group of (a subset of) his users, and then the admin for
    # repo group A (alphabetically before B) adds himself to that same group
    # in his own fragment.

    # as a result, admin_A now has access to group B repos :(

    # so now we lock the groups hash to the value it had after parsing
    # "master", and localise any changes to it by this fragment so that they
    # don't propagate to the next fragment.  Thus, each fragment now has only
    # those groups that are defined in "master" and itself

    local %groups = %groups;

    my $fragment = $fragment_file;
    $fragment =~ s/^conf\/fragments\/(.*).conf$/$1/;
    parse_conf_file($fragment_file, $fragment);
}

# ----------------------------------------------------------------------------
#       (that ends the config file compiler, though we postpone the writing
#       for now to deal with the latest GL_BIG_CONFIG innovation!)
# ----------------------------------------------------------------------------

# ----------------------------------------------------------------------------
#       what's the git version?
# ----------------------------------------------------------------------------

# we don't like stuff older than 1.6.2

my $git_version = `git --version`;
die "
    *** ERROR ***
    did not get a proper version number.  Please see if git is in the PATH on
    the server.  If it is not, please edit ~/.gitolite.rc on the server and
    set the \$GIT_PATH variable to the correct value\n
" unless $git_version;
my ($gv_maj, $gv_min, $gv_patchrel) = ($git_version =~ m/git version (\d+)\.(\d+)\.(\d+)/);
die "$ABRT I can't understand $git_version\n" unless ($gv_maj >= 1);
$git_version = $gv_maj*10000 + $gv_min*100 + $gv_patchrel;  # now it's "normalised"

die "\n\t\t***** AAARGH! *****\n" .
    "\tyour git version is older than 1.6.2\n" .
    "\tsince that is now more than one year old, and gitolite needs some of\n" .
    "\tthe newer features, please upgrade.\n"
    if $git_version < 10602;     # that's 1.6.2 to you

# ----------------------------------------------------------------------------
# most of the rest of this program can be "switched off"; see
# doc/big-config.mkd for details.
# ----------------------------------------------------------------------------

# process the normal repos in %repos.  This includes creating them if needed
# (and GL_NO_CREATE_REPOS is not set), checking hooks, and finally, if
# GL_BIG_CONFIG is set, writing out the one-repo config file for directly
# specified repos (i.e., "repo foo", not "@grp = foo" + "repo @grp")
do_normal_repos();
write_compiled_conf();    # write out the final compiled config

# ----------------------------------------------------------------------------
#       process the normal repos in %repos (create, hook, one_repo config...)
# ----------------------------------------------------------------------------

sub do_normal_repos
{
    wrap_chdir($REPO_BASE);

    # start with the ones that are normal repos in %repos
    my @repos = grep { $_ =~ $REPONAME_PATT and not /^@/ } sort keys %repos;
    # then, for each repogroup, find the members of the group and add them in
    map { push @repos, keys %{ $groups{$_} } } grep { /^@/ } keys %repos;
    # weed out duplicates (the code in the loop below is disk activity!)
    my %seen = map { $_ => 1 } @repos;
    @repos = sort keys %seen;

    for my $repo (sort @repos) {
        next unless $repo =~ $REPONAME_PATT;    # skip repo patterns
        next if $repo =~ m(^\@|EXTCMD/);        # skip groups and fake repos

        unless ($GL_NO_CREATE_REPOS) {
            unless (-d "$repo.git") {
                print STDERR "creating $repo...\n";
                new_repo($repo, "$GL_ADMINDIR/hooks/common");
                # new_repo would have chdir'd us away; come back
                wrap_chdir($REPO_BASE);
            }

            # when repos are copied over from elsewhere, one had to run easy install
            # once again to make the new (OS-copied) repo contain the proper update
            # hook.  Perhaps we can make this easier now, and eliminate the easy
            # install, with a quick check (and a new, empty, "hook" as a sentinel)
            unless (-l "$repo.git/hooks/gitolite-hooked") {
                ln_sf("$GL_ADMINDIR/hooks/common", "*", "$repo.git/hooks");
                # in case of package install, GL_ADMINDIR is no longer the top cop;
                # override with the package hooks
                ln_sf("$GL_PACKAGE_HOOKS/common", "*", "$repo.git/hooks") if $GL_PACKAGE_HOOKS;
            }
        }

        # write a one_repo config for normal repos declared directly (not just via a group)
        write_1_compiled_conf($repo) if $GL_BIG_CONFIG and $repos{$repo} and -d "$repo.git";
    }
}

sub write_1_compiled_conf
{
    # warning: writes and *deletes* it from %repos and %git_configs
    my ($repo) = shift;
    my (%one_repo, %one_git_config);

    open(my $compiled_fh, ">", "$repo.git/gl-conf") or return;

    $one_repo{$repo} = $repos{$repo};
    delete $repos{$repo};
    my $dumped_data = Data::Dumper->Dump([\%one_repo], [qw(*one_repo)]);

    if ($git_configs{$repo}) {
        $one_git_config{$repo} = $git_configs{$repo};
        delete $git_configs{$repo};
        $dumped_data .= Data::Dumper->Dump([\%one_git_config], [qw(*one_git_config)]);
    }

    # the dump uses single quotes, but we convert any strings containing $creator
    # and $gl_user to double quoted strings.  A bit sneaky, but not too much...
    $dumped_data =~ s/'(?=[^']*\$(?:creator|gl_user))~?(.*?)'/"$1"/g;
    print $compiled_fh $dumped_data;
    close $compiled_fh;

    $split_conf{$repo} = 1;
}

sub write_compiled_conf
{
    my $compiled_fh = wrap_open( ">", "$GL_CONF_COMPILED.new" );
    my $data_version = $current_data_version;
    print $compiled_fh Data::Dumper->Dump([$data_version], [qw(*data_version)]);
    my $dumped_data = Data::Dumper->Dump([\%repos], [qw(*repos)]);
    $dumped_data .= Data::Dumper->Dump([\%git_configs], [qw(*git_configs)]) if %git_configs;
    # the dump uses single quotes, but we convert any strings containing $creator
    # and $gl_user to double quoted strings.  A bit sneaky, but not too much...
    $dumped_data =~ s/'(?=[^']*\$(?:creator|gl_user))~?(.*?)'/"$1"/g;
    print $compiled_fh $dumped_data;
    if (%groups) {
        $dumped_data = Data::Dumper->Dump([\%groups], [qw(*groups)]);
        $dumped_data =~ s/\bCREAT[EO]R\b/\$creator/g;
        $dumped_data =~ s/'(?=[^']*\$(?:creator|gl_user))~?(.*?)'/"$1"/g;
        print $compiled_fh $dumped_data;
    }
    print $compiled_fh Data::Dumper->Dump([\%split_conf], [qw(*split_conf)]) if %split_conf;
    close $compiled_fh or die "$ABRT close compiled-conf failed: $!\n";
    rename "$GL_CONF_COMPILED.new", "$GL_CONF_COMPILED";
}

# ----------------------------------------------------------------------------
#       get a list of physical repos for later
# ----------------------------------------------------------------------------

my @phy_repos = ();
@phy_repos = list_phy_repos() unless $GL_NO_DAEMON_NO_GITWEB;

# NOTE: we're overloading GL_NO_DAEMON_NO_GITWEB to mean "no git config" also.
# In fact anything that requires trawling through the existing repos doing
# stuff to all of them is skipped if this variable is set.  This is primarily
# for the Fedora folks, but it should be useful for anyone who has a huge set
# of repos and wants to manage gitweb/daemon/etc access via other means (they
# typically have the whole thing controlled by a web-app and a database
# anyway, and gitolite is only doing the access control and nothing more).

# ----------------------------------------------------------------------------
#       various updates to all real repos
# ----------------------------------------------------------------------------

# ----------------------------------------------------------------------------
#       update repo configurations, gitweb description, daemon export-ok, etc
# ----------------------------------------------------------------------------

my %projlist = ();

# for each real repo (and remember this will be empty, thus skipping all this,
# if $GL_NO_DAEMON_NO_GITWEB is on!)

# note: we do them in 2 separate loops to avoid breaking the optimisation in
# sub parse_acl (look for variable $saved_crwu)

for my $repo (@phy_repos) {
    wrap_chdir("$REPO_BASE/$repo.git");
    # daemon is easy
    setup_daemon_access($repo);
}

for my $repo (@phy_repos) {
    wrap_chdir("$REPO_BASE/$repo.git");
    # gitweb is a little more complicated.  Here're some notes:
    # - "setup_gitweb_access" also sets "owner", despite the name
    # - specifying a description also counts as enabling gitweb
    # - description and owner are not specified for wildrepos; they're
    #   specified for *actual* repos, even if the repo was created by a
    #   wild card spec and "C" permissions.  If you see the docs for the
    #   gitolite.conf file, you will see that repo owner/desc don't go
    #   into the "repo foo" section; they're essentialy independent.
    #   Anyway, I believe it doesn't make sense to have all wild repos
    #   (for some pattern) to have the same description and owner.
    $projlist{"$repo.git"} = 1 if setup_gitweb_access($repo, $desc{"$repo.git"} || '', $owner{"$repo.git"} || '');

    # git config
    # implementation note: this must happen *after* one of the previous 2
    # calls (setup daemon or gitweb).  The reason is that they call
    # "can_read", which eventually calls parse_acl with the right "creator"
    # set for the *current* repo, which in turn stores translated values for
    # $creator in the git_configs hash, which, (phew!) is needed for a match
    # that eventually gets you a valid $git_configs{} below
    setup_git_configs($repo, \%git_configs) if $git_configs{$repo};
}

# write out the project list, but not if GL_NO_DAEMON_NO_GITWEB is set
unless ($GL_NO_DAEMON_NO_GITWEB) {
    my $projlist_fh = wrap_open( ">", "$PROJECTS_LIST.$$");
    for my $proj (sort keys %projlist) {
        print $projlist_fh "$proj\n";
    }
    close $projlist_fh;
    rename "$PROJECTS_LIST.$$", $PROJECTS_LIST;
}

# ----------------------------------------------------------------------------
#       "compile" ssh authorized_keys
# ----------------------------------------------------------------------------

unless ($GL_NO_SETUP_AUTHKEYS) {
    setup_authkeys($GL_KEYDIR, \%user_list);
}
