From 2b5621c76003be46d8805a52bf16b421e42c8266 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 29 Jul 2026 18:53:49 +0500 Subject: [PATCH 1/2] fix: eliminate TOCTOU race in zip packaging Open file once and derive both stat info and content from the same file descriptor to prevent race conditions where the file is modified between stat() and read_bytes() calls. --- src/specify_cli/bundler/services/packager.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundler/services/packager.py index 6a0778e3ab..4e14934e0a 100644 --- a/src/specify_cli/bundler/services/packager.py +++ b/src/specify_cli/bundler/services/packager.py @@ -93,9 +93,11 @@ def build_bundle( # extraction, but collapse to two canonical modes (0755 when any # execute bit is set on the source, otherwise 0644) so identical # inputs yield a byte-for-byte identical artifact. - mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644 - info.external_attr = mode << 16 - archive.writestr(info, file_path.read_bytes()) + with file_path.open("rb") as fh: + st = os.fstat(fh.fileno()) + mode = 0o755 if st.st_mode & 0o111 else 0o644 + info.external_attr = mode << 16 + archive.writestr(info, fh.read()) return BuildResult(artifact_path=artifact_path, file_count=len(files)) From cf7095497125b35f343784f33244913e62c1c1ec Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 31 Jul 2026 21:48:56 +0500 Subject: [PATCH 2/2] test: add regression test for TOCTOU stat/read consistency in packager The old implementation called file_path.stat() then file_path.read_bytes() as separate syscalls. The fix opens the file once and uses os.fstat() + fh.read() on the same handle. This test verifies the archived bytes and mode are consistent with the opened file descriptor. --- tests/unit/test_bundler_packager.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/test_bundler_packager.py b/tests/unit/test_bundler_packager.py index 53a3c37462..d203f7ffb0 100644 --- a/tests/unit/test_bundler_packager.py +++ b/tests/unit/test_bundler_packager.py @@ -207,4 +207,30 @@ def test_executable_bit_preserved_in_artifact(tmp_path: Path): } # Executable source -> 0755; plain text files -> 0644. assert modes["scripts/hook.sh"] == 0o755 + + +def test_toctou_stat_read_consistency(tmp_path: Path): + """Regression: stat() and read() must use the same file descriptor. + + The old implementation called file_path.stat() then file_path.read_bytes() + as separate syscalls. Between the two, another process could replace the + file. The fix opens the file once and uses os.fstat() + fh.read() on the + same handle. This test verifies the archived bytes and mode are consistent. + """ + bundle = _make_bundle(tmp_path / "b") + target = bundle / "assets" / "data.bin" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"\x00\x01\x02\x03") + target.chmod(0o644) + + result = build_bundle(bundle, output_dir=tmp_path / "out") + with zipfile.ZipFile(result.artifact_path) as archive: + content = archive.read("assets/data.bin") + modes = { + info.filename: (info.external_attr >> 16) & 0o777 + for info in archive.infolist() + } + + assert content == b"\x00\x01\x02\x03" + assert modes["assets/data.bin"] == 0o644 assert modes["README.md"] == 0o644