contrib / hooks / update-paranoidon commit Don't diff empty tree on branch creation in paranoid update hook (97561ff)
   1#!/usr/bin/perl
   2
   3use strict;
   4use File::Spec;
   5
   6$ENV{PATH}     = '/opt/git/bin';
   7my $acl_git    = '/vcs/acls.git';
   8my $acl_branch = 'refs/heads/master';
   9my $debug      = 0;
  10
  11=doc
  12Invoked as: update refname old-sha1 new-sha1
  13
  14This script is run by git-receive-pack once for each ref that the
  15client is trying to modify.  If we exit with a non-zero exit value
  16then the update for that particular ref is denied, but updates for
  17other refs in the same run of receive-pack may still be allowed.
  18
  19We are run after the objects have been uploaded, but before the
  20ref is actually modified.  We take advantage of that fact when we
  21look for "new" commits and tags (the new objects won't show up in
  22`rev-list --all`).
  23
  24This script loads and parses the content of the config file
  25"users/$this_user.acl" from the $acl_branch commit of $acl_git ODB.
  26The acl file is a git-config style file, but uses a slightly more
  27restricted syntax as the Perl parser contained within this script
  28is not nearly as permissive as git-config.
  29
  30Example:
  31
  32  [user]
  33    committer = John Doe <john.doe@example.com>
  34    committer = John R. Doe <john.doe@example.com>
  35
  36  [repository "acls"]
  37    allow = heads/master
  38    allow = CDUR for heads/jd/
  39    allow = C    for ^tags/v\\d+$
  40
  41For all new commit or tag objects the committer (or tagger) line
  42within the object must exactly match one of the user.committer
  43values listed in the acl file ("HEAD:users/$this_user.acl").
  44
  45For a branch to be modified an allow line within the matching
  46repository section must be matched for both the refname and the
  47opcode.
  48
  49Repository sections are matched on the basename of the repository
  50(after removing the .git suffix).
  51
  52The opcode abbrevations are:
  53
  54  C: create new ref
  55  D: delete existing ref
  56  U: fast-forward existing ref (no commit loss)
  57  R: rewind/rebase existing ref (commit loss)
  58
  59if no opcodes are listed before the "for" keyword then "U" (for
  60fast-forward update only) is assumed as this is the most common
  61usage.
  62
  63Refnames are matched by always assuming a prefix of "refs/".
  64This hook forbids pushing or deleting anything not under "refs/".
  65
  66Refnames that start with ^ are Perl regular expressions, and the ^
  67is kept as part of the regexp.  \\ is needed to get just one \, so
  68\\d expands to \d in Perl.  The 3rd allow line above is an example.
  69
  70Refnames that don't start with ^ but that end with / are prefix
  71matches (2nd allow line above); all other refnames are strict
  72equality matches (1st allow line).
  73
  74Anything pushed to "heads/" (ok, really "refs/heads/") must be
  75a commit.  Tags are not permitted here.
  76
  77Anything pushed to "tags/" (err, really "refs/tags/") must be an
  78annotated tag.  Commits, blobs, trees, etc. are not permitted here.
  79Annotated tag signatures aren't checked, nor are they required.
  80
  81The special subrepository of 'info/new-commit-check' can
  82be created and used to allow users to push new commits and
  83tags from another local repository to this one, even if they
  84aren't the committer/tagger of those objects.  In a nut shell
  85the info/new-commit-check directory is a Git repository whose
  86objects/info/alternates file lists this repository and all other
  87possible sources, and whose refs subdirectory contains symlinks
  88to this repository's refs subdirectory, and to all other possible
  89sources refs subdirectories.  Yes, this means that you cannot
  90use packed-refs in those repositories as they won't be resolved
  91correctly.
  92
  93=cut
  94
  95my $git_dir = $ENV{GIT_DIR};
  96my $new_commit_check = "$git_dir/info/new-commit-check";
  97my $ref = $ARGV[0];
  98my $old = $ARGV[1];
  99my $new = $ARGV[2];
 100my $new_type;
 101my ($this_user) = getpwuid $<; # REAL_USER_ID
 102my $repository_name;
 103my %user_committer;
 104my @allow_rules;
 105my @path_rules;
 106my %diff_cache;
 107
 108sub deny ($) {
 109        print STDERR "-Deny-    $_[0]\n" if $debug;
 110        print STDERR "\ndenied: $_[0]\n\n";
 111        exit 1;
 112}
 113
 114sub grant ($) {
 115        print STDERR "-Grant-   $_[0]\n" if $debug;
 116        exit 0;
 117}
 118
 119sub info ($) {
 120        print STDERR "-Info-    $_[0]\n" if $debug;
 121}
 122
 123sub git_value (@) {
 124        open(T,'-|','git',@_); local $_ = <T>; chop; close T; $_;
 125}
 126
 127sub match_string ($$) {
 128        my ($acl_n, $ref) = @_;
 129           ($acl_n eq $ref)
 130        || ($acl_n =~ m,/$, && substr($ref,0,length $acl_n) eq $acl_n)
 131        || ($acl_n =~ m,^\^, && $ref =~ m:$acl_n:);
 132}
 133
 134sub parse_config ($$$$) {
 135        my $data = shift;
 136        local $ENV{GIT_DIR} = shift;
 137        my $br = shift;
 138        my $fn = shift;
 139        info "Loading $br:$fn";
 140        open(I,'-|','git','cat-file','blob',"$br:$fn");
 141        my $section = '';
 142        while (<I>) {
 143                chomp;
 144                if (/^\s*$/ || /^\s*#/) {
 145                } elsif (/^\[([a-z]+)\]$/i) {
 146                        $section = lc $1;
 147                } elsif (/^\[([a-z]+)\s+"(.*)"\]$/i) {
 148                        $section = join('.',lc $1,$2);
 149                } elsif (/^\s*([a-z][a-z0-9]+)\s*=\s*(.*?)\s*$/i) {
 150                        push @{$data->{join('.',$section,lc $1)}}, $2;
 151                } else {
 152                        deny "bad config file line $. in $br:$fn";
 153                }
 154        }
 155        close I;
 156}
 157
 158sub all_new_committers () {
 159        local $ENV{GIT_DIR} = $git_dir;
 160        $ENV{GIT_DIR} = $new_commit_check if -d $new_commit_check;
 161
 162        info "Getting committers of new commits.";
 163        my %used;
 164        open(T,'-|','git','rev-list','--pretty=raw',$new,'--not','--all');
 165        while (<T>) {
 166                next unless s/^committer //;
 167                chop;
 168                s/>.*$/>/;
 169                info "Found $_." unless $used{$_}++;
 170        }
 171        close T;
 172        info "No new commits." unless %used;
 173        keys %used;
 174}
 175
 176sub all_new_taggers () {
 177        my %exists;
 178        open(T,'-|','git','for-each-ref','--format=%(objectname)','refs/tags');
 179        while (<T>) {
 180                chop;
 181                $exists{$_} = 1;
 182        }
 183        close T;
 184
 185        info "Getting taggers of new tags.";
 186        my %used;
 187        my $obj = $new;
 188        my $obj_type = $new_type;
 189        while ($obj_type eq 'tag') {
 190                last if $exists{$obj};
 191                $obj_type = '';
 192                open(T,'-|','git','cat-file','tag',$obj);
 193                while (<T>) {
 194                        chop;
 195                        if (/^object ([a-z0-9]{40})$/) {
 196                                $obj = $1;
 197                        } elsif (/^type (.+)$/) {
 198                                $obj_type = $1;
 199                        } elsif (s/^tagger //) {
 200                                s/>.*$/>/;
 201                                info "Found $_." unless $used{$_}++;
 202                                last;
 203                        }
 204                }
 205                close T;
 206        }
 207        info "No new tags." unless %used;
 208        keys %used;
 209}
 210
 211sub check_committers (@) {
 212        my @bad;
 213        foreach (@_) { push @bad, $_ unless $user_committer{$_}; }
 214        if (@bad) {
 215                print STDERR "\n";
 216                print STDERR "You are not $_.\n" foreach (sort @bad);
 217                deny "You cannot push changes not committed by you.";
 218        }
 219}
 220
 221sub load_diff ($) {
 222        my $base = shift;
 223        my $d = $diff_cache{$base};
 224        unless ($d) {
 225                local $/ = "\0";
 226                my %this_diff;
 227                if ($base =~ /^0{40}$/) {
 228                        # Don't load the diff at all; we are making the
 229                        # branch and have no base to compare to in this
 230                        # case.  A file level ACL makes no sense in this
 231                        # context.  Having an empty diff will allow the
 232                        # branch creation.
 233                        #
 234                } else {
 235                        open(T,'-|','git','diff-tree',
 236                                '-r','--name-status','-z',
 237                                $base,$new) or return undef;
 238                        while (<T>) {
 239                                my $op = $_;
 240                                chop $op;
 241
 242                                my $path = <T>;
 243                                chop $path;
 244
 245                                $this_diff{$path} = $op;
 246                        }
 247                        close T or return undef;
 248                }
 249                $d = \%this_diff;
 250                $diff_cache{$base} = $d;
 251        }
 252        return $d;
 253}
 254
 255deny "No GIT_DIR inherited from caller" unless $git_dir;
 256deny "Need a ref name" unless $ref;
 257deny "Refusing funny ref $ref" unless $ref =~ s,^refs/,,;
 258deny "Bad old value $old" unless $old =~ /^[a-z0-9]{40}$/;
 259deny "Bad new value $new" unless $new =~ /^[a-z0-9]{40}$/;
 260deny "Cannot determine who you are." unless $this_user;
 261
 262$repository_name = File::Spec->rel2abs($git_dir);
 263$repository_name =~ m,/([^/]+)(?:\.git|/\.git)$,;
 264$repository_name = $1;
 265info "Updating in '$repository_name'.";
 266
 267my $op;
 268if    ($old =~ /^0{40}$/) { $op = 'C'; }
 269elsif ($new =~ /^0{40}$/) { $op = 'D'; }
 270else                      { $op = 'R'; }
 271
 272# This is really an update (fast-forward) if the
 273# merge base of $old and $new is $old.
 274#
 275$op = 'U' if ($op eq 'R'
 276        && $ref =~ m,^heads/,
 277        && $old eq git_value('merge-base',$old,$new));
 278
 279# Load the user's ACL file. Expand groups (user.memberof) one level.
 280{
 281        my %data = ('user.committer' => []);
 282        parse_config(\%data,$acl_git,$acl_branch,"external/$repository_name.acl");
 283
 284        %data = (
 285                'user.committer' => $data{'user.committer'},
 286                'user.memberof' => [],
 287        );
 288        parse_config(\%data,$acl_git,$acl_branch,"users/$this_user.acl");
 289
 290        %user_committer = map {$_ => $_} @{$data{'user.committer'}};
 291        my $rule_key = "repository.$repository_name.allow";
 292        my $rules = $data{$rule_key} || [];
 293
 294        foreach my $group (@{$data{'user.memberof'}}) {
 295                my %g;
 296                parse_config(\%g,$acl_git,$acl_branch,"groups/$group.acl");
 297                my $group_rules = $g{$rule_key};
 298                push @$rules, @$group_rules if $group_rules;
 299        }
 300
 301RULE:
 302        foreach (@$rules) {
 303                while (/\${user\.([a-z][a-zA-Z0-9]+)}/) {
 304                        my $k = lc $1;
 305                        my $v = $data{"user.$k"};
 306                        next RULE unless defined $v;
 307                        next RULE if @$v != 1;
 308                        next RULE unless defined $v->[0];
 309                        s/\${user\.$k}/$v->[0]/g;
 310                }
 311
 312                if (/^([AMD ]+)\s+of\s+([^\s]+)\s+for\s+([^\s]+)\s+diff\s+([^\s]+)$/) {
 313                        my ($ops, $pth, $ref, $bst) = ($1, $2, $3, $4);
 314                        $ops =~ s/ //g;
 315                        $pth =~ s/\\\\/\\/g;
 316                        $ref =~ s/\\\\/\\/g;
 317                        push @path_rules, [$ops, $pth, $ref, $bst];
 318                } elsif (/^([AMD ]+)\s+of\s+([^\s]+)\s+for\s+([^\s]+)$/) {
 319                        my ($ops, $pth, $ref) = ($1, $2, $3);
 320                        $ops =~ s/ //g;
 321                        $pth =~ s/\\\\/\\/g;
 322                        $ref =~ s/\\\\/\\/g;
 323                        push @path_rules, [$ops, $pth, $ref, $old];
 324                } elsif (/^([CDRU ]+)\s+for\s+([^\s]+)$/) {
 325                        my $ops = $1;
 326                        my $ref = $2;
 327                        $ops =~ s/ //g;
 328                        $ref =~ s/\\\\/\\/g;
 329                        push @allow_rules, [$ops, $ref];
 330                } elsif (/^for\s+([^\s]+)$/) {
 331                        # Mentioned, but nothing granted?
 332                } elsif (/^[^\s]+$/) {
 333                        s/\\\\/\\/g;
 334                        push @allow_rules, ['U', $_];
 335                }
 336        }
 337}
 338
 339if ($op ne 'D') {
 340        $new_type = git_value('cat-file','-t',$new);
 341
 342        if ($ref =~ m,^heads/,) {
 343                deny "$ref must be a commit." unless $new_type eq 'commit';
 344        } elsif ($ref =~ m,^tags/,) {
 345                deny "$ref must be an annotated tag." unless $new_type eq 'tag';
 346        }
 347
 348        check_committers (all_new_committers);
 349        check_committers (all_new_taggers) if $new_type eq 'tag';
 350}
 351
 352info "$this_user wants $op for $ref";
 353foreach my $acl_entry (@allow_rules) {
 354        my ($acl_ops, $acl_n) = @$acl_entry;
 355        next unless $acl_ops =~ /^[CDRU]+$/; # Uhh.... shouldn't happen.
 356        next unless $acl_n;
 357        next unless $op =~ /^[$acl_ops]$/;
 358        next unless match_string $acl_n, $ref;
 359
 360        # Don't test path rules on branch deletes.
 361        #
 362        grant "Allowed by: $acl_ops for $acl_n" if $op eq 'D';
 363
 364        # Aggregate matching path rules; allow if there aren't
 365        # any matching this ref.
 366        #
 367        my %pr;
 368        foreach my $p_entry (@path_rules) {
 369                my ($p_ops, $p_n, $p_ref, $p_bst) = @$p_entry;
 370                next unless $p_ref;
 371                push @{$pr{$p_bst}}, $p_entry if match_string $p_ref, $ref;
 372        }
 373        grant "Allowed by: $acl_ops for $acl_n" unless %pr;
 374
 375        # Allow only if all changes against a single base are
 376        # allowed by file path rules.
 377        #
 378        my @bad;
 379        foreach my $p_bst (keys %pr) {
 380                my $diff_ref = load_diff $p_bst;
 381                deny "Cannot difference trees." unless ref $diff_ref;
 382
 383                my %fd = %$diff_ref;
 384                foreach my $p_entry (@{$pr{$p_bst}}) {
 385                        my ($p_ops, $p_n, $p_ref, $p_bst) = @$p_entry;
 386                        next unless $p_ops =~ /^[AMD]+$/;
 387                        next unless $p_n;
 388
 389                        foreach my $f_n (keys %fd) {
 390                                my $f_op = $fd{$f_n};
 391                                next unless $f_op;
 392                                next unless $f_op =~ /^[$p_ops]$/;
 393                                delete $fd{$f_n} if match_string $p_n, $f_n;
 394                        }
 395                        last unless %fd;
 396                }
 397
 398                if (%fd) {
 399                        push @bad, [$p_bst, \%fd];
 400                } else {
 401                        # All changes relative to $p_bst were allowed.
 402                        #
 403                        grant "Allowed by: $acl_ops for $acl_n diff $p_bst";
 404                }
 405        }
 406
 407        foreach my $bad_ref (@bad) {
 408                my ($p_bst, $fd) = @$bad_ref;
 409                print STDERR "\n";
 410                print STDERR "Not allowed to make the following changes:\n";
 411                print STDERR "(base: $p_bst)\n";
 412                foreach my $f_n (sort keys %$fd) {
 413                        print STDERR "  $fd->{$f_n} $f_n\n";
 414                }
 415        }
 416        deny "You are not permitted to $op $ref";
 417}
 418close A;
 419deny "You are not permitted to $op $ref";