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