Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 61: [PATCH 61/68] fix(release): assemble platform-specific license notices

ci/test/test_release_notices.py

Before: /dev/null · After: ci/test/test_release_notices.py

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..6178f6560d0aa77c8a9618b6b634378b2409666a

Download original patch · Release notes

Diff rows 1–275 of 275. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. Before is on the left; after is on the right. Unified view fits narrow screens more easily.

ci/test/test_release_notices.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026-present The Bitcoin Roots developers
3
+ # Distributed under the MIT software license, see the accompanying
4
+ # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5
+
6
+ import importlib.util
7
+ import io
8
+ import json
9
+ from pathlib import Path
10
+ import subprocess
11
+ import sys
12
+ import tarfile
13
+ import tempfile
14
+ import unittest
15
+ import zipfile
16
+
17
+ ROOT = Path(__file__).resolve().parents[2]
18
+ NOTICE_TOOL = ROOT / "ci/release/notices.py"
19
+ REPACK_TOOL = ROOT / "ci/release/repack.py"
20
+ SPEC = importlib.util.spec_from_file_location("release_notices", NOTICE_TOOL)
21
+ NOTICES = importlib.util.module_from_spec(SPEC)
22
+ SPEC.loader.exec_module(NOTICES)
23
+
24
+
25
+ class ReleaseNoticesTest(unittest.TestCase):
26
+ def descriptor(self, work, platform):
27
+ # These are executable provider-interface fixtures, not real release inputs.
28
+ receipt = work / "build-receipt.json"
29
+ receipt.write_text('{"version": "fixture-1", "provider": "test"}\n')
30
+ license_file = work / "LICENSE"
31
+ license_file.write_bytes(b"exact fixture dependency license\n")
32
+ entries = {component: {"version": "fixture-1", "source": "fixture:build-receipt.json",
33
+ "receipt": receipt.name, "files": [license_file.name]}
34
+ for component in NOTICES.required_components(platform)}
35
+ path = work / "descriptor.json"
36
+ path.write_text(json.dumps(entries))
37
+ return path
38
+
39
+ def collect(self, work, platform):
40
+ descriptor = self.descriptor(work, platform)
41
+ destination = work / "collected"
42
+ subprocess.run([sys.executable, NOTICE_TOOL, "collect", platform, descriptor, destination], check=True)
43
+ return destination / "index.json"
44
+
45
+ def test_collect_preserves_tracked_and_dependency_bytes(self):
46
+ with tempfile.TemporaryDirectory() as temporary_dir:
47
+ work = Path(temporary_dir)
48
+ index_path = self.collect(work, "linux-x86_64")
49
+ index = json.loads(index_path.read_text())
50
+ for component, source in NOTICES.TRACKED_NOTICES.items():
51
+ self.assertEqual((index_path.parent / f"tracked/{component}.txt").read_bytes(), (ROOT / source).read_bytes())
52
+ for entry in index["components"].values():
53
+ self.assertEqual((index_path.parent / entry["files"][0]).read_bytes(), (work / "LICENSE").read_bytes())
54
+ self.assertEqual((index_path.parent / entry["evidence"]).read_bytes(), (work / "build-receipt.json").read_bytes())
55
+
56
+ def test_missing_receipt_component_or_notice_rejected_before_output(self):
57
+ for problem in ("component", "receipt", "notice"):
58
+ with self.subTest(problem=problem), tempfile.TemporaryDirectory() as temporary_dir:
59
+ work = Path(temporary_dir)
60
+ descriptor = self.descriptor(work, "darwin-arm64")
61
+ metadata = json.loads(descriptor.read_text())
62
+ if problem == "component":
63
+ del metadata["openssl"]
64
+ elif problem == "receipt":
65
+ metadata["qt"]["receipt"] = "absent"
66
+ else:
67
+ metadata["qt"]["files"] = []
68
+ descriptor.write_text(json.dumps(metadata))
69
+ result = subprocess.run([sys.executable, NOTICE_TOOL, "collect", "darwin-arm64", descriptor, work / "collected"], capture_output=True)
70
+ self.assertNotEqual(result.returncode, 0)
71
+ self.assertFalse((work / "collected").exists())
72
+
73
+ def test_independent_source_provenance_does_not_require_runner_receipt(self):
74
+ with tempfile.TemporaryDirectory() as temporary_dir:
75
+ work = Path(temporary_dir)
76
+ descriptor = self.descriptor(work, "linux-x86_64")
77
+ metadata = json.loads(descriptor.read_text())
78
+ for entry in metadata.values():
79
+ entry["evidence"] = entry.pop("receipt")
80
+ entry["source_sha256"] = "a" * 64
81
+ entry["provenance_type"] = "pinned-source-archive"
82
+ descriptor.write_text(json.dumps(metadata))
83
+ NOTICES.collect("linux-x86_64", descriptor, work / "collected")
84
+ index = json.loads((work / "collected/index.json").read_text())
85
+ self.assertEqual(index["components"]["qt"]["source_sha256"], "a" * 64)
86
+ self.assertNotIn("receipt", index["components"]["qt"])
87
+
88
+ def test_unknown_version_requires_explicit_stability_provenance(self):
89
+ for stable in (False, True):
90
+ with self.subTest(stable=stable), tempfile.TemporaryDirectory() as temporary_dir:
91
+ work = Path(temporary_dir)
92
+ descriptor = self.descriptor(work, "linux-x86_64")
93
+ metadata = json.loads(descriptor.read_text())
94
+ metadata["qt"]["version"] = "unknown"
95
+ if stable:
96
+ metadata["qt"]["provenance_type"] = "source-version-stability"
97
+ metadata["qt"]["notice_stable_versions"] = ["fixture-1", "fixture-2"]
98
+ descriptor.write_text(json.dumps(metadata))
99
+ if stable:
100
+ NOTICES.collect("linux-x86_64", descriptor, work / "collected")
101
+ else:
102
+ with self.assertRaisesRegex(ValueError, "Unknown version"):
103
+ NOTICES.collect("linux-x86_64", descriptor, work / "collected")
104
+
105
+ def test_staging_rejects_altered_notice_and_symlink_escape(self):
106
+ for problem in ("altered", "escape"):
107
+ with self.subTest(problem=problem), tempfile.TemporaryDirectory() as temporary_dir:
108
+ work = Path(temporary_dir)
109
+ index_path = self.collect(work, "linux-aarch64")
110
+ index = json.loads(index_path.read_text())
111
+ path = index_path.parent / "tracked/leveldb.txt"
112
+ if problem == "altered":
113
+ path.write_bytes(b"different")
114
+ else:
115
+ outside = work / "outside"
116
+ path.parent.rename(outside)
117
+ path.parent.symlink_to(outside, target_is_directory=True)
118
+ root = work / "package"
119
+ root.mkdir()
120
+ with self.assertRaises(ValueError):
121
+ NOTICES.stage(root, index_path, "linux-aarch64")
122
+ self.assertFalse((root / "notices").exists())
123
+ self.assertIn("tracked/leveldb.txt", index["files"])
124
+
125
+ def test_unsafe_notice_paths_rejected(self):
126
+ for path in ("", "/absolute", "a//b", "a/../b", "./a", "a\\b", "nul\x00"):
127
+ with self.subTest(path=path), self.assertRaises(ValueError):
128
+ NOTICES.safe_path(path)
129
+
130
+ def test_archive_notice_tampering_rejected(self):
131
+ with tempfile.TemporaryDirectory() as temporary_dir:
132
+ work = Path(temporary_dir)
133
+ inputs = self.collect(work, "windows-x86_64")
134
+ root = "bitcoin-roots-30.3-roots.1"
135
+ archive = work / "tampered.zip"
136
+ with zipfile.ZipFile(archive, "w") as package:
137
+ for path in inputs.parent.rglob("*"):
138
+ if path.is_file():
139
+ content = b"altered" if path.name == "leveldb.txt" else path.read_bytes()
140
+ package.writestr(f"{root}/notices/{path.relative_to(inputs.parent).as_posix()}", content)
141
+ with self.assertRaisesRegex(ValueError, "Notice bytes differ"):
142
+ NOTICES.validate_archive(archive, root, "windows-x86_64")
143
+
144
+ def test_wrong_platform_bundle_rejected(self):
145
+ with tempfile.TemporaryDirectory() as temporary_dir:
146
+ work = Path(temporary_dir)
147
+ inputs = self.collect(work, "darwin-arm64")
148
+ root = work / "package"
149
+ root.mkdir()
150
+ with self.assertRaisesRegex(ValueError, "platform mismatch"):
151
+ NOTICES.stage(root, inputs, "darwin-x86_64")
152
+ self.assertFalse((root / "notices").exists())
153
+
154
+ def test_repack_refuses_dangling_destination_symlink(self):
155
+ with tempfile.TemporaryDirectory() as temporary_dir:
156
+ work = Path(temporary_dir)
157
+ inputs = self.collect(work, "windows-x86_64")
158
+ source = work / "original.zip"
159
+ with zipfile.ZipFile(source, "w") as package:
160
+ package.writestr("bitcoin-roots-30.3-roots.1/bin/bitcoind", b"payload")
161
+ destination = work / "repacked.zip"
162
+ target = work / "must-not-be-created.zip"
163
+ destination.symlink_to(target)
164
+ result = subprocess.run([sys.executable, REPACK_TOOL, source, destination,
165
+ "bitcoin-roots-30.3-roots.1", inputs, "windows-x86_64"], capture_output=True)
166
+ self.assertNotEqual(result.returncode, 0)
167
+ self.assertTrue(destination.is_symlink())
168
+ self.assertFalse(target.exists())
169
+
170
+ def test_repack_rejects_changed_or_nonregular_existing_copying(self):
171
+ for kind in ("changed", "symlink", "directory"):
172
+ with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temporary_dir:
173
+ work = Path(temporary_dir)
174
+ inputs = self.collect(work, "windows-x86_64")
175
+ source = work / "original.zip"
176
+ root = "bitcoin-roots-30.3-roots.1"
177
+ with zipfile.ZipFile(source, "w") as package:
178
+ package.writestr(f"{root}/bin/bitcoind", b"binary")
179
+ info = zipfile.ZipInfo(f"{root}/COPYING" + ("/" if kind == "directory" else ""))
180
+ info.create_system = 3
181
+ info.external_attr = (0o120777 if kind == "symlink" else 0o40755 if kind == "directory" else 0o100644) << 16
182
+ package.writestr(info, "bin/bitcoind" if kind == "symlink" else b"wrong COPYING")
183
+ destination = work / "assembled.zip"
184
+ result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root,
185
+ inputs, "windows-x86_64"], capture_output=True, text=True)
186
+ self.assertNotEqual(result.returncode, 0)
187
+ self.assertIn("COPYING", result.stderr)
188
+ self.assertFalse(destination.exists())
189
+
190
+ def test_existing_canonical_copying_is_preserved_but_notices_are_not_replaced(self):
191
+ for existing_notices in (False, True):
192
+ with self.subTest(existing_notices=existing_notices), tempfile.TemporaryDirectory() as temporary_dir:
193
+ work = Path(temporary_dir)
194
+ inputs = self.collect(work, "windows-x86_64")
195
+ source = work / "original.zip"
196
+ root = "bitcoin-roots-30.3-roots.1"
197
+ with zipfile.ZipFile(source, "w") as package:
198
+ package.writestr(f"{root}/bin/bitcoind", b"binary")
199
+ copying = zipfile.ZipInfo(f"{root}/COPYING", (2019, 1, 2, 3, 4, 6))
200
+ copying.create_system = 3
201
+ copying.external_attr = 0o100640 << 16
202
+ package.writestr(copying, (ROOT / "COPYING").read_bytes())
203
+ if existing_notices:
204
+ package.writestr(f"{root}/notices/previous.txt", b"existing notice")
205
+ destination = work / "assembled.zip"
206
+ result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root,
207
+ inputs, "windows-x86_64"], capture_output=True, text=True)
208
+ if existing_notices:
209
+ self.assertNotEqual(result.returncode, 0)
210
+ self.assertIn("already contains notices", result.stderr)
211
+ self.assertFalse(destination.exists())
212
+ else:
213
+ self.assertEqual(result.returncode, 0, result.stderr)
214
+ self.assertEqual(json.loads(result.stdout)["unchanged_members"], 2)
215
+ with zipfile.ZipFile(destination) as package:
216
+ self.assertEqual(package.read(copying.filename), (ROOT / "COPYING").read_bytes())
217
+ self.assertEqual(package.getinfo(copying.filename).external_attr, copying.external_attr)
218
+ self.assertEqual(package.getinfo(copying.filename).date_time, copying.date_time)
219
+
220
+ def test_repack_all_platforms_preserves_binary_modes_and_app_links(self):
221
+ for platform in sorted(NOTICES.PLATFORMS):
222
+ with self.subTest(platform=platform), tempfile.TemporaryDirectory() as temporary_dir:
223
+ work = Path(temporary_dir)
224
+ inputs = self.collect(work, platform)
225
+ root = "bitcoin-roots-30.3-roots.1"
226
+ source = work / ("original.tar.gz" if platform.startswith("linux") else "original.zip")
227
+ destination = work / ("repacked.tar.gz" if platform.startswith("linux") else "repacked.zip")
228
+ payload = b"binary bytes including signature fixture\x00\xff"
229
+ member_name = f"{root}/Bitcoin-Qt.app/Contents/MacOS/Bitcoin-Qt" if platform.startswith("darwin") else f"{root}/bin/bitcoind"
230
+ if platform.startswith("linux"):
231
+ with tarfile.open(source, "w:gz") as package:
232
+ info = tarfile.TarInfo(member_name)
233
+ info.mode = 0o755
234
+ info.mtime = 123456
235
+ info.size = len(payload)
236
+ package.addfile(info, io.BytesIO(payload))
237
+ else:
238
+ with zipfile.ZipFile(source, "w") as package:
239
+ info = zipfile.ZipInfo(member_name, (2020, 1, 2, 3, 4, 6))
240
+ info.create_system = 3
241
+ info.external_attr = 0o100755 << 16
242
+ package.writestr(info, payload)
243
+ if platform.startswith("windows"):
244
+ # PowerShell archives may carry no Unix mode at all.
245
+ package.filelist[-1].external_attr = 0
246
+ if platform.startswith("darwin"):
247
+ link = zipfile.ZipInfo(f"{root}/Bitcoin-Qt.app/Contents/MacOS/current")
248
+ link.create_system = 3
249
+ link.external_attr = 0o120755 << 16
250
+ package.writestr(link, "Bitcoin-Qt")
251
+ result = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root, inputs, platform], capture_output=True, text=True)
252
+ self.assertEqual(result.returncode, 0, result.stderr)
253
+ proof = json.loads(result.stdout)
254
+ self.assertTrue(proof["payload_bytes_modes_links_unchanged"])
255
+ self.assertEqual(proof["unchanged_members"], 2 if platform.startswith("darwin") else 1)
256
+ NOTICES.validate_archive(destination, root, platform)
257
+ if destination.suffix == ".zip":
258
+ with zipfile.ZipFile(destination) as package:
259
+ self.assertEqual(package.read(member_name), payload)
260
+ self.assertEqual(package.getinfo(member_name).external_attr >> 16,
261
+ 0 if platform.startswith("windows") else 0o100755)
262
+ if platform.startswith("darwin"):
263
+ self.assertEqual(package.read(link.filename), b"Bitcoin-Qt")
264
+ added = [name for name in package.namelist() if "/COPYING" in name or "/notices/" in name]
265
+ self.assertTrue(all(".app/" not in name for name in added))
266
+ else:
267
+ with tarfile.open(destination) as package:
268
+ self.assertEqual(package.extractfile(member_name).read(), payload)
269
+ self.assertEqual(package.getmember(member_name).mode, 0o755)
270
+ retry = subprocess.run([sys.executable, REPACK_TOOL, source, destination, root, inputs, platform], capture_output=True)
271
+ self.assertNotEqual(retry.returncode, 0)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ unittest.main()