feat(materialize): add §5 materialization with collision gate and replacement
TDD step 5: materialize_bundle takes three explicit inputs (manifest, bundle dir, regex-validated ingested_at — no wall-clock default) and stages every extraction in memory before the first disk mutation. The §3 collision gate refuses to overwrite any file without the ingest stamp, before any mutation; replacement removes exactly the stamped set. Frontmatter is the seven §5 keys in spec order with whitespace collapse; output is LF-only raw bytes with one trailing newline; the provenance stamp hashes the same bytes that are parsed. Fresh index.md gets bundle_summary plus idempotent links in extraction order (§6 creation path; preserve/removal rules come next). The §8 network gate refuses http fail-fast without the per-run opt-in; the http transport itself lands in step 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
parent
c94ed6d525
commit
0ff946696c
5 changed files with 593 additions and 6 deletions
|
|
@ -17,11 +17,11 @@ from .errors import SourceError
|
|||
from .render import sql_value_to_text
|
||||
|
||||
|
||||
def _safe_resolve(root: Path, relative: str) -> Path:
|
||||
def safe_resolve(root: Path, relative: str) -> Path:
|
||||
"""Resolve `relative` against `root`, fail-closed (the OKF path rule).
|
||||
|
||||
Raises SourceError if the resolved path escapes `root` — `..` traversal,
|
||||
an absolute query path, a symlink escape, or a prefix-collision sibling
|
||||
an absolute path, a symlink escape, or a prefix-collision sibling
|
||||
(`/a/data-evil` vs `/a/data`; commonpath on canonical paths catches what
|
||||
a naive startswith would not).
|
||||
"""
|
||||
|
|
@ -33,7 +33,7 @@ def _safe_resolve(root: Path, relative: str) -> Path:
|
|||
# Different drives / mixed absolute-relative -> not within.
|
||||
within = False
|
||||
if not within:
|
||||
raise SourceError(f"extraction query escapes the source root: {relative!r}")
|
||||
raise SourceError(f"path escapes its root directory: {relative!r}")
|
||||
return Path(candidate)
|
||||
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ def read_csv(root: str | Path, query: str, *, max_rows: int) -> tuple[list[str],
|
|||
root_path = Path(root)
|
||||
if not root_path.is_dir():
|
||||
raise SourceError(f"file-source root is not a directory: {root_path}")
|
||||
resolved = _safe_resolve(root_path, query)
|
||||
resolved = safe_resolve(root_path, query)
|
||||
if not resolved.is_file():
|
||||
raise SourceError(f"extraction query does not resolve to a file: {query!r}")
|
||||
with resolved.open(encoding="utf-8-sig", newline="") as handle:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue