chiark / gitweb /
5bf4416267c72c4cec207e20908faf64ade5af26
[dgit.git] / infra / dgit-repos-server
1 #!/usr/bin/perl -w
2 # dgit-repos-server
3 #
4 # usages:
5 #   dgit-repos-server DISTRO DISTRO-DIR AUTH-SPEC [<settings>] --ssh
6 #   dgit-repos-server DISTRO DISTRO-DIR AUTH-SPEC [<settings>] --cron
7 # settings
8 #   --repos=GIT-REPOS-DIR      default DISTRO-DIR/repos/
9 #   --suites=SUITES-FILE       default DISTRO-DIR/suites
10 #   --suites-master=SUITES-FILE default DISTRO-DIR/suites-master
11 #   --policy-hook=POLICY-HOOK  default DISTRO-DIR/policy-hook
12 #   --mirror-hook=MIRROR-HOOK  default DISTRO-DIR/mirror-hook
13 #   --dgit-live=DGIT-LIVE-DIR  default DISTRO-DIR/dgit-live
14 # (DISTRO-DIR is not used other than as default and to pass to policy
15 # and mirror hooks)
16 # internal usage:
17 #  .../dgit-repos-server --pre-receive-hook PACKAGE
18 #
19 # Invoked as the ssh restricted command
20 #
21 # Works like git-receive-pack
22 #
23 # SUITES-FILE is the name of a file which lists the permissible suites
24 # one per line (#-comments and blank lines ignored).  For --suites-master
25 # it is a list of the suite(s) which should, when pushed to, update
26 # `master' on the server (if fast forward).
27 #
28 # AUTH-SPEC is a :-separated list of
29 #   KEYRING.GPG,AUTH-SPEC
30 # where AUTH-SPEC is one of
31 #   a
32 #   mDM.TXT
33 # (With --cron AUTH-SPEC is not used and may be the empty string.)
34
35 use strict;
36
37 use Debian::Dgit qw(:DEFAULT :policyflags);
38 setup_sigwarn();
39
40 # DGIT-REPOS-DIR contains:
41 # git tree (or other object)      lock (in acquisition order, outer first)
42 #
43 #  _tmp/PACKAGE_prospective       ! } SAME.lock, held during receive-pack
44 #
45 #  _tmp/PACKAGE_incoming$$        ! } SAME.lock, held during receive-pack
46 #  _tmp/PACKAGE_incoming$$_fresh  ! }
47 #
48 #  PACKAGE.git                      } PACKAGE.git.lock
49 #  PACKAGE_garbage                  }   (also covers executions of
50 #  PACKAGE_garbage-old              }    policy hook script for PACKAGE)
51 #  PACKAGE_garbage-tmp              }
52 #  policy*                          } (for policy hook script, covered by
53 #                                   }  lock only when invoked for a package)
54 #
55 # leaf locks, held during brief operaton only:
56 #
57 #  _empty                           } SAME.lock
58 #  _empty.new                       }
59 #
60 #  _template                        } SAME.lock
61 #
62 # locks marked ! may be held during client data transfer
63
64 # What we do on push is this:
65 #  - extract the destination repo name
66 #  - make a hardlink clone of the destination repo
67 #  - provide the destination with a stunt pre-receive hook
68 #  - run actual git-receive-pack with that new destination
69 #   as a result of this the stunt pre-receive hook runs; it does this:
70 #    + understand what refs we are allegedly updating and
71 #      check some correspondences:
72 #        * we are updating only refs/tags/[archive/]DISTRO/* and refs/dgit/*
73 #        * and only one of each
74 #        * and the tag does not already exist
75 #      and
76 #        * recover the suite name from the destination refs/dgit/ ref
77 #    + disassemble the signed tag into its various fields and signature
78 #      including:
79 #        * parsing the first line of the tag message to recover
80 #          the package name, version and suite
81 #        * checking that the package name corresponds to the dest repo name
82 #        * checking that the suite name is as recovered above
83 #    + verify the signature on the signed tag
84 #      and if necessary check that the keyid and package are listed in dm.txt
85 #    + check various correspondences:
86 #        * the signed tag must refer to a commit
87 #        * the signed tag commit must be the refs/dgit value
88 #        * the name in the signed tag must correspond to its ref name
89 #        * the tag name must be [archive/]debian/<version> (massaged as needed)
90 #        * the suite is one of those permitted
91 #        * the signed tag has a suitable name
92 #        * run the "push" policy hook
93 #        * replay prevention for --deliberately-not-fast-forward
94 #        * check the commit is a fast forward
95 #        * handle a request from the policy hook for a fresh repo
96 #    + push the signed tag and new dgit branch to the actual repo
97 #
98 # If the destination repo does not already exist, we need to make
99 # sure that we create it reasonably atomically, and also that
100 # we don't every have a destination repo containing no refs at all
101 # (because such a thing causes git-fetch-pack to barf).  So then we
102 # do as above, except:
103 #  - before starting, we take out our own lock for the destination repo
104 #  - we create a prospective new destination repo by making a copy
105 #    of _template
106 #  - we use the prospective new destination repo instead of the
107 #    actual new destination repo (since the latter doesn't exist)
108 #  - after git-receive-pack exits, we
109 #    + check that the prospective repo contains a tag and head
110 #    + rename the prospective destination repo into place
111 #
112 # Cleanup strategy:
113 #  - We are crash-only
114 #  - Temporary working trees and their locks are cleaned up
115 #    opportunistically by a program which tries to take each lock and
116 #    if successful deletes both the tree and the lockfile
117 #  - Prospective working trees and their locks are cleaned up by
118 #    a program which tries to take each lock and if successful
119 #    deletes any prospective working tree and the lock (but not
120 #    of course any actual tree)
121 #  - It is forbidden to _remove_ the lockfile without removing
122 #    the corresponding temporary tree, as the lockfile is also
123 #    a stampfile whose presence indicates that there may be
124 #    cleanup to do
125 #
126 # Policy hook scripts are invoked like this:
127 #   POLICY-HOOK-SCRIPT DISTRO DGIT-REPOS-DIR DGIT-LIVE-DIR DISTRO-DIR ACTION...
128 # ie.
129 #   POLICY-HOOK-SCRIPT ... check-list [...]
130 #   POLICY-HOOK-SCRIPT ... check-package PACKAGE [...]
131 #   POLICY-HOOK-SCRIPT ... push PACKAGE \
132 #         VERSION SUITE TAGNAME DELIBERATELIES [...]
133 #   POLICY-HOOK-SCRIPT ... push-confirm PACKAGE \
134 #         VERSION SUITE TAGNAME DELIBERATELIES FRESH-REPO|'' [...]
135 #
136 # DELIBERATELIES is like this: --deliberately-foo,--deliberately-bar,...
137 #
138 # Exit status of policy hook is a bitmask.
139 # Bit weight constants are defined in Dgit.pm.
140 #    NOFFCHECK   (2)
141 #         suppress dgit-repos-server's fast-forward check ("push" only)
142 #    FRESHREPO   (4)
143 #         blow away repo right away (ie, as if before push or fetch)
144 #         ("check-package" and "push" only)
145 # any unexpected bits mean failure, and then known set bits are ignored
146 # if no unexpected bits set, operation continues (subject to meaning
147 # of any expected bits set).  So, eg, exit 0 means "continue normally"
148 # and would be appropriate for an unknown action.
149 #
150 # cwd for push and push-confirm is a temporary repo where the incoming
151 # objects have been received; TAGNAME is the version-based tag.
152 #
153 # FRESH-REPO is '' iff the repo for this package already existed, or
154 # the pathname of the newly-created repo which will be renamed into
155 # place if everything goes well.  (NB that this is generally not the
156 # same repo as the cwd, because the objects are first received into a
157 # temporary repo so they can be examined.)  In this case FRESH-REPO
158 # contains exactly the objects and refs that will appear in the
159 # destination if push-confirm approves.
160
161 # if push requested FRESHREPO, push-confirm happens in the old working
162 # repo and FRESH-REPO is guaranteed not to be ''.
163 #
164 # policy hook for a particular package will be invoked only once at
165 # a time - (see comments about DGIT-REPOS-DIR, above)
166 #
167 # check-list and check-package are invoked via the --cron option.
168 # First, without any locking, check-list is called.  It should produce
169 # a list of package names (one per line).  Then check-package will be
170 # invoked for each named package, in each case after taking an
171 # appropriate lock.
172 #
173 # If policy hook wants to run dgit (or something else in the dgit
174 # package), it should use DGIT-LIVE-DIR/dgit (etc.), or if that is
175 # ENOENT, use the installed version.
176 #
177 # Mirror hook scripts are invoked like this:
178 #   MIRROR-HOOK-SCRIPT DISTRO-DIR ACTION...
179 # and currently there is only one action invoked by dgit-repos-server:
180 #   MIRROR-HOOK-SCRIPT DISTRO-DIR updated-hook PACKAGE [...]
181 #
182 # Exit status of the mirror hook is advisory only.  The mirror hook
183 # runs too late to do anything useful about a problem, so the only
184 # effect of a mirror hook exiting nonzero is a warning message to
185 # stderr (which the pushing user should end up seeing).
186 #
187 # If the mirror hook does not exist, it is silently skipped.
188
189 use POSIX;
190 use Fcntl qw(:flock);
191 use File::Path qw(rmtree);
192 use File::Temp qw(tempfile);
193
194 initdebug('');
195
196 our $func;
197 our $dgitrepos;
198 our $package;
199 our $distro;
200 our $suitesfile;
201 our $suitesformasterfile;
202 our $policyhook;
203 our $mirrorhook;
204 our $dgitlive;
205 our $distrodir;
206 our $destrepo;
207 our $workrepo;
208 our $keyrings;
209 our @lockfhs;
210
211 our @deliberatelies;
212 our %previously;
213 our $policy;
214 our @policy_args;
215
216 #----- utilities -----
217
218 sub realdestrepo () { "$dgitrepos/$package.git"; }
219
220 sub acquirelock ($$) {
221     my ($lock, $must) = @_;
222     my $fh;
223     printdebug sprintf "locking %s %d\n", $lock, $must;
224     for (;;) {
225         close $fh if $fh;
226         $fh = new IO::File $lock, ">" or die "open $lock: $!";
227         my $ok = flock $fh, $must ? LOCK_EX : (LOCK_EX|LOCK_NB);
228         if (!$ok) {
229             die "flock $lock: $!" if $must;
230             printdebug " locking $lock failed\n";
231             return undef;
232         }
233         next unless stat_exists $lock;
234         my $want = (stat _)[1];
235         stat $fh or die $!;
236         my $got = (stat _)[1];
237         last if $got == $want;
238     }
239     return $fh;
240 }
241
242 sub acquirermtree ($$) {
243     my ($tree, $must) = @_;
244     my $fh = acquirelock("$tree.lock", $must);
245     if ($fh) {
246         push @lockfhs, $fh;
247         rmtree $tree;
248     }
249     return $fh;
250 }
251
252 sub locksometree ($) {
253     my ($tree) = @_;
254     acquirelock("$tree.lock", 1);
255 }
256
257 sub lockrealtree () {
258     locksometree(realdestrepo);
259 }
260
261 sub mkrepotmp () { ensuredir "$dgitrepos/_tmp" };
262
263 sub removedtagsfile () { "$dgitrepos/_removed-tags/$package"; }
264
265 sub recorderror ($) {
266     my ($why) = @_;
267     my $w = $ENV{'DGIT_DRS_WORK'}; # we are in stunthook
268     if (defined $w) {
269         chomp $why;
270         open ERR, ">", "$w/drs-error" or die $!;
271         print ERR $why, "\n" or die $!;
272         close ERR or die $!;
273         return 1;
274     }
275     return 0;
276 }
277
278 sub reject ($) {
279     my ($why) = @_;
280     recorderror "reject: $why";
281     die "\ndgit-repos-server: reject: $why\n\n";
282 }
283
284 sub runcmd {
285     debugcmd '+',@_;
286     $!=0; $?=0;
287     my $r = system @_;
288     die (shellquote @_)." $? $!" if $r;
289 }
290
291 sub policyhook {
292     my ($policyallowbits, @polargs) = @_;
293     # => ($exitstatuspolicybitmap);
294     die if $policyallowbits & ~0x3e;
295     my @cmd = ($policyhook,$distro,$dgitrepos,$dgitlive,$distrodir,@polargs);
296     debugcmd '+M',@cmd;
297     my $r = system @cmd;
298     die "system: $!" if $r < 0;
299     die "dgit-repos-server: policy hook failed (or rejected) ($?)\n"
300         if $r & ~($policyallowbits << 8);
301     printdebug sprintf "hook => %#x\n", $r;
302     return $r >> 8;
303 }
304
305 sub mkemptyrepo ($$) {
306     my ($dir,$sharedperm) = @_;
307     runcmd qw(git init --bare --quiet), "--shared=$sharedperm", $dir;
308 }
309
310 sub mkrepo_fromtemplate ($) {
311     my ($dir) = @_;
312     my $template = "$dgitrepos/_template";
313     my $templatelock = locksometree($template);
314     printdebug "copy template $template -> $dir\n";
315     my $r = system qw(cp -a --), $template, $dir;
316     !$r or die "create new repo $dir failed: $r $!";
317     close $templatelock;
318 }
319
320 sub movetogarbage () {
321     # realdestrepo must have been locked
322
323     my $real = realdestrepo;
324     return unless stat_exists $real;
325
326     my $garbagerepo = "$dgitrepos/${package}_garbage";
327     # We arrange to always keep at least one old tree, for recovery
328     # from mistakes.  This is either $garbage or $garbage-old.
329     if (stat_exists "$garbagerepo") {
330         printdebug "movetogarbage: rmtree $garbagerepo-tmp\n";
331         rmtree "$garbagerepo-tmp";
332         if (rename "$garbagerepo-old", "$garbagerepo-tmp") {
333             printdebug "movetogarbage: $garbagerepo-old -> -tmp, rmtree\n";
334             rmtree "$garbagerepo-tmp";
335         } else {
336             die "$garbagerepo $!" unless $!==ENOENT;
337             printdebug "movetogarbage: $garbagerepo-old -> -tmp\n";
338         }
339         printdebug "movetogarbage: $garbagerepo -> -old\n";
340         rename "$garbagerepo", "$garbagerepo-old" or die "$garbagerepo $!";
341     }
342
343     ensuredir "$dgitrepos/_removed-tags";
344     open PREVIOUS, ">>", removedtagsfile or die removedtagsfile." $!";
345     git_for_each_ref([ map { 'refs/tags/'.$_ } debiantags('*',$distro) ],
346                      sub {
347         my ($objid,$objtype,$fullrefname,$reftail) = @_;
348         print PREVIOUS "\n$objid $reftail .\n" or die $!;
349     }, $real);
350     close PREVIOUS or die $!;
351
352     printdebug "movetogarbage: $real -> $garbagerepo\n";
353     rename $real, $garbagerepo
354         or $! == ENOENT
355         or die "$garbagerepo $!";
356 }
357
358 sub policy_checkpackage () {
359     my $lfh = lockrealtree();
360
361     $policy = policyhook(FRESHREPO,'check-package',$package);
362     if ($policy & FRESHREPO) {
363         movetogarbage();
364     }
365
366     close $lfh;
367 }
368
369 #----- git-receive-pack -----
370
371 sub fixmissing__git_receive_pack () {
372     mkrepotmp();
373     $destrepo = "$dgitrepos/_tmp/${package}_prospective";
374     acquirermtree($destrepo, 1);
375     mkrepo_fromtemplate($destrepo);
376 }
377
378 sub makeworkingclone () {
379     mkrepotmp();
380     $workrepo = "$dgitrepos/_tmp/${package}_incoming$$";
381     acquirermtree($workrepo, 1);
382     my $lfh = lockrealtree();
383     runcmd qw(git clone -l -q --mirror), $destrepo, $workrepo;
384     close $lfh;
385     rmtree "${workrepo}_fresh";
386 }
387
388 sub setupstunthook () {
389     my $prerecv = "$workrepo/hooks/pre-receive";
390     my $fh = new IO::File $prerecv, O_WRONLY|O_CREAT|O_TRUNC, 0777
391         or die "$prerecv: $!";
392     print $fh <<END or die "$prerecv: $!";
393 #!/bin/sh
394 set -e
395 exec $0 --pre-receive-hook $package
396 END
397     close $fh or die "$prerecv: $!";
398     $ENV{'DGIT_DRS_WORK'}= $workrepo;
399     $ENV{'DGIT_DRS_DEST'}= $destrepo;
400     printdebug " stunt hook set up $prerecv\n";
401 }
402
403 sub dealwithfreshrepo () {
404     my $freshrepo = "${workrepo}_fresh";
405     return unless stat_exists $freshrepo;
406     $destrepo = $freshrepo;
407 }
408
409 sub mirrorhook {
410     my @cmd = ($mirrorhook,$distrodir,@_);
411     debugcmd '+',@cmd;
412     return unless stat_exists $mirrorhook;
413     my $r = system @cmd;
414     if ($r) {
415         printf STDERR <<END,
416 dgit-repos-server: warning: mirror hook failed: %s
417 dgit-repos-server: push complete but may not fully visible.
418 END
419             ($r < 0 ? "exec: $!" :
420              $r == (124 << 8) ? "exited status 124 (timeout?)" :
421              !($r & ~0xff00) ? "exited ".($? >> 8) :
422              "wait status $?");
423     }
424 }
425
426 sub maybeinstallprospective () {
427     return if $destrepo eq realdestrepo;
428
429     if (open REJ, "<", "$workrepo/drs-error") {
430         local $/ = undef;
431         my $msg = <REJ>;
432         REJ->error and die $!;
433         print STDERR $msg;
434         exit 1;
435     } else {
436         $!==&ENOENT or die $!;
437     }
438
439     printdebug " show-ref ($destrepo) ...\n";
440
441     my $child = open SR, "-|";
442     defined $child or die $!;
443     if (!$child) {
444         chdir $destrepo or die $!;
445         exec qw(git show-ref);
446         die $!;
447     }
448     my %got = qw(newtag 0 omtag 0 head 0);
449     while (<SR>) {
450         chomp or die;
451         printdebug " show-refs| $_\n";
452         s/^\S*[1-9a-f]\S* (\S+)$/$1/ or die;
453         next if m{^refs/heads/master$};
454         my $wh =
455             m{^refs/tags/archive/} ? 'newtag' :
456             m{^refs/tags/} ? 'omtag' :
457             m{^refs/dgit/} ? 'head' :
458             die;
459         use Data::Dumper;
460         die if $got{$wh}++;
461     }
462     $!=0; $?=0; close SR or $?==256 or die "$? $!";
463
464     printdebug "installprospective ?\n";
465     die Dumper(\%got)." -- missing refs in new repo"
466         unless $got{head} && grep { m/tag$/ && $got{$_} } keys %got;
467
468     lockrealtree();
469
470     if ($destrepo eq "${workrepo}_fresh") {
471         movetogarbage;
472     }
473
474     printdebug "install $destrepo => ".realdestrepo."\n";
475     rename $destrepo, realdestrepo or die $!;
476     remove realdestrepo.".lock" or die $!;
477 }
478
479 sub main__git_receive_pack () {
480     makeworkingclone();
481     setupstunthook();
482     runcmd qw(git receive-pack), $workrepo;
483     dealwithfreshrepo();
484     maybeinstallprospective();
485     mirrorhook('updated-hook', $package);
486 }
487
488 #----- stunt post-receive hook -----
489
490 our ($tagname, $tagval, $suite, $oldcommit, $commit);
491 our ($version, %tagh);
492 our ($maint_tagname, $maint_tagval);
493
494 our ($tagexists_error);
495
496 sub readupdates () {
497     printdebug " updates ...\n";
498     my %tags;
499     while (<STDIN>) {
500         chomp or die;
501         printdebug " upd.| $_\n";
502         m/^(\S+) (\S+) (\S+)$/ or die "$_ ?";
503         my ($old, $sha1, $refname) = ($1, $2, $3);
504         if ($refname =~ m{^refs/tags/(?=(?:archive/)?$distro/)}) {
505             my $tn = $'; #';
506             $tags{$tn} = $sha1;
507             $tagexists_error= "tag $tn already exists -".
508                 " not replacing previously-pushed version"
509                 if $old =~ m/[^0]/;
510         } elsif ($refname =~ m{^refs/dgit/}) {
511             reject "pushing multiple heads!" if defined $suite;
512             $suite = $'; #';
513             $oldcommit = $old;
514             $commit = $sha1;
515         } else {
516             reject "pushing unexpected ref!";
517         }
518     }
519     STDIN->error and die $!;
520
521     reject "push is missing tag ref update" unless %tags;
522     my @newtags = grep { m#^archive/# } keys %tags;
523     my @omtags = grep { !m#^archive/# } keys %tags;
524     reject "pushing too many similar tags" if @newtags>1 || @omtags>1;
525     if (@newtags) {
526         ($tagname) = @newtags;
527         ($maint_tagname) = @omtags;
528     } else {
529         ($tagname) = @omtags or die;
530     }
531     $tagval = $tags{$tagname};
532     $maint_tagval = $tags{$maint_tagname // ''};
533
534     reject "push is missing head ref update" unless defined $suite;
535     printdebug " updates ok.\n";
536 }
537
538 sub parsetag () {
539     printdebug " parsetag...\n";
540     open PT, ">dgit-tmp/plaintext" or die $!;
541     open DS, ">dgit-tmp/plaintext.asc" or die $!;
542     open T, "-|", qw(git cat-file tag), $tagval or die $!;
543     for (;;) {
544         $!=0; $_=<T>; defined or die $!;
545         print PT or die $!;
546         if (m/^(\S+) (.*)/) {
547             push @{ $tagh{$1} }, $2;
548         } elsif (!m/\S/) {
549             last;
550         } else {
551             die;
552         }
553     }
554     $!=0; $_=<T>; defined or die $!;
555     m/^($package_re) release (\S+) for \S+ \((\S+)\) \[dgit\]$/ or
556         reject "tag message not in expected format";
557
558     die unless $1 eq $package;
559     $version = $2;
560     die "$3 != $suite " unless $3 eq $suite;
561
562     my $copyl = $_;
563     for (;;) {
564         print PT $copyl or die $!;
565         $!=0; $_=<T>; defined or die "missing signature? $!";
566         $copyl = $_;
567         if (m/^\[dgit ([^"].*)\]$/) { # [dgit "something"] is for future
568             $_ = $1." ";
569             while (length) {
570                 if (s/^distro\=(\S+) //) {
571                     die "$1 != $distro" unless $1 eq $distro;
572                 } elsif (s/^(--deliberately-$deliberately_re) //) {
573                     push @deliberatelies, $1;
574                 } elsif (s/^previously:(\S+)=(\w+) //) {
575                     die "previously $1 twice" if defined $previously{$1};
576                     $previously{$1} = $2;
577                 } elsif (s/^[-+.=0-9a-z]\S* //) {
578                 } else {
579                     die "unknown dgit info in tag ($_)";
580                 }
581             }
582             next;
583         }
584         last if m/^-----BEGIN PGP/;
585     }
586     $_ = $copyl;
587     for (;;) {
588         print DS or die $!;
589         $!=0; $_=<T>;
590         last if !defined;
591     }
592     T->error and die $!;
593     close PT or die $!;
594     close DS or die $!;
595     printdebug " parsetag ok.\n";
596 }
597
598 sub checksig_keyring ($) {
599     my ($keyringfile) = @_;
600     # returns primary-keyid if signed by a key in this keyring
601     # or undef if not
602     # or dies on other errors
603
604     my $ok = undef;
605
606     printdebug " checksig keyring $keyringfile...\n";
607
608     our @cmd = (qw(gpgv --status-fd=1 --keyring),
609                    $keyringfile,
610                    qw(dgit-tmp/plaintext.asc dgit-tmp/plaintext));
611     debugcmd '|',@cmd;
612
613     open P, "-|", @cmd
614         or die $!;
615
616     while (<P>) {
617         next unless s/^\[GNUPG:\] //;
618         chomp or die;
619         printdebug " checksig| $_\n";
620         my @l = split / /, $_;
621         if ($l[0] eq 'NO_PUBKEY') {
622             last;
623         } elsif ($l[0] eq 'VALIDSIG') {
624             my $sigtype = $l[9];
625             $sigtype eq '00' or reject "signature is not of type 00!";
626             $ok = $l[10];
627             die unless defined $ok;
628             last;
629         }
630     }
631     close P;
632
633     printdebug sprintf " checksig ok=%d\n", !!$ok;
634
635     return $ok;
636 }
637
638 sub dm_txt_check ($$) {
639     my ($keyid, $dmtxtfn) = @_;
640     printdebug " dm_txt_check $keyid $dmtxtfn\n";
641     open DT, '<', $dmtxtfn or die "$dmtxtfn $!";
642     while (<DT>) {
643         m/^fingerprint:\s+\Q$keyid\E$/oi
644             ..0 or next;
645         if (s/^allow:/ /i..0) {
646         } else {
647             m/^./
648                 or reject "key $keyid missing Allow section in permissions!";
649             next;
650         }
651         # in right stanza...
652         s/^[ \t]+//
653             or reject "package $package not allowed for key $keyid";
654         # in allow field...
655         s/\([^()]+\)//;
656         s/\,//;
657         chomp or die;
658         printdebug " dm_txt_check allow| $_\n";
659         foreach my $p (split /\s+/) {
660             if ($p eq $package) {
661                 # yay!
662                 printdebug " dm_txt_check ok\n";
663                 return;
664             }
665         }
666     }
667     DT->error and die $!;
668     close DT or die $!;
669     reject "key $keyid not in permissions list although in keyring!";
670 }
671
672 sub verifytag () {
673     foreach my $kas (split /:/, $keyrings) {
674         printdebug "verifytag $kas...\n";
675         $kas =~ s/^([^,]+),// or die;
676         my $keyid = checksig_keyring $1;
677         if (defined $keyid) {
678             if ($kas =~ m/^a$/) {
679                 printdebug "verifytag a ok\n";
680                 return; # yay
681             } elsif ($kas =~ m/^m([^,]+)$/) {
682                 dm_txt_check($keyid, $1);
683                 printdebug "verifytag m ok\n";
684                 return;
685             } else {
686                 die;
687             }
688         }   
689     }
690     reject "key not found in keyrings";
691 }
692
693 sub suite_is_in ($) {
694     my ($sf) = @_;
695     printdebug "suite_is_in ($sf)\n";
696     if (!open SUITES, "<", $sf) {
697         $!==ENOENT or die $!;
698         return 0;
699     }
700     while (<SUITES>) {
701         chomp;
702         next unless m/\S/;
703         next if m/^\#/;
704         s/\s+$//;
705         return 1 if $_ eq $suite;
706     }
707     die $! if SUITES->error;
708     return 0;
709 }
710
711 sub checksuite () {
712     printdebug "checksuite ($suitesfile)\n";
713     return if suite_is_in $suitesfile;
714     reject "unknown suite";
715 }
716
717 sub checktagnoreplay () {
718     # We need to prevent a replay attack using an earlier signed tag.
719     # We also want to archive in the history the object ids of
720     # anything we remove, even if we get rid of the actual objects.
721     #
722     # So, we check that the signed tag mentions the name and tag
723     # object id of:
724     #
725     # (a) In the case of FRESHREPO: all tags and refs/heads/* in
726     #     the repo.  That is, effectively, all the things we are
727     #     deleting.
728     #
729     #     This prevents any tag implying a FRESHREPO push
730     #     being replayed into a different state of the repo.
731     #
732     #     There is still the folowing risk: If a non-ff push is of a
733     #     head which is an ancestor of a previous ff-only push, the
734     #     previous push can be replayed.
735     #
736     #     So we keep a separate list, as a file in the repo, of all
737     #     the tag object ids we have ever seen and removed.  Any such
738     #     tag object id will be rejected even for ff-only pushes.
739     #
740     # (b) In the case of just NOFFCHECK: all tags referring to the
741     #     current head for the suite (there must be at least one).
742     #
743     #     This prevents any tag implying a NOFFCHECK push being
744     #     replayed to rewind from a different head.
745     #
746     #     The possibility of an earlier ff-only push being replayed is
747     #     eliminated as follows: the tag from such a push would still
748     #     be in our repo, and therefore the replayed push would be
749     #     rejected because the set of refs being updated would be
750     #     wrong.
751
752     if (!open PREVIOUS, "<", removedtagsfile) {
753         die removedtagsfile." $!" unless $!==ENOENT;
754     } else {
755         # Protocol for updating this file is to append to it, not
756         # write-new-and-rename.  So all updates are prefixed with \n
757         # and suffixed with " .\n" so that partial writes can be
758         # ignored.
759         while (<PREVIOUS>) {
760             next unless m/^(\w+) (.*) \.\n/;
761             next unless $1 eq $tagval;
762             reject "Replay of previously-rewound upload ($tagval $2)";
763         }
764         die removedtagsfile." $!" if PREVIOUS->error;
765         close PREVIOUS;
766     }
767
768     return unless $policy & (FRESHREPO|NOFFCHECK);
769
770     my $garbagerepo = "$dgitrepos/${package}_garbage";
771     lockrealtree();
772
773     my $nchecked = 0;
774     my @problems;
775
776     my $check_ref_previously= sub {
777         my ($objid,$objtype,$fullrefname,$reftail) = @_;
778         my $supkey = $fullrefname;
779         $supkey =~ s{^refs/}{} or die "$supkey $objid ?";
780         my $supobjid = $previously{$supkey};
781         if (!defined $supobjid) {
782             printdebug "checktagnoreply - missing\n";
783             push @problems, "does not declare previously $supkey";
784         } elsif ($supobjid ne $objid) {
785             push @problems, "declared previously $supkey=$supobjid".
786                 " but actually previously $supkey=$objid";
787         } else {
788             $nchecked++;
789         }
790     };
791
792     if ($policy & FRESHREPO) {
793         foreach my $kind (qw(tags heads)) {
794             git_for_each_ref("refs/$kind", $check_ref_previously);
795         }
796     } else {
797         my $branch= server_branch($suite);
798         my $branchhead= git_get_ref(server_ref($suite));
799         if (!length $branchhead) {
800             # No such branch - NOFFCHECK was unnecessary.  Oh well.
801             printdebug "checktagnoreplay - not FRESHREPO, new branch, ok\n";
802         } else {
803             printdebug "checktagnoreplay - not FRESHREPO,".
804                 " checking for overwriting refs/$branch=$branchhead\n";
805             git_for_each_tag_referring($branchhead, sub {
806                 my ($tagobjid,$refobjid,$fullrefname,$tagname) = @_;
807                 $check_ref_previously->($tagobjid,undef,$fullrefname,undef);
808             });
809             printdebug "checktagnoreplay - not FRESHREPO, nchecked=$nchecked";
810             push @problems, "does not declare previously any tag".
811                 " referring to branch head $branch=$branchhead"
812                 unless $nchecked;
813         }
814     }
815
816     if (@problems) {
817         reject "replay attack prevention check failed:".
818             " signed tag for $version: ".
819             join("; ", @problems).
820             "\n";
821     }
822     printdebug "checktagnoreplay - all ok ($tagval)\n"
823 }
824
825 sub tagh1 ($) {
826     my ($tag) = @_;
827     my $vals = $tagh{$tag};
828     reject "missing header $tag in signed tag object" unless $vals;
829     reject "multiple headers $tag in signed tag object" unless @$vals == 1;
830     return $vals->[0];
831 }
832
833 sub checks () {
834     printdebug "checks\n";
835
836     tagh1('type') eq 'commit' or reject "tag refers to wrong kind of object";
837     tagh1('object') eq $commit or reject "tag refers to wrong commit";
838     tagh1('tag') eq $tagname or reject "tag name in tag is wrong";
839
840     my @expecttagnames = debiantags($version, $distro);
841     printdebug "expected tag @expecttagnames\n";
842     grep { $tagname eq $_ } @expecttagnames or die;
843
844     foreach my $othertag (grep { $_ ne $tagname } @expecttagnames) {
845         reject "tag $othertag (pushed with differing dgit version)".
846             " already exists -".
847             " not replacing previously-pushed version"
848             if git_get_ref "refs/tags/".$othertag;
849     }
850
851     lockrealtree();
852
853     @policy_args = ($package,$version,$suite,$tagname,
854                     join(",",@deliberatelies));
855     $policy = policyhook(NOFFCHECK|FRESHREPO, 'push', @policy_args);
856
857     if (defined $tagexists_error) {
858         if ($policy & FRESHREPO) {
859             printdebug "ignoring tagexists_error: $tagexists_error\n";
860         } else {
861             reject $tagexists_error;
862         }
863     }
864
865     checktagnoreplay();
866     checksuite();
867
868     # check that our ref is being fast-forwarded
869     printdebug "oldcommit $oldcommit\n";
870     if (!($policy & NOFFCHECK) && $oldcommit =~ m/[^0]/) {
871         $?=0; $!=0; my $mb = `git merge-base $commit $oldcommit`;
872         chomp $mb;
873         $mb eq $oldcommit or reject "not fast forward on dgit branch";
874     }
875     if ($policy & FRESHREPO) {
876         # It's a bit late to be discovering this here, isn't it ?
877         #
878         # What we do is: Generate a fresh destination repo right now,
879         # and arrange to treat it from now on as if it were a
880         # prospective repo.
881         #
882         # The presence of this fresh destination repo is detected by
883         # the parent, which responds by making a fresh master repo
884         # from the template.  (If the repo didn't already exist then
885         # $destrepo was _prospective, and we change it here.  This is
886         # OK because the parent's check for _fresh persuades it not to
887         # use _prospective.)
888         #
889         $destrepo = "${workrepo}_fresh"; # workrepo lock covers
890         mkrepo_fromtemplate $destrepo;
891     }
892 }
893
894 sub onwardpush () {
895     my @cmdbase = (qw(git send-pack), $destrepo);
896     push @cmdbase, qw(--force) if $policy & NOFFCHECK;
897
898     my @cmd = @cmdbase;
899     push @cmd, "$commit:refs/dgit/$suite",
900                "$tagval:refs/tags/$tagname";
901     push @cmd, "$maint_tagval:refs/tags/$maint_tagname"
902         if defined $maint_tagname;
903     debugcmd '+',@cmd;
904     $!=0;
905     my $r = system @cmd;
906     !$r or die "onward push to $destrepo failed: $r $!";
907
908     if (suite_is_in $suitesformasterfile) {
909         @cmd = @cmdbase;
910         push @cmd, "$commit:refs/heads/master";
911         debugcmd '+', @cmd;
912         $!=0; my $r = system @cmd;
913         # tolerate errors (might be not ff)
914         !($r & ~0xff00) or die
915             "onward push to $destrepo#master failed: $r $!";
916     }
917 }
918
919 sub finalisepush () {
920     if ($destrepo eq realdestrepo) {
921         policyhook(0, 'push-confirm', @policy_args, '');
922         onwardpush();
923     } else {
924         # We are to receive the push into a new repo (perhaps
925         # because the policy push hook asked us to with FRESHREPO, or
926         # perhaps because the repo didn't exist before).
927         #
928         # We want to provide the policy push-confirm hook with a repo
929         # which looks like the one which is going to be installed.
930         # The working repo is no good because it might contain
931         # previous history.
932         #
933         # So we push the objects into the prospective new repo right
934         # away.  If the hook declines, we decline, and the prospective
935         # repo is never installed.
936         onwardpush();
937         policyhook(0, 'push-confirm', @policy_args, $destrepo);
938     }
939 }
940
941 sub stunthook () {
942     printdebug "stunthook in $workrepo\n";
943     chdir $workrepo or die "chdir $workrepo: $!";
944     mkdir "dgit-tmp" or $!==EEXIST or die $!;
945     readupdates();
946     parsetag();
947     verifytag();
948     checks();
949     finalisepush();
950     printdebug "stunthook done.\n";
951 }
952
953 #----- git-upload-pack -----
954
955 sub fixmissing__git_upload_pack () {
956     $destrepo = "$dgitrepos/_empty";
957     my $lfh = locksometree($destrepo);
958     return if stat_exists $destrepo;
959     rmtree "$destrepo.new";
960     mkemptyrepo "$destrepo.new", "0644";
961     rename "$destrepo.new", $destrepo or die $!;
962     unlink "$destrepo.lock" or die $!;
963     close $lfh;
964 }
965
966 sub main__git_upload_pack () {
967     my $lfh = locksometree($destrepo);
968     printdebug "git-upload-pack in $destrepo\n";
969     chdir $destrepo or die "$destrepo: $!";
970     close $lfh;
971     runcmd qw(git upload-pack), ".";
972 }
973
974 #----- arg parsing and main program -----
975
976 sub argval () {
977     die unless @ARGV;
978     my $v = shift @ARGV;
979     die if $v =~ m/^-/;
980     return $v;
981 }
982
983 our %indistrodir = (
984     # keys are used for DGIT_DRS_XXX too
985     'repos' => \$dgitrepos,
986     'suites' => \$suitesfile,
987     'suites-master' => \$suitesformasterfile,
988     'policy-hook' => \$policyhook,
989     'mirror-hook' => \$mirrorhook,
990     'dgit-live' => \$dgitlive,
991     );
992
993 our @hookenvs = qw(distro suitesfile suitesformasterfile policyhook
994                    mirrorhook dgitlive keyrings dgitrepos distrodir);
995
996 # workrepo and destrepo handled ad-hoc
997
998 sub mode_ssh () {
999     die if @ARGV;
1000
1001     my $cmd = $ENV{'SSH_ORIGINAL_COMMAND'};
1002     $cmd =~ m{
1003         ^
1004         (?: \S* / )?
1005         ( [-0-9a-z]+ )
1006         \s+
1007         '? (?: \S* / )?
1008         ($package_re) \.git
1009         '?$
1010     }ox 
1011     or reject "command string not understood";
1012     my $method = $1;
1013     $package = $2;
1014
1015     my $funcn = $method;
1016     $funcn =~ y/-/_/;
1017     my $mainfunc = $main::{"main__$funcn"};
1018
1019     reject "unknown method" unless $mainfunc;
1020
1021     policy_checkpackage();
1022
1023     if (stat_exists realdestrepo) {
1024         $destrepo = realdestrepo;
1025     } else {
1026         printdebug " fixmissing $funcn\n";
1027         my $fixfunc = $main::{"fixmissing__$funcn"};
1028         &$fixfunc;
1029     }
1030
1031     printdebug " running main $funcn\n";
1032     &$mainfunc;
1033 }
1034
1035 sub mode_cron () {
1036     die if @ARGV;
1037
1038     my $listfh = tempfile();
1039     open STDOUT, ">&", $listfh or die $!;
1040     policyhook(0,'check-list');
1041     open STDOUT, ">&STDERR" or die $!;
1042
1043     seek $listfh, 0, 0 or die $!;
1044     while (<$listfh>) {
1045         chomp or die;
1046         next if m/^\s*\#/;
1047         next unless m/\S/;
1048         die unless m/^($package_re)$/;
1049         
1050         $package = $1;
1051         policy_checkpackage();
1052     }
1053     die $! if $listfh->error;
1054 }    
1055
1056 sub parseargsdispatch () {
1057     die unless @ARGV;
1058
1059     delete $ENV{'GIT_DIR'}; # if not run via ssh, our parent git process
1060     delete $ENV{'GIT_PREFIX'}; # sets these and they mess things up
1061
1062     if ($ENV{'DGIT_DRS_DEBUG'}) {
1063         enabledebug();
1064     }
1065
1066     if ($ARGV[0] eq '--pre-receive-hook') {
1067         if ($debuglevel) {
1068             $debugprefix.="=";
1069             printdebug "in stunthook ".(shellquote @ARGV)."\n";
1070             foreach my $k (sort keys %ENV) {
1071                 printdebug "$k=$ENV{$k}\n" if $k =~  m/^DGIT/;
1072             }
1073         }
1074         shift @ARGV;
1075         @ARGV == 1 or die;
1076         $package = shift @ARGV;
1077         ${ $main::{$_} } = $ENV{"DGIT_DRS_\U$_"} foreach @hookenvs;
1078         defined($workrepo = $ENV{'DGIT_DRS_WORK'}) or die;
1079         defined($destrepo = $ENV{'DGIT_DRS_DEST'}) or die;
1080         open STDOUT, ">&STDERR" or die $!;
1081         eval {
1082             stunthook();
1083         };
1084         if ($@) {
1085             recorderror "$@" or die;
1086             die $@;
1087         }
1088         exit 0;
1089     }
1090
1091     $distro    = argval();
1092     $distrodir = argval();
1093     $keyrings  = argval();
1094
1095     foreach my $dk (keys %indistrodir) {
1096         ${ $indistrodir{$dk} } = "$distrodir/$dk";
1097     }
1098
1099     while (@ARGV && $ARGV[0] =~ m/^--([-0-9a-z]+)=/ && $indistrodir{$1}) {
1100         ${ $indistrodir{$1} } = $'; #';
1101         shift @ARGV;
1102     }
1103
1104     $ENV{"DGIT_DRS_\U$_"} = ${ $main::{$_} } foreach @hookenvs;
1105
1106     die unless @ARGV==1;
1107
1108     my $mode = shift @ARGV;
1109     die unless $mode =~ m/^--(\w+)$/;
1110     my $fn = ${*::}{"mode_$1"};
1111     die unless $fn;
1112     $fn->();
1113 }
1114
1115 sub unlockall () {
1116     while (my $fh = pop @lockfhs) { close $fh; }
1117 }
1118
1119 sub cleanup () {
1120     unlockall();
1121     if (!chdir "$dgitrepos/_tmp") {
1122         $!==ENOENT or die $!;
1123         return;
1124     }
1125     foreach my $lf (<*.lock>) {
1126         my $tree = $lf;
1127         $tree =~ s/\.lock$//;
1128         next unless acquirermtree($tree, 0);
1129         remove $lf or warn $!;
1130         unlockall();
1131     }
1132 }
1133
1134 parseargsdispatch();
1135 cleanup();