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

Make render status receipt based

This commit is contained in:
Andraxion 2026-07-29 04:42:55 -04:00
parent 21c4992f9c
commit 0fe968c475
10 changed files with 913 additions and 27 deletions

View file

@ -4,18 +4,33 @@ from __future__ import annotations
import fcntl
import hashlib
import json
import os
import stat
import tempfile
from collections.abc import Callable, Generator
from contextlib import contextmanager
from pathlib import Path
from typing import cast
from .changesets import ChangesetStore
from .errors import DocForgeError
from .models import ProjectService, ProjectSnapshot, RenderConfig, RenderView
from .models import (
GenerationRecordingProject,
IncrementalStateProject,
ProjectDescriptor,
ProjectService,
ProjectSnapshot,
ProjectState,
RenderConfig,
RenderView,
)
from .project import project_root_fingerprint
from .render_contract import PreparedRender, relative_output, renderer_for
RENDER_RECEIPT_SCHEMA_VERSION = 1
MAX_RENDER_RECEIPT_BYTES = 64_000
class RenderService:
"""Render only declared views through fixed built-in renderer implementations."""
@ -25,6 +40,47 @@ class RenderService:
self.changesets = changesets or ChangesetStore(project)
def status(self, view_id: str | None = None) -> dict[str, object]:
"""Report publication state from bounded receipts without rendering canonical content."""
descriptor = self.project.descriptor
config = descriptor.render
current_state = self._current_state()
if config is None:
return self._status_result(
descriptor,
current_state,
configured=False,
state="not_configured",
verification="receipt",
outputs=[],
)
views = self._views(config, view_id)
first_outputs = [self._receipt_status(descriptor, view, current_state) for view in views]
outputs = [self._receipt_status(descriptor, view, current_state) for view in views]
if outputs != first_outputs:
for output in outputs:
if output["state"] == "current":
output["state"] = "stale"
output["reason"] = "publication_changed_during_status"
final_state = self._current_state()
if final_state != current_state:
for output in outputs:
if output["state"] == "current":
output["state"] = "stale"
output["reason"] = "source_changed_during_status"
identity = final_state if final_state is not None else current_state
return self._status_result(
descriptor,
identity,
configured=True,
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
verification="receipt",
outputs=outputs,
)
def deep_status(self, view_id: str | None = None) -> dict[str, object]:
"""Recompute render output as the explicit side-effect-free equivalence oracle."""
snapshot = self.project.load()
config = snapshot.descriptor.render
if config is None:
@ -32,11 +88,20 @@ class RenderService:
snapshot,
configured=False,
state="not_configured",
verification="deep",
outputs=[],
)
views = self._views(config, view_id)
outputs: list[dict[str, object]] = []
for view in views:
template_before = self._safe_file_identity(
snapshot.descriptor.root,
view.template_path,
)
output_before = self._safe_file_identity(
snapshot.descriptor.root,
view.output_path,
)
prepared, _ = self._prepare(snapshot, view, changeset_hash=None)
state = "missing"
actual_hash: str | None = None
@ -50,13 +115,31 @@ class RenderService:
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)
result = self._view_result(
snapshot,
view,
prepared,
state=state,
actual_hash=actual_hash,
)
if template_before != self._safe_file_identity(
snapshot.descriptor.root, view.template_path
) or output_before != self._safe_file_identity(
snapshot.descriptor.root, view.output_path
):
result["state"] = "stale"
result["reason"] = "publication_changed_during_deep_status"
outputs.append(result)
current = self.project.load()
if current.source_hash != snapshot.source_hash or current.revision != snapshot.revision:
for output in outputs:
output["state"] = "stale"
output["reason"] = "source_changed_during_deep_status"
return self._result(
snapshot,
configured=True,
state="current" if all(item["state"] == "current" for item in outputs) else "stale",
verification="deep",
outputs=outputs,
)
@ -71,10 +154,32 @@ class RenderService:
prepared.output,
verify=lambda: self._verify_canonical(snapshot, view, template_bytes),
)
receipt: dict[str, object]
state = "current"
try:
if isinstance(self.project, GenerationRecordingProject):
self.project.record_generation(snapshot)
receipt = self._publish_receipt(snapshot, view, prepared)
except (DocForgeError, OSError) as error:
state = "degraded"
receipt = {
"state": "failed",
"error": (
error.as_dict()
if isinstance(error, DocForgeError)
else {
"code": "render_receipt_failure",
"message": "Rendered output was published but its receipt failed",
"details": {},
}
),
}
return self._result(
snapshot,
configured=True,
state="current",
state=state,
publication="published",
receipt=receipt,
output=self._view_result(
snapshot,
view,
@ -84,6 +189,496 @@ class RenderService:
),
)
def _receipt_status(
self,
descriptor: ProjectDescriptor,
view: RenderView,
current_state: ProjectState | None,
) -> dict[str, object]:
receipt, receipt_state = self._read_receipt(view)
output_state = self._safe_file_identity(descriptor.root, view.output_path)
if output_state is None:
state = "unsafe" if view.output_path.is_symlink() else "missing"
return self._receipt_view_result(
descriptor,
view,
receipt,
state=state,
reason="output_not_safe" if state == "unsafe" else "output_missing",
)
if receipt is None:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unverified",
reason=receipt_state,
)
if not self._receipt_matches_binding(descriptor, view, receipt):
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unverified",
reason="foreign_or_incompatible_receipt",
)
template_state = self._safe_file_identity(descriptor.root, view.template_path)
if template_state is None:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="unsafe",
reason="template_not_safe",
)
if receipt.get("template_file") != template_state:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="template_changed",
)
if receipt.get("output_file") != output_state:
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="output_changed",
)
if current_state is None:
reason = (
"source_generation_unavailable"
if isinstance(self.project, GenerationRecordingProject)
else "source_generation_unsupported"
)
return self._receipt_view_result(
descriptor,
view,
receipt,
state=(
"stale"
if isinstance(self.project, GenerationRecordingProject)
else "unverified"
),
reason=reason,
)
if (
receipt.get("source_hash") != current_state.source_hash
or receipt.get("revision") != current_state.revision
):
return self._receipt_view_result(
descriptor,
view,
receipt,
state="stale",
reason="source_generation_changed",
)
return self._receipt_view_result(
descriptor,
view,
receipt,
state="current",
reason=None,
)
def _publish_receipt(
self,
snapshot: ProjectSnapshot,
view: RenderView,
prepared: PreparedRender,
) -> dict[str, object]:
source_before = self._current_state()
if isinstance(self.project, GenerationRecordingProject) and (
source_before is None
or source_before.source_hash != snapshot.source_hash
or source_before.revision != snapshot.revision
):
raise DocForgeError(
"render_receipt_failure",
"Canonical source changed before render receipt publication",
)
if source_before is not None and (
source_before.source_hash != snapshot.source_hash
or source_before.revision != snapshot.revision
):
raise DocForgeError(
"render_receipt_failure",
"Canonical source changed before render receipt publication",
)
template_file, template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
output_file, output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
if (
template_hash != prepared.template_hash
or output_hash != prepared.output_hash
or output_file["size"] != len(prepared.output)
):
raise DocForgeError(
"render_receipt_failure",
"Published render files do not match the verified render",
)
final_template_file, final_template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
final_output_file, final_output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
source_after = self._current_state()
if (
template_file != final_template_file
or output_file != final_output_file
or template_hash != final_template_hash
or output_hash != final_output_hash
or source_before != source_after
):
raise DocForgeError(
"render_receipt_failure",
"Render publication changed while its receipt was being prepared",
)
payload: dict[str, object] = {
"schema_version": RENDER_RECEIPT_SCHEMA_VERSION,
"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,
"view_id": view.view_id,
"view_config_hash": self._view_config_hash(snapshot.descriptor, view),
"renderer": prepared.renderer,
"renderer_version": prepared.renderer_version,
"render_identity": prepared.render_identity,
"template_hash": prepared.template_hash,
"output_hash": prepared.output_hash,
"output_bytes": len(prepared.output),
"template_file": final_template_file,
"output_file": final_output_file,
}
raw = json.dumps(payload, sort_keys=True, indent=2).encode("utf-8") + b"\n"
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
raise DocForgeError(
"render_receipt_failure",
"Render publication receipt exceeds its fixed size limit",
)
root = self._receipt_root(create=True)
path = root / f"{view.view_id}.json"
if path.is_symlink():
raise DocForgeError(
"path_escape",
"Render publication receipt path is not safe",
)
descriptor, temporary_name = tempfile.mkstemp(prefix=".render-receipt-", dir=root)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(raw)
handle.flush()
os.fsync(handle.fileno())
last_template_file, last_template_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.template_path,
snapshot.descriptor.limits.max_template_bytes,
)
last_output_file, last_output_hash = self._verified_file_digest(
snapshot.descriptor.root,
view.output_path,
snapshot.descriptor.limits.max_render_bytes,
)
if (
last_template_file != final_template_file
or last_output_file != final_output_file
or last_template_hash != final_template_hash
or last_output_hash != final_output_hash
or self._current_state() != source_after
):
raise DocForgeError(
"render_receipt_failure",
"Render publication changed before receipt publication",
)
os.replace(temporary, path)
directory_descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(directory_descriptor)
finally:
os.close(directory_descriptor)
except Exception:
temporary.unlink(missing_ok=True)
raise
return {
"state": "current",
"schema_version": RENDER_RECEIPT_SCHEMA_VERSION,
"path": path.relative_to(snapshot.descriptor.root).as_posix(),
}
def _read_receipt(
self,
view: RenderView,
) -> tuple[dict[str, object] | None, str]:
try:
root = self._receipt_root(create=False)
except DocForgeError:
return None, "receipt_root_unsafe"
path = root / f"{view.view_id}.json"
if path.is_symlink():
return None, "receipt_unsafe"
if not path.is_file():
return None, "receipt_missing"
try:
if path.stat().st_size > MAX_RENDER_RECEIPT_BYTES:
return None, "receipt_oversized"
raw = path.read_bytes()
if len(raw) > MAX_RENDER_RECEIPT_BYTES:
return None, "receipt_oversized"
parsed: object = json.loads(raw)
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return None, "receipt_corrupt"
if not isinstance(parsed, dict):
return None, "receipt_corrupt"
return cast(dict[str, object], parsed), "receipt"
def _receipt_root(self, *, create: bool) -> Path:
cache_root = self.project.descriptor.cache_root
root = cache_root / "render-receipts"
if (
cache_root.resolve(strict=False) != cache_root
or root.is_symlink()
or root.resolve(strict=False) != root
or not root.is_relative_to(cache_root)
):
raise DocForgeError("path_escape", "Render receipt root is not safe")
if create:
cache_root.mkdir(parents=True, exist_ok=True)
root.mkdir(parents=True, exist_ok=True)
if root.exists() and not root.is_dir():
raise DocForgeError("path_escape", "Render receipt root is not safe")
return root
@staticmethod
def _safe_file_identity(root: Path, path: Path) -> dict[str, object] | None:
if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root):
return None
try:
current = path.lstat()
except OSError:
return None
if not stat.S_ISREG(current.st_mode):
return None
return {
"path": path.relative_to(root).as_posix(),
"device": current.st_dev,
"inode": current.st_ino,
"mode": current.st_mode,
"size": current.st_size,
"mtime_ns": current.st_mtime_ns,
"ctime_ns": current.st_ctime_ns,
}
@staticmethod
def _verified_file_digest(
root: Path,
path: Path,
maximum: int,
) -> tuple[dict[str, object], str]:
if path.is_symlink() or path.resolve(strict=False) != path or not path.is_relative_to(root):
raise DocForgeError(
"render_receipt_failure",
"Render publication file is not safe for verification",
)
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise DocForgeError(
"render_receipt_failure",
"Render publication file is not readable for verification",
) from error
with os.fdopen(descriptor, "rb") as handle:
current = os.fstat(handle.fileno())
if not stat.S_ISREG(current.st_mode) or current.st_size > maximum:
raise DocForgeError(
"render_receipt_failure",
"Render publication file failed receipt validation",
)
digest = hashlib.file_digest(handle, "sha256").hexdigest()
return (
{
"path": path.relative_to(root).as_posix(),
"device": current.st_dev,
"inode": current.st_ino,
"mode": current.st_mode,
"size": current.st_size,
"mtime_ns": current.st_mtime_ns,
"ctime_ns": current.st_ctime_ns,
},
digest,
)
@staticmethod
def _view_config_hash(descriptor: ProjectDescriptor, view: RenderView) -> str:
payload = {
"view_id": view.view_id,
"renderer": view.renderer,
"template": view.template_path.relative_to(descriptor.root).as_posix(),
"output": view.output_path.relative_to(descriptor.root).as_posix(),
"title": view.title,
"families": list(view.families),
}
return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _receipt_matches_binding(
self,
descriptor: ProjectDescriptor,
view: RenderView,
receipt: dict[str, object],
) -> bool:
required = {
"schema_version",
"project_id",
"project_root_fingerprint",
"adapter",
"revision",
"source_hash",
"view_id",
"view_config_hash",
"renderer",
"renderer_version",
"render_identity",
"template_hash",
"output_hash",
"output_bytes",
"template_file",
"output_file",
}
renderer = renderer_for(view)
template_file = receipt.get("template_file")
output_file = receipt.get("output_file")
return (
set(receipt) == required
and receipt.get("schema_version") == RENDER_RECEIPT_SCHEMA_VERSION
and receipt.get("project_id") == descriptor.project_id
and receipt.get("project_root_fingerprint") == project_root_fingerprint(descriptor.root)
and receipt.get("adapter") == descriptor.adapter
and receipt.get("view_id") == view.view_id
and receipt.get("view_config_hash") == self._view_config_hash(descriptor, view)
and receipt.get("renderer") == renderer.renderer_id
and receipt.get("renderer_version") == renderer.renderer_version
and self._is_hash(receipt.get("source_hash"))
and isinstance(receipt.get("revision"), str)
and bool(receipt.get("revision"))
and self._is_hash(receipt.get("view_config_hash"))
and self._is_hash(receipt.get("render_identity"))
and self._is_hash(receipt.get("template_hash"))
and self._is_hash(receipt.get("output_hash"))
and type(receipt.get("output_bytes")) is int
and 0 <= cast(int, receipt["output_bytes"]) <= descriptor.limits.max_render_bytes
and self._valid_receipt_file(
template_file,
view.template_path.relative_to(descriptor.root).as_posix(),
descriptor.limits.max_template_bytes,
)
and self._valid_receipt_file(
output_file,
view.output_path.relative_to(descriptor.root).as_posix(),
descriptor.limits.max_render_bytes,
)
and cast(dict[str, object], output_file)["size"] == receipt.get("output_bytes")
)
@staticmethod
def _is_hash(value: object) -> bool:
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
@staticmethod
def _valid_receipt_file(
value: object,
expected_path: str,
maximum: int,
) -> bool:
if not isinstance(value, dict):
return False
payload = cast(dict[str, object], value)
return (
set(payload)
== {
"path",
"device",
"inode",
"mode",
"size",
"mtime_ns",
"ctime_ns",
}
and payload.get("path") == expected_path
and all(
type(payload.get(key)) is int and cast(int, payload[key]) >= 0
for key in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns")
)
and cast(int, payload["size"]) <= maximum
)
@staticmethod
def _receipt_view_result(
descriptor: ProjectDescriptor,
view: RenderView,
receipt: dict[str, object] | None,
*,
state: str,
reason: str | None,
) -> dict[str, object]:
payload = receipt or {}
return {
"view_id": view.view_id,
"renderer": payload.get("renderer", view.renderer),
"renderer_version": payload.get("renderer_version"),
"render_identity": payload.get("render_identity"),
"expected_output_hash": payload.get("output_hash"),
"actual_output_hash": (payload.get("output_hash") if state == "current" else None),
"template_hash": payload.get("template_hash"),
"path": view.output_path.relative_to(descriptor.root).as_posix(),
"state": state,
"reason": reason,
"verification": "receipt",
"receipt_schema_version": payload.get("schema_version"),
}
def _current_state(self) -> ProjectState | None:
if isinstance(self.project, IncrementalStateProject):
return self.project.incremental_state()
return None
@staticmethod
def _status_result(
descriptor: ProjectDescriptor,
identity: ProjectState | None,
**payload: object,
) -> dict[str, object]:
return {
"status": "ok",
"project_id": descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(descriptor.root),
"adapter": descriptor.adapter,
"revision": identity.revision if identity is not None else "unknown",
"source_hash": identity.source_hash if identity is not None else None,
**payload,
}
def preview(self, changeset_id: str, view_id: str) -> dict[str, object]:
with self._lock():
snapshot, changeset_hash = self.changesets.projected_snapshot(changeset_id)