Add deterministic client integration diagnostics
This commit is contained in:
parent
eb9355b003
commit
fb0df5e4a1
15 changed files with 6193 additions and 18 deletions
|
|
@ -11,6 +11,7 @@ import tempfile
|
|||
import tomllib
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, cast
|
||||
|
|
@ -39,6 +40,7 @@ from .telemetry import increment, stage
|
|||
|
||||
SOURCE_GENERATION_SCHEMA_VERSION = 1
|
||||
GENERIC_SOURCE_CONTRACT = "docforge-core:0.7.1:index:1"
|
||||
MAX_PROJECT_DESCRIPTOR_BYTES = 1_000_000
|
||||
|
||||
_CORE_METADATA = frozenset(
|
||||
{
|
||||
|
|
@ -210,12 +212,167 @@ def _receipt_signature(path: Path) -> tuple[int, int, int, int, int] | None:
|
|||
)
|
||||
|
||||
|
||||
def _read_descriptor(descriptor_path: Path) -> bytes:
|
||||
try:
|
||||
parent = descriptor_path.parent
|
||||
parent_status = parent.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(parent_status.st_mode)
|
||||
or not stat.S_ISDIR(parent_status.st_mode)
|
||||
or parent.resolve(strict=True) != parent
|
||||
):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor parent must be one real confined directory",
|
||||
)
|
||||
directory_fd = os.open(
|
||||
parent,
|
||||
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
|
||||
except DocForgeError:
|
||||
raise
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be inspected safely",
|
||||
) from error
|
||||
opened_parent = os.fstat(directory_fd)
|
||||
if opened_parent.st_dev != parent_status.st_dev or opened_parent.st_ino != parent_status.st_ino:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed while it was opened",
|
||||
)
|
||||
try:
|
||||
|
||||
def parent_current() -> bool:
|
||||
try:
|
||||
before = parent.lstat()
|
||||
resolved = parent.resolve(strict=True)
|
||||
after = parent.lstat()
|
||||
opened = os.fstat(directory_fd)
|
||||
return (
|
||||
not stat.S_ISLNK(before.st_mode)
|
||||
and stat.S_ISDIR(before.st_mode)
|
||||
and resolved == parent
|
||||
and (before.st_dev, before.st_ino, before.st_mode)
|
||||
== (after.st_dev, after.st_ino, after.st_mode)
|
||||
== (opened.st_dev, opened.st_ino, opened.st_mode)
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if not parent_current():
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed before it was read",
|
||||
)
|
||||
try:
|
||||
before = os.stat(
|
||||
descriptor_path.name,
|
||||
dir_fd=directory_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise DocForgeError("missing_config", "Missing .docforge/project.toml") from error
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be inspected safely",
|
||||
) from error
|
||||
if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor must be a regular file and not a symbolic link",
|
||||
)
|
||||
if before.st_size > MAX_PROJECT_DESCRIPTOR_BYTES:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_oversized",
|
||||
"Project descriptor exceeds the bounded configuration limit",
|
||||
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
|
||||
)
|
||||
try:
|
||||
descriptor = os.open(
|
||||
descriptor_path.name,
|
||||
os.O_RDONLY | os.O_NOFOLLOW,
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_unsafe",
|
||||
"Project descriptor cannot be opened safely",
|
||||
) from error
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if opened.st_dev != before.st_dev or opened.st_ino != before.st_ino:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was opened",
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_PROJECT_DESCRIPTOR_BYTES + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(65_536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(descriptor)
|
||||
if len(raw) > MAX_PROJECT_DESCRIPTOR_BYTES:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_oversized",
|
||||
"Project descriptor exceeds the bounded configuration limit",
|
||||
maximum_bytes=MAX_PROJECT_DESCRIPTOR_BYTES,
|
||||
)
|
||||
try:
|
||||
after = os.stat(
|
||||
descriptor_path.name,
|
||||
dir_fd=directory_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was read",
|
||||
) from error
|
||||
if (
|
||||
before.st_dev,
|
||||
before.st_ino,
|
||||
before.st_size,
|
||||
before.st_mtime_ns,
|
||||
before.st_ctime_ns,
|
||||
) != (
|
||||
after.st_dev,
|
||||
after.st_ino,
|
||||
after.st_size,
|
||||
after.st_mtime_ns,
|
||||
after.st_ctime_ns,
|
||||
):
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor changed while it was read",
|
||||
)
|
||||
if not parent_current():
|
||||
raise DocForgeError(
|
||||
"project_descriptor_changed",
|
||||
"Project descriptor parent changed while it was read",
|
||||
)
|
||||
return raw
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _load_descriptor(root: Path) -> ProjectDescriptor:
|
||||
descriptor_path = root / ".docforge" / "project.toml"
|
||||
if not descriptor_path.is_file():
|
||||
raise DocForgeError("missing_config", "Missing .docforge/project.toml")
|
||||
descriptor_bytes = _read_descriptor(descriptor_path)
|
||||
try:
|
||||
descriptor_bytes = descriptor_path.read_bytes()
|
||||
document = cast(dict[str, object], tomllib.loads(descriptor_bytes.decode("utf-8")))
|
||||
except UnicodeDecodeError as error:
|
||||
raise DocForgeError("invalid_config", "Project descriptor is not UTF-8") from error
|
||||
|
|
@ -472,6 +629,17 @@ def _load_descriptor(root: Path) -> ProjectDescriptor:
|
|||
)
|
||||
|
||||
|
||||
def validate_descriptor_binding(descriptor: ProjectDescriptor) -> None:
|
||||
"""Require the bounded descriptor bytes to match one opened project binding."""
|
||||
|
||||
descriptor_bytes = _read_descriptor(descriptor.descriptor_path)
|
||||
if hashlib.sha256(descriptor_bytes).hexdigest() != descriptor.descriptor_hash:
|
||||
raise DocForgeError(
|
||||
"source_changed",
|
||||
"Project descriptor changed after the project was opened",
|
||||
)
|
||||
|
||||
|
||||
def _markdown_record(path: Path, text: str) -> tuple[dict[str, Any], str]:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "+++":
|
||||
|
|
@ -739,11 +907,7 @@ class Project:
|
|||
|
||||
def load(self) -> ProjectSnapshot:
|
||||
increment("project_loads")
|
||||
descriptor_bytes = self.descriptor.descriptor_path.read_bytes()
|
||||
if hashlib.sha256(descriptor_bytes).hexdigest() != self.descriptor.descriptor_hash:
|
||||
raise DocForgeError(
|
||||
"source_changed", "Project descriptor changed after the project was opened"
|
||||
)
|
||||
validate_descriptor_binding(self.descriptor)
|
||||
ordered_sources, ordered_directories = self._canonical_inventory()
|
||||
generation_paths = (
|
||||
self.descriptor.descriptor_path,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue