Skip to content

Commit 7c0c90e

Browse files
committed
fix: harden portable release workspace provenance
1 parent 37e15b9 commit 7c0c90e

3 files changed

Lines changed: 78 additions & 11 deletions

File tree

scripts/release_gate.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def _git_head_commit(checkout: Path) -> str:
6464

6565

6666
def _checkout_clean(checkout: Path) -> str | None:
67-
"""Return a description of any tracked difference from HEAD, else None."""
67+
"""Return a description of any non-ignored difference from HEAD, else None."""
6868
result = subprocess.run(
6969
["git", "-C", str(checkout), "status", "--porcelain"],
7070
capture_output=True,
@@ -78,6 +78,20 @@ def _checkout_clean(checkout: Path) -> str | None:
7878
return None
7979

8080

81+
def _prepare_workspace(workspace: Path) -> None:
82+
"""Create an empty gate workspace or reject stale caller-owned state."""
83+
if workspace.exists():
84+
if not workspace.is_dir():
85+
raise SystemExit(f"FAIL: release-gate workspace is not a directory: {workspace}")
86+
if any(workspace.iterdir()):
87+
raise SystemExit(
88+
"FAIL: --work-dir must be absent or empty; refusing to reuse "
89+
f"stale release-gate state under {workspace}"
90+
)
91+
else:
92+
workspace.mkdir(parents=True)
93+
94+
8195
def _extract_head_archive(checkout: Path, target: Path) -> int:
8296
"""Extract `git archive HEAD` into target; return the file count."""
8397
archive = subprocess.run(
@@ -96,7 +110,7 @@ def _extract_head_archive(checkout: Path, target: Path) -> int:
96110

97111

98112
def _snapshot_matches_head(checkout: Path, srctree: Path) -> list[str]:
99-
"""Prove every snapshot file is byte-identical to HEAD's blob."""
113+
"""Prove the snapshot file set and contents are identical to HEAD."""
100114
tree = subprocess.run(
101115
["git", "-C", str(checkout), "ls-tree", "-r", "HEAD"],
102116
capture_output=True,
@@ -112,21 +126,29 @@ def _snapshot_matches_head(checkout: Path, srctree: Path) -> list[str]:
112126
for entry in tree.stdout.splitlines()
113127
)
114128
}
129+
actual_names = {
130+
path.relative_to(srctree).as_posix()
131+
for path in srctree.rglob("*")
132+
if path.is_file() or path.is_symlink()
133+
}
134+
expected_names = set(expected)
135+
mismatches = expected_names.symmetric_difference(actual_names)
136+
common_names = sorted(expected_names & actual_names)
115137
hashed = subprocess.run(
116138
["git", "-C", str(checkout), "hash-object", "--stdin-paths"],
117-
input="".join(str(srctree / name) + "\n" for name in expected),
139+
input="".join(str(srctree / name) + "\n" for name in common_names),
118140
capture_output=True,
119141
text=True,
120142
)
121143
if hashed.returncode != 0:
122144
raise SystemExit("FAIL: git hash-object --stdin-paths failed")
123145
actual = hashed.stdout.splitlines()
124-
mismatches = [
146+
mismatches.update(
125147
name
126-
for name, blob in zip(expected, actual)
148+
for name, blob in zip(common_names, actual)
127149
if blob != expected[name]
128-
]
129-
return mismatches
150+
)
151+
return sorted(mismatches)
130152

131153

132154
def _copy_snapshot_subtree(srctree: Path, rel_dir: str, target: Path) -> int:
@@ -240,7 +262,7 @@ def main(argv: list[str] | None = None) -> int:
240262
if owned_workspace
241263
else args.work_dir.resolve()
242264
)
243-
workspace.mkdir(parents=True, exist_ok=True)
265+
_prepare_workspace(workspace)
244266
print(f"workspace: {workspace}")
245267

246268
srctree = workspace / "srctree"

scripts/release_gate_installed_check.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import importlib.util
1919
import json
2020
import os
21+
import re
2122
import subprocess
2223
import sys
2324
import sysconfig
@@ -56,7 +57,7 @@ def _venv_site_packages() -> list[Path]:
5657
for key in ("purelib", "platlib"):
5758
path = sysconfig.get_path(key)
5859
if path:
59-
paths.append(Path(path))
60+
paths.append(Path(path).resolve())
6061
return paths
6162

6263

@@ -66,6 +67,16 @@ def _check_import_provenance(report: dict[str, object]) -> bool:
6667
module_path = Path(valleyscope.__file__).resolve()
6768
site_packages = _venv_site_packages()
6869
report["venv_purelib_platlib"] = [str(path) for path in site_packages]
70+
prefix = Path(sys.prefix).resolve()
71+
if not site_packages or any(
72+
not site_path.is_relative_to(prefix) for site_path in site_packages
73+
):
74+
print(
75+
"FAIL: purelib/platlib are not contained under this interpreter's "
76+
f"sys.prefix {prefix}: {site_packages}",
77+
file=sys.stderr,
78+
)
79+
return False
6980
if not _module_in_venv(module_path, site_packages):
7081
print(
7182
"FAIL: valleyscope resolves to "
@@ -229,10 +240,12 @@ def main(argv: list[str] | None = None) -> int:
229240
)
230241
parser.add_argument(
231242
"--commit",
232-
default="unknown",
243+
required=True,
233244
help="Full commit hash the gate is bound to (provenance identity).",
234245
)
235246
args = parser.parse_args(argv)
247+
if re.fullmatch(r"[0-9a-f]{40}", args.commit) is None:
248+
parser.error("--commit must be a full 40-character lowercase Git hash")
236249

237250
checkout_root = args.checkout.resolve()
238251
sys.path[:] = [

tests/test_release_gate_probes.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@
2424
_checkout_clean,
2525
_extract_head_archive,
2626
_audit_archive,
27+
_prepare_workspace,
2728
_snapshot_matches_head,
2829
)
29-
from scripts.release_gate_installed_check import _module_in_venv
30+
from scripts.release_gate_installed_check import (
31+
_check_import_provenance,
32+
_module_in_venv,
33+
)
3034

3135
NEEDS_GIT = pytest.mark.skipif(
3236
shutil.which("git") is None, reason="git is required for snapshot probes"
@@ -115,6 +119,20 @@ def test_archive_snapshot_matches_head(tmp_path: Path) -> None:
115119
assert (srctree / "tracked.txt").read_text() == "committed"
116120
assert _snapshot_matches_head(repo, srctree) == []
117121

122+
# Extra files from a reused workspace must invalidate snapshot identity.
123+
(srctree / "stale.py").write_text("not from HEAD")
124+
assert _snapshot_matches_head(repo, srctree) == ["stale.py"]
125+
126+
127+
def test_reused_nonempty_workspace_is_rejected(tmp_path: Path) -> None:
128+
workspace = tmp_path / "release-gate"
129+
_prepare_workspace(workspace)
130+
assert workspace.is_dir()
131+
132+
(workspace / "stale-artifact.whl").write_text("stale")
133+
with pytest.raises(SystemExit, match="absent or empty"):
134+
_prepare_workspace(workspace)
135+
118136

119137
def test_user_site_valleyscope_cannot_satisfy_provenance(tmp_path: Path) -> None:
120138
venv_purelib = tmp_path / "venv" / "lib" / "python3.13" / "site-packages"
@@ -126,6 +144,20 @@ def test_user_site_valleyscope_cannot_satisfy_provenance(tmp_path: Path) -> None
126144
) is True
127145

128146

147+
def test_site_packages_must_be_under_current_prefix(
148+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
149+
) -> None:
150+
import valleyscope
151+
152+
source_root = Path(valleyscope.__file__).resolve().parent.parent
153+
monkeypatch.setattr(sys, "prefix", str(tmp_path / "venv"))
154+
monkeypatch.setattr(
155+
"scripts.release_gate_installed_check._venv_site_packages",
156+
lambda: [source_root],
157+
)
158+
assert _check_import_provenance({}) is False
159+
160+
129161
def test_pythonpath_injection_cannot_satisfy_provenance(tmp_path: Path) -> None:
130162
fake = tmp_path / "fake-pythonpath"
131163
(fake / "valleyscope").mkdir(parents=True)

0 commit comments

Comments
 (0)