1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

feat: add deterministic preview rendering

This commit is contained in:
Andraxion 2026-07-22 03:32:05 -04:00
parent 8c75f4f44d
commit 411f417670
23 changed files with 1413 additions and 142 deletions

299
src/docforge/rendering.py Normal file
View file

@ -0,0 +1,299 @@
"""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)