chiark / gitweb /
Infra: add some more debugging
[dgit.git] / infra / dgit-repos-policy-debian
1 #!/usr/bin/perl -w
2 # dgit repos policy hook script for Debian
3
4 use strict;
5 $SIG{__WARN__} = sub { die $_[0]; };
6
7 use POSIX;
8 use JSON;
9 use File::Temp qw(tempfile);
10 use DBI;
11 use IPC::Open2;
12
13 use Debian::Dgit qw(:DEFAULT :policyflags);
14 use Debian::Dgit::Policy::Debian;
15
16 our $distro = shift @ARGV // die "need DISTRO";
17 our $repos = shift @ARGV // die "need DGIT-REPOS-DIR";
18 our $dgitlive = shift @ARGV // die "need DGIT-LIVE-DIR";
19 our $action = shift @ARGV // die "need ACTION";
20
21 our $publicmode = 02775;
22 our $new_upload_propagation_slop = 3600*4 + 100;
23
24 our $poldbh;
25 our $pkg;
26 our $pkgdir;
27 our ($pkg_exists,$pkg_secret);
28
29 our $stderr;
30
31 our ($version,$suite,$tagname);
32 our %deliberately;
33
34 # We assume that it is not possible for NEW to have a version older
35 # than sid.
36
37 # Whenever pushing, we check for
38 #   source-package-local tainted history
39 #   global tainted history
40 #   can be overridden by --deliberately except for an admin prohib taint
41
42 # ALL of the following apply only if history is secret:
43
44 # if NEW has no version, or a version which is not in our history[1]
45 #   (always)
46 #   check all suites
47 #   if any suite's version is in our history[1], publish our history
48 #   otherwise discard our history,
49 #     tainting --deliberately-include-questionable-history
50
51 # if NEW has a version which is in our history[1]
52 #   (on push only)
53 #   require explicit specification of one of
54 #     --deliberately-include-questionable-history
55 #     --deliberately-not-fast-forward
56 #       (latter will taint old NEW version --d-i-q-h)
57 #   (otherwise)
58 #   leave it be
59
60 # [1] looking for the relevant git tag for the version number and not
61 #    caring what that tag refers to.
62 #
63 # A wrinkle: if we approved a push recently, we treat NEW as having
64 # a version which is in our history.  This is because the package may
65 # still be being uploaded.  (We record this using the timestamp of the
66 # package's git repo directory.)
67
68 # We aim for the following invariants and properties:
69 #
70 # - .dsc of published dgit package will have corresponding publicly
71 #   visible dgit-repo (soon)
72 #
73 # - when a new package is rejected we help maintainer avoid
74 #   accidentally including bad objects in published dgit history
75 #
76 # - .dsc of NEW dgit package has corresponding dgit-repo but not
77 #   publicly readable
78
79 sub apiquery ($) {
80     my ($subpath) = @_;
81     local $/=undef;
82     my $cmd = "$dgitlive/dgit -d $distro ".
83         "\$DGIT_TEST_OPTS \$DGIT_TEST_DEBUG archive-api-query $subpath";
84     $!=0; $?=0; my $json = `$cmd`;
85     defined $json or die "$subpath $! $?";
86     return decode_json $json;
87 }
88
89 sub specific_suite_has_vsn_in_our_history ($) {
90     my ($suite) = @_;
91     my $in_suite = apiquery "/dsc_in_suite/$suite/$pkg";
92     foreach my $entry (@$in_suite) {
93         my $vsn = $entry->{version};
94         die "$pkg ?" unless defined $vsn;
95         my $tag = debiantag $vsn;
96         $?=0; my $r = system qw(git show-ref --verify --quiet), $tag;
97         return 1 if !$r;
98         next if $r==256;
99         die "$pkg tag $tag $? $!";
100     }
101     return 0;
102 }
103
104 sub new_has_vsn_in_our_history () {
105     stat $pkgdir or die "$pkgdir $!";
106     my $mtime = ((stat _)[9]);
107     my $age = time -  $mtime;
108     return 1 if $age < $new_upload_propagation_slop;
109     return specific_suite_has_vsn_in_our_history('new');
110 }
111
112 sub good_suite_has_vsn_in_our_history () {
113     my $suites = apiquery "/suites";
114     foreach my $suitei (@$suites) {
115         my $suite = $suitei->{name};
116         die unless defined $suite;
117         next if $suite =~ m/\bnew$/;
118         return 1 if specific_suite_has_vsn_in_our_history($suite);
119     }
120     return 0;
121 }
122
123 sub statpackage () {
124     $pkgdir = "$repos/$pkg.git";
125     if (!stat_exists $pkgdir) {
126         $pkg_exists = 0;
127     } else {
128         $pkg_exists = 1;
129         $pkg_secret = !!(~(stat _)[2] & 05);
130     }
131 }
132
133 sub getpackage () {
134     die unless @ARGV >= 1;
135     $pkg = shift @ARGV;
136     die unless $pkg =~ m/^$package_re$/;
137
138     statpackage();
139 }
140
141 sub add_taint ($$) {
142     my ($refobj, $reason);
143
144     my $tf = new File::Temp or die $!;
145     print $tf "$refobj^0\n" or die $!;
146
147     my $gcfpid = open GCF, "-|";
148     defined $gcfpid or die $!;
149     if (!$gcfpid) {
150         open STDIN, "<&", $tf or die $!;
151         exec 'git', 'cat-file';
152         die $!;
153     }
154
155     close $tf or die $!;
156     $_ = <GCF>;
157     m/^(\w+) (\w+) (\d+)\n/ or die "$_ ?";
158     my $gitobjid = $1;
159     my $gitobjtype = $2;
160     my $bytes = $3;
161
162     my $gitobjdata;
163     if ($gitobjtype eq 'commit' or $gitobjtype eq 'tag') {
164         $!=0; read GCF, $gitobjdata, $bytes == $bytes
165             or die "$gitobjid $bytes $!";
166     }
167     close GCF;
168
169     $poldbh->do("INSERT INTO taints".
170                 " (package, gitobjid, gitobjtype, gitobjdata, time, comment)",
171                 " VALUES (?,?,?,?,?,?)", {},
172                 $pkg, $gitobjid, $gitobjtype, $gitobjdata, time, $reason);
173
174     my $taint_id = $poldbh->last_insert_id(undef,undef,"taints","taint_id");
175     die unless defined $taint_id;
176
177     $poldbh->do("INSERT INTO taintoverrides".
178                 " (taint_id, deliberately)",
179                 " VALUES (?, 'include-questionable-history')", {},
180                 $taint_id);
181 }
182
183 sub add_taint_by_tag ($$) {
184     my ($tagname,$refobjid) = @_;
185     add_taint($refobjid,
186               "tag $tagname referred to this object in git tree but all".
187               " previously pushed versions were found to have been".
188               " removed from NEW (ie, rejected) (or never arrived)");
189 }
190
191 sub action__check_package () {
192     getpackage();
193     return 0 unless $pkg_exists;
194     return 0 unless $pkg_secret;
195
196     chdir $pkgdir or die "$pkgdir $!";
197     return if new_has_vsn_in_our_history();
198
199     if (good_suite_has_vsn_in_our_history) {
200         chmod $publicmode, "." or die $!;
201         return 0;
202     }
203
204     git_for_each_ref('refs/tags', sub {
205         my ($objid,$objtype,$fullrefname,$tagname) = @_;
206         add_taint_by_tag($tagname,$objid);
207     });
208
209     return FRESHREPO;
210 }
211
212 sub getpushinfo () {
213     die unless @ARGV >= 4;
214     $version = shift @ARGV;
215     $suite = shift @ARGV;
216     $tagname = shift @ARGV;
217     my $delibs = shift @ARGV;
218     foreach my $delib (split /\,/, $delibs) {
219         $deliberately{$delib} = 1;
220     }
221 }
222
223 sub deliberately ($) { return $deliberately{$_[0]}; }
224
225 sub action_push () {
226     getpackage();
227     return 0 unless $pkg_exists;
228     return 0 unless $pkg_secret;
229
230     # we suppose that NEW has a version which is already in our
231     # history, as otherwise the repo would have been blown away
232
233     if (deliberately('not-fast-forward')) {
234         add_taint(server_ref($suite),
235                   "suite $suite when --deliberately-not-fast-forward".
236                   " specified in signed tag $tagname for upload of".
237                   " version $version into suite $suite");
238         return NOFFCHECK|FRESHREPO;
239     }
240     if (deliberately('include-questionable-history')) {
241         return 0;
242     }
243     die "Package is in NEW and has not been accepted or rejected yet;".
244         " use a --deliberately option to specify whether you are".
245         " keeping or discarding the previously pushed history. ".
246         " Please RTFM dgit(1).\n";
247 }
248
249 sub action_push_confirm () {
250     getpackage();
251     die unless @ARGV;
252     my $freshrepo = shift @ARGV;
253
254     my $initq = $poldbh->prepare(<<END);
255         SELECT taint_id, gitobjid FROM taints t
256             WHERE (package = ? OR package = '')
257 END
258     $initq->execute($pkg);
259
260     my @taintids;
261     my $chkinput = tempfile();
262     while (my $taint = $initq->fetchrow_hashref()) {
263         push @taintids, $taint->{taint_id};
264         print $chkinput $taint->{gitobjid}, "\n" or die $!;
265     }
266     flush $chkinput or die $!;
267     seek $chkinput,0,0 or die $!;
268
269     my $checkpid = open CHKOUT, "-|" // die $!;
270     if (!$checkpid) {
271         open STDIN, "<&", $chkinput or die $!;
272         exec qw(git cat-file --batch) or die $!;
273     }
274
275     my ($taintinfoq,$overridesanyq,$untaintq,$overridesq);
276
277     my $overridesstmt = <<END;
278         SELECT deliberately FROM taintoverrides WHERE ( 1
279 END
280     my @overridesv = sort keys %deliberately;
281     $overridesstmt .= join '', (<<END x @overridesv);
282             OR deliberately = ?
283 END
284     $overridesstmt .= <<END;
285         ) AND taint_id = ?
286         ORDER BY deliberately ASC
287 END
288
289     my $mustreject=0;
290
291     while (my $taintid = shift @taintids) {
292         # git cat-file prints a spurious newline after it gets EOF
293         # This is not documented.  I guess it might go away.  So we
294         # just read what we expect and then let it get SIGPIPE.
295         $!=0; $_ = <CHKOUT>;
296         die "$? $!" unless defined $_;
297
298         next if m/^\w+ missing$/;
299         die unless m/^(\w+) (\w+) (\d+)\s/;
300         my ($objid,$objtype,$nbytes) = ($1,$2,$3);
301
302         my $drop;
303         (read CHKOUT, $drop, $nbytes) == $nbytes or die;
304
305         $taintinfoq ||= $poldbh->prepare(<<END);
306             SELECT package, time, comment FROM taints WHERE taint_id =  ?
307 END
308         $taintinfoq->execute($taintid);
309
310         my $ti = $taintinfoq->fetchrow_hashref();
311         die unless $ti;
312
313         my $timeshow = defined $ti->{time}
314             ? " at time ".strftime("%Y-%m-%d %H:%M:%S Z", gmtime $ti->{time})
315             : "";
316         my $pkgshow = length $ti->{package}
317             ? "package $ti->{package}"
318             : "any package";
319
320         $stderr .= <<END;
321
322 History contains tainted $objtype $objid
323 Taint recorded$timeshow for $pkgshow
324 Reason: $ti->{comment}
325 END
326
327         $overridesq ||= $poldbh->prepare($overridesstmt);
328         $overridesq->execute(@overridesv, $taintid);
329         my ($ovwhy) = $overridesq->fetchrow_array();
330         if (!defined $ovwhy) {
331             $overridesanyq ||= $poldbh->prepare(<<END);
332                 SELECT 1 FROM taintoverrides WHERE taint_id = ? LIMIT 1
333 END
334             $overridesanyq->execute($taintid);
335             my ($ovany) = $overridesanyq->fetchrow_array();
336             $stderr .= $ovany ? <<END : <<END;
337 Could be forced using --deliberately.  Consult documentation.
338 END
339 Uncorrectable error.  If confused, consult administrator.
340 END
341             $mustreject = 1;
342         } else {
343             $stderr .= <<END;
344 Forcing due to --deliberately-$ovwhy
345 END
346             $untaintq ||= $poldbh->prepare(<<END);
347                 DELETE FROM taints WHERE taint_id = ?
348 END
349             $untaintq->execute($taintid);
350         }
351     }
352     close CHKOUT;
353
354     if ($mustreject) {
355         $stderr .= <<END;
356
357 Rejecting push due to questionable history.
358 END
359         return 1;
360     }
361
362     if (length $freshrepo) {
363         if (!good_suite_has_vsn_in_our_history()) {
364             stat $freshrepo or die "$freshrepo $!";
365             my $oldmode = ((stat _)[2]);
366             my $oldwrites = $oldmode & 0222;
367             # remove r and x bits which have corresponding w bits clear
368             my $newmode = $oldmode & ($oldwrites << 1) & ($oldwrites > 1);
369             chmod $newmode, $freshrepo or die $!;
370         }
371     }
372
373     return 0;
374 }
375
376 sub action_check_list () {
377     opendir L, "$repos" or die "$repos $!";
378     while (defined (my $dent = readdir L)) {
379         next unless $dent =~ m/^($package_re)\.git$/;
380         $pkg = $1;
381         statpackage();
382         next unless $pkg_exists;
383         next unless $pkg_secret;
384         print "$pkg\n" or die $!;
385     }
386     closedir L or die $!;
387     close STDOUT or die $!;
388     return 0;
389 }
390
391 $action =~ y/-/_/;
392 my $fn = ${*::}{"action_$action"};
393 if (!$fn) {
394     exit 0;
395 }
396
397 my $sleepy=0;
398 our $rcode = 127;
399
400 for (;;) {
401     poldb_setup(poldb_path($repos));
402     $stderr = '';
403
404     $rcode = $fn->();
405     die unless defined $rcode;
406
407     eval { $poldbh->commit; };
408     last unless length $@;
409
410     die if $sleepy >= 20;
411     print STDERR "[policy database busy, retrying]\n";
412     sleep ++$sleepy;
413
414     $poldbh->rollback;
415 }
416
417 print STDERR $stderr;
418 exit $rcode;