"""Confined preview and derived-output orchestration for declared render views.""" from __future__ import annotations import fcntl import hashlib import os import tempfile from collections.abc import Callable, Iterator from contextlib import contextmanager from pathlib import Path from .changesets import ChangesetStore from .errors import DocForgeError from .models import ProjectSnapshot, RenderConfig, RenderView from .project import Project, project_root_fingerprint from .render_contract import PreparedRender, relative_output, renderer_for class RenderService: """Render only declared views through fixed built-in renderer implementations.""" def __init__(self, project: Project, changesets: ChangesetStore | None = None) -> None: self.project = project self.changesets = changesets or ChangesetStore(project) def status(self, view_id: str | None = None) -> dict[str, object]: snapshot = self.project.load() config = snapshot.descriptor.render if config is None: return self._result( snapshot, configured=False, state="not_configured", outputs=[], ) views = self._views(config, view_id) outputs: list[dict[str, object]] = [] for view in views: prepared, _ = self._prepare(snapshot, view, changeset_hash=None) state = "missing" actual_hash: str | None = None output = view.output_path if output.is_symlink() or output.resolve(strict=False) != output: state = "unsafe" elif output.is_file(): if output.stat().st_size > snapshot.descriptor.limits.max_render_bytes: state = "oversized" else: raw = output.read_bytes() actual_hash = hashlib.sha256(raw).hexdigest() state = "current" if actual_hash == prepared.output_hash else "stale" outputs.append( self._view_result(snapshot, view, prepared, state=state, actual_hash=actual_hash) ) return self._result( snapshot, configured=True, state="current" if all(item["state"] == "current" for item in outputs) else "stale", outputs=outputs, ) def render(self, view_id: str) -> dict[str, object]: with self._lock(): snapshot = self.project.load() config = self._config(snapshot) view = self._views(config, view_id)[0] prepared, template_bytes = self._prepare(snapshot, view, changeset_hash=None) self._atomic_write( view.output_path, prepared.output, verify=lambda: self._verify_canonical(snapshot, view, template_bytes), ) return self._result( snapshot, configured=True, state="current", output=self._view_result( snapshot, view, prepared, state="current", actual_hash=prepared.output_hash, ), ) def preview(self, changeset_id: str, view_id: str) -> dict[str, object]: with self._lock(): snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id) config = self._config(snapshot) view = self._views(config, view_id)[0] prepared, template_bytes = self._prepare(snapshot, view, changeset_hash=changeset_hash) preview_path = config.preview_root / changeset_id / f"{view.view_id}.html" if not preview_path.is_relative_to(config.preview_root): raise DocForgeError("path_escape", "Preview path escaped its configured root") def verify() -> None: current, current_hash = self.changesets.projected_snapshot(changeset_id) if current.source_hash != snapshot.source_hash or current_hash != changeset_hash: raise DocForgeError( "render_input_changed", "Changeset or canonical input changed during preview", ) self._verify_template(view, template_bytes) self._atomic_write(preview_path, prepared.output, verify=verify, preview=True) return self._result( snapshot, configured=True, state="current", changeset_id=changeset_id, changeset_hash=changeset_hash, preview_identity=prepared.render_identity, preview={ **self._view_result( snapshot, view, prepared, state="current", actual_hash=prepared.output_hash, ), "path": relative_output(snapshot, preview_path), }, ) def _prepare( self, snapshot: ProjectSnapshot, view: RenderView, *, changeset_hash: str | None, ) -> tuple[PreparedRender, bytes]: template = self._template_bytes(snapshot, view) prepared = renderer_for(view).prepare( snapshot, view, template, changeset_hash=changeset_hash, ) if len(prepared.output) > snapshot.descriptor.limits.max_render_bytes: raise DocForgeError("render_too_large", "Rendered output exceeds the configured limit") return prepared, template def _template_bytes(self, snapshot: ProjectSnapshot, view: RenderView) -> bytes: path = view.template_path config = self._config(snapshot) if ( path.is_symlink() or path.resolve(strict=False) != path or config.template_root.resolve(strict=False) != config.template_root or not path.is_file() or not path.is_relative_to(config.template_root) ): raise DocForgeError("unsafe_template", "Render template is missing or unsafe") if path.stat().st_size > snapshot.descriptor.limits.max_template_bytes: raise DocForgeError( "template_too_large", "Render template exceeds the configured limit" ) raw = path.read_bytes() if len(raw) > snapshot.descriptor.limits.max_template_bytes: raise DocForgeError( "template_too_large", "Render template exceeds the configured limit" ) return raw def _verify_canonical( self, snapshot: ProjectSnapshot, view: RenderView, template_bytes: bytes ) -> None: current = self.project.load() if current.source_hash != snapshot.source_hash: raise DocForgeError("render_input_changed", "Canonical input changed during rendering") self._verify_template(view, template_bytes) @staticmethod def _verify_template(view: RenderView, template_bytes: bytes) -> None: if ( view.template_path.is_symlink() or view.template_path.resolve(strict=False) != view.template_path or not view.template_path.is_file() ): raise DocForgeError("render_input_changed", "Render template changed during rendering") if view.template_path.read_bytes() != template_bytes: raise DocForgeError("render_input_changed", "Render template changed during rendering") def _atomic_write( self, output: Path, content: bytes, *, verify: Callable[[], None], preview: bool = False, ) -> None: root = self.project.descriptor.root if output.is_symlink() or not output.is_relative_to(root): raise DocForgeError("path_escape", "Render output path is unsafe") parent = output.parent if parent.resolve(strict=False) != parent: raise DocForgeError("path_escape", "Render output directory is unsafe") parent.mkdir(parents=True, exist_ok=True) if parent.resolve() != parent or not parent.is_relative_to(root): raise DocForgeError("path_escape", "Render output directory is unsafe") descriptor, temporary_name = tempfile.mkstemp(prefix=".docforge-render-", dir=parent) temporary = Path(temporary_name) try: with os.fdopen(descriptor, "wb") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) verify() if output.is_symlink(): raise DocForgeError("path_escape", "Render output became unsafe") os.replace(temporary, output) except Exception: temporary.unlink(missing_ok=True) if preview: self._remove_empty_preview_parents(parent) raise def _remove_empty_preview_parents(self, parent: Path) -> None: config = self.project.descriptor.render if config is None: return current = parent while current != config.preview_root: try: current.rmdir() except OSError: return current = current.parent def _view_result( self, snapshot: ProjectSnapshot, view: RenderView, prepared: PreparedRender, *, state: str, actual_hash: str | None, ) -> dict[str, object]: return { "view_id": view.view_id, "renderer": prepared.renderer, "renderer_version": prepared.renderer_version, "render_identity": prepared.render_identity, "expected_output_hash": prepared.output_hash, "actual_output_hash": actual_hash, "template_hash": prepared.template_hash, "path": relative_output(snapshot, view.output_path), "state": state, } @staticmethod def _config(snapshot: ProjectSnapshot) -> RenderConfig: if snapshot.descriptor.render is None: raise DocForgeError("render_not_configured", "Project has no configured render views") return snapshot.descriptor.render @staticmethod def _views(config: RenderConfig, view_id: str | None) -> tuple[RenderView, ...]: if view_id is None: return config.views views = tuple(view for view in config.views if view.view_id == view_id) if not views: raise DocForgeError( "unknown_render_view", "Render view is not declared by this project", view=view_id ) return views @staticmethod def _result(snapshot: ProjectSnapshot, **payload: object) -> dict[str, object]: return { "status": "ok", "project_id": snapshot.descriptor.project_id, "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), "adapter": snapshot.descriptor.adapter, "revision": snapshot.revision, "source_hash": snapshot.source_hash, **payload, } @contextmanager def _lock(self) -> Iterator[None]: root = self.project.descriptor.cache_root if root.resolve(strict=False) != root: raise DocForgeError("path_escape", "Render lock directory is not safe") root.mkdir(parents=True, exist_ok=True) if not root.is_dir() or root.resolve(strict=False) != root: raise DocForgeError("path_escape", "Render lock directory is not safe") lock_path = root / "render.lock" try: descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) except OSError as error: raise DocForgeError("path_escape", "Render lock path is not safe") from error with os.fdopen(descriptor, "a+b") as handle: fcntl.flock(handle.fileno(), fcntl.LOCK_EX) try: yield finally: fcntl.flock(handle.fileno(), fcntl.LOCK_UN)