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