diff --git a/src/quant_advisor_research/preview_bundle.py b/src/quant_advisor_research/preview_bundle.py index e031b19..adc0b6e 100644 --- a/src/quant_advisor_research/preview_bundle.py +++ b/src/quant_advisor_research/preview_bundle.py @@ -1,10 +1,16 @@ -"""Concrete daily report-to-preview bundle with no legacy or runtime integration.""" +"""Concrete daily preview bundle with an explicit trusted-parent boundary. + +The caller must provide a stable, non-symlink parent and coordinate all writers +through the bundle install lock. This module fails closed for filesystem races +within that contract; it does not claim to pin hostile ancestor replacement. +""" from __future__ import annotations import hashlib import html import json import os +import stat import tempfile from collections.abc import Mapping from dataclasses import dataclass @@ -22,6 +28,7 @@ _MANIFEST_KEYS = frozenset({"bundle_contract", "source", "artifacts"}) _SOURCE_KEYS = frozenset({"schema_version", "contract_version", "cadence", "as_of", "generated_at"}) _ARTIFACT_KEYS = frozenset({"name", "role", "sha256"}) +_NOFOLLOW = getattr(os, "O_NOFOLLOW", None) class PreviewBundleError(ValueError): @@ -108,16 +115,181 @@ def artifact(name: str, role: str, content: bytes) -> dict[str, str]: def _output_parent(path: str | Path) -> Path: output = Path(path) + _assert_parent_chain(output) try: - if output.exists(): - raise _error("output_exists") - if not output.parent.is_dir(): - raise _error("output_parent_invalid") + os.lstat(output) + raise _error("output_exists") + except FileNotFoundError: + return output + except (OSError, TypeError, ValueError): + raise _error("output_exists") from None + + +def _assert_parent_chain(path: Path) -> None: + try: + current = path.parent + while True: + info = os.lstat(current) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise _error("output_parent_invalid") + if current.parent == current: + return + current = current.parent except PreviewBundleError: raise except (OSError, TypeError, ValueError): raise _error("output_parent_invalid") from None - return output + + +def _assert_directory(path: Path) -> None: + try: + info = os.lstat(path) + except (OSError, TypeError, ValueError): + raise _error("output_write_failed") from None + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise _error("output_write_failed") + + +def _safe_file_flags(base: int) -> int: + if _NOFOLLOW is None: + raise _error("filesystem_unsupported") + return base | _NOFOLLOW + + +def _assert_regular_file(path: Path, descriptor: int | None = None) -> None: + try: + info = os.fstat(descriptor) if descriptor is not None else os.lstat(path) + except (OSError, TypeError, ValueError): + raise _error("filesystem_invalid") from None + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise _error("filesystem_invalid") + if descriptor is not None: + try: + path_info = os.lstat(path) + except OSError: + raise _error("filesystem_invalid") from None + if (path_info.st_dev, path_info.st_ino) != (info.st_dev, info.st_ino): + raise _error("filesystem_invalid") + + +def _write_member(path: Path, content: bytes) -> None: + fd = -1 + try: + fd = os.open(path, _safe_file_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), 0o600) + _assert_regular_file(path, fd) + offset = 0 + while offset < len(content): + offset += os.write(fd, content[offset:]) + os.fsync(fd) + _assert_regular_file(path, fd) + except PreviewBundleError: + raise + except (OSError, TypeError, ValueError): + raise _error("output_write_failed") from None + finally: + if fd >= 0: + os.close(fd) + + +def _read_member(path: Path) -> bytes: + fd = -1 + try: + _assert_regular_file(path) + fd = os.open(path, _safe_file_flags(os.O_RDONLY)) + _assert_regular_file(path, fd) + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + _assert_regular_file(path, fd) + return b"".join(chunks) + except PreviewBundleError: + raise + except (OSError, TypeError, ValueError): + raise _error("readback_invalid") from None + finally: + if fd >= 0: + os.close(fd) + + +def _acquire_install_lock(parent: Path, output: Path) -> tuple[int, Path]: + lock = parent / f".{output.name}.install-lock" + try: + fd = os.open(lock, _safe_file_flags(os.O_WRONLY | os.O_CREAT | os.O_EXCL), 0o600) + _assert_regular_file(lock, fd) + return fd, lock + except PreviewBundleError: + raise + except (FileExistsError, OSError, TypeError, ValueError): + raise _error("output_exists") from None + + +def _release_install_lock(fd: int, path: Path) -> None: + try: + os.close(fd) + finally: + try: + os.unlink(path) + except OSError: + pass + + +def _cleanup_staging(path: Path) -> None: + try: + for entry in os.scandir(path): + child = Path(entry.path) + try: + os.unlink(child) + except IsADirectoryError: + os.rmdir(child) + os.rmdir(path) + except OSError: + pass + + +def _fsync_directory(path: Path) -> None: + fd = -1 + try: + fd = os.open(path, _safe_file_flags(os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))) + os.fsync(fd) + except PreviewBundleError: + raise + except (OSError, TypeError, ValueError): + raise _error("output_write_failed") from None + finally: + if fd >= 0: + os.close(fd) + + +def _read_bundle_files(output: Path, *, allow_install_lock: bool = False) -> tuple[bytes, bytes, bytes]: + try: + _assert_parent_chain(output) + lock = output.parent / f".{output.name}.install-lock" + if not allow_install_lock: + try: + os.lstat(lock) + except FileNotFoundError: + pass + else: + raise _error("readback_incomplete") + output_info = os.lstat(output) + if stat.S_ISLNK(output_info.st_mode) or not stat.S_ISDIR(output_info.st_mode): + raise _error("readback_invalid") + if {entry.name for entry in os.scandir(output)} != _FIXED_FILES: + raise _error("readback_file_set_invalid") + return ( + _read_member(output / "report.json"), + _read_member(output / "report.html"), + _read_member(output / "manifest.json"), + ) + except PreviewBundleError as exc: + if exc.code == "readback_file_set_invalid": + raise + raise _error("readback_invalid") from None + except (OSError, TypeError, ValueError): + raise _error("readback_invalid") from None def build_preview_bundle(report: Mapping[str, Any], output_dir: str | Path) -> PreviewBundleEvidence: @@ -130,22 +302,44 @@ def build_preview_bundle(report: Mapping[str, Any], output_dir: str | Path) -> P manifest_bytes = _canonical_json(manifest) files = {"report.json": report_bytes, "report.html": html_bytes, "manifest.json": manifest_bytes} staging_dir: str | None = None + lock_fd = -1 + lock_path: Path | None = None + output_created = False + install_succeeded = False try: + output = _output_parent(output) staging_dir = tempfile.mkdtemp(prefix=f".{output.name}.staging-", dir=output.parent) + staging_path = Path(staging_dir) + _assert_directory(staging_path) for name, content in files.items(): - Path(staging_dir, name).write_bytes(content) - read_preview_bundle(staging_dir) - os.rename(staging_dir, output) + _write_member(staging_path / name, content) + try: + read_preview_bundle(staging_path) + except PreviewBundleError: + raise _error("output_write_failed") from None + lock_fd, lock_path = _acquire_install_lock(output.parent, output) + _output_parent(output) + os.mkdir(output, 0o700) + output_created = True + for name in files: + os.rename(staging_path / name, output / name) + _read_preview_bundle(output, allow_install_lock=True) + _fsync_directory(output) + install_succeeded = True staging_dir = None except FileExistsError: raise _error("output_exists") from None + except PreviewBundleError: + raise except (OSError, TypeError, ValueError): raise _error("output_write_failed") from None finally: if staging_dir is not None: - for child in Path(staging_dir).iterdir(): - child.unlink(missing_ok=True) - Path(staging_dir).rmdir() + _cleanup_staging(Path(staging_dir)) + if output_created and not install_succeeded: + _cleanup_staging(output) + if lock_fd >= 0 and lock_path is not None: + _release_install_lock(lock_fd, lock_path) return PreviewBundleEvidence(MappingProxyType(snapshot), MappingProxyType(manifest)) @@ -157,18 +351,8 @@ def _parse_json_bytes(content: bytes) -> object: raise _error("readback_invalid") from None -def read_preview_bundle(output_dir: str | Path) -> PreviewBundleEvidence: - output = Path(output_dir) - try: - if not output.is_dir() or {item.name for item in output.iterdir()} != _FIXED_FILES: - raise _error("readback_file_set_invalid") - report_bytes = (output / "report.json").read_bytes() - html_bytes = (output / "report.html").read_bytes() - manifest_bytes = (output / "manifest.json").read_bytes() - except PreviewBundleError: - raise - except (OSError, TypeError, ValueError): - raise _error("readback_invalid") from None +def _read_preview_bundle(output: Path, *, allow_install_lock: bool = False) -> PreviewBundleEvidence: + report_bytes, html_bytes, manifest_bytes = _read_bundle_files(output, allow_install_lock=allow_install_lock) report = _parse_json_bytes(report_bytes) if not isinstance(report, Mapping): @@ -179,6 +363,8 @@ def read_preview_bundle(output_dir: str | Path) -> PreviewBundleEvidence: manifest = _parse_json_bytes(manifest_bytes) if not isinstance(manifest, Mapping): raise _error("manifest_invalid") + if _canonical_json(manifest) != manifest_bytes: + raise _error("manifest_bytes_noncanonical") if set(manifest) != _MANIFEST_KEYS or set(manifest.get("source", {})) != _SOURCE_KEYS: raise _error("manifest_shape_invalid") artifacts = manifest.get("artifacts") @@ -196,6 +382,10 @@ def read_preview_bundle(output_dir: str | Path) -> PreviewBundleEvidence: return PreviewBundleEvidence(MappingProxyType(snapshot), MappingProxyType(dict(manifest))) +def read_preview_bundle(output_dir: str | Path) -> PreviewBundleEvidence: + return _read_preview_bundle(Path(output_dir)) + + __all__ = [ "BUNDLE_CONTRACT", "SOURCE_CONTRACT_VERSION", "PreviewBundleError", "PreviewBundleEvidence", "build_preview_bundle", "read_preview_bundle", diff --git a/tests/test_preview_bundle.py b/tests/test_preview_bundle.py index 044be71..02a23ca 100644 --- a/tests/test_preview_bundle.py +++ b/tests/test_preview_bundle.py @@ -2,6 +2,7 @@ import hashlib import json +import os from pathlib import Path import pytest @@ -150,18 +151,37 @@ def inspect(path): def test_concurrent_destination_winner_is_not_overwritten_or_cleaned(tmp_path, monkeypatch): output = tmp_path / "preview" - real_rename = preview_bundle.os.rename + real_mkdir = preview_bundle.os.mkdir - def concurrent_winner(source, destination): - Path(destination).mkdir() - raise FileExistsError(destination) + def concurrent_winner(path, mode=0o777): + if Path(path) == output: + real_mkdir(path, mode) + (output / "winner").write_text("keep") + raise FileExistsError(path) + return real_mkdir(path, mode) - monkeypatch.setattr(preview_bundle.os, "rename", concurrent_winner) + monkeypatch.setattr(preview_bundle.os, "mkdir", concurrent_winner) with pytest.raises(PreviewBundleError, match="output_exists"): build_preview_bundle(report(), output) - assert output.is_dir() + assert (output / "winner").read_text() == "keep" assert not list(tmp_path.glob(".preview.staging-*")) - monkeypatch.setattr(preview_bundle.os, "rename", real_rename) + + +def test_relative_output_dir_works_from_current_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + build_preview_bundle(report(), "preview") + assert {path.name for path in Path("preview").iterdir()} == {"report.json", "report.html", "manifest.json"} + + +def test_readback_rejects_symlinked_ancestor(tmp_path): + real_parent = tmp_path / "real" + real_parent.mkdir() + output = real_parent / "preview" + build_preview_bundle(report(), output) + alias = tmp_path / "alias" + alias.symlink_to(real_parent, target_is_directory=True) + with pytest.raises(PreviewBundleError, match="readback"): + read_preview_bundle(alias / "preview") def test_staging_directory_is_cleaned_when_install_fails(tmp_path, monkeypatch): @@ -175,3 +195,73 @@ def fail_readback(_path): build_preview_bundle(report(), output) assert not output.exists() assert not list(tmp_path.glob(".preview.staging-*")) + + +def test_builder_rejects_symlink_member_without_touching_external_target(tmp_path, monkeypatch): + output = tmp_path / "preview" + external = tmp_path / "external.json" + external.write_bytes(b"keep") + original_mkdtemp = preview_bundle.tempfile.mkdtemp + + def inject_symlink(**kwargs): + staging = original_mkdtemp(**kwargs) + (Path(staging) / "report.json").symlink_to(external) + return staging + + monkeypatch.setattr(preview_bundle.tempfile, "mkdtemp", inject_symlink) + with pytest.raises(PreviewBundleError, match="output_write_failed"): + build_preview_bundle(report(), output) + assert external.read_bytes() == b"keep" + assert not output.exists() + + +def test_readback_rejects_symlink_member(tmp_path): + output = tmp_path / "preview" + build_preview_bundle(report(), output) + member = output / "report.json" + original = member.read_bytes() + member.unlink() + outside = output.parent / "outside" + outside.write_bytes(original) + member.symlink_to(outside) + with pytest.raises(PreviewBundleError, match="readback"): + read_preview_bundle(output) + + +def test_readback_rejects_fifo_member(tmp_path): + output = tmp_path / "preview" + build_preview_bundle(report(), output) + member = output / "report.json" + member.unlink() + os.mkfifo(member) + with pytest.raises(PreviewBundleError, match="readback"): + read_preview_bundle(output) + + +def test_readback_rejects_hardlink_alias(tmp_path): + output = tmp_path / "preview" + build_preview_bundle(report(), output) + alias = tmp_path / "alias.json" + alias.hardlink_to(output / "report.json") + with pytest.raises(PreviewBundleError, match="readback"): + read_preview_bundle(output) + + +def test_destination_symlink_is_rejected_without_following_it(tmp_path): + external = tmp_path / "external" + external.mkdir() + output = tmp_path / "preview" + output.symlink_to(external, target_is_directory=True) + with pytest.raises(PreviewBundleError, match="output_exists"): + build_preview_bundle(report(), output) + assert not (external / "report.json").exists() + + +def test_parent_symlink_is_rejected_without_writing_through_alias(tmp_path): + real_parent = tmp_path / "real" + real_parent.mkdir() + alias_parent = tmp_path / "alias" + alias_parent.symlink_to(real_parent, target_is_directory=True) + with pytest.raises(PreviewBundleError, match="output_parent_invalid"): + build_preview_bundle(report(), alias_parent / "preview") + assert not (real_parent / "preview").exists()