#!/usr/bin/perl -w # SECURITY: look for the word SECURITY below and decide... # handle pull-requests and related stuff # developer notes: # - 'requestor' is too long, so I use "bob"; if you see the documentation # you'll realise this isn't as crazy as you think :-) use strict; use warnings; die "ENV GL_RC not set\n" unless $ENV{GL_RC}; die "ENV GL_BINDIR not set\n" unless $ENV{GL_BINDIR}; sub usage { print STDERR <<'EOF'; GENERAL USAGE: ssh git@server hub See docs for concepts; this usage message is only a refresher! Requestor's commands (repo child): request-pull child b1 [parent] request-status child [parent] [request-number] Parent repo owner's commands (repo parent): list-requests parent view-request parent request-number view-log parent request-number view-diff parent request-number reject parent request-number fetch parent request-number accept parent request-number EOF exit 1; } our $tempdir; END { wrap_chdir($ENV{GL_REPO_BASE_ABS}); system("rm", "-rf", "$tempdir.git") if $tempdir and $tempdir =~ /gl-internal-temp-repo/; } my %dispatch = ( rp => \&rp, 'request-pull' => \&rp, rs => \&rs, 'request-status' => \&rs, lr => \&lr, 'list-requests' => \&lr, vr => \&vr, 'view-request' => \&vr, vl => \&vl, 'view-log' => \&vl, vd => \&vd, 'view-diff' => \&vd, reject => \&reject, fetch => \&fetch, accept => \&accept, ); my $cmd = shift || ''; usage() unless ($dispatch{$cmd}); unshift @INC, $ENV{GL_BINDIR}; require gitolite or die "parse gitolite.pm failed\n"; gitolite->import; # find what is effectively GL_ADC_PATH, then get the config var we're interested in use FindBin; my $BASE_FETCH_URL = `. $FindBin::Bin/adc.common-functions; echo \$BASE_FETCH_URL`; chomp($BASE_FETCH_URL); my $GL_FORKED_FROM = `. $FindBin::Bin/adc.common-functions; echo \$GL_FORKED_FROM`; chomp($GL_FORKED_FROM); my @args = @ARGV; @ARGV = (); $dispatch{$cmd}->(@args); # -------------------- bob's commands sub rp { # request-pull child b1 [parent] usage() unless @_ == 2 or @_ == 3; # implicitly gives owner-parent read access to part of child, so requestor # should already have read access to child (to prevent someone gaining # access to child by faking a pull request against it!) # XXX would it be better to ensure it is writable by Bob, because how/why # would he make a pull request if he didn't just write to it? my ($repo, $creator) = readable_repo(shift); my $ref = valid_ref($repo, shift); # the parent is either explicitly given, or the name of the parent # recorded by the 'fork' ADC is used my $repo_to = shift || parent_repo($repo); # requestor need not have any access to parent; it is quite possible he # gets this via git-daemon or something, so we just need to make sure it's # a valid repo $repo_to = valid_repo($repo_to); # the 'cover letter' message comes from STDIN my $cover = join("", <>); # now create/update the pull request file cd2repo($repo_to); my %hub = get_hub(); $hub{$repo}{$ref}{BOB} = $ENV{GL_USER}; $hub{$repo}{$ref}{COVER} = $cover; $hub{$repo}{$ref}{TIME} = time(); $hub{$repo}{$ref}{STATUS} = 'pending'; dump_hub(%hub); } sub rs { # request-status child [parent] [request-number] usage() unless @_ > 0 and @_ < 4; # 1 or 2 or 3 # same checks as in 'rp' above my ($repo_from, $creator) = readable_repo(shift); my $repo; if ($_[0] and $_[0] !~ /^\d+$/) { # next arg is not a number, so it should be 'parent' $repo = shift; } else { $repo = parent_repo($repo_from); } $repo = valid_repo($repo); my $rqno = 0; $rqno = shift if ($_[0] and $_[0] =~ /^\d+$/); # there should not be any arguments left over usage() if @_; unless ($rqno) { cd2repo($repo); my %hub_full = get_hub(); return unless $hub_full{$repo_from}; my %hub; $hub{$repo_from} = $hub_full{$repo_from}; list_hub('', %hub); return; } my ($child, $ref, %hub) = get_request_N($repo, $rqno); # this also does a chdir to $repo, by the way my %hub1; $hub1{$child}{$ref} = $hub{$child}{$ref}; list_hub('', %hub1); print "\nMessage:\n$hub1{$child}{$ref}{COVER}\n"; } # -------------------- alice's commands sub lr { # list-requests parent [optional search strings] usage() unless @_ >= 1; # Alice must have write access to parent, otherwise she can't really # accept a pull request eventually right? my ($repo, $creator) = writable_repo(shift); cd2repo($repo); my %hub = get_hub(); return unless %hub; # create the search pattern. ADC arg checking is very strict; it doesn't # allow &, | etc., so we just generate an OR condition out of the pieces my $patt = join("|", @_); list_hub($patt, %hub); } sub vr { # view-request parent request-number usage() unless @_ == 2; my ($repo, $n) = @_; my ($child, $ref, %hub) = get_request_N($repo, $n); # this also does a chdir to $repo, by the way my %hub1; $hub1{$child}{$ref} = $hub{$child}{$ref}; list_hub('', %hub1); print "\nMessage:\n$hub1{$child}{$ref}{COVER}\n"; } sub vl { # view-log parent request-number usage() unless @_ >= 2; my ($repo, $n) = (shift, shift); my ($child, $ref, %hub) = get_request_N($repo, $n); # so now we can find the set of SHAs that we already have # XXX should we include tags also? my @known_shas = grep { chomp; } `git for-each-ref refs/heads --format='%(objectname)'`; # make a copy of the child repo (Bob's repo) containing only the ref being # offered for fetch, then cd to it. This is easier to do than to sanitise # all possible git-log arguments. We're doing this to prevent Alice from # seeing anything more than the ref offered. temp_clone($child, $ref); # verify the list of "known_shas" because what's known in Alice's repo may # not be known here. While you're about it, negate them. (We don't want # to use "--not" because we're not sure what arguments the user will want # to add and we don't want to negate some of them by mistake @known_shas = grep { $_ = `git rev-parse --verify -q $_`; chomp && s/^/^/ } @known_shas; # run the log command # XXX SECURITY XXX do we need to check these arguments? Don't forget they # are restricted by $ADC_CMD_ARGS_PATT (defined in gitolite_rc.pm), which # is pretty tight to start with, so we know this cannot be used to run # external programs. The question is, are any of git-log's arguments # dangerous in their own right? my @args = ('git', 'log', $ref); push @args, @known_shas if @known_shas; check_SHAs($ref, @_); # each SHA in @_ must be a parent of $ref. Non-shas are not allowed # since all refs other than $ref have been deleted in the temp clone push @args, @_ if @_; system @args; } sub vd { # view-diff parent request-number usage() unless @_ >= 4; # we just check for 4 arguments; I guess later on we could also check # to make sure at least 2 of them are SHAs or something but unless # there's a security risk it's not needed my ($repo, $n) = (shift, shift); my ($child, $ref, %hub) = get_request_N($repo, $n); # this also does a chdir to $repo, by the way # now go to the child repo (Bob's repo) temp_clone($child, $ref); # run the diff command # XXX SECURITY XXX do we need to check these arguments? Don't forget they # are restricted by $ADC_CMD_ARGS_PATT (defined in gitolite_rc.pm), which # is pretty tight to start with, so we know this cannot be used to run # external programs. The question is, are any of git-diff's arguments # dangerous in their own right? my @args = ('git', 'diff'); check_SHAs($ref, @_); push @args, @_ if @_; system @args; } sub reject { # reject parent request-number usage() unless @_ == 2; my ($repo, $n) = @_; writable_repo($repo); # yeah we're throwing away the return values my ($child, $ref, %hub) = get_request_N($repo, $n); map { die "request status is already '$_'\n" if $_ ne 'pending' } $hub{$child}{$ref}{STATUS}; # the 'cover letter' message comes from STDIN my $cover = join("", <>); $hub{$child}{$ref}{STATUS} = "rejected by $ENV{GL_USER}"; $hub{$child}{$ref}{COVER} .= "\n\nRejected. Message to requestor:\n$cover"; dump_hub(%hub); } sub fetch { # fetch parent request-number usage() unless @_ == 2; my ($repo, $n) = @_; writable_repo($repo); # yeah we're throwing away the return values my ($child, $ref, %hub) = get_request_N($repo, $n); map { die "request status is already '$_'\n" if $_ ne 'pending' } $hub{$child}{$ref}{STATUS}; print "user $hub{$child}{$ref}{BOB} asked you to\n\tgit fetch $BASE_FETCH_URL/$child $ref\n"; print "hit enter to accept the fetch request or Ctrl-C to cancel..."; <>; my $fetched_ref = "refs/heads/requests/child/$ref"; # you're already chdir'd to parent, by get_request_N system("git", "update-ref", "-d", "refs/heads/$fetched_ref"); system("git", "fetch", "$ENV{GL_REPO_BASE_ABS}/$child.git", "$ref:$fetched_ref"); $hub{$child}{$ref}{STATUS} = "fetched by $ENV{GL_USER}"; dump_hub(%hub); } sub accept { # accept parent request-number usage() unless @_ == 2; my ($repo, $n) = @_; writable_repo($repo); # yeah we're throwing away the return values my ($child, $ref, %hub) = get_request_N($repo, $n); map { die "request status is '$_'; must be 'fetched'\n" if $_ !~ /^fetched by / } $hub{$child}{$ref}{STATUS}; # the 'cover letter' message comes from STDIN my $cover = join("", <>); $hub{$child}{$ref}{STATUS} = "accepted by $ENV{GL_USER}"; $hub{$child}{$ref}{COVER} .= "\n\nAccepted. Message to requestor:\n$cover"; dump_hub(%hub); } # -------------------- service subs sub assert { my ($expr, $message) = @_; eval $expr or die ($message ? "$message\n" : "assert '$expr' failed\n"); } sub cd2repo { my $repo = shift; wrap_chdir("$ENV{GL_REPO_BASE_ABS}/$repo.git"); } sub dump_hub { # pwd assumed to git repo.git; dump a file called "gl-adc-hub-requests" use Data::Dumper; $Data::Dumper::Indent = 1; $Data::Dumper::Sortkeys = 1; my %hub = @_; my $fh = wrap_open(">", "gl-adc-hub-requests"); print $fh Data::Dumper->Dump([\%hub], [qw(*hub)]); close $fh; } sub get_hub { # pwd assumed to git repo.git; "do" a file called "gl-adc-hub-requests" return () unless -w "gl-adc-hub-requests"; our %hub = (); do "gl-adc-hub-requests" or die "error parsing gl-adc-hub-requests\n"; return %hub; } sub get_request_N { # given a repo and an N, return "child", "ref", and %hub (or die trying!) # you can't look at pull requests for repos you don't have at least read access to my ($repo, $creator) = readable_repo(shift); cd2repo($repo); my %hub = get_hub(); die "you have no pending requests\n" unless %hub; my $n = shift || ''; usage() unless ($n =~ /^\d+$/); my @hub = hub_sort(%hub); die "you have only " . scalar(@hub) . " requests\n" if @hub < $n; $n--; # make it 0-relative return ($hub[$n]->{REPO}, $hub[$n]->{REF}, %hub); } sub hub_sort { my %hub = @_; my %sorted_hub = (); for my $child (sort keys %hub) { for my $ref (sort keys %{ $hub{$child} }) { my $key = $hub{$child}{$ref}{TIME} . "-$child-$ref"; $sorted_hub{$key} = { REPO=>$child, REF=>$ref }; } } my @hub = (); for my $key (sort keys %sorted_hub) { push @hub, $sorted_hub{$key}; } return @hub; } sub list_hub { my ($status, %hub) = @_; my $header = "#\tchild-repository-name\t(requestor)\tbranch-or-tag-to-pull\tstatus\n----\n"; my @hub = hub_sort(%hub); my $sn = 0; for my $pr (@hub) { $sn++; my $child = $pr->{REPO}; my $ref = $pr->{REF}; my $pr_status = $hub{$child}{$ref}{STATUS}; next if $status and $pr_status !~ /$status/; print $header if $header; $header = ''; print "$sn\t$child\t($hub{$child}{$ref}{BOB})\t$ref\t$pr_status\n"; } } sub parent_repo { my ($repo) = shift; cd2repo($repo); die "parent repo was not recorded, sorry!\n" unless -f $GL_FORKED_FROM; my $gff = `cat $GL_FORKED_FROM`; chomp($gff); return $gff; } sub readable_repo { my $repo = valid_repo(shift); my ($perm, $creator) = check_access($repo); die "$repo does not exist or you have no read access\n" unless $perm =~ /R/; return ($repo, $creator); } sub valid_log_options { } sub valid_ref { my ($repo, $ref) = @_; cd2repo($repo); die "invalid ref $ref\n" unless `git cat-file -t $ref` =~ /^commit$/; die "invalid ref $ref\n" unless `git rev-parse $ref` =~ /^[0-9a-f]{40}$/; return $ref; } sub valid_repo { my $repo = shift; $repo =~ s/\.git$//; die "$repo does not exist or you have no read access\n" unless -d "$ENV{GL_REPO_BASE_ABS}/$repo.git"; return $repo; } sub writable_repo { my $repo = valid_repo(shift); my ($perm, $creator) = check_access($repo); die "$repo does not exist or you have no write access\n" unless $perm =~ /W/; return ($repo, $creator); } sub temp_clone { my ($repo, $ref) = @_; die "internal error; temp_clone called twice?\n" if $tempdir; # some of this code is also in "rrr" branch # first make a temp directory within $REPO_BASE $ENV{TMPDIR} = $ENV{GL_REPO_BASE_ABS}; $tempdir = `mktemp -d -t gl-internal-temp-repo.XXXXXXXXXX`; chomp($tempdir); rename $tempdir, "$tempdir.git"; # make the clone wrap_chdir("$ENV{GL_REPO_BASE_ABS}"); system("git clone --mirror -l $repo.git $tempdir.git >/dev/null 2>&1"); # go to the clone and delete refs he's not allowed to read wrap_chdir("$ENV{GL_REPO_BASE_ABS}"); wrap_chdir("$tempdir.git"); # for each available ref for my $ar (`git for-each-ref refs '--format=%(refname)'`) { chomp($ar); system('git', 'update-ref', '-d', $ar) unless $ar eq "refs/heads/$ref"; } # you've already cd-d to the temp repo, just set the name up properly $tempdir =~ s/^\Q$ENV{GL_REPO_BASE_ABS}\///; } sub check_SHAs { my $ref = shift; for (@_) { next unless /^[0-9a-f]+$/i; my $fullsha = `git rev-parse $_`; chomp($fullsha); die "invalid SHA: $_\n" unless $fullsha =~ /^[0-9a-f]{40}$/; my $mergebase = `git merge-base $fullsha $ref`; chomp($mergebase); die "invalid SHA: $_\n" unless $mergebase eq $fullsha; } }