diff --git a/loopx/contract.py b/loopx/contract.py index 992f39746a..4f14268ef6 100644 --- a/loopx/contract.py +++ b/loopx/contract.py @@ -117,20 +117,28 @@ def _prefilter_fold(text: str) -> str: "author" + "ization:", ), ), + # The private roots take the host's own separator, so a Windows host + # writes the same path class natively with backslash separators. Allowing + # a run of separators also covers serialized text, where a JSON or source + # string literal escapes each backslash into two. "local_private_path": LeakRule( pattern=re.compile( - "(" - + "/" + r"([\\/]+" + "Users" - + "/" - + r"[^/\s]+/(?:Documents|code" + + r"[\\/]+[^\\/\s]+[\\/]+(?:Documents|code" + "-" - + r"reading)|" - + "/ext" - + "_data/" - + ")" + + r"reading)|[\\/]+ext" + + "_data" + + r"[\\/]+)" + ), + # Cheap necessary condition: every private root is followed by at + # least one separator, whatever the host or serialization chose. + required_literals=( + "users/", + "users\\", + "ext" + "_data/", + "ext" + "_data\\", ), - required_literals=("/" + "users/", "/ext" + "_data/"), ), "internal_task_id": LeakRule( pattern=re.compile(r"\bt-" + r"20\d{12}-[a-z0-9]+\b"), diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 2e48944626..e2dd4d7a27 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -1047,7 +1047,7 @@ }, { "site": "loopx/contract.py::.check_contract::codec_read:load_registry#1", - "line": 1099, + "line": 1107, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/test_contract_public_boundary_prefilter.py b/tests/test_contract_public_boundary_prefilter.py index d8ab82eeb7..5682f0510b 100644 --- a/tests/test_contract_public_boundary_prefilter.py +++ b/tests/test_contract_public_boundary_prefilter.py @@ -1,5 +1,6 @@ from __future__ import annotations +from itertools import product from pathlib import Path import subprocess from typing import Any @@ -70,6 +71,15 @@ def test_explicit_scan_file_keeps_unsupported_suffix(tmp_path: Path) -> None: ("local_private_path", "/" + "Users/alice/Documents/example.md"), ("local_private_path", "/" + "Users/alice/code-reading/example.md"), ("local_private_path", "/ext" + "_data/example.md"), + ("local_private_path", "\\" + "Users\\alice\\Documents\\example.md"), + ("local_private_path", "\\" + "Users\\alice\\code-reading\\example.md"), + ("local_private_path", "\\ext" + "_data\\example.md"), + # Either separator at each junction: a Windows host mixes them when + # it appends a POSIX-style segment to a native home directory, and a + # serialized document double-escapes each backslash. + ("local_private_path", "\\" + "Users/alice/Documents/example.md"), + ("local_private_path", "/Users" + "\\alice\\Documents\\example.md"), + ("local_private_path", "C:" + "\\\\" + "Users" + "\\\\" + "alice" + "\\\\" + "Documents" + "\\\\" + "example.md"), ("internal_task_id", "ticket t-" + "20260828123456-example"), ("private_ip", "host 10" + ".1.2.3"), ("private_ip", "host 172" + ".31.2.3"), @@ -86,6 +96,22 @@ def test_every_authoritative_leak_pattern_has_a_matching_prefilter( assert rule.is_candidate(contract._prefilter_fold(line)) +def test_prefilter_is_a_necessary_condition_for_every_separator_spelling() -> None: + # The prefilter may be looser than the pattern but never stricter: every + # root spelling the authoritative pattern classifies, whatever separator + # or escape run the host or serializer chose, must remain a candidate. + rule = contract.LEAK_RULES["local_private_path"] + separators = ("/", "\\", "//", "\\\\") + for outer, inner in product(separators, repeat=2): + line = f"C:{outer}Users{inner}alice{inner}Documents{inner}report.md" + assert rule.pattern.search(line), line + assert rule.is_candidate(contract._prefilter_fold(line)), line + for separator in separators: + line = f"Mounted at {separator}ext_data{separator}report.md" + assert rule.pattern.search(line), line + assert rule.is_candidate(contract._prefilter_fold(line)), line + + @pytest.mark.parametrize( ("rule_name", "line"), [ @@ -225,3 +251,33 @@ def search(self, line: str) -> None: (tmp_path / "sample.md").write_text("\n".join(lines), encoding="utf-8") assert contract.scan_public_boundary([tmp_path])["ok"] is True assert pattern.lines == ["actual a|b", "actual 10."] + + +@pytest.mark.parametrize( + "line", + [ + "See C:" + "\\" + "Users\\alice\\Documents\\report.md", + "See C:" + "\\" + "Users\\alice\\code-reading\\report.md", + "Mounted at " + "\\ext" + "_data\\report.md", + "See C:" + "\\" + "Users/alice/Documents/report.md", + 'json: {"path": "C:' + "\\\\" + 'Users\\\\alice\\\\Documents\\\\report.md"}', + ], +) +def test_native_windows_private_paths_are_blocked(line: str, tmp_path: Path) -> None: + # The same private path class must be blocked when a Windows host spells it + # with the native separator, not only in its POSIX form. + (tmp_path / "sample.md").write_text(line + "\n", encoding="utf-8") + + payload = contract.scan_public_boundary([tmp_path]) + + assert payload["ok"] is False + assert payload["hits"] == ["sample.md:1: local_private_path"] + + +def test_windows_paths_outside_the_private_roots_stay_public(tmp_path: Path) -> None: + # Accepting the Windows separator must not widen the private roots the rule + # names, so an unrelated profile subdirectory stays public. + line = "Cache at C:" + "\\" + "Users\\alice\\AppData\\Local\\Temp\\report.md" + (tmp_path / "sample.md").write_text(line + "\n", encoding="utf-8") + + assert contract.scan_public_boundary([tmp_path])["ok"] is True