| ... | @@ -1,10 +1,15 @@ | ... | @@ -1,10 +1,15 @@ |
| 1 | import base64 | 1 | import base64 |
| | 2 | from contextlib import redirect_stderr, redirect_stdout |
| 2 | from datetime import datetime, timezone | 3 | from datetime import datetime, timezone |
| 3 | import hashlib | 4 | import hashlib |
| | 5 | import io |
| | 6 | import json |
| 4 | from pathlib import Path | 7 | from pathlib import Path |
| 5 | import runpy | 8 | import runpy |
| | 9 | import stat |
| 6 | import tempfile | 10 | import tempfile |
| 7 | import unittest | 11 | import unittest |
| | 12 | from unittest.mock import Mock, patch |
| 8 | | 13 | |
| 9 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) | 14 | release = runpy.run_path(str(Path(__file__).resolve().parent / 'release.py')) |
| 10 | | 15 | |
| ... | @@ -14,6 +19,103 @@ def utc(text): | ... | @@ -14,6 +19,103 @@ def utc(text): |
| 14 | | 19 | |
| 15 | | 20 | |
| 16 | class ReleaseTest(unittest.TestCase): | 21 | class ReleaseTest(unittest.TestCase): |
| | 22 | def test_download_bytes_survive_rejected_permissions(self): |
| | 23 | with tempfile.TemporaryDirectory() as folder: |
| | 24 | source, destination = Path(folder) / 'source', Path(folder) / 'download' |
| | 25 | source.write_bytes(b'an executable') |
| | 26 | warning = io.StringIO() |
| | 27 | with patch.object(release['shutil'], 'copymode', side_effect=PermissionError('chmod denied')), \ |
| | 28 | redirect_stderr(warning): |
| | 29 | release['copy_download'](source, destination) |
| | 30 | self.assertEqual(destination.read_bytes(), source.read_bytes()) |
| | 31 | self.assertIn(str(destination), warning.getvalue()) |
| | 32 | self.assertIn('could not preserve file permissions', warning.getvalue()) |
| | 33 | |
| | 34 | def test_download_preserves_supported_permissions(self): |
| | 35 | with tempfile.TemporaryDirectory() as folder: |
| | 36 | source, destination = Path(folder) / 'source', Path(folder) / 'download' |
| | 37 | source.write_bytes(b'an executable') |
| | 38 | source.chmod(0o750) |
| | 39 | release['copy_download'](source, destination) |
| | 40 | self.assertEqual(destination.read_bytes(), source.read_bytes()) |
| | 41 | self.assertEqual(stat.S_IMODE(destination.stat().st_mode), 0o750) |
| | 42 | |
| | 43 | def test_download_content_failure_stops_before_permissions(self): |
| | 44 | for error in (PermissionError('write denied'), OSError('disk full')): |
| | 45 | with self.subTest(error=error), \ |
| | 46 | patch.object(release['shutil'], 'copyfile', side_effect=error), \ |
| | 47 | patch.object(release['shutil'], 'copymode') as copymode: |
| | 48 | with self.assertRaises(type(error)) as raised: |
| | 49 | release['copy_download'](Path('source'), Path('download')) |
| | 50 | self.assertIs(raised.exception, error) |
| | 51 | copymode.assert_not_called() |
| | 52 | |
| | 53 | def test_download_other_mode_copy_errors_propagate(self): |
| | 54 | with tempfile.TemporaryDirectory() as folder: |
| | 55 | source, destination = Path(folder) / 'source', Path(folder) / 'download' |
| | 56 | source.write_bytes(b'an executable') |
| | 57 | with patch.object(release['shutil'], 'copymode', side_effect=OSError('missing source')): |
| | 58 | with self.assertRaises(OSError): |
| | 59 | release['copy_download'](source, destination) |
| | 60 | |
| | 61 | def test_publication_finishes_when_share_rejects_permissions(self): |
| | 62 | main, scope = release['main'], release['main'].__globals__ |
| | 63 | with tempfile.TemporaryDirectory() as folder: |
| | 64 | root = Path(folder) |
| | 65 | published = root / 'published' |
| | 66 | published.mkdir() |
| | 67 | source = root / 'snowbound' |
| | 68 | source.write_bytes(b'an executable') |
| | 69 | source.chmod(0o750) |
| | 70 | denied = [] |
| | 71 | copymode = release['shutil'].copymode |
| | 72 | |
| | 73 | def share_modes(source, destination, **kwargs): |
| | 74 | if Path(destination).is_relative_to(published): |
| | 75 | denied.append(Path(destination)) |
| | 76 | raise PermissionError('share rejects chmod') |
| | 77 | return copymode(source, destination, **kwargs) |
| | 78 | |
| | 79 | def minisign(files): |
| | 80 | for file in files: |
| | 81 | Path(f'{file}.minisig').write_bytes(b'archive signature') |
| | 82 | |
| | 83 | def split_debug(executable, debug): |
| | 84 | debug.write_bytes(b'debug symbols') |
| | 85 | |
| | 86 | overrides = { |
| | 87 | 'ROOT': root, 'PUBLISHED': published, 'FIRST': 'a', |
| | 88 | 'jj': Mock(return_value='a'), 'clean': Mock(), 'run': Mock(), |
| | 89 | 'history': lambda: {'a': ([], utc('2026-10-05T12:00:00'), 'fix: publish')}, |
| | 90 | 'build_linux': lambda architectures: {'linux-x86_64': source}, |
| | 91 | 'split_debug': split_debug, 'sign': lambda files: ['00' for _ in files], |
| | 92 | 'minisign': minisign, |
| | 93 | } |
| | 94 | warning = io.StringIO() |
| | 95 | with patch.dict(scope, overrides), \ |
| | 96 | patch.object(release['sys'], 'argv', ['release.py', '--platforms', 'linux-x86_64']), \ |
| | 97 | patch.dict(release['os'].environ), \ |
| | 98 | patch.object(release['subprocess'], 'run', return_value=Mock(stdout='')), \ |
| | 99 | patch.object(release['shutil'], 'copymode', side_effect=share_modes), \ |
| | 100 | redirect_stderr(warning), redirect_stdout(io.StringIO()): |
| | 101 | main() |
| | 102 | target = published / '2026-10-05.r1' |
| | 103 | archive = 'snowbound-2026-10-05-r1-linux-x86_64' |
| | 104 | stable = published / 'latest' / 'snowbound-linux-x86_64' |
| | 105 | self.assertEqual((target / archive).read_bytes(), source.read_bytes()) |
| | 106 | self.assertEqual((target / f'{archive}.minisig').read_bytes(), b'archive signature') |
| | 107 | build = json.loads((target / 'build.json').read_text()) |
| | 108 | self.assertEqual(build['archives']['linux-x86_64']['sha256'], |
| | 109 | hashlib.sha256(source.read_bytes()).hexdigest()) |
| | 110 | self.assertEqual((target / 'build.json.sig').read_text(), '00\n') |
| | 111 | self.assertEqual(stable.read_bytes(), source.read_bytes()) |
| | 112 | self.assertEqual(Path(f'{stable}.minisig').read_bytes(), b'archive signature') |
| | 113 | self.assertEqual(json.loads((published / 'latest.json').read_text()), |
| | 114 | {'linux-x86_64': '2026-10-05-r1'}) |
| | 115 | self.assertIn(published / '.2026-10-05.r1.partial' / archive, denied) |
| | 116 | self.assertIn(stable.with_name(f'.{stable.name}.partial'), denied) |
| | 117 | self.assertEqual(warning.getvalue().count('could not preserve file permissions'), len(denied)) |
| | 118 | |
| 17 | def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self): | 119 | def test_a_build_counts_the_commits_of_its_day_in_los_angeles(self): |
| 18 | # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th. | 120 | # 07:34 UTC on the 29th is 00:34 in Los Angeles; 05:00 UTC on the 30th is 22:00 on the 29th. |
| 19 | commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'), | 121 | commits = [utc('2026-09-30T05:00:00'), utc('2026-09-29T19:23:00'), utc('2026-09-29T07:34:00'), |