Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions loopx/canary/maintainability_ratchet.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections import Counter
from importlib.util import resolve_name
import json
import os
from pathlib import Path
import subprocess
from typing import Any, Mapping, Sequence
Expand Down Expand Up @@ -209,18 +210,24 @@ def tracked_python_paths(repository_root: Path) -> set[Path]:
# `-z` frames each pathname on NUL, so a path carrying U+0085/U+2028/U+2029
# stays one record even in a repository that sets `core.quotePath=false`,
# where `git ls-files` emits it raw and `str.splitlines()` would tear it.
# The read stays in bytes so the record's own identity survives: a text
# read turns a CR inside a legal name into LF and an undecodable record
# into U+FFFD, and the rebuilt path would not name the tracked file, so
# that module would silently leave the ratchet.
completed = subprocess.run(
["git", "ls-files", "-z", "*.py"],
cwd=repository_root,
check=True,
text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE,
)
return {
repository_root / relative_path
for relative_path in completed.stdout.split("\0")
if relative_path and (repository_root / relative_path).is_file()
}
paths: set[Path] = set()
for record in completed.stdout.split(b"\0"):
if not record:
continue
path = repository_root / os.fsdecode(record)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Linux 文件名进入发现后会破坏 JSON 输出。 在 Linux Git 索引中加入一个名称含不可解码字节的文件,让该文件生成一条检查发现,再请求 JSON 输出。 两个命令均已返回失败退出码,但 stdout 混入原始字节,严格 UTF-8 解码在 JSON 解析前失败,机器调用者无法读取违规位置。 文件查找继续使用无损字节身份。将路径和派生标识投影到现有报告时做可区分的转义,使 JSON 始终可按 UTF-8 解码并定位对应条目。 在真实 Linux Git fixture 上覆盖普通名、含 CR 和含不可解码字节的名称。让违规命中进入两个实际 JSON 入口,分别断言退出码、严格解码、发现内容和位置。

if path.is_file():
paths.add(path)
return paths


def _dependency_finding(
Expand Down
46 changes: 41 additions & 5 deletions loopx/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,13 @@ class _GitAnswer:
stdout: str


def _retire_owned_probe(process: subprocess.Popen[str]) -> None:
@dataclass(frozen=True)
class _GitBytesAnswer:
returncode: int
stdout: bytes


def _retire_owned_probe(process: subprocess.Popen[str] | subprocess.Popen[bytes]) -> None:
"""Stop this probe's own process group, then drop its pipes unread.

A descendant that inherited the pipes keeps them open after the leader
Expand Down Expand Up @@ -429,6 +435,36 @@ def _git_query(*arguments: str) -> _GitAnswer | None:
return _GitAnswer(returncode=process.returncode, stdout=stdout)


def _git_query_bytes(*arguments: str) -> _GitBytesAnswer | None:
"""One bounded local Git read that keeps the raw output bytes.

Pathname listings carry their record identity in these bytes. The text read
above translates a CR inside a legal name into LF and replaces an
undecodable record with U+FFFD, so a pathname rebuilt from it can name a
file that does not exist and the tracked file would leave the scan.
"""

argv = ["git", *arguments]
try:
process = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=os.name == "posix",
)
except OSError:
return None
try:
stdout, _stderr = process.communicate(timeout=GIT_PROBE_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
_retire_owned_probe(process)
return None
except BaseException:
_retire_owned_probe(process)
raise
return _GitBytesAnswer(returncode=process.returncode, stdout=stdout)


def _git_worktree_root(path: Path) -> Path | None:
"""Return the worktree root, or None when Git says this is not one.

Expand Down Expand Up @@ -1071,17 +1107,17 @@ def _tracked_scan_files(scan_root: Path) -> list[Path]:
if not rel_root:
rel_root = "."

tracked = _git_query("-C", str(repo_root), "ls-files", "-z", "--", rel_root)
tracked = _git_query_bytes("-C", str(repo_root), "ls-files", "-z", "--", rel_root)
if tracked is None:
raise GitProbeUnavailable("git did not answer the tracked-file enumeration")
if tracked.returncode != 0:
return []

files: list[Path] = []
for rel_path in tracked.stdout.split("\0"):
if not rel_path:
for record in tracked.stdout.split(b"\0"):
if not record:
continue
path = repo_root / rel_path
path = repo_root / os.fsdecode(record)
if path.suffix not in DEFAULT_SCAN_SUFFIXES and not path.is_symlink():
continue
path = path.resolve()
Expand Down
33 changes: 33 additions & 0 deletions tests/canary/test_git_path_framing.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,36 @@ def test_premerge_keeps_explicit_changed_file_normalization(tmp_path: Path) -> N
assert response.returncode == 0, response.stderr
payload = json.loads(response.stdout)
assert payload["changed_files"] == ["docs/notes.py"]


def _repo_with_carriage_return_python_path(tmp_path: Path) -> tuple[Path, str]:
"""A repository whose tracked Python module name carries a CR.

A CR is a legal POSIX filename byte and Git frames it inside one NUL
record. A text-mode read applies universal-newline translation to the same
bytes, so ``name\r.py`` arrives as two lines and the rebuilt path names no
file at all.
"""

repo = tmp_path / "repo"
repo.mkdir()
_git(repo, "init", "-b", "main")
_git(repo, "config", "user.name", "LoopX Test")
_git(repo, "config", "user.email", "loopx@example.invalid")
(repo / "base.py").write_text("x = 1\n", encoding="utf-8")
_git(repo, "add", "base.py")
_git(repo, "commit", "-m", "base")
name = "carriage\rreturn.py"
(repo / name).write_text("y = 2\n", encoding="utf-8")
_git(repo, "add", name)
_git(repo, "commit", "-m", "add a pathname with a CR")
return repo, name


@pytest.mark.skipif(os.name != "posix", reason="POSIX allows CR in a filename")
def test_tracked_python_paths_keeps_a_carriage_return_name(tmp_path: Path) -> None:
repo, name = _repo_with_carriage_return_python_path(tmp_path)

paths = tracked_python_paths(repo)

assert repo / name in paths
26 changes: 26 additions & 0 deletions tests/test_contract_public_boundary_prefilter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from itertools import product
import os
from pathlib import Path
import subprocess
from typing import Any
Expand Down Expand Up @@ -297,3 +298,28 @@ def test_windows_paths_outside_the_private_roots_stay_public(tmp_path: Path) ->
(tmp_path / "sample.md").write_text(line + "\n", encoding="utf-8")

assert contract.scan_public_boundary([tmp_path])["ok"] is True


@pytest.mark.skipif(os.name != "posix", reason="POSIX allows CR in a filename")
def test_pruned_scan_root_keeps_a_carriage_return_tracked_name(tmp_path: Path) -> None:
"""A CR inside a tracked name survives the Git-side enumeration.

Pruned roots such as a virtualenv are enumerated from Git alone, so a
pathname the text-mode read tore apart would leave the public-boundary
scan without any other way to notice the file.
"""

repo = tmp_path / "repo"
scan_root = repo / "venv"
scan_root.mkdir(parents=True)
(scan_root / "pyvenv.cfg").write_text("home = /usr/bin\n", encoding="utf-8")
name = "carriage\rreturn.py"
(scan_root / name).write_text("x = 1\n", encoding="utf-8")
subprocess.run(["git", "init", "-q", str(repo)], check=True)
subprocess.run(
["git", "-C", str(repo), "add", "-f", "--", "venv/pyvenv.cfg", f"venv/{name}"],
check=True,
)

assert contract._tracked_scan_files(scan_root) == [scan_root / name]
assert contract.iter_scan_files(scan_root) == [scan_root / name]
Loading