From 266eb7fff5ab01c0d8eca1b57e1c5cc45330dddb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:35:10 +0900 Subject: [PATCH 1/9] test(release): expose unbounded sdist member enumeration --- ...istribution_verifier_sdist_member_bound.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_distribution_verifier_sdist_member_bound.py diff --git a/tests/test_distribution_verifier_sdist_member_bound.py b/tests/test_distribution_verifier_sdist_member_bound.py new file mode 100644 index 0000000..532f530 --- /dev/null +++ b/tests/test_distribution_verifier_sdist_member_bound.py @@ -0,0 +1,84 @@ +"""Regression coverage for bounded source-distribution member verification.""" + +from __future__ import annotations + +import importlib.util +import io +import tarfile +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +VERIFIER_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "verify_distribution.py" +VERSION = "0.3.0" +PREFIX = f"egressweave-{VERSION}" +REQUIRED_SDIST_PATHS = ( + f"{PREFIX}/pyproject.toml", + f"{PREFIX}/README.md", + f"{PREFIX}/CHANGELOG.md", + f"{PREFIX}/LICENSE", + f"{PREFIX}/src/egressweave/__init__.py", + f"{PREFIX}/src/egressweave/py.typed", + f"{PREFIX}/src/egressweave/schemas/decision-evidence-v1.schema.json", + f"{PREFIX}/tests/test_quality_contracts.py", + f"{PREFIX}/docs/release.md", +) + + +def _load_verifier(): + """Load the non-packaged distribution verifier from the repository tree.""" + specification = importlib.util.spec_from_file_location( + "egressweave_verify_distribution_member_bound", + VERIFIER_PATH, + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _write_sdist(path: Path, extra_paths: tuple[str, ...] = ()) -> None: + """Write one deterministic zero-payload gzip tar with the requested paths.""" + with tarfile.open(path, mode="w:gz") as archive: + for name in (*REQUIRED_SDIST_PATHS, *extra_paths): + member = tarfile.TarInfo(name) + member.size = 0 + archive.addfile(member, io.BytesIO()) + + +def test_sdist_verifier_never_materializes_the_complete_member_list( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Require streaming semantic admission instead of ``TarFile.getmembers()``.""" + verifier = _load_verifier() + sdist_path = tmp_path / f"egressweave-{VERSION}.tar.gz" + _write_sdist(sdist_path) + + def reject_getmembers(self: tarfile.TarFile): + del self + raise AssertionError("distribution verifier materialized every sdist member") + + monkeypatch.setattr(tarfile.TarFile, "getmembers", reject_getmembers) + + digest = verifier._verify_sdist(sdist_path, {"version": VERSION}) + + assert len(digest) == 64 + + +def test_sdist_verifier_rejects_the_first_member_beyond_the_finite_budget( + tmp_path: Path, +) -> None: + """Stop semantic enumeration at the reviewed ceiling before retaining more names.""" + verifier = _load_verifier() + assert verifier.MAX_SDIST_MEMBERS == 4096 + sdist_path = tmp_path / f"egressweave-{VERSION}.tar.gz" + extra_count = verifier.MAX_SDIST_MEMBERS - len(REQUIRED_SDIST_PATHS) + 1 + extra_paths = tuple( + f"{PREFIX}/bounded-member-{index:04d}.txt" for index in range(extra_count) + ) + _write_sdist(sdist_path, extra_paths) + + with pytest.raises(SystemExit, match="source distribution member limit"): + verifier._verify_sdist(sdist_path, {"version": VERSION}) From d2c3750cb3de43049d4c50ddf88cf922f8434feb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:38:59 +0900 Subject: [PATCH 2/9] test(release): bound tar metadata retention --- ...istribution_verifier_sdist_member_bound.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_distribution_verifier_sdist_member_bound.py b/tests/test_distribution_verifier_sdist_member_bound.py index 532f530..d775337 100644 --- a/tests/test_distribution_verifier_sdist_member_bound.py +++ b/tests/test_distribution_verifier_sdist_member_bound.py @@ -67,6 +67,32 @@ def reject_getmembers(self: tarfile.TarFile): assert len(digest) == 64 +def test_sdist_verifier_does_not_accumulate_tarinfo_cache( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep tarfile's internal metadata cache bounded during streaming admission.""" + verifier = _load_verifier() + sdist_path = tmp_path / f"egressweave-{VERSION}.tar.gz" + extra_paths = tuple(f"{PREFIX}/cache-probe-{index:02d}.txt" for index in range(32)) + _write_sdist(sdist_path, extra_paths) + original_next = tarfile.TarFile.next + max_cached_members = 0 + + def recording_next(self: tarfile.TarFile): + nonlocal max_cached_members + max_cached_members = max(max_cached_members, len(self.members)) + member = original_next(self) + max_cached_members = max(max_cached_members, len(self.members)) + return member + + monkeypatch.setattr(tarfile.TarFile, "next", recording_next) + + verifier._verify_sdist(sdist_path, {"version": VERSION}) + + assert max_cached_members <= 1 + + def test_sdist_verifier_rejects_the_first_member_beyond_the_finite_budget( tmp_path: Path, ) -> None: From 7f5ece53338a222e2b2e99fe618e0282782648eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:40:46 +0900 Subject: [PATCH 3/9] fix(release): stream bounded sdist members --- scripts/ci/verify_distribution.py | 46 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/scripts/ci/verify_distribution.py b/scripts/ci/verify_distribution.py index 1341721..405b1e0 100644 --- a/scripts/ci/verify_distribution.py +++ b/scripts/ci/verify_distribution.py @@ -33,6 +33,7 @@ DISTRIBUTION_NAME = "egressweave" HASH_CHUNK_SIZE = 1024 * 1024 MAX_DISTRIBUTION_BYTES = 256 * 1024 * 1024 +MAX_SDIST_MEMBERS = 4096 CHANGELOG_RELEASE_PATTERN = re.compile( r"^## \[(?P\d+\.\d+\.\d+)\] - (?P\d{4}-\d{2}-\d{2})$", flags=re.MULTILINE, @@ -199,20 +200,25 @@ def _select_archives(dist_dir: Path, name: str, version: str) -> tuple[Path, Pat return wheel_path, sdist_path +def _admit_archive_name(name: str, seen: set[str]) -> None: + """Validate and retain one archive path without building an unbounded name list.""" + pure_path = PurePosixPath(name) + if ( + not name + or "\\" in name + or pure_path.is_absolute() + or any(part in {"", ".", ".."} for part in pure_path.parts) + or name in seen + ): + raise SystemExit(f"distribution contains an unsafe archive path: {name!r}") + seen.add(name) + + def _safe_archive_names(names: list[str]) -> set[str]: """Reject absolute, parent-traversing, duplicate, or backslash archive paths.""" seen: set[str] = set() for name in names: - pure_path = PurePosixPath(name) - if ( - not name - or "\\" in name - or pure_path.is_absolute() - or any(part in {"", ".", ".."} for part in pure_path.parts) - or name in seen - ): - raise SystemExit(f"distribution contains an unsafe archive path: {name!r}") - seen.add(name) + _admit_archive_name(name, seen) return seen @@ -260,7 +266,7 @@ def _verify_wheel(wheel_path: Path, project: dict[str, object]) -> str: def _verify_sdist(sdist_path: Path, project: dict[str, object]) -> str: - """Verify source-distribution paths and return its parsed snapshot digest.""" + """Verify source-distribution paths with bounded streaming member admission.""" version = str(project["version"]) prefix = f"{DISTRIBUTION_NAME}-{version}" required_paths = { @@ -281,11 +287,17 @@ def _verify_sdist(sdist_path: Path, project: dict[str, object]) -> str: with _open_stable_distribution(sdist_path) as sdist_file: sdist_digest = _sha256_stream(sdist_file) sdist_file.seek(0) - with tarfile.open(fileobj=sdist_file, mode="r:gz") as sdist_archive: - members = sdist_archive.getmembers() - names = _safe_archive_names([member.name for member in members]) - if any(member.issym() or member.islnk() or member.isdev() for member in members): - raise SystemExit("source distribution contains a link or device entry") + names: set[str] = set() + with tarfile.open(fileobj=sdist_file, mode="r|gz") as sdist_archive: + for member_count, member in enumerate(sdist_archive, start=1): + if member_count > MAX_SDIST_MEMBERS: + raise SystemExit("source distribution member limit exceeded") + _admit_archive_name(member.name, names) + if member.issym() or member.islnk() or member.isdev(): + raise SystemExit("source distribution contains a link or device entry") + # Python 3.10-3.14 retain yielded TarInfo objects in this public list. + # Links are rejected above, so no later member may need that cache. + sdist_archive.members.clear() missing = required_paths - names if missing: @@ -398,4 +410,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 89d99625b79e8a32833250ec9e2271d5ef9ec45a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:51:15 +0900 Subject: [PATCH 4/9] test(release): expose unbounded wheel member materialization --- ...istribution_verifier_wheel_member_bound.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/test_distribution_verifier_wheel_member_bound.py diff --git a/tests/test_distribution_verifier_wheel_member_bound.py b/tests/test_distribution_verifier_wheel_member_bound.py new file mode 100644 index 0000000..73a8606 --- /dev/null +++ b/tests/test_distribution_verifier_wheel_member_bound.py @@ -0,0 +1,115 @@ +"""Regression coverage for bounded wheel member verification.""" + +from __future__ import annotations + +import importlib.util +import zipfile +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +VERIFIER_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "verify_distribution.py" +VERSION = "0.3.0" +DIST_INFO = f"egressweave-{VERSION}.dist-info" +PROJECT = { + "name": "egressweave", + "version": VERSION, + "requires-python": ">=3.10", + "license": "Apache-2.0", +} +REQUIRED_WHEEL_PATHS = { + "egressweave/__init__.py": b"", + "egressweave/py.typed": b"", + "egressweave/schemas/decision-evidence-v1.schema.json": b"{}", + f"{DIST_INFO}/METADATA": ( + b"Metadata-Version: 2.4\n" + b"Name: egressweave\n" + b"Version: 0.3.0\n" + b"Requires-Python: >=3.10\n" + b"License-Expression: Apache-2.0\n" + b"License-File: LICENSE\n\n" + ), + f"{DIST_INFO}/WHEEL": b"Wheel-Version: 1.0\nTag: py3-none-any\n", + f"{DIST_INFO}/RECORD": b"", + f"{DIST_INFO}/licenses/LICENSE": b"Apache License\n", +} + + +def _load_verifier(): + """Load the non-packaged distribution verifier from the repository tree.""" + specification = importlib.util.spec_from_file_location( + "egressweave_verify_distribution_wheel_bound", + VERIFIER_PATH, + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _write_wheel(path: Path, extra_member_count: int = 0) -> None: + """Write one canonical tiny wheel plus a requested count of zero-byte members.""" + with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_STORED) as archive: + for name, payload in REQUIRED_WHEEL_PATHS.items(): + archive.writestr(name, payload) + for index in range(extra_member_count): + archive.writestr(f"egressweave/bounded-member-{index:04d}.txt", b"") + + +def test_wheel_verifier_preflights_member_budget_before_zipfile_materialization( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Require bounded central-directory admission before ``ZipFile`` construction.""" + verifier = _load_verifier() + wheel_path = tmp_path / f"egressweave-{VERSION}-py3-none-any.whl" + _write_wheel(wheel_path) + preflight_called = False + + def record_preflight(stream) -> None: + nonlocal preflight_called + preflight_called = True + stream.seek(0) + + monkeypatch.setattr( + verifier, + "_preflight_wheel_members", + record_preflight, + raising=False, + ) + original_zipfile = zipfile.ZipFile + + class GuardedZipFile(original_zipfile): + """Reject standard ZIP parsing unless the verifier first admitted the budget.""" + + def __init__(self, *args, **kwargs) -> None: + assert preflight_called, "ZipFile materialized members before bounded preflight" + super().__init__(*args, **kwargs) + + monkeypatch.setattr(zipfile, "ZipFile", GuardedZipFile) + + digest = verifier._verify_wheel(wheel_path, PROJECT) + + assert len(digest) == 64 + + +def test_wheel_verifier_rejects_over_budget_central_directory(tmp_path: Path) -> None: + """Reject an over-budget canonical wheel before semantic ``ZipInfo`` allocation.""" + verifier = _load_verifier() + wheel_path = tmp_path / f"egressweave-{VERSION}-py3-none-any.whl" + budget = 4096 + _write_wheel( + wheel_path, + extra_member_count=budget - len(REQUIRED_WHEEL_PATHS) + 1, + ) + + with pytest.raises(SystemExit, match="wheel member limit"): + verifier._verify_wheel(wheel_path, PROJECT) + + +def test_wheel_member_budget_is_explicit_and_reviewable() -> None: + """Keep the publication verifier's central-directory object budget stable.""" + verifier = _load_verifier() + + assert verifier.MAX_WHEEL_MEMBERS == 4096 From a6f1bea8b410a19fc3a9f87e0244c6b7826de6c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:53:24 +0900 Subject: [PATCH 5/9] fix(release): preflight bounded wheel members --- scripts/ci/verify_distribution.py | 137 +++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/scripts/ci/verify_distribution.py b/scripts/ci/verify_distribution.py index 405b1e0..7849501 100644 --- a/scripts/ci/verify_distribution.py +++ b/scripts/ci/verify_distribution.py @@ -15,6 +15,7 @@ import os import re import stat +import struct import tarfile import tempfile import zipfile @@ -34,6 +35,13 @@ HASH_CHUNK_SIZE = 1024 * 1024 MAX_DISTRIBUTION_BYTES = 256 * 1024 * 1024 MAX_SDIST_MEMBERS = 4096 +MAX_WHEEL_MEMBERS = 4096 +ZIP_EOCD_SIGNATURE = b"PK\x05\x06" +ZIP64_EOCD_LOCATOR_SIGNATURE = b"PK\x06\x07" +ZIP64_EOCD_LOCATOR_SIZE = 20 +ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02" +ZIP_EOCD = struct.Struct("<4s4H2LH") +ZIP_CENTRAL_HEADER = struct.Struct("<4s6H3L5H2L") CHANGELOG_RELEASE_PATTERN = re.compile( r"^## \[(?P\d+\.\d+\.\d+)\] - (?P\d{4}-\d{2}-\d{2})$", flags=re.MULTILINE, @@ -222,8 +230,134 @@ def _safe_archive_names(names: list[str]) -> set[str]: return seen +def _read_exact(stream: BinaryIO, size: int, error_message: str) -> bytes: + """Read exactly ``size`` stable-snapshot bytes or fail closed on truncation.""" + payload = stream.read(size) + if len(payload) != size: + raise SystemExit(error_message) + return payload + + +def _wheel_snapshot_size(stream: BinaryIO) -> int: + """Return one finite stable wheel snapshot size without reopening its path.""" + try: + size = stream.seek(0, os.SEEK_END) + except OSError: + raise SystemExit("wheel is not a valid ZIP archive") from None + if ( + isinstance(size, bool) + or not isinstance(size, int) + or size < 0 + or size > MAX_DISTRIBUTION_BYTES + ): + raise SystemExit("wheel is not a valid ZIP archive") + return size + + +def _find_wheel_eocd(stream: BinaryIO) -> tuple[int, tuple[int, ...]]: + """Locate one canonical single-disk ZIP end record with a bounded tail read.""" + invalid = "wheel is not a valid ZIP archive" + archive_size = _wheel_snapshot_size(stream) + tail_size = min(archive_size, ZIP_EOCD.size + 65_535) + stream.seek(archive_size - tail_size) + tail = _read_exact(stream, tail_size, invalid) + candidate = tail.rfind(ZIP_EOCD_SIGNATURE) + while candidate >= 0: + if candidate + ZIP_EOCD.size <= len(tail): + record = ZIP_EOCD.unpack_from(tail, candidate) + if candidate + ZIP_EOCD.size + record[-1] == len(tail): + return archive_size - tail_size + candidate, record[1:] + candidate = tail.rfind(ZIP_EOCD_SIGNATURE, 0, candidate) + raise SystemExit(invalid) + + +def _wheel_extra_uses_zip64(extra: bytes) -> bool: + """Return whether a central-directory extra field declares ZIP64 data.""" + invalid = "wheel is not a valid ZIP archive" + cursor = 0 + while cursor < len(extra): + if cursor + 4 > len(extra): + raise SystemExit(invalid) + field_id, field_size = struct.unpack_from(" len(extra): + raise SystemExit(invalid) + if field_id == 0x0001: + return True + cursor += field_size + return False + + +def _wheel_tail_before(stream: BinaryIO, offset: int, size: int) -> bytes: + """Read at most ``size`` bytes immediately before one ZIP structure.""" + start = max(0, offset - size) + stream.seek(start) + return _read_exact( + stream, + offset - start, + "wheel is not a valid ZIP archive", + ) + + +def _preflight_wheel_members(stream: BinaryIO) -> None: + """Bound canonical ZIP entries before ``ZipFile`` allocates ``ZipInfo`` objects.""" + invalid = "wheel is not a valid ZIP archive" + eocd_offset, fields = _find_wheel_eocd(stream) + disk_number, directory_disk, disk_entries, total_entries, size, offset, _ = fields + if ( + disk_number != 0 + or directory_disk != 0 + or disk_entries != total_entries + or ZIP64_EOCD_LOCATOR_SIGNATURE + in _wheel_tail_before(stream, eocd_offset, ZIP64_EOCD_LOCATOR_SIZE) + ): + raise SystemExit(invalid) + if ( + total_entries == 0xFFFF + or size == 0xFFFFFFFF + or offset == 0xFFFFFFFF + or offset + size != eocd_offset + ): + raise SystemExit(invalid) + if total_entries > MAX_WHEEL_MEMBERS: + raise SystemExit("wheel member limit exceeded") + + stream.seek(offset) + consumed = 0 + actual_entries = 0 + while consumed < size: + fixed = _read_exact(stream, ZIP_CENTRAL_HEADER.size, invalid) + consumed += len(fixed) + values = ZIP_CENTRAL_HEADER.unpack(fixed) + if values[0] != ZIP_CENTRAL_SIGNATURE: + raise SystemExit(invalid) + compressed_size, uncompressed_size = values[8], values[9] + name_size, extra_size, comment_size = values[10], values[11], values[12] + start_disk, local_offset = values[13], values[16] + variable_size = name_size + extra_size + comment_size + if consumed + variable_size > size: + raise SystemExit(invalid) + variable = _read_exact(stream, variable_size, invalid) + consumed += variable_size + extra = variable[name_size : name_size + extra_size] + if ( + start_disk != 0 + or compressed_size == 0xFFFFFFFF + or uncompressed_size == 0xFFFFFFFF + or local_offset == 0xFFFFFFFF + or _wheel_extra_uses_zip64(extra) + ): + raise SystemExit(invalid) + actual_entries += 1 + if actual_entries > MAX_WHEEL_MEMBERS: + raise SystemExit("wheel member limit exceeded") + if consumed != size or actual_entries != total_entries: + raise SystemExit(invalid) + stream.seek(0) + + def _verify_wheel(wheel_path: Path, project: dict[str, object]) -> str: - """Verify wheel contents and return the digest of the exact parsed snapshot.""" + """Verify wheel contents after bounded central-directory member admission.""" version = str(project["version"]) dist_info = f"{DISTRIBUTION_NAME}-{version}.dist-info" required_paths = { @@ -239,6 +373,7 @@ def _verify_wheel(wheel_path: Path, project: dict[str, object]) -> str: with _open_stable_distribution(wheel_path) as wheel_file: wheel_digest = _sha256_stream(wheel_file) wheel_file.seek(0) + _preflight_wheel_members(wheel_file) with zipfile.ZipFile(wheel_file) as wheel_archive: names = _safe_archive_names(wheel_archive.namelist()) missing = required_paths - names From 9891b1d9cc75e4e47cf58a71c2d4a36b39050a37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 13:31:27 +0900 Subject: [PATCH 6/9] test: allow ZIP64 signature bytes in wheel member comments --- ...istribution_verifier_wheel_member_bound.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_distribution_verifier_wheel_member_bound.py b/tests/test_distribution_verifier_wheel_member_bound.py index 73a8606..2381956 100644 --- a/tests/test_distribution_verifier_wheel_member_bound.py +++ b/tests/test_distribution_verifier_wheel_member_bound.py @@ -57,6 +57,18 @@ def _write_wheel(path: Path, extra_member_count: int = 0) -> None: archive.writestr(f"egressweave/bounded-member-{index:04d}.txt", b"") +def _write_wheel_with_member_comment(path: Path) -> None: + """Write a standard non-ZIP64 wheel whose final member has a ZIP-signature comment.""" + entries = list(REQUIRED_WHEEL_PATHS.items()) + with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_STORED) as archive: + for name, payload in entries[:-1]: + archive.writestr(name, payload) + name, payload = entries[-1] + member = zipfile.ZipInfo(name) + member.comment = b"comment-PK\x06\x07-tail" + archive.writestr(member, payload) + + def test_wheel_verifier_preflights_member_budget_before_zipfile_materialization( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -108,6 +120,19 @@ def test_wheel_verifier_rejects_over_budget_central_directory(tmp_path: Path) -> verifier._verify_wheel(wheel_path, PROJECT) +def test_wheel_verifier_allows_zip64_signature_bytes_inside_member_comment( + tmp_path: Path, +) -> None: + """Treat ZIP64 locator bytes as structure only at the locator's exact position.""" + verifier = _load_verifier() + wheel_path = tmp_path / f"egressweave-{VERSION}-py3-none-any.whl" + _write_wheel_with_member_comment(wheel_path) + + digest = verifier._verify_wheel(wheel_path, PROJECT) + + assert len(digest) == 64 + + def test_wheel_member_budget_is_explicit_and_reviewable() -> None: """Keep the publication verifier's central-directory object budget stable.""" verifier = _load_verifier() From d8795dede7a74ff7fd60f938c556a8c726d6635a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:42:35 +0900 Subject: [PATCH 7/9] fix(release): recognize ZIP64 locator only at exact slot --- scripts/ci/verify_distribution.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/ci/verify_distribution.py b/scripts/ci/verify_distribution.py index 7849501..644eeac 100644 --- a/scripts/ci/verify_distribution.py +++ b/scripts/ci/verify_distribution.py @@ -308,8 +308,11 @@ def _preflight_wheel_members(stream: BinaryIO) -> None: disk_number != 0 or directory_disk != 0 or disk_entries != total_entries - or ZIP64_EOCD_LOCATOR_SIGNATURE - in _wheel_tail_before(stream, eocd_offset, ZIP64_EOCD_LOCATOR_SIZE) + or _wheel_tail_before( + stream, + eocd_offset, + ZIP64_EOCD_LOCATOR_SIZE, + ).startswith(ZIP64_EOCD_LOCATOR_SIGNATURE) ): raise SystemExit(invalid) if ( @@ -545,4 +548,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 1a1e56e82afe65ac642097056c825a786a4f5dcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:50:51 +0900 Subject: [PATCH 8/9] test(release): cover archive member comment boundary --- ...istribution_verifier_wheel_member_bound.py | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/test_distribution_verifier_wheel_member_bound.py b/tests/test_distribution_verifier_wheel_member_bound.py index 2381956..832dd70 100644 --- a/tests/test_distribution_verifier_wheel_member_bound.py +++ b/tests/test_distribution_verifier_wheel_member_bound.py @@ -57,15 +57,18 @@ def _write_wheel(path: Path, extra_member_count: int = 0) -> None: archive.writestr(f"egressweave/bounded-member-{index:04d}.txt", b"") -def _write_wheel_with_member_comment(path: Path) -> None: - """Write a standard non-ZIP64 wheel whose final member has a ZIP-signature comment.""" +def _write_wheel_with_member_comment( + path: Path, + comment: bytes = b"comment-PK\x06\x07-tail", +) -> None: + """Write a standard non-ZIP64 wheel with one chosen final-member comment.""" entries = list(REQUIRED_WHEEL_PATHS.items()) with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_STORED) as archive: for name, payload in entries[:-1]: archive.writestr(name, payload) name, payload = entries[-1] member = zipfile.ZipInfo(name) - member.comment = b"comment-PK\x06\x07-tail" + member.comment = comment archive.writestr(member, payload) @@ -133,6 +136,21 @@ def test_wheel_verifier_allows_zip64_signature_bytes_inside_member_comment( assert len(digest) == 64 +def test_wheel_verifier_allows_locator_shaped_exact_slot_member_comment( + tmp_path: Path, +) -> None: + """Do not confuse one exact-length final member comment with a ZIP64 locator.""" + verifier = _load_verifier() + wheel_path = tmp_path / f"egressweave-{VERSION}-py3-none-any.whl" + locator_shaped_comment = b"PK\x06\x07" + (b"x" * 16) + assert len(locator_shaped_comment) == 20 + _write_wheel_with_member_comment(wheel_path, locator_shaped_comment) + + digest = verifier._verify_wheel(wheel_path, PROJECT) + + assert len(digest) == 64 + + def test_wheel_member_budget_is_explicit_and_reviewable() -> None: """Keep the publication verifier's central-directory object budget stable.""" verifier = _load_verifier() From 7254db0217034890e7500f54492110938f843dae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:38:57 +0900 Subject: [PATCH 9/9] fix(release): distinguish wheel comments from ZIP64 locators --- scripts/ci/verify_distribution.py | 104 +++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/scripts/ci/verify_distribution.py b/scripts/ci/verify_distribution.py index 644eeac..941f80b 100644 --- a/scripts/ci/verify_distribution.py +++ b/scripts/ci/verify_distribution.py @@ -37,10 +37,13 @@ MAX_SDIST_MEMBERS = 4096 MAX_WHEEL_MEMBERS = 4096 ZIP_EOCD_SIGNATURE = b"PK\x05\x06" +ZIP64_EOCD_SIGNATURE = b"PK\x06\x06" ZIP64_EOCD_LOCATOR_SIGNATURE = b"PK\x06\x07" ZIP64_EOCD_LOCATOR_SIZE = 20 ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02" ZIP_EOCD = struct.Struct("<4s4H2LH") +ZIP64_EOCD_LOCATOR = struct.Struct("<4sLQL") +ZIP64_EOCD_PREFIX = struct.Struct("<4sQ") ZIP_CENTRAL_HEADER = struct.Struct("<4s6H3L5H2L") CHANGELOG_RELEASE_PATTERN = re.compile( r"^## \[(?P\d+\.\d+\.\d+)\] - (?P\d{4}-\d{2}-\d{2})$", @@ -299,8 +302,39 @@ def _wheel_tail_before(stream: BinaryIO, offset: int, size: int) -> bytes: ) -def _preflight_wheel_members(stream: BinaryIO) -> None: - """Bound canonical ZIP entries before ``ZipFile`` allocates ``ZipInfo`` objects.""" +def _wheel_zip64_locator_is_structural(stream: BinaryIO, eocd_offset: int) -> bool: + """Recognize a ZIP64 locator only when its pointer frames a ZIP64 end record.""" + if eocd_offset < ZIP64_EOCD_LOCATOR_SIZE: + return False + locator = _wheel_tail_before(stream, eocd_offset, ZIP64_EOCD_LOCATOR_SIZE) + if len(locator) != ZIP64_EOCD_LOCATOR_SIZE: + return False + signature, disk_number, zip64_offset, total_disks = ZIP64_EOCD_LOCATOR.unpack(locator) + if ( + signature != ZIP64_EOCD_LOCATOR_SIGNATURE + or disk_number != 0 + or total_disks != 1 + ): + return False + locator_offset = eocd_offset - ZIP64_EOCD_LOCATOR_SIZE + if zip64_offset > locator_offset - ZIP64_EOCD_PREFIX.size: + return False + stream.seek(zip64_offset) + prefix = _read_exact( + stream, + ZIP64_EOCD_PREFIX.size, + "wheel is not a valid ZIP archive", + ) + record_signature, record_size = ZIP64_EOCD_PREFIX.unpack(prefix) + return ( + record_signature == ZIP64_EOCD_SIGNATURE + and record_size >= 44 + and zip64_offset + ZIP64_EOCD_PREFIX.size + record_size == locator_offset + ) + + +def _preflight_wheel_members(stream: BinaryIO) -> int | None: + """Bound ZIP entries and return a safe stdlib-locator mask offset if needed.""" invalid = "wheel is not a valid ZIP archive" eocd_offset, fields = _find_wheel_eocd(stream) disk_number, directory_disk, disk_entries, total_entries, size, offset, _ = fields @@ -308,11 +342,7 @@ def _preflight_wheel_members(stream: BinaryIO) -> None: disk_number != 0 or directory_disk != 0 or disk_entries != total_entries - or _wheel_tail_before( - stream, - eocd_offset, - ZIP64_EOCD_LOCATOR_SIZE, - ).startswith(ZIP64_EOCD_LOCATOR_SIGNATURE) + or _wheel_zip64_locator_is_structural(stream, eocd_offset) ): raise SystemExit(invalid) if ( @@ -328,6 +358,7 @@ def _preflight_wheel_members(stream: BinaryIO) -> None: stream.seek(offset) consumed = 0 actual_entries = 0 + locator_comment_offset: int | None = None while consumed < size: fixed = _read_exact(stream, ZIP_CENTRAL_HEADER.size, invalid) consumed += len(fixed) @@ -351,12 +382,43 @@ def _preflight_wheel_members(stream: BinaryIO) -> None: or _wheel_extra_uses_zip64(extra) ): raise SystemExit(invalid) + if ( + consumed == size + and comment_size >= ZIP64_EOCD_LOCATOR_SIZE + and variable[-ZIP64_EOCD_LOCATOR_SIZE:].startswith( + ZIP64_EOCD_LOCATOR_SIGNATURE + ) + ): + locator_comment_offset = eocd_offset - ZIP64_EOCD_LOCATOR_SIZE actual_entries += 1 if actual_entries > MAX_WHEEL_MEMBERS: raise SystemExit("wheel member limit exceeded") if consumed != size or actual_entries != total_entries: raise SystemExit(invalid) stream.seek(0) + return locator_comment_offset + + +def _wheel_zipfile_compatible_snapshot(stream: BinaryIO, offset: int) -> BinaryIO: + """Mask one validated member-comment signature only in the stdlib parser view.""" + invalid = "wheel is not a valid ZIP archive" + parser_snapshot = tempfile.TemporaryFile(mode="w+b") # noqa: SIM115 + try: + stream.seek(0) + while block := stream.read(HASH_CHUNK_SIZE): + parser_snapshot.write(block) + parser_snapshot.seek(offset) + if _read_exact(parser_snapshot, 4, invalid) != ZIP64_EOCD_LOCATOR_SIGNATURE: + raise SystemExit(invalid) + parser_snapshot.seek(offset) + parser_snapshot.write(b"EW64") + parser_snapshot.seek(0) + return parser_snapshot + except BaseException: + parser_snapshot.close() + raise + finally: + stream.seek(0) def _verify_wheel(wheel_path: Path, project: dict[str, object]) -> str: @@ -376,15 +438,27 @@ def _verify_wheel(wheel_path: Path, project: dict[str, object]) -> str: with _open_stable_distribution(wheel_path) as wheel_file: wheel_digest = _sha256_stream(wheel_file) wheel_file.seek(0) - _preflight_wheel_members(wheel_file) - with zipfile.ZipFile(wheel_file) as wheel_archive: - names = _safe_archive_names(wheel_archive.namelist()) - missing = required_paths - names - if missing: - raise SystemExit(f"wheel is missing required files: {sorted(missing)}") - metadata = BytesParser(policy=default).parsebytes( - wheel_archive.read(f"{dist_info}/METADATA") + locator_comment_offset = _preflight_wheel_members(wheel_file) + parser_snapshot: BinaryIO | None = None + parser_stream = wheel_file + if locator_comment_offset is not None: + parser_snapshot = _wheel_zipfile_compatible_snapshot( + wheel_file, + locator_comment_offset, ) + parser_stream = parser_snapshot + try: + with zipfile.ZipFile(parser_stream) as wheel_archive: + names = _safe_archive_names(wheel_archive.namelist()) + missing = required_paths - names + if missing: + raise SystemExit(f"wheel is missing required files: {sorted(missing)}") + metadata = BytesParser(policy=default).parsebytes( + wheel_archive.read(f"{dist_info}/METADATA") + ) + finally: + if parser_snapshot is not None: + parser_snapshot.close() expected_metadata = { "Name": str(project["name"]),