]> git.scottworley.com Git - pinch/blame - pinch.py
Support "log.showSignature = true"
[pinch] / pinch.py
CommitLineData
0e5e611d 1import argparse
f15e458d 2import configparser
2f96f32a
SW
3import filecmp
4import functools
736c25eb 5import getpass
2f96f32a
SW
6import hashlib
7import operator
8import os
9import os.path
9a78329e 10import shlex
2f96f32a 11import shutil
73bec7e8 12import subprocess
9d7844bb 13import sys
2f96f32a 14import tempfile
89e79125 15import types
2f96f32a
SW
16import urllib.parse
17import urllib.request
18import xml.dom.minidom
19
20from typing import (
2f96f32a
SW
21 Dict,
22 Iterable,
23 List,
73bec7e8 24 NewType,
2f96f32a
SW
25 Tuple,
26)
27
3603dde2
SW
28# Use xdg module when it's less painful to have as a dependency
29
30
31class XDG(types.SimpleNamespace):
32 XDG_CACHE_HOME: str
33
34
35xdg = XDG(
36 XDG_CACHE_HOME=os.getenv(
37 'XDG_CACHE_HOME',
38 os.path.expanduser('~/.cache')))
26125a28
SW
39
40
73bec7e8
SW
41Digest16 = NewType('Digest16', str)
42Digest32 = NewType('Digest32', str)
43
2f96f32a 44
72d3478a 45class ChannelTableEntry(types.SimpleNamespace):
e434d96d 46 absolute_url: str
73bec7e8 47 digest: Digest16
89e79125
SW
48 file: str
49 size: int
50 url: str
51
52
72d3478a 53class Channel(types.SimpleNamespace):
17906b27 54 alias_of: str
89e79125 55 channel_html: bytes
b17def3f 56 channel_url: str
89e79125 57 forwarded_url: str
dc038df0
SW
58 git_ref: str
59 git_repo: str
89e79125 60 git_revision: str
8fca6c28 61 old_git_revision: str
3e6421c4 62 release_name: str
72d3478a 63 table: Dict[str, ChannelTableEntry]
89e79125
SW
64
65
2f96f32a
SW
66class VerificationError(Exception):
67 pass
68
69
70class Verification:
71
72 def __init__(self) -> None:
73 self.line_length = 0
74
75 def status(self, s: str) -> None:
9d7844bb 76 print(s, end=' ', file=sys.stderr, flush=True)
2f96f32a
SW
77 self.line_length += 1 + len(s) # Unicode??
78
79 @staticmethod
80 def _color(s: str, c: int) -> str:
81 return '\033[%2dm%s\033[00m' % (c, s)
82
83 def result(self, r: bool) -> None:
84 message, color = {True: ('OK ', 92), False: ('FAIL', 91)}[r]
85 length = len(message)
dd1026fe 86 cols = shutil.get_terminal_size().columns or 80
2f96f32a 87 pad = (cols - (self.line_length + length)) % cols
9d7844bb 88 print(' ' * pad + self._color(message, color), file=sys.stderr)
2f96f32a
SW
89 self.line_length = 0
90 if not r:
91 raise VerificationError()
92
93 def check(self, s: str, r: bool) -> None:
94 self.status(s)
95 self.result(r)
96
97 def ok(self) -> None:
98 self.result(True)
99
100
dc038df0 101def compare(a: str, b: str) -> Tuple[List[str], List[str], List[str]]:
2f96f32a
SW
102
103 def throw(error: OSError) -> None:
104 raise error
105
106 def join(x: str, y: str) -> str:
107 return y if x == '.' else os.path.join(x, y)
108
109 def recursive_files(d: str) -> Iterable[str]:
110 all_files: List[str] = []
111 for path, dirs, files in os.walk(d, onerror=throw):
112 rel = os.path.relpath(path, start=d)
113 all_files.extend(join(rel, f) for f in files)
114 for dir_or_link in dirs:
115 if os.path.islink(join(path, dir_or_link)):
116 all_files.append(join(rel, dir_or_link))
117 return all_files
118
119 def exclude_dot_git(files: Iterable[str]) -> Iterable[str]:
120 return (f for f in files if not f.startswith('.git/'))
121
122 files = functools.reduce(
123 operator.or_, (set(
124 exclude_dot_git(
125 recursive_files(x))) for x in [a, b]))
126 return filecmp.cmpfiles(a, b, files, shallow=False)
127
128
ca2c3edd 129def fetch(v: Verification, channel: Channel) -> None:
2f96f32a 130 v.status('Fetching channel')
b17def3f 131 request = urllib.request.urlopen(channel.channel_url, timeout=10)
72d3478a
SW
132 channel.channel_html = request.read()
133 channel.forwarded_url = request.geturl()
2f96f32a 134 v.result(request.status == 200)
b17def3f 135 v.check('Got forwarded', channel.channel_url != channel.forwarded_url)
2f96f32a
SW
136
137
72d3478a 138def parse_channel(v: Verification, channel: Channel) -> None:
2f96f32a 139 v.status('Parsing channel description as XML')
72d3478a 140 d = xml.dom.minidom.parseString(channel.channel_html)
2f96f32a
SW
141 v.ok()
142
3e6421c4
SW
143 v.status('Extracting release name:')
144 title_name = d.getElementsByTagName(
145 'title')[0].firstChild.nodeValue.split()[2]
146 h1_name = d.getElementsByTagName('h1')[0].firstChild.nodeValue.split()[2]
147 v.status(title_name)
148 v.result(title_name == h1_name)
72d3478a 149 channel.release_name = title_name
3e6421c4
SW
150
151 v.status('Extracting git commit:')
2f96f32a 152 git_commit_node = d.getElementsByTagName('tt')[0]
61aaf799
SW
153 channel.git_revision = git_commit_node.firstChild.nodeValue
154 v.status(channel.git_revision)
2f96f32a
SW
155 v.ok()
156 v.status('Verifying git commit label')
157 v.result(git_commit_node.previousSibling.nodeValue == 'Git commit ')
158
159 v.status('Parsing table')
72d3478a 160 channel.table = {}
2f96f32a
SW
161 for row in d.getElementsByTagName('tr')[1:]:
162 name = row.childNodes[0].firstChild.firstChild.nodeValue
163 url = row.childNodes[0].firstChild.getAttribute('href')
164 size = int(row.childNodes[1].firstChild.nodeValue)
73bec7e8 165 digest = Digest16(row.childNodes[2].firstChild.firstChild.nodeValue)
dc038df0
SW
166 channel.table[name] = ChannelTableEntry(
167 url=url, digest=digest, size=size)
2f96f32a
SW
168 v.ok()
169
170
dc038df0
SW
171def digest_string(s: bytes) -> Digest16:
172 return Digest16(hashlib.sha256(s).hexdigest())
173
174
73bec7e8
SW
175def digest_file(filename: str) -> Digest16:
176 hasher = hashlib.sha256()
177 with open(filename, 'rb') as f:
178 # pylint: disable=cell-var-from-loop
179 for block in iter(lambda: f.read(4096), b''):
180 hasher.update(block)
181 return Digest16(hasher.hexdigest())
182
183
184def to_Digest16(v: Verification, digest32: Digest32) -> Digest16:
185 v.status('Converting digest to base16')
186 process = subprocess.run(
ba596fc0 187 ['nix', 'to-base16', '--type', 'sha256', digest32], stdout=subprocess.PIPE)
73bec7e8
SW
188 v.result(process.returncode == 0)
189 return Digest16(process.stdout.decode().strip())
190
191
192def to_Digest32(v: Verification, digest16: Digest16) -> Digest32:
193 v.status('Converting digest to base32')
194 process = subprocess.run(
ba596fc0 195 ['nix', 'to-base32', '--type', 'sha256', digest16], stdout=subprocess.PIPE)
73bec7e8
SW
196 v.result(process.returncode == 0)
197 return Digest32(process.stdout.decode().strip())
198
199
200def fetch_with_nix_prefetch_url(
201 v: Verification,
202 url: str,
203 digest: Digest16) -> str:
204 v.status('Fetching %s' % url)
205 process = subprocess.run(
ba596fc0 206 ['nix-prefetch-url', '--print-path', url, digest], stdout=subprocess.PIPE)
73bec7e8
SW
207 v.result(process.returncode == 0)
208 prefetch_digest, path, empty = process.stdout.decode().split('\n')
209 assert empty == ''
210 v.check("Verifying nix-prefetch-url's digest",
211 to_Digest16(v, Digest32(prefetch_digest)) == digest)
212 v.status("Verifying file digest")
213 file_digest = digest_file(path)
214 v.result(file_digest == digest)
215 return path
2f96f32a 216
73bec7e8 217
72d3478a 218def fetch_resources(v: Verification, channel: Channel) -> None:
2f96f32a 219 for resource in ['git-revision', 'nixexprs.tar.xz']:
72d3478a 220 fields = channel.table[resource]
e434d96d
SW
221 fields.absolute_url = urllib.parse.urljoin(
222 channel.forwarded_url, fields.url)
223 fields.file = fetch_with_nix_prefetch_url(
224 v, fields.absolute_url, fields.digest)
73bec7e8
SW
225 v.status('Verifying git commit on main page matches git commit in table')
226 v.result(
227 open(
61aaf799 228 channel.table['git-revision'].file).read(999) == channel.git_revision)
2f96f32a 229
971d3659 230
9836141c 231def git_cachedir(git_repo: str) -> str:
26125a28
SW
232 return os.path.join(
233 xdg.XDG_CACHE_HOME,
234 'pinch/git',
eb0c6f1b
SW
235 digest_string(git_repo.encode()))
236
237
238def tarball_cache_file(channel: Channel) -> str:
239 return os.path.join(
240 xdg.XDG_CACHE_HOME,
241 'pinch/git-tarball',
242 '%s-%s-%s' %
243 (digest_string(channel.git_repo.encode()),
244 channel.git_revision,
245 channel.release_name))
971d3659
SW
246
247
248def verify_git_ancestry(v: Verification, channel: Channel) -> None:
249 cachedir = git_cachedir(channel.git_repo)
250 v.status('Verifying rev is an ancestor of ref')
251 process = subprocess.run(['git',
252 '-C',
253 cachedir,
254 'merge-base',
255 '--is-ancestor',
256 channel.git_revision,
257 channel.git_ref])
258 v.result(process.returncode == 0)
259
260 if hasattr(channel, 'old_git_revision'):
261 v.status(
262 'Verifying rev is an ancestor of previous rev %s' %
263 channel.old_git_revision)
264 process = subprocess.run(['git',
265 '-C',
266 cachedir,
267 'merge-base',
268 '--is-ancestor',
269 channel.old_git_revision,
270 channel.git_revision])
271 v.result(process.returncode == 0)
9836141c 272
2f96f32a 273
dc038df0
SW
274def git_fetch(v: Verification, channel: Channel) -> None:
275 # It would be nice if we could share the nix git cache, but as of the time
276 # of writing it is transitioning from gitv2 (deprecated) to gitv3 (not ready
277 # yet), and trying to straddle them both is too far into nix implementation
278 # details for my comfort. So we re-implement here half of nix.fetchGit.
279 # :(
280
9836141c
SW
281 cachedir = git_cachedir(channel.git_repo)
282 if not os.path.exists(cachedir):
dc038df0
SW
283 v.status("Initializing git repo")
284 process = subprocess.run(
9836141c 285 ['git', 'init', '--bare', cachedir])
dc038df0
SW
286 v.result(process.returncode == 0)
287
971d3659
SW
288 v.status('Fetching ref "%s" from %s' % (channel.git_ref, channel.git_repo))
289 # We don't use --force here because we want to abort and freak out if forced
290 # updates are happening.
291 process = subprocess.run(['git',
292 '-C',
293 cachedir,
294 'fetch',
295 channel.git_repo,
296 '%s:%s' % (channel.git_ref,
297 channel.git_ref)])
298 v.result(process.returncode == 0)
299
8fca6c28 300 if hasattr(channel, 'git_revision'):
971d3659 301 v.status('Verifying that fetch retrieved this rev')
8fca6c28 302 process = subprocess.run(
9836141c 303 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
dc038df0 304 v.result(process.returncode == 0)
971d3659 305 else:
8fca6c28
SW
306 channel.git_revision = open(
307 os.path.join(
9836141c 308 cachedir,
8fca6c28
SW
309 'refs',
310 'heads',
311 channel.git_ref)).read(999).strip()
dc038df0 312
971d3659 313 verify_git_ancestry(v, channel)
dc038df0 314
971d3659
SW
315
316def ensure_git_rev_available(v: Verification, channel: Channel) -> None:
317 cachedir = git_cachedir(channel.git_repo)
318 if os.path.exists(cachedir):
319 v.status('Checking if we already have this rev:')
320 process = subprocess.run(
321 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
322 if process.returncode == 0:
323 v.status('yes')
324 if process.returncode == 1:
325 v.status('no')
326 v.result(process.returncode == 0 or process.returncode == 1)
327 if process.returncode == 0:
328 verify_git_ancestry(v, channel)
329 return
330 git_fetch(v, channel)
7d889b12 331
dc038df0 332
925c801b
SW
333def compare_tarball_and_git(
334 v: Verification,
335 channel: Channel,
336 channel_contents: str,
337 git_contents: str) -> None:
338 v.status('Comparing channel tarball with git checkout')
339 match, mismatch, errors = compare(os.path.join(
340 channel_contents, channel.release_name), git_contents)
341 v.ok()
342 v.check('%d files match' % len(match), len(match) > 0)
343 v.check('%d files differ' % len(mismatch), len(mismatch) == 0)
344 expected_errors = [
345 '.git-revision',
346 '.version-suffix',
347 'nixpkgs',
348 'programs.sqlite',
349 'svn-revision']
350 benign_errors = []
351 for ee in expected_errors:
352 if ee in errors:
353 errors.remove(ee)
354 benign_errors.append(ee)
355 v.check(
356 '%d unexpected incomparable files' %
357 len(errors),
358 len(errors) == 0)
359 v.check(
360 '(%d of %d expected incomparable files)' %
361 (len(benign_errors),
362 len(expected_errors)),
363 len(benign_errors) == len(expected_errors))
364
365
366def extract_tarball(v: Verification, channel: Channel, dest: str) -> None:
367 v.status('Extracting tarball %s' %
368 channel.table['nixexprs.tar.xz'].file)
369 shutil.unpack_archive(
370 channel.table['nixexprs.tar.xz'].file,
371 dest)
372 v.ok()
373
374
375def git_checkout(v: Verification, channel: Channel, dest: str) -> None:
376 v.status('Checking out corresponding git revision')
377 git = subprocess.Popen(['git',
378 '-C',
9836141c 379 git_cachedir(channel.git_repo),
925c801b 380 'archive',
61aaf799 381 channel.git_revision],
925c801b
SW
382 stdout=subprocess.PIPE)
383 tar = subprocess.Popen(
384 ['tar', 'x', '-C', dest, '-f', '-'], stdin=git.stdout)
de68382a
SW
385 if git.stdout:
386 git.stdout.close()
925c801b
SW
387 tar.wait()
388 git.wait()
389 v.result(git.returncode == 0 and tar.returncode == 0)
390
391
736c25eb 392def git_get_tarball(v: Verification, channel: Channel) -> str:
eb0c6f1b
SW
393 cache_file = tarball_cache_file(channel)
394 if os.path.exists(cache_file):
395 cached_tarball = open(cache_file).read(9999)
396 if os.path.exists(cached_tarball):
397 return cached_tarball
398
736c25eb
SW
399 with tempfile.TemporaryDirectory() as output_dir:
400 output_filename = os.path.join(
401 output_dir, channel.release_name + '.tar.xz')
402 with open(output_filename, 'w') as output_file:
403 v.status(
404 'Generating tarball for git revision %s' %
405 channel.git_revision)
406 git = subprocess.Popen(['git',
407 '-C',
408 git_cachedir(channel.git_repo),
409 'archive',
410 '--prefix=%s/' % channel.release_name,
411 channel.git_revision],
412 stdout=subprocess.PIPE)
413 xz = subprocess.Popen(['xz'], stdin=git.stdout, stdout=output_file)
414 xz.wait()
415 git.wait()
416 v.result(git.returncode == 0 and xz.returncode == 0)
417
418 v.status('Putting tarball in Nix store')
419 process = subprocess.run(
ba596fc0 420 ['nix-store', '--add', output_filename], stdout=subprocess.PIPE)
736c25eb 421 v.result(process.returncode == 0)
eb0c6f1b
SW
422 store_tarball = process.stdout.decode().strip()
423
424 os.makedirs(os.path.dirname(cache_file), exist_ok=True)
425 open(cache_file, 'w').write(store_tarball)
426 return store_tarball
736c25eb
SW
427
428
f9cd7bdc
SW
429def check_channel_metadata(
430 v: Verification,
431 channel: Channel,
432 channel_contents: str) -> None:
433 v.status('Verifying git commit in channel tarball')
434 v.result(
435 open(
436 os.path.join(
437 channel_contents,
438 channel.release_name,
61aaf799 439 '.git-revision')).read(999) == channel.git_revision)
f9cd7bdc
SW
440
441 v.status(
442 'Verifying version-suffix is a suffix of release name %s:' %
443 channel.release_name)
444 version_suffix = open(
445 os.path.join(
446 channel_contents,
447 channel.release_name,
448 '.version-suffix')).read(999)
449 v.status(version_suffix)
450 v.result(channel.release_name.endswith(version_suffix))
451
452
72d3478a 453def check_channel_contents(v: Verification, channel: Channel) -> None:
dc038df0
SW
454 with tempfile.TemporaryDirectory() as channel_contents, \
455 tempfile.TemporaryDirectory() as git_contents:
925c801b
SW
456
457 extract_tarball(v, channel, channel_contents)
f9cd7bdc
SW
458 check_channel_metadata(v, channel, channel_contents)
459
925c801b
SW
460 git_checkout(v, channel, git_contents)
461
462 compare_tarball_and_git(v, channel, channel_contents, git_contents)
463
dc038df0 464 v.status('Removing temporary directories')
2f96f32a
SW
465 v.ok()
466
467
8fca6c28
SW
468def pin_channel(v: Verification, channel: Channel) -> None:
469 fetch(v, channel)
470 parse_channel(v, channel)
471 fetch_resources(v, channel)
971d3659 472 ensure_git_rev_available(v, channel)
8fca6c28
SW
473 check_channel_contents(v, channel)
474
475
e3cae769
SW
476def git_revision_name(v: Verification, channel: Channel) -> str:
477 v.status('Getting commit date')
478 process = subprocess.run(['git',
479 '-C',
9836141c 480 git_cachedir(channel.git_repo),
bed32182 481 'log',
e3cae769
SW
482 '-n1',
483 '--format=%ct-%h',
484 '--abbrev=11',
88af5903 485 '--no-show-signature',
e3cae769 486 channel.git_revision],
ba596fc0 487 stdout=subprocess.PIPE)
de68382a 488 v.result(process.returncode == 0 and process.stdout != b'')
e3cae769
SW
489 return '%s-%s' % (os.path.basename(channel.git_repo),
490 process.stdout.decode().strip())
491
492
01ba0eb2
SW
493def read_config(filename: str) -> configparser.ConfigParser:
494 config = configparser.ConfigParser()
495 config.read_file(open(filename), filename)
496 return config
497
498
0e5e611d 499def pin(args: argparse.Namespace) -> None:
2f96f32a 500 v = Verification()
01ba0eb2 501 config = read_config(args.channels_file)
5cfa8e11 502 for section in config.sections():
98853153
SW
503 if args.channels and section not in args.channels:
504 continue
736c25eb
SW
505
506 channel = Channel(**dict(config[section].items()))
17906b27
SW
507
508 if hasattr(channel, 'alias_of'):
509 assert not hasattr(channel, 'git_repo')
510 continue
511
736c25eb
SW
512 if hasattr(channel, 'git_revision'):
513 channel.old_git_revision = channel.git_revision
514 del channel.git_revision
515
8fca6c28
SW
516 if 'channel_url' in config[section]:
517 pin_channel(v, channel)
736c25eb 518 config[section]['release_name'] = channel.release_name
8fca6c28
SW
519 config[section]['tarball_url'] = channel.table['nixexprs.tar.xz'].absolute_url
520 config[section]['tarball_sha256'] = channel.table['nixexprs.tar.xz'].digest
521 else:
522 git_fetch(v, channel)
736c25eb 523 config[section]['release_name'] = git_revision_name(v, channel)
8fca6c28
SW
524 config[section]['git_revision'] = channel.git_revision
525
0e5e611d 526 with open(args.channels_file, 'w') as configfile:
e434d96d 527 config.write(configfile)
2f96f32a
SW
528
529
10ddbaff
SW
530def fetch_channel(
531 v: Verification,
532 section: str,
533 conf: configparser.SectionProxy) -> str:
534 if 'git_repo' not in conf or 'release_name' not in conf:
535 raise Exception(
536 'Cannot update unpinned channel "%s" (Run "pin" before "update")' %
537 section)
538
539 if 'channel_url' in conf:
540 return fetch_with_nix_prefetch_url(
541 v, conf['tarball_url'], Digest16(
542 conf['tarball_sha256']))
543
544 channel = Channel(**dict(conf.items()))
545 ensure_git_rev_available(v, channel)
546 return git_get_tarball(v, channel)
547
548
736c25eb
SW
549def update(args: argparse.Namespace) -> None:
550 v = Verification()
551 config = configparser.ConfigParser()
da135b07 552 exprs: Dict[str, str] = {}
01ba0eb2
SW
553 configs = [read_config(filename) for filename in args.channels_file]
554 for config in configs:
555 for section in config.sections():
01ba0eb2
SW
556 if 'alias_of' in config[section]:
557 assert 'git_repo' not in config[section]
558 continue
10ddbaff 559 tarball = fetch_channel(v, section, config[section])
ab7ebb2f
SW
560 if section in exprs:
561 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2
SW
562 exprs[section] = (
563 'f: f { name = "%s"; channelName = "%%s"; src = builtins.storePath "%s"; }' %
564 (config[section]['release_name'], tarball))
565
566 for config in configs:
567 for section in config.sections():
568 if 'alias_of' in config[section]:
ab7ebb2f
SW
569 if section in exprs:
570 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2 571 exprs[section] = exprs[str(config[section]['alias_of'])]
17906b27 572
9a78329e
SW
573 command = [
574 'nix-env',
575 '--profile',
576 '/nix/var/nix/profiles/per-user/%s/channels' %
577 getpass.getuser(),
578 '--show-trace',
579 '--file',
580 '<nix/unpack-channel.nix>',
581 '--install',
17906b27 582 '--from-expression'] + [exprs[name] % name for name in sorted(exprs.keys())]
9a78329e
SW
583 if args.dry_run:
584 print(' '.join(map(shlex.quote, command)))
585 else:
586 v.status('Installing channels with nix-env')
587 process = subprocess.run(command)
588 v.result(process.returncode == 0)
736c25eb
SW
589
590
0e5e611d
SW
591def main() -> None:
592 parser = argparse.ArgumentParser(prog='pinch')
593 subparsers = parser.add_subparsers(dest='mode', required=True)
594 parser_pin = subparsers.add_parser('pin')
595 parser_pin.add_argument('channels_file', type=str)
98853153 596 parser_pin.add_argument('channels', type=str, nargs='*')
0e5e611d 597 parser_pin.set_defaults(func=pin)
736c25eb 598 parser_update = subparsers.add_parser('update')
9a78329e 599 parser_update.add_argument('--dry-run', action='store_true')
01ba0eb2 600 parser_update.add_argument('channels_file', type=str, nargs='+')
736c25eb 601 parser_update.set_defaults(func=update)
0e5e611d
SW
602 args = parser.parse_args()
603 args.func(args)
604
605
b5964ec3
SW
606if __name__ == '__main__':
607 main()