summaryrefslogtreecommitdiff
path: root/dgit-repos-server
blob: 0cbcf3579aa1ce598dd9a9d7b8792d3931c89e81 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
#!/usr/bin/perl -w
# dgit-repos-push-receiver
#
# usages:
#  .../dgit-repos-push-receiver KEYRING-AUTH-SPEC DGIT-REPOS-DIR --ssh
#  .../dgit-repos-push-receiver KEYRING-AUTH-SPEC DGIT-REPOS-DIR PACKAGE
# internal usage:
#  .../dgit-repos-push-receiver --pre-receive-hook PACKAGE
#
# Invoked as the ssh restricted command
#
# Works like git-receive-pack
#
# KEYRING-AUTH-SPEC is a :-separated list of
#   KEYRING.GPG,AUTH-SPEC
# where AUTH-SPEC is one of
#   a
#   mDM.TXT

use strict;

# What we do is this:
#  - extract the destination repo name
#  - make a hardlink clone of the destination repo
#  - provide the destination with a stunt pre-receive hook
#  - run actual git-receive-pack with that new destination
#   as a result of this the stunt pre-receive hook runs; it does this
#    + understand what refs we are allegedly updating and
#      check some correspondences:
#        * we are updating only refs/tags/debian/* and refs/dgit/*
#        * and only one of each
#        * and the tag does not already exist
#      and
#        * recovering the suite name from the destination refs/dgit/ ref
#    + disassemble the signed tag into its various fields and signature
#      including:
#        * parsing the first line of the tag message to recover
#          the package name, version and suite
#        * checking that the package name corresponds to the dest repo name
#        * checking that the suite name is as recovered above
#    + verify the signature on the signed tag
#      and if necessary check that the keyid and package are listed in dm.txt
#    + check various correspondences:
#        * the suite is one of those permitted
#        * the signed tag must refer to a commit
#        * the signed tag commit must be the refs/dgit value
#        * the name in the signed tag must correspond to its ref name
#        * the tag name must be debian/<version> (massaged as needed)
#        * the signed tag has a suitable name
#        * the commit is a fast forward
#    + push the signed tag and new dgit branch to the actual repo
#
# If the destination repo does not already exist, we need to make
# sure that we create it reasonably atomically, and also that
# we don't every have a destination repo containing no refs at all
# (because such a thing causes git-fetch-pack to barf).  So then we
# do as above, except:
#  - before starting, we take out our own lock for the destination repo
#  - we don't make a hardline clone of the destination repo; instead
#    we make a copy (not a hardlink clone) of _template
#  - we set up a post-receive hook as well, which does the following:
#    + check that exactly two refs were updated
#    + delete the two stunt hooks
#    + rename the working repo into place as the destination repo

use POSIX;
use Fcntl qw(:flock);

our $package_re = '[0-9a-z][-+.0-9a-z]+';

our $dgitrepos;
our $pkg;
our $destrepo;
our $workrepo;
our @keyrings;

sub acquirelock ($$) {
    my ($lock, $must) = @_;
    for (;;) {
	my $fh = new IO::File, ">", $lock or die "open $lock: $!";
	my $ok = flock $fh, $must ? LOCK_EX : (LOCK_EX|LOCK_NB);
	if (!$ok) {
	    return unless $must;
	    die "flock $lock: $!";
	}
	if (!stat $lock) {
	    next if $! == ENOENT;
	    die "stat $lock: $!";
	}
	my $want = (stat _)[1];
	stat $fh or die $!;
	my $got = (stat _)[1];
	return $fh if $got == $want;
    }
}

sub makeworkingclone () {
    $workrepo = "$dgitrepos/_tmp/${pkg}_incoming$$";
    my $lock = "$workrepo.lock";
    my $lockfh = acquirelock($lock, 1);
    if (!stat $destrepo) {
	$! == ENOENT or die "stat dest repo $destrepo: $!";
	mkdir $workrepo or die "create work repo $workrepo: $!";
	runcmd qw(git init --bare), $workrepo;
    } else {
	runcmd qw(git clone -l -q --mirror), $destrepo, $workrepo;
    }
}

sub setupstunthook () {
    my $prerecv = "$workrepo/hooks/pre-receive";
    my $fh = new IO::File, $prerecv, O_WRONLY|O_CREAT|O_TRUNC, 0777
	or die "$prerecv: $!";
    print $fh <<END or die "$prerecv: $!";
#!/bin/sh
set -e
exec $0 --pre-receive-hook $pkg
END
    close $fh or die "$prerecv: $!";
    $ENV{'DGIT_RPR_WORK'}= $workrepo;
    $ENV{'DGIT_RPR_DEST'}= $destrepo;
}

#----- stunt post-receive hook -----

our ($tagname, $tagval, $suite, $oldcommit, $commit);
our ($version, %tagh);

sub readupdates () {
    while (<STDIN>) {
	m/^(\S+) (\S+) (\S+)$/ or die "$_ ?";
	my ($old, $sha1, $refname) = ($1, $2, $3);
	if ($refname =~ m{^refs/tags/(?=debian/)}) {
	    die if defined $tagname;
	    $tagname = $'; #';
	    $tagval = $sha1;
	    reject "tag $tagname already exists -".
		" not replacing previously-pushed version"
		if $old =~ m/[^0]/;
	} elsif ($refname =~ m{^refs/dgit/}) {
	    die if defined $suite;
	    $suite = $'; #';
	    $oldcommit = $old;
	    $commit = $sha1;
	} else {
	    die;
	}
    }
    STDIN->error and die $!;

    die unless defined $refname;
    die unless defined $branchname;
}

sub parsetag () {
    open PT, ">dgit-tmp/plaintext" or die $!;
    open DS, ">dgit-tmp/plaintext.asc" or die $!;
    open T, "-|", qw(git cat-file tag), $tagval or die $!;
    for (;;) {
	$!=0; $_=<T>; defined or die $!;
	print PT or die $!;
	if (m/^(\S+) (.*)/) {
	    push @{ $tagh{$1} }, $2;
	} elsif (!m/\S/) {
	    last;
	} else {
	    die;
	}
    }
    $!=0; $_=<T>; defined or die $!;
    m/^($package_re) release (\S+) for (\S+) \[dgit\]$/ or die;

    die unless $1 eq $pkg;
    $version = $2;
    die unless $3 eq $suite;

    for (;;) {
	print PT or die $!;
	$!=0; $_=<T>; defined or die $!;
	last if m/^-----BEGIN PGP/;
    }
    for (;;) {
	print DS or die $!;
	$!=0; $_=<T>;
	last if !defined;
    }
    T->error and die $!;
    close PT or die $!;
    close DS or die $!;
}

sub checksig_keyring ($) {
    my ($keyringfile) = @_;
    # returns primary-keyid if signed by a key in this keyring
    # or undef if not
    # or dies on other errors

    my $ok = undef;

    open P, "-|", (qw(gpgv --status-fd=1),
		   map { '--keyring', $_ }, @keyrings,
		   qw(dgit-tmp/plaintext.asc dgit-tmp/plaintext))
	or die $!;

    while (<P>) {
	next unless s/^\[GNUPG:\]: //;
	chomp or die;
	my @l = split / /, $_;
	if ($l[0] eq 'NO_PUBKEY') {
	    last;
	} elsif ($l[0] eq 'VALIDSIG') {
	    my $sigtype = $l[9];
	    $sigtype eq '00' or reject "signature is not of type 00!";
	    $ok = $l[10];
	    die unless defined $ok;
	    last;
	}
    }
    close P;

    return $ok;
}

sub dm_txt_check ($$) {
    my ($keyid, $dmtxtfn) = @_;
    open DT, '<', $dmtxtfn or die "$dmtxtfn $!";
    while (<DT>) {
	m/^fingerprint:\s+$keyid$/oi
	    ..0 or next;
	m/^\S/
	    or reject "key $keyid missing Allow section in permissions!";
	# in right stanza...
	s/^allow:/ /i
	    ..0 or next;
	s/^\s+//
	    or reject "package $package not allowed for key $keyid";
	# in allow field...
	s/\([^()]+\)//;
	s/\,//;
	foreach my $p (split /\s+/) {
	    return if $p eq $package; # yay!
	}
    }
    DT->error and die $!;
    close DT or die $!;
    reject "key $keyid not in permissions list although in keyring!";
}

sub verifytag () {
    foreach my $kas (split /:/, $keyrings) {
	$kas =~ s/^([^,]+),// or die;
	my $keyid = checksig_keyring $1;
	if (defined $keyid) {
	    if ($kas =~ m/^a$/) {
		return; # yay
	    } elsif ($kas =~ m/^m([^,]+)$/) {
		dm_txt_check($keyid, $1);
		return;
	    } else {
		die;
	    }
	}   
    }
    reject "key not found in keyrings";
}

sub checks () {
fixme check the suite against the approved list
    tagh1('type') eq 'commit' or die;
    tagh1('object') eq $commit or die;
    tagh1('tag') eq $tagname or die;

    my $v = $version;
    $v =~ y/~:/_%/;
    $tagname eq "debian/$v" or die;

    # check that our ref is being fast-forwarded
    if ($oldcommit =~ m/[^0]/) {
	$?=0; $!=0; my $mb = `git merge-base $commit $oldcommit`;
	chomp $mb;
	$mb eq $oldcommit or reject "not fast forward on dgit branch";
    }
}

sub onwardpush () {
    $!=0;
    my $r = system (qw(git send-pack),
		    $destrepo,
		    "$commit:refs/dgit/$suite",
		    "$tagval:refs/tags/$tagname");
    !$r or die "onward push failed: $r $!";
}	

sub stunthook () {
    chdir $workrepo or die "chdir $workrepo: $!";
    mkdir "dgit-tmp" or $!==EEXIST or die $!;
    readupdates();
    parsetag();
    verifytag();
    checks();
    onwardpush();
}

#----- arg parsing and main program -----

sub parseargs () {
    die unless @ARGV;

    if ($ARGV[0] eq '--pre-receive-hook') {
	shift @ARGV;
	@ARGV == 1 or die;
	$pkg = shift @ARGV;
	defined($workrepo = $ENV{'DGIT_RPR_WORK'}) or die;
	defined($destrepo = $ENV{'DGIT_RPR_DEST'}) or die;
	defined($keyrings = $ENV{'DGIT_RPR_KEYRINGS'}) or die $!;
	open STDOUT, ">&STDERR" or die $!;
	stunthook();
	exit 0;
    }

    die unless @ARGV>=2;

    die if $ARGV[0] =~ m/^-/;
    $ENV{'DGIT_RPR_KEYRINGS'} = shift @ARGV;
    die if $ARGV[0] =~ m/^-/;
    $dgitrepos = shift @ARGV;

    die unless @ARGV;
    if ($ARGV[0] != m/^-/) {
	@ARGV == 1 or die;
	$pkg = shift @ARGV;
    } elsif ($ARGV[0] eq '--ssh') {
	shift @ARGV;
	!@ARGV or die;
	my $cmd = $ENV{'SSH_ORIGINAL_COMMAND'};
	$cmd =~ m{
            ^
            (?:\S*/)?
	    (git-receive-pack|git-upload-pack)
	    \s+
            (?:\S*/)?
	    ($package_re)\.git
            $
        }ox 
	    or die "requested command $cmd not understood";
	$method = $1;
	$pkg = $2;
	my $func = $method;
	$func =~ y/-/_/;
	$func = $main::{"main__$func"};
	&$func;
    } else {
	die;
    }

    $destrepo = "$dgitrepos/$pkg.git";
}

sub main__git_receive_pack () {
    parseargs();
fixme check method;
    makeworkingclone();
    setupstunthook();
    runcmd qw(git receive-pack), $destdir;
}