fix(v0.61.0): POSIX CI green — lstat RAW path before realpath in 3 loaders

CI on ubuntu / macOS exposed a missed TOCTOU detail: `os.lstat(realpath(path))`
silently resolves symlinks before the lstat, so `S_ISLNK` never trips and a
symlinked input path passes the rejection. Windows CI was skipped (POSIX-only
symlink test), so the bug didn't surface in local smoke.

Three fixes, matching the v0.53.7 #106 project policy of "lstat the RAW path
before realpath":

- `edit_diff.load_probes` — lstat path first, then realpath after rejection.
- `unlearning_eval.load_evidence_file` — same.
- `unlearning_eval.get_fixture_path` — lstat raw candidate before realpath.

No new tests — the existing `test_symlink_rejected` covers it; it now passes
on POSIX where it previously failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alpamys 2026-05-19 15:27:50 +05:00
parent 740832e1b4
commit f2c74040ef
2 changed files with 20 additions and 13 deletions

View File

@ -115,13 +115,17 @@ def load_probes(path: str) -> Tuple[str, ...]:
raise ValueError("probe path must not contain null bytes")
if not is_under_cwd(path):
raise ValueError(f"probe path must stay under cwd: {path!r}")
real = os.path.realpath(path)
# CRITICAL: lstat the RAW path BEFORE realpath (review-fix from CI)
# — realpath resolves symlinks, so `lstat(realpath(path))` always sees
# the target file. We need to detect the symlink at the raw path.
# Matches v0.53.7 #106 TOCTOU policy.
try:
st = os.lstat(real)
st = os.lstat(path)
except FileNotFoundError as exc:
raise FileNotFoundError(f"probe file not found: {path!r}") from exc
if stat.S_ISLNK(st.st_mode):
raise ValueError("probe path must not be a symlink")
real = os.path.realpath(path)
if st.st_size > _MAX_PROBE_BYTES:
raise ValueError(
f"probe file exceeds {_MAX_PROBE_BYTES} bytes"

View File

@ -470,21 +470,22 @@ def get_fixture_path(benchmark: str) -> Optional[Path]:
pkg_root = files("soup_cli")
except (ModuleNotFoundError, TypeError):
return None
candidate = Path(
os.path.realpath(
os.path.join(str(pkg_root), "data", "_fixtures",
"unlearning", fixture_name)
)
raw_candidate = Path(
os.path.join(str(pkg_root), "data", "_fixtures",
"unlearning", fixture_name)
)
if not candidate.is_file():
return None
# Symlink rejection (TOCTOU defence - matches project policy).
# Symlink rejection at the RAW path BEFORE realpath (review-fix —
# realpath resolves symlinks so lstat on the resolved target always
# sees a regular file). Matches v0.53.7 #106 TOCTOU policy.
try:
st = os.lstat(candidate)
st = os.lstat(raw_candidate)
except OSError:
return None
if stat.S_ISLNK(st.st_mode):
return None
candidate = Path(os.path.realpath(raw_candidate))
if not candidate.is_file():
return None
return candidate
@ -502,13 +503,15 @@ def load_evidence_file(path: str) -> Mapping[str, Mapping[str, Any]]:
raise ValueError("evidence path must not contain null bytes")
if not is_under_cwd(path):
raise ValueError(f"evidence path must stay under cwd: {path!r}")
real = os.path.realpath(path)
# CRITICAL: lstat the RAW path BEFORE realpath (review-fix from CI)
# — realpath resolves symlinks. Matches v0.53.7 #106 TOCTOU policy.
try:
st = os.lstat(real)
st = os.lstat(path)
except FileNotFoundError as exc:
raise FileNotFoundError(f"evidence file not found: {path!r}") from exc
if stat.S_ISLNK(st.st_mode):
raise ValueError("evidence path must not be a symlink")
real = os.path.realpath(path)
if st.st_size > _MAX_EVIDENCE_BYTES:
raise ValueError(
f"evidence file exceeds {_MAX_EVIDENCE_BYTES} bytes"