From f2c74040ef055d249e9e13e28431b761e9de990b Mon Sep 17 00:00:00 2001 From: Alpamys Date: Tue, 19 May 2026 15:27:50 +0500 Subject: [PATCH] =?UTF-8?q?fix(v0.61.0):=20POSIX=20CI=20green=20=E2=80=94?= =?UTF-8?q?=20lstat=20RAW=20path=20before=20realpath=20in=203=20loaders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- soup_cli/utils/edit_diff.py | 8 ++++++-- soup_cli/utils/unlearning_eval.py | 25 ++++++++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/soup_cli/utils/edit_diff.py b/soup_cli/utils/edit_diff.py index aa2a8fc..ab19fb8 100644 --- a/soup_cli/utils/edit_diff.py +++ b/soup_cli/utils/edit_diff.py @@ -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" diff --git a/soup_cli/utils/unlearning_eval.py b/soup_cli/utils/unlearning_eval.py index 0da38a4..209ef8b 100644 --- a/soup_cli/utils/unlearning_eval.py +++ b/soup_cli/utils/unlearning_eval.py @@ -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"