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