Skip to content
Bitcoin Roots

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()