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. The two line-number columns refer to before and after.
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()
|