fix(errors): type the NUL-byte path failure as SourceError

safe_resolve let an untyped ValueError escape when a target carried an
embedded NUL: os.path.realpath rejects the string before the boundary
check runs. That broke SourceError's documented contract ("always typed,
never a leaked OSError") and the v0.3.0 promise of stable machine-readable
codes on every failure.

A target that cannot be resolved at all now fails closed under the
existing `path_escape` code, with its own message. No new code is added —
`path_escape` already means "the boundary refused this target"; its
docstring is widened to say so. Consumers asserting on the code are
unaffected.

Found by consumer review: portfolio-optimiser's navigate_bundle leaks the
same ValueError from the same class of input, which makes an entire bundle
unreadable instead of skipping one link. That defect is theirs to fix; this
commit closes the library-side half.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdVgowYC4LARgvNdNMiuvz
This commit is contained in:
Kjell Tore Guttormsen 2026-07-20 10:30:42 +02:00
commit 64e3187639
3 changed files with 21 additions and 2 deletions

View file

@ -35,7 +35,14 @@ def safe_resolve(root: Path, relative: str) -> Path:
a naive startswith would not).
"""
real_root = os.path.realpath(root)
candidate = os.path.realpath(os.path.join(real_root, relative))
try:
candidate = os.path.realpath(os.path.join(real_root, relative))
except ValueError as exc:
# An embedded NUL makes the path syscalls reject the string before
# any boundary check can run. SourceError promises a typed failure,
# so a target that cannot be resolved at all fails closed under the
# boundary code rather than leaking an untyped ValueError.
raise SourceError(f"path is not resolvable: {relative!r}", code="path_escape") from exc
try:
within = os.path.commonpath([real_root, candidate]) == real_root
except ValueError:

View file

@ -55,7 +55,8 @@ class SourceError(IngestError):
OSError and never silent truncation.
Codes:
- `path_escape` a path resolves outside its root directory
- `path_escape` a path resolves outside its root directory, or cannot
be resolved at all (e.g. an embedded NUL byte)
- `source_root_missing` the file-source root is not a directory
- `source_file_missing` the query does not resolve to a file
- `csv_no_header` the CSV has no header row

View file

@ -157,6 +157,17 @@ def test_path_escape(tmp_path: Path) -> None:
assert code_of(excinfo) == "path_escape"
def test_path_escape_covers_embedded_null(tmp_path: Path) -> None:
# A NUL byte makes the OS path syscalls raise ValueError before any
# boundary check runs. SourceError promises "always typed, never a
# leaked OSError" — an untyped ValueError breaks that contract, so a
# malformed target must fail closed under the same code as any other
# target that cannot resolve inside the root.
with pytest.raises(SourceError) as excinfo:
safe_resolve(tmp_path, "bad\x00.md")
assert code_of(excinfo) == "path_escape"
def test_source_root_missing(tmp_path: Path) -> None:
with pytest.raises(SourceError) as excinfo:
read_csv(tmp_path / "nope", "a.csv", max_rows=1)