Files
spec-kit/tests/unit/test_bundler_packager.py
Quratulain-bilalandGitHub 642fa56c0a fix: eliminate TOCTOU race in zip packaging (#3855)
* 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.

* 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.
2026-07-31 12:05:47 -05:00

237 lines
9.9 KiB
Python

"""Unit tests for the artifact packager (T023): contents, versioning, determinism."""
from __future__ import annotations
import os
import zipfile
from pathlib import Path
import pytest
import yaml
from specify_cli.bundler import BundlerError
from specify_cli.bundler.services.packager import build_bundle
from tests.bundler_helpers import valid_manifest_dict
def _make_bundle(directory: Path, *, extra_files: dict | None = None) -> Path:
directory.mkdir(parents=True, exist_ok=True)
(directory / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
(directory / "README.md").write_text("# Demo bundle", encoding="utf-8")
for rel, content in (extra_files or {}).items():
target = directory / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return directory
def test_artifact_named_by_id_and_version(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b")
result = build_bundle(bundle, output_dir=tmp_path / "out")
assert result.artifact_path.name == "demo-bundle-1.2.0.zip"
def test_artifact_contains_manifest_and_assets(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b", extra_files={"assets/logo.txt": "logo"})
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
names = set(archive.namelist())
assert "bundle.yml" in names
assert "README.md" in names
assert "assets/logo.txt" in names
def test_build_refuses_invalid_manifest(tmp_path: Path):
bundle = tmp_path / "b"
bundle.mkdir()
data = valid_manifest_dict()
del data["bundle"]["license"]
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
(bundle / "README.md").write_text("# x", encoding="utf-8")
with pytest.raises(BundlerError, match="validate"):
build_bundle(bundle, output_dir=tmp_path / "out")
def test_build_missing_manifest_errors(tmp_path: Path):
with pytest.raises(BundlerError, match="No bundle.yml"):
build_bundle(tmp_path, output_dir=tmp_path / "out")
def test_build_is_deterministic(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a", "z.txt": "z"})
first = build_bundle(bundle, output_dir=tmp_path / "out1")
second = build_bundle(bundle, output_dir=tmp_path / "out2")
with zipfile.ZipFile(first.artifact_path) as a, zipfile.ZipFile(second.artifact_path) as b:
# Same files, same order (sorted).
assert a.namelist() == b.namelist()
# Fixed timestamps + permissions make each member byte-identical.
for left, right in zip(a.infolist(), b.infolist()):
assert left.date_time == right.date_time
assert left.external_attr == right.external_attr
# The whole artifact is byte-for-byte reproducible.
assert first.artifact_path.read_bytes() == second.artifact_path.read_bytes()
def test_member_order_is_platform_independent(tmp_path: Path):
# Members must be laid out in canonical POSIX-arcname order (the same key
# build_bundle uses to NAME them), not pathlib.Path order — which folds case
# on Windows and would otherwise reorder members across build hosts, breaking
# the byte-for-byte reproducibility guarantee. Mixed-case names make the
# difference observable: Path order on Windows groups differently than the
# canonical string sort.
bundle = _make_bundle(
tmp_path / "b",
extra_files={"Zeta.txt": "z", "apple.txt": "a", "Foo.txt": "f", "bar.txt": "b"},
)
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert names == sorted(names)
def test_output_dir_inside_bundle_excludes_prior_artifacts(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
out_dir = bundle / "dist"
# Build twice into a dir nested in the bundle; the second build must not
# re-package the first artifact, so contents stay identical and bounded.
first = build_bundle(bundle, output_dir=out_dir)
second = build_bundle(bundle, output_dir=out_dir)
with zipfile.ZipFile(second.artifact_path) as archive:
names = archive.namelist()
assert not any(name.startswith("dist/") for name in names)
assert not any(name.endswith(".zip") for name in names)
assert first.file_count == second.file_count
def test_prior_version_artifact_not_repackaged(tmp_path: Path):
# An older artifact sitting next to bundle.yml must not be packaged.
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
(bundle / "demo-bundle-0.9.0.zip").write_bytes(b"PK\x03\x04 old artifact")
result = build_bundle(bundle, output_dir=bundle)
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert not any(name.endswith(".zip") for name in names)
assert "demo-bundle-0.9.0.zip" not in names
def test_symlinked_directory_is_not_followed(tmp_path: Path):
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("secret", encoding="utf-8")
bundle = _make_bundle(tmp_path / "b", extra_files={"a.txt": "a"})
link = bundle / "linkdir"
try:
link.symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks not supported on this platform")
# Build must succeed (no ensure_within failure) and must not pull in the
# out-of-tree file behind the symlinked directory.
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
names = archive.namelist()
assert "linkdir/secret.txt" not in names
assert not any("secret" in name for name in names)
def test_unsafe_bundle_id_is_rejected_before_build(tmp_path: Path):
data = valid_manifest_dict()
data["bundle"]["id"] = "../evil"
bundle = tmp_path / "b"
bundle.mkdir(parents=True)
(bundle / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
(bundle / "README.md").write_text("# x", encoding="utf-8")
with pytest.raises(BundlerError):
build_bundle(bundle, output_dir=tmp_path / "out")
# The traversal target must not have been written outside out_dir.
assert not (tmp_path / "evil-1.2.0.zip").exists()
def test_build_refuses_missing_readme(tmp_path: Path):
bundle = tmp_path / "b"
bundle.mkdir()
(bundle / "bundle.yml").write_text(
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
)
with pytest.raises(BundlerError, match="README.md"):
build_bundle(bundle, output_dir=tmp_path / "out")
def test_asset_zip_starting_with_bundle_id_is_packaged(tmp_path: Path):
# A non-artifact asset whose name merely starts with the bundle id (but is
# not a semver-named build artifact) must still be included.
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-assets.zip": "data"})
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
names = set(archive.namelist())
assert "demo-bundle-assets.zip" in names
def test_prior_semver_artifact_is_excluded(tmp_path: Path):
bundle = _make_bundle(tmp_path / "b", extra_files={"demo-bundle-0.9.0.zip": "old"})
result = build_bundle(bundle, output_dir=bundle)
with zipfile.ZipFile(result.artifact_path) as archive:
names = set(archive.namelist())
assert "demo-bundle-0.9.0.zip" not in names
def test_prior_artifact_with_prerelease_and_build_is_excluded(tmp_path: Path):
# A semver artifact carrying both prerelease and build metadata must still
# be recognized as a prior build artifact and excluded.
bundle = _make_bundle(
tmp_path / "b", extra_files={"demo-bundle-1.0.0-rc1+build5.zip": "old"}
)
result = build_bundle(bundle, output_dir=bundle)
with zipfile.ZipFile(result.artifact_path) as archive:
names = set(archive.namelist())
assert "demo-bundle-1.0.0-rc1+build5.zip" not in names
@pytest.mark.skipif(
os.name == "nt",
reason="Windows filesystems do not carry Unix execute bits, so chmod(0o755) "
"is a no-op and there is no executability to preserve.",
)
def test_executable_bit_preserved_in_artifact(tmp_path: Path):
bundle = _make_bundle(tmp_path / "bundle")
script = bundle / "scripts" / "hook.sh"
script.parent.mkdir(parents=True, exist_ok=True)
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
script.chmod(0o755)
result = build_bundle(bundle, output_dir=tmp_path / "out")
with zipfile.ZipFile(result.artifact_path) as archive:
modes = {
info.filename: (info.external_attr >> 16) & 0o777
for info in archive.infolist()
}
# 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