v30.3-roots.1 · Commit 60: [PATCH 60/68] fix(release): validate internal macOS framework symlinks
ci/test/test_prepare_release.py
Before: ci/test/test_prepare_release.py · After: ci/test/test_prepare_release.py
File metadata
index 07a61705d3d554d35869bcc7bca987d9ae0d148a..7e3d14cf91fbb3452357231d066b4d0e788ad82c 100755
Download original patch · Release notes
Diff rows 1–89 of 89. 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_prepare_release.py
CHANGED
|
@@ -181,6 +181,89 @@ class PrepareReleaseTest(unittest.TestCase):
|
|
|
181
181
|
)
|
|
182
182
|
self.assertNotEqual(result.returncode, 0)
|
|
183
183
|
|
|
184
|
+
def validate_zip_links(self, links, files):
|
|
185
|
+
root = "bitcoin-roots-30.3-roots.1"
|
|
186
|
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
|
187
|
+
archive = Path(temporary_dir) / "framework.zip"
|
|
188
|
+
with zipfile.ZipFile(archive, mode="w") as package:
|
|
189
|
+
for name in files:
|
|
190
|
+
info = zipfile.ZipInfo(f"{root}/{name}")
|
|
191
|
+
info.create_system = 3
|
|
192
|
+
info.external_attr = (0o40755 << 16) | 0x10 if name.endswith("/") else 0o100755 << 16
|
|
193
|
+
package.writestr(info, b"" if name.endswith("/") else b"payload")
|
|
194
|
+
for name, target in links.items():
|
|
195
|
+
info = zipfile.ZipInfo(f"{root}/{name}")
|
|
196
|
+
info.create_system = 3
|
|
197
|
+
info.external_attr = 0o120777 << 16
|
|
198
|
+
package.writestr(info, target)
|
|
199
|
+
return subprocess.run(
|
|
200
|
+
["python3", ARCHIVE_TOOL, "validate", archive, root],
|
|
201
|
+
capture_output=True, text=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def test_accepts_internal_macos_framework_symlink_chains(self):
|
|
205
|
+
framework = "Bitcoin-Qt.app/Contents/Frameworks/QtGui.framework"
|
|
206
|
+
result = self.validate_zip_links(
|
|
207
|
+
{f"{framework}/Versions/Current": "A",
|
|
208
|
+
f"{framework}/Resources": "Versions/Current/Resources",
|
|
209
|
+
f"{framework}/QtGui": "Versions/Current/QtGui"},
|
|
210
|
+
["Bitcoin-Qt.app/", "Bitcoin-Qt.app/Contents/", "Bitcoin-Qt.app/Contents/Frameworks/",
|
|
211
|
+
f"{framework}/", f"{framework}/Versions/", f"{framework}/Versions/A/",
|
|
212
|
+
f"{framework}/Versions/A/Resources/", f"{framework}/Versions/A/Resources/Info.plist",
|
|
213
|
+
f"{framework}/Versions/A/QtGui"],
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
216
|
+
|
|
217
|
+
def test_accepts_repeated_noncyclic_symlink_traversal(self):
|
|
218
|
+
result = self.validate_zip_links(
|
|
219
|
+
{"alias": "dir", "link": "alias/../alias/file"}, ["dir/", "dir/file"],
|
|
220
|
+
)
|
|
221
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
222
|
+
|
|
223
|
+
def test_rejects_unsafe_dangling_and_cyclic_zip_symlinks(self):
|
|
224
|
+
cases = [
|
|
225
|
+
{"link": "/tmp/outside"},
|
|
226
|
+
{"link": "../outside"},
|
|
227
|
+
{"link": "C:/outside"},
|
|
228
|
+
{"link": "..\\outside"},
|
|
229
|
+
{"link": "outside\0"},
|
|
230
|
+
{"link": ""},
|
|
231
|
+
{"link": b"\xff"},
|
|
232
|
+
{"link": "x" * 4097},
|
|
233
|
+
{"link": "missing"},
|
|
234
|
+
{"link": "other", "other": "link"},
|
|
235
|
+
# Resolve alias before ..; lexical normalization alone misses this escape.
|
|
236
|
+
{"alias": ".", "link": "alias/../outside"},
|
|
237
|
+
]
|
|
238
|
+
for links in cases:
|
|
239
|
+
with self.subTest(links=links):
|
|
240
|
+
result = self.validate_zip_links(links, ["bin/bitcoind", "outside"])
|
|
241
|
+
self.assertNotEqual(result.returncode, 0)
|
|
242
|
+
|
|
243
|
+
def test_symlinks_alone_do_not_count_as_release_payload(self):
|
|
244
|
+
result = self.validate_zip_links({"link": "."}, [])
|
|
245
|
+
self.assertNotEqual(result.returncode, 0)
|
|
246
|
+
|
|
247
|
+
def test_rejects_duplicate_and_special_zip_members(self):
|
|
248
|
+
root = "bitcoin-roots-30.3-roots.1"
|
|
249
|
+
with tempfile.TemporaryDirectory() as temporary_dir:
|
|
250
|
+
for kind in ("duplicate", "fifo", "device"):
|
|
251
|
+
with self.subTest(kind=kind):
|
|
252
|
+
archive = Path(temporary_dir) / f"{kind}.zip"
|
|
253
|
+
with zipfile.ZipFile(archive, mode="w") as package:
|
|
254
|
+
package.writestr(f"{root}/bin/bitcoind", b"payload")
|
|
255
|
+
info = zipfile.ZipInfo(f"{root}/bin/bitcoind" if kind == "duplicate" else f"{root}/special")
|
|
256
|
+
info.create_system = 3
|
|
257
|
+
mode = {"duplicate": 0o100755, "fifo": 0o10755, "device": 0o20755}[kind]
|
|
258
|
+
info.external_attr = mode << 16
|
|
259
|
+
package.writestr(info, b"payload")
|
|
260
|
+
result = subprocess.run(
|
|
261
|
+
["python3", ARCHIVE_TOOL, "validate", archive, root],
|
|
262
|
+
capture_output=True, text=True,
|
|
263
|
+
)
|
|
264
|
+
self.assertNotEqual(result.returncode, 0)
|
|
265
|
+
self.assertIn("Duplicate archive member" if kind == "duplicate" else "Unsupported archive member", result.stderr)
|
|
266
|
+
|
|
184
267
|
|
|
185
268
|
if __name__ == "__main__":
|
|
186
269
|
unittest.main()
|