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