Contribute a fairly paranoid update hook
[git/git.git] / contrib / hooks / update-paranoid
CommitLineData
9398e5aa
SP
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;
105
106sub deny ($) {
107 print STDERR "-Deny- $_[0]\n" if $debug;
108 print STDERR "\ndenied: $_[0]\n\n";
109 exit 1;
110}
111
112sub grant ($) {
113 print STDERR "-Grant- $_[0]\n" if $debug;
114 exit 0;
115}
116
117sub info ($) {
118 print STDERR "-Info- $_[0]\n" if $debug;
119}
120
121sub parse_config ($$) {
122 my ($data, $fn) = @_;
123 info "Loading $fn";
124 open(I,'-|','git',"--git-dir=$acl_git",'cat-file','blob',$fn);
125 my $section = '';
126 while (<I>) {
127 chomp;
128 if (/^\s*$/ || /^\s*#/) {
129 } elsif (/^\[([a-z]+)\]$/i) {
130 $section = $1;
131 } elsif (/^\[([a-z]+)\s+"(.*)"\]$/i) {
132 $section = "$1.$2";
133 } elsif (/^\s*([a-z][a-z0-9]+)\s*=\s*(.*?)\s*$/i) {
134 push @{$data->{"$section.$1"}}, $2;
135 } else {
136 deny "bad config file line $. in $fn";
137 }
138 }
139 close I;
140}
141
142sub all_new_committers () {
143 local $ENV{GIT_DIR} = $git_dir;
144 $ENV{GIT_DIR} = $new_commit_check if -d $new_commit_check;
145
146 info "Getting committers of new commits.";
147 my %used;
148 open(T,'-|','git','rev-list','--pretty=raw',$new,'--not','--all');
149 while (<T>) {
150 next unless s/^committer //;
151 chop;
152 s/>.*$/>/;
153 info "Found $_." unless $used{$_}++;
154 }
155 close T;
156 info "No new commits." unless %used;
157 keys %used;
158}
159
160sub all_new_taggers () {
161 my %exists;
162 open(T,'-|','git','for-each-ref','--format=%(objectname)','refs/tags');
163 while (<T>) {
164 chop;
165 $exists{$_} = 1;
166 }
167 close T;
168
169 info "Getting taggers of new tags.";
170 my %used;
171 my $obj = $new;
172 my $obj_type = $new_type;
173 while ($obj_type eq 'tag') {
174 last if $exists{$obj};
175 $obj_type = '';
176 open(T,'-|','git','cat-file','tag',$obj);
177 while (<T>) {
178 chop;
179 if (/^object ([a-z0-9]{40})$/) {
180 $obj = $1;
181 } elsif (/^type (.+)$/) {
182 $obj_type = $1;
183 } elsif (s/^tagger //) {
184 s/>.*$/>/;
185 info "Found $_." unless $used{$_}++;
186 last;
187 }
188 }
189 close T;
190 }
191 info "No new tags." unless %used;
192 keys %used;
193}
194
195sub check_committers (@) {
196 my @bad;
197 foreach (@_) { push @bad, $_ unless $user_committer{$_}; }
198 if (@bad) {
199 print STDERR "\n";
200 print STDERR "You are not $_.\n" foreach (sort @bad);
201 deny "You cannot push changes not committed by you.";
202 }
203}
204
205sub git_value (@) {
206 open(T,'-|','git',@_); local $_ = <T>; chop; close T;
207 $_;
208}
209
210deny "No GIT_DIR inherited from caller" unless $git_dir;
211deny "Need a ref name" unless $ref;
212deny "Refusing funny ref $ref" unless $ref =~ s,^refs/,,;
213deny "Bad old value $old" unless $old =~ /^[a-z0-9]{40}$/;
214deny "Bad new value $new" unless $new =~ /^[a-z0-9]{40}$/;
215deny "Cannot determine who you are." unless $this_user;
216
217$repository_name = File::Spec->rel2abs($git_dir);
218$repository_name =~ m,/([^/]+)(?:\.git|/\.git)$,;
219$repository_name = $1;
220info "Updating in '$repository_name'.";
221
222my $op;
223if ($old =~ /^0{40}$/) { $op = 'C'; }
224elsif ($new =~ /^0{40}$/) { $op = 'D'; }
225else { $op = 'R'; }
226
227# This is really an update (fast-forward) if the
228# merge base of $old and $new is $old.
229#
230$op = 'U' if ($op eq 'R'
231 && $ref =~ m,^heads/,
232 && $old eq git_value('merge-base',$old,$new));
233
234# Load the user's ACL file.
235{
236 my %data = ('user.committer' => []);
237 parse_config(\%data, "$acl_branch:users/$this_user.acl");
238 %user_committer = map {$_ => $_} @{$data{'user.committer'}};
239 my $rules = $data{"repository.$repository_name.allow"} || [];
240 foreach (@$rules) {
241 if (/^([CDRU ]+)\s+for\s+([^\s]+)$/) {
242 my $ops = $1;
243 my $ref = $2;
244 $ops =~ s/ //g;
245 $ref =~ s/\\\\/\\/g;
246 push @allow_rules, [$ops, $ref];
247 } elsif (/^for\s+([^\s]+)$/) {
248 # Mentioned, but nothing granted?
249 } elsif (/^[^\s]+$/) {
250 s/\\\\/\\/g;
251 push @allow_rules, ['U', $_];
252 }
253 }
254}
255
256if ($op ne 'D') {
257 $new_type = git_value('cat-file','-t',$new);
258
259 if ($ref =~ m,^heads/,) {
260 deny "$ref must be a commit." unless $new_type eq 'commit';
261 } elsif ($ref =~ m,^tags/,) {
262 deny "$ref must be an annotated tag." unless $new_type eq 'tag';
263 }
264
265 check_committers (all_new_committers);
266 check_committers (all_new_taggers) if $new_type eq 'tag';
267}
268
269info "$this_user wants $op for $ref";
270foreach my $acl_entry (@allow_rules) {
271 my ($acl_ops, $acl_n) = @$acl_entry;
272 next unless $acl_ops =~ /^[CDRU]+$/; # Uhh.... shouldn't happen.
273 next unless $acl_n;
274 next unless $op =~ /^[$acl_ops]$/;
275
276 grant "Allowed by: $acl_ops for $acl_n"
277 if (
278 ($acl_n eq $ref)
279 || ($acl_n =~ m,/$, && substr($ref,0,length $acl_n) eq $acl_n)
280 || ($acl_n =~ m,^\^, && $ref =~ m:$acl_n:)
281 );
282}
283close A;
284deny "You are not permitted to $op $ref";