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:
parent
692f2df2ba
commit
64e3187639
3 changed files with 21 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue