fix(okf): tolerate embedded-NUL cross-link target — skip, never raise

navigate_bundle's out-of-bundle filter drops every '/'- and '\'-bearing
target before resolution, but a NUL-byte target carries no path separator
and slipped through to (bundle_dir / target).resolve(), which raises
ValueError: embedded null character — propagating instead of being skipped.
method-spec §72 requires a broken cross-link to be tolerated (skipped,
never raised). Wrap resolve/is_file in `except ValueError: continue`;
parse_concept_file stays outside the guard so malformed concept files
still raise.

Load-bearing test drives the first dangerous target THROUGH the filter
into resolution; detach-proved RED (ValueError propagates) when the guard
is removed. Closes the nullbyte item reported OPEN in OKF trinn E.

442→443 green, golden byte-exact, full gate clean (ruff+format+mypy strict).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:20:50 +02:00
commit 613b00f882
2 changed files with 27 additions and 2 deletions

View file

@ -112,8 +112,15 @@ def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
if "/" in target or "\\" in target or target in seen: if "/" in target or "\\" in target or target in seen:
continue continue
seen.add(target) seen.add(target)
resolved = (bundle_dir / target).resolve() try:
if not resolved.is_relative_to(bundle_root) or not resolved.is_file(): resolved = (bundle_dir / target).resolve()
if not resolved.is_relative_to(bundle_root) or not resolved.is_file():
continue
except ValueError:
# An unrepresentable target (e.g. an embedded NUL byte, which carries no
# path separator and so slips past the out-of-bundle filter) makes
# ``resolve``/``is_file`` raise ``ValueError``. That is a broken link, not
# a fatal error — skip it, never raise (method-spec §72, OKF robustness).
continue continue
concepts.append(parse_concept_file(resolved)) concepts.append(parse_concept_file(resolved))
return concepts return concepts

View file

@ -104,6 +104,24 @@ class TestNavigation:
names = [c.path.name for c in navigate_bundle(bundle)] names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "a.md"] assert names == ["index.md", "a.md"]
def test_null_byte_target_is_tolerated_never_raised(self, tmp_path: Path) -> None:
# LOAD-BEARING (§11, method-spec §72: a broken cross-link MUST be "skipped,
# never raised"). A NUL-byte target carries no path separator, so it slips
# past the out-of-bundle filter and reaches resolution, where
# ``(bundle_dir / target).resolve()`` raises ``ValueError: embedded null
# byte``. That ValueError MUST be caught and the link skipped — a NUL target
# is a broken link, not a fatal error. This is the FIRST test that drives a
# dangerous target THROUGH the filter into resolution (the ``/``-filter
# short-circuits every ``/``-bearing and scheme-bearing case before it).
# RED (ValueError propagates) if the resolve guard is detached.
bundle = _make_bundle(
tmp_path,
"Links: [bad](ba\x00d.md), [ok](a.md).",
{"a.md": "---\ntype: project\ntitle: A\n---\nBody A."},
)
names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "a.md"]
def test_shared_bundle_navigates_all_linked_concepts(self) -> None: def test_shared_bundle_navigates_all_linked_concepts(self) -> None:
# Integration on the shared example bundle: every index-linked file is # Integration on the shared example bundle: every index-linked file is
# reached exactly once; the out-of-bundle ../../README.md link is skipped. # reached exactly once; the out-of-bundle ../../README.md link is skipped.