| #!/usr/bin/perl |
| |
| use strict; |
| use File::Spec; |
| |
| $ENV{PATH} = '/opt/git/bin'; |
| my $acl_git = '/vcs/acls.git'; |
| my $acl_branch = 'refs/heads/master'; |
| my $debug = 0; |
| |
| =doc |
| Invoked as: update refname old-sha1 new-sha1 |
| |
| This script is run by git-receive-pack once for each ref that the |
| client is trying to modify. If we exit with a non-zero exit value |
| then the update for that particular ref is denied, but updates for |
| other refs in the same run of receive-pack may still be allowed. |
| |
| We are run after the objects have been uploaded, but before the |
| ref is actually modified. We take advantage of that fact when we |
| look for "new" commits and tags (the new objects won't show up in |
| `rev-list --all`). |
| |
| This script loads and parses the content of the config file |
| "users/$this_user.acl" from the $acl_branch commit of $acl_git ODB. |
| The acl file is a git-config style file, but uses a slightly more |
| restricted syntax as the Perl parser contained within this script |
| is not nearly as permissive as git-config. |
| |
| Example: |
| |
| [user] |
| committer = John Doe <john.doe@example.com> |
| committer = John R. Doe <john.doe@example.com> |
| |
| [repository "acls"] |
| allow = heads/master |
| allow = CDUR for heads/jd/ |
| allow = C for ^tags/v\\d+$ |
| |
| For all new commit or tag objects the committer (or tagger) line |
| within the object must exactly match one of the user.committer |
| values listed in the acl file ("HEAD:users/$this_user.acl"). |
| |
| For a branch to be modified an allow line within the matching |
| repository section must be matched for both the refname and the |
| opcode. |
| |
| Repository sections are matched on the basename of the repository |
| (after removing the .git suffix). |
| |
| The opcode abbrevations are: |
| |
| C: create new ref |
| D: delete existing ref |
| U: fast-forward existing ref (no commit loss) |
| R: rewind/rebase existing ref (commit loss) |
| |
| if no opcodes are listed before the "for" keyword then "U" (for |
| fast-forward update only) is assumed as this is the most common |
| usage. |
| |
| Refnames are matched by always assuming a prefix of "refs/". |
| This hook forbids pushing or deleting anything not under "refs/". |
| |
| Refnames that start with ^ are Perl regular expressions, and the ^ |
| is kept as part of the regexp. \\ is needed to get just one \, so |
| \\d expands to \d in Perl. The 3rd allow line above is an example. |
| |
| Refnames that don't start with ^ but that end with / are prefix |
| matches (2nd allow line above); all other refnames are strict |
| equality matches (1st allow line). |
| |
| Anything pushed to "heads/" (ok, really "refs/heads/") must be |
| a commit. Tags are not permitted here. |
| |
| Anything pushed to "tags/" (err, really "refs/tags/") must be an |
| annotated tag. Commits, blobs, trees, etc. are not permitted here. |
| Annotated tag signatures aren't checked, nor are they required. |
| |
| The special subrepository of 'info/new-commit-check' can |
| be created and used to allow users to push new commits and |
| tags from another local repository to this one, even if they |
| aren't the committer/tagger of those objects. In a nut shell |
| the info/new-commit-check directory is a Git repository whose |
| objects/info/alternates file lists this repository and all other |
| possible sources, and whose refs subdirectory contains symlinks |
| to this repository's refs subdirectory, and to all other possible |
| sources refs subdirectories. Yes, this means that you cannot |
| use packed-refs in those repositories as they won't be resolved |
| correctly. |
| |
| =cut |
| |
| my $git_dir = $ENV{GIT_DIR}; |
| my $new_commit_check = "$git_dir/info/new-commit-check"; |
| my $ref = $ARGV[0]; |
| my $old = $ARGV[1]; |
| my $new = $ARGV[2]; |
| my $new_type; |
| my ($this_user) = getpwuid $<; # REAL_USER_ID |
| my $repository_name; |
| my %user_committer; |
| my @allow_rules; |
| |
| sub deny ($) { |
| print STDERR "-Deny- $_[0]\n" if $debug; |
| print STDERR "\ndenied: $_[0]\n\n"; |
| exit 1; |
| } |
| |
| sub grant ($) { |
| print STDERR "-Grant- $_[0]\n" if $debug; |
| exit 0; |
| } |
| |
| sub info ($) { |
| print STDERR "-Info- $_[0]\n" if $debug; |
| } |
| |
| sub parse_config ($$) { |
| my ($data, $fn) = @_; |
| info "Loading $fn"; |
| open(I,'-|','git',"--git-dir=$acl_git",'cat-file','blob',$fn); |
| my $section = ''; |
| while (<I>) { |
| chomp; |
| if (/^\s*$/ || /^\s*#/) { |
| } elsif (/^\[([a-z]+)\]$/i) { |
| $section = $1; |
| } elsif (/^\[([a-z]+)\s+"(.*)"\]$/i) { |
| $section = "$1.$2"; |
| } elsif (/^\s*([a-z][a-z0-9]+)\s*=\s*(.*?)\s*$/i) { |
| push @{$data->{"$section.$1"}}, $2; |
| } else { |
| deny "bad config file line $. in $fn"; |
| } |
| } |
| close I; |
| } |
| |
| sub all_new_committers () { |
| local $ENV{GIT_DIR} = $git_dir; |
| $ENV{GIT_DIR} = $new_commit_check if -d $new_commit_check; |
| |
| info "Getting committers of new commits."; |
| my %used; |
| open(T,'-|','git','rev-list','--pretty=raw',$new,'--not','--all'); |
| while (<T>) { |
| next unless s/^committer //; |
| chop; |
| s/>.*$/>/; |
| info "Found $_." unless $used{$_}++; |
| } |
| close T; |
| info "No new commits." unless %used; |
| keys %used; |
| } |
| |
| sub all_new_taggers () { |
| my %exists; |
| open(T,'-|','git','for-each-ref','--format=%(objectname)','refs/tags'); |
| while (<T>) { |
| chop; |
| $exists{$_} = 1; |
| } |
| close T; |
| |
| info "Getting taggers of new tags."; |
| my %used; |
| my $obj = $new; |
| my $obj_type = $new_type; |
| while ($obj_type eq 'tag') { |
| last if $exists{$obj}; |
| $obj_type = ''; |
| open(T,'-|','git','cat-file','tag',$obj); |
| while (<T>) { |
| chop; |
| if (/^object ([a-z0-9]{40})$/) { |
| $obj = $1; |
| } elsif (/^type (.+)$/) { |
| $obj_type = $1; |
| } elsif (s/^tagger //) { |
| s/>.*$/>/; |
| info "Found $_." unless $used{$_}++; |
| last; |
| } |
| } |
| close T; |
| } |
| info "No new tags." unless %used; |
| keys %used; |
| } |
| |
| sub check_committers (@) { |
| my @bad; |
| foreach (@_) { push @bad, $_ unless $user_committer{$_}; } |
| if (@bad) { |
| print STDERR "\n"; |
| print STDERR "You are not $_.\n" foreach (sort @bad); |
| deny "You cannot push changes not committed by you."; |
| } |
| } |
| |
| sub git_value (@) { |
| open(T,'-|','git',@_); local $_ = <T>; chop; close T; |
| $_; |
| } |
| |
| deny "No GIT_DIR inherited from caller" unless $git_dir; |
| deny "Need a ref name" unless $ref; |
| deny "Refusing funny ref $ref" unless $ref =~ s,^refs/,,; |
| deny "Bad old value $old" unless $old =~ /^[a-z0-9]{40}$/; |
| deny "Bad new value $new" unless $new =~ /^[a-z0-9]{40}$/; |
| deny "Cannot determine who you are." unless $this_user; |
| |
| $repository_name = File::Spec->rel2abs($git_dir); |
| $repository_name =~ m,/([^/]+)(?:\.git|/\.git)$,; |
| $repository_name = $1; |
| info "Updating in '$repository_name'."; |
| |
| my $op; |
| if ($old =~ /^0{40}$/) { $op = 'C'; } |
| elsif ($new =~ /^0{40}$/) { $op = 'D'; } |
| else { $op = 'R'; } |
| |
| # This is really an update (fast-forward) if the |
| # merge base of $old and $new is $old. |
| # |
| $op = 'U' if ($op eq 'R' |
| && $ref =~ m,^heads/, |
| && $old eq git_value('merge-base',$old,$new)); |
| |
| # Load the user's ACL file. |
| { |
| my %data = ('user.committer' => []); |
| parse_config(\%data, "$acl_branch:users/$this_user.acl"); |
| %user_committer = map {$_ => $_} @{$data{'user.committer'}}; |
| my $rules = $data{"repository.$repository_name.allow"} || []; |
| foreach (@$rules) { |
| if (/^([CDRU ]+)\s+for\s+([^\s]+)$/) { |
| my $ops = $1; |
| my $ref = $2; |
| $ops =~ s/ //g; |
| $ref =~ s/\\\\/\\/g; |
| push @allow_rules, [$ops, $ref]; |
| } elsif (/^for\s+([^\s]+)$/) { |
| # Mentioned, but nothing granted? |
| } elsif (/^[^\s]+$/) { |
| s/\\\\/\\/g; |
| push @allow_rules, ['U', $_]; |
| } |
| } |
| } |
| |
| if ($op ne 'D') { |
| $new_type = git_value('cat-file','-t',$new); |
| |
| if ($ref =~ m,^heads/,) { |
| deny "$ref must be a commit." unless $new_type eq 'commit'; |
| } elsif ($ref =~ m,^tags/,) { |
| deny "$ref must be an annotated tag." unless $new_type eq 'tag'; |
| } |
| |
| check_committers (all_new_committers); |
| check_committers (all_new_taggers) if $new_type eq 'tag'; |
| } |
| |
| info "$this_user wants $op for $ref"; |
| foreach my $acl_entry (@allow_rules) { |
| my ($acl_ops, $acl_n) = @$acl_entry; |
| next unless $acl_ops =~ /^[CDRU]+$/; # Uhh.... shouldn't happen. |
| next unless $acl_n; |
| next unless $op =~ /^[$acl_ops]$/; |
| |
| grant "Allowed by: $acl_ops for $acl_n" |
| if ( |
| ($acl_n eq $ref) |
| || ($acl_n =~ m,/$, && substr($ref,0,length $acl_n) eq $acl_n) |
| || ($acl_n =~ m,^\^, && $ref =~ m:$acl_n:) |
| ); |
| } |
| close A; |
| deny "You are not permitted to $op $ref"; |