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

Harden canonical publication against races

This commit is contained in:
Andraxion 2026-07-29 16:03:14 -04:00
parent 8919e2af32
commit a901c9705b
8 changed files with 1059 additions and 78 deletions

View file

@ -4,13 +4,16 @@ from __future__ import annotations
import json
import os
import stat
import tempfile
from collections import defaultdict
from collections.abc import Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, cast
from typing import Literal, Protocol, cast
from ._fs_safety import open_bound_directory, rename_exchange_at, require_bound_directory
from .changesets import ChangesetStore
from .errors import DocForgeError
from .index import ProjectIndex
@ -30,6 +33,58 @@ class CanonicalApplier(Protocol):
) -> dict[str, object]: ...
@dataclass(frozen=True)
class _CanonicalFile:
device: int
inode: int
mode: int
owner: int
group: int
size: int
modified_ns: int
changed_ns: int
content: bytes
def unchanged(self, other: _CanonicalFile | None) -> bool:
return self == other
def renamed_to(self, other: _CanonicalFile | None) -> bool:
if other is None:
return False
return (
self.device,
self.inode,
self.mode,
self.owner,
self.group,
self.size,
self.modified_ns,
self.content,
) == (
other.device,
other.inode,
other.mode,
other.owner,
other.group,
other.size,
other.modified_ns,
other.content,
)
@dataclass
class _CanonicalPublication:
relative: str
target: Path
action: Literal["create", "update", "delete"]
expected: _CanonicalFile | None
staged: Path
staged_snapshot: _CanonicalFile
backup_snapshot: _CanonicalFile | None = None
published_snapshot: _CanonicalFile | None = None
committed: bool = False
class GenericCanonicalApplier:
"""Apply generic Markdown/TOML projections inside declared content roots."""
@ -46,35 +101,49 @@ class GenericCanonicalApplier:
changed_sources = self._changed_sources(base, projected)
if not changed_sources:
raise DocForgeError("empty_changeset", "Changeset produces no canonical changes")
base_by_source = self._nodes_by_source(base)
projected_by_source = self._nodes_by_source(projected)
staged: dict[Path, Path] = {}
previous: dict[Path, bytes | None] = {}
publications: list[_CanonicalPublication] = []
created_directories: list[Path] = []
targets = {relative: self._target(relative) for relative in sorted(changed_sources)}
try:
for relative, target in targets.items():
previous[target] = target.read_bytes() if target.is_file() else None
nodes = projected_by_source.get(relative, ())
if not nodes:
continue
self._prepare_parent(target.parent, created_directories)
raw = self._serialize_source(projected, relative, nodes)
if len(raw) > base.descriptor.limits.max_source_bytes:
expected = self._capture(target, base.descriptor.limits.max_source_bytes)
existed_in_base = relative in base_by_source
if existed_in_base != (expected is not None):
raise DocForgeError(
"base_conflict",
"Canonical target changed before application staging",
source=relative,
)
nodes = projected_by_source.get(relative, ())
raw = self._serialize_source(projected, relative, nodes) if nodes else b""
if nodes and len(raw) > base.descriptor.limits.max_source_bytes:
raise DocForgeError(
"source_too_large",
"Applied canonical source exceeds the configured limit",
source=relative,
)
descriptor, temporary_name = tempfile.mkstemp(
prefix=".docforge-apply-",
dir=target.parent,
staged, staged_snapshot = self._stage(
target,
raw,
mode=(stat.S_IMODE(expected.mode) if expected is not None else 0o600),
)
publications.append(
_CanonicalPublication(
relative=relative,
target=target,
action=(
"delete"
if not nodes
else ("update" if expected is not None else "create")
),
expected=expected,
staged=staged,
staged_snapshot=staged_snapshot,
)
)
temporary = Path(temporary_name)
with os.fdopen(descriptor, "wb") as handle:
handle.write(raw)
handle.flush()
os.fsync(handle.fileno())
staged[target] = temporary
current = self.project.load()
if current.source_hash != base.source_hash or current.revision != base.revision:
@ -89,13 +158,8 @@ class GenericCanonicalApplier:
for target in targets.values():
if target.is_symlink():
raise DocForgeError("path_escape", "Canonical target became a symbolic link")
for target in sorted(targets.values(), key=str):
temporary = staged.get(target)
if temporary is None:
target.unlink(missing_ok=True)
else:
os.replace(temporary, target)
self._fsync_directory(target.parent)
for publication in publications:
self._publish(publication)
applied = self.project.load()
if self._semantic_snapshot(applied) != self._semantic_snapshot(projected):
@ -103,19 +167,522 @@ class GenericCanonicalApplier:
"application_mismatch",
"Applied canonical files do not reproduce the validated proposal",
)
except Exception:
for temporary in staged.values():
temporary.unlink(missing_ok=True)
self._restore(previous)
except Exception as error:
recovery = self._rollback(publications)
self._discard_unowned_staging(publications)
self._remove_empty_directories(created_directories)
if recovery:
cause = error.code if isinstance(error, DocForgeError) else type(error).__name__
raise DocForgeError(
"application_recovery_required",
(
"Canonical application raced external writes and could not be "
"rolled back without overwriting foreign data"
),
cause=cause,
conflicts=recovery,
remediation=(
"Preserve the retained files, compare them with the named canonical "
"targets, and resolve the project before retrying a new changeset."
),
) from error
if isinstance(error, DocForgeError) and error.code == "application_recovery_required":
raise DocForgeError(
"base_conflict",
"Canonical target raced publication but was restored without data loss",
) from error
raise
retained = self._discard_backups(publications)
return {
"applied_sources": sorted(changed_sources),
"removed_sources": sorted(
source for source in changed_sources if source not in projected_by_source
),
"retained_recovery_files": retained,
}
def _publish(self, publication: _CanonicalPublication) -> None:
target = publication.target
parent_fd = open_bound_directory(target.parent)
try:
current = self._capture_at(
target.parent,
parent_fd,
target.name,
self.project.descriptor.limits.max_source_bytes,
)
if publication.expected is None:
if current is not None:
raise DocForgeError(
"base_conflict",
"Canonical create target appeared during application",
source=publication.relative,
)
elif not publication.expected.unchanged(current):
raise DocForgeError(
"base_conflict",
"Canonical target changed during application",
source=publication.relative,
)
staged = self._capture_at(
target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
if staged is None or not publication.staged_snapshot.unchanged(staged):
raise DocForgeError(
"application_mismatch",
"Canonical staging file changed before publication",
source=publication.relative,
)
if publication.action == "create":
self._publish_create(publication, parent_fd)
else:
self._publish_exchange(publication, parent_fd)
finally:
os.close(parent_fd)
def _publish_create(
self,
publication: _CanonicalPublication,
parent_fd: int,
) -> None:
try:
os.link(
publication.staged.name,
publication.target.name,
src_dir_fd=parent_fd,
dst_dir_fd=parent_fd,
follow_symlinks=False,
)
except FileExistsError as error:
raise DocForgeError(
"base_conflict",
"Canonical create target appeared during application",
source=publication.relative,
) from error
except OSError as error:
raise DocForgeError(
"application_failure",
"Canonical create target could not be published",
source=publication.relative,
) from error
publication.committed = True
published = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
if not publication.staged_snapshot.renamed_to(published):
raise DocForgeError(
"application_mismatch",
"Published canonical create target does not match its staging file",
source=publication.relative,
)
publication.published_snapshot = published
os.unlink(publication.staged.name, dir_fd=parent_fd)
os.fsync(parent_fd)
require_bound_directory(publication.target.parent, parent_fd)
def _publish_exchange(
self,
publication: _CanonicalPublication,
parent_fd: int,
) -> None:
rename_exchange_at(parent_fd, publication.staged.name, publication.target.name)
publication.committed = True
displaced = self._capture_at(
publication.target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
published = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
publication.backup_snapshot = displaced
publication.published_snapshot = published
if (
publication.expected is None
or not publication.expected.renamed_to(displaced)
or not publication.staged_snapshot.renamed_to(published)
):
if not self._exchange_back(publication, parent_fd):
raise DocForgeError(
"application_recovery_required",
"Canonical target raced atomic publication and displaced data was retained",
source=publication.relative,
retained=self._relative(publication.staged),
)
raise DocForgeError(
"base_conflict",
"Canonical target changed at the atomic publication boundary",
source=publication.relative,
)
if publication.action == "delete":
os.unlink(publication.target.name, dir_fd=parent_fd)
publication.published_snapshot = None
os.fsync(parent_fd)
require_bound_directory(publication.target.parent, parent_fd)
def _exchange_back(
self,
publication: _CanonicalPublication,
parent_fd: int,
) -> bool:
backup = publication.backup_snapshot
published = publication.published_snapshot
if backup is None or published is None:
return False
try:
rename_exchange_at(parent_fd, publication.staged.name, publication.target.name)
restored = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
staged = self._capture_at(
publication.target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
if not backup.renamed_to(restored) or not published.renamed_to(staged):
return False
publication.backup_snapshot = None
publication.published_snapshot = None
publication.committed = False
os.fsync(parent_fd)
return True
except (DocForgeError, OSError):
return False
def _rollback(
self,
publications: Sequence[_CanonicalPublication],
) -> list[dict[str, object]]:
conflicts: list[dict[str, object]] = []
for publication in reversed(publications):
if not publication.committed:
continue
try:
if publication.action == "create":
conflict = self._rollback_create(publication)
elif publication.action == "delete" and publication.published_snapshot is None:
conflict = self._rollback_delete(publication)
else:
conflict = self._rollback_update(publication)
except Exception as error:
conflict = self._recovery_conflict(publication, "rollback_failed")
conflict["error"] = (
error.code if isinstance(error, DocForgeError) else type(error).__name__
)
if conflict is not None:
conflicts.append(conflict)
return conflicts
def _rollback_update(
self,
publication: _CanonicalPublication,
) -> dict[str, object] | None:
parent_fd = open_bound_directory(publication.target.parent)
try:
current = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
backup = self._capture_at(
publication.target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
if (
publication.published_snapshot is None
or publication.backup_snapshot is None
or not publication.published_snapshot.unchanged(current)
or not publication.backup_snapshot.unchanged(backup)
):
return self._recovery_conflict(publication, "target_or_backup_changed")
if not self._exchange_back(publication, parent_fd):
return self._recovery_conflict(publication, "atomic_restore_unconfirmed")
os.unlink(publication.staged.name, dir_fd=parent_fd)
os.fsync(parent_fd)
return None
finally:
os.close(parent_fd)
def _rollback_delete(
self,
publication: _CanonicalPublication,
) -> dict[str, object] | None:
parent_fd = open_bound_directory(publication.target.parent)
try:
current = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
backup = self._capture_at(
publication.target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
if current is not None:
return self._recovery_conflict(publication, "deleted_target_reappeared")
if publication.backup_snapshot is None or not publication.backup_snapshot.unchanged(
backup
):
return self._recovery_conflict(publication, "backup_changed")
try:
os.link(
publication.staged.name,
publication.target.name,
src_dir_fd=parent_fd,
dst_dir_fd=parent_fd,
follow_symlinks=False,
)
except FileExistsError:
return self._recovery_conflict(publication, "deleted_target_reappeared")
restored = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
if not publication.backup_snapshot.renamed_to(restored):
return self._recovery_conflict(publication, "restore_unconfirmed")
os.unlink(publication.staged.name, dir_fd=parent_fd)
publication.backup_snapshot = None
publication.committed = False
os.fsync(parent_fd)
return None
finally:
os.close(parent_fd)
def _rollback_create(
self,
publication: _CanonicalPublication,
) -> dict[str, object] | None:
parent_fd = open_bound_directory(publication.target.parent)
tombstone: Path | None = None
try:
current = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
if current is None:
publication.committed = False
return None
if (
publication.published_snapshot is None
or not publication.published_snapshot.unchanged(current)
):
return self._recovery_conflict(publication, "created_target_changed")
tombstone, tombstone_snapshot = self._stage(publication.target, b"", mode=0o600)
rename_exchange_at(parent_fd, tombstone.name, publication.target.name)
displaced = self._capture_at(
publication.target.parent,
parent_fd,
tombstone.name,
self.project.descriptor.limits.max_source_bytes,
)
published_tombstone = self._capture_at(
publication.target.parent,
parent_fd,
publication.target.name,
self.project.descriptor.limits.max_source_bytes,
)
if not publication.published_snapshot.renamed_to(
displaced
) or not tombstone_snapshot.renamed_to(published_tombstone):
with suppress(DocForgeError):
rename_exchange_at(parent_fd, tombstone.name, publication.target.name)
return self._recovery_conflict(publication, "create_rollback_raced")
os.unlink(publication.target.name, dir_fd=parent_fd)
os.unlink(tombstone.name, dir_fd=parent_fd)
tombstone = None
publication.committed = False
publication.published_snapshot = None
os.fsync(parent_fd)
return None
finally:
if tombstone is not None:
tombstone.unlink(missing_ok=True)
os.close(parent_fd)
def _discard_backups(
self,
publications: Sequence[_CanonicalPublication],
) -> list[dict[str, object]]:
retained: list[dict[str, object]] = []
for publication in publications:
if publication.action != "create" and publication.staged.exists():
parent_fd = open_bound_directory(publication.target.parent)
try:
backup = self._capture_at(
publication.target.parent,
parent_fd,
publication.staged.name,
self.project.descriptor.limits.max_source_bytes,
)
if (
publication.backup_snapshot is None
or not publication.backup_snapshot.unchanged(backup)
):
retained.append(
self._recovery_conflict(publication, "backup_cleanup_raced")
)
continue
os.unlink(publication.staged.name, dir_fd=parent_fd)
os.fsync(parent_fd)
finally:
os.close(parent_fd)
publication.committed = False
publication.backup_snapshot = None
return retained
@staticmethod
def _discard_unowned_staging(publications: Sequence[_CanonicalPublication]) -> None:
for publication in publications:
if not publication.committed and publication.staged.exists():
publication.staged.unlink()
def _stage(self, target: Path, content: bytes, *, mode: int) -> tuple[Path, _CanonicalFile]:
descriptor, temporary_name = tempfile.mkstemp(
prefix=".docforge-apply-",
dir=target.parent,
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, mode)
with os.fdopen(descriptor, "wb") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
snapshot = self._capture(temporary, max(1, len(content)))
if snapshot is None:
raise DocForgeError(
"application_mismatch",
"Canonical staging file disappeared",
source=self._relative(target),
)
return temporary, snapshot
except Exception:
with suppress(OSError):
os.close(descriptor)
temporary.unlink(missing_ok=True)
raise
def _capture(self, path: Path, maximum: int) -> _CanonicalFile | None:
parent_fd = open_bound_directory(path.parent)
try:
return self._capture_at(path.parent, parent_fd, path.name, maximum)
finally:
os.close(parent_fd)
@staticmethod
def _capture_at(
parent: Path,
parent_fd: int,
name: str,
maximum: int,
) -> _CanonicalFile | None:
del parent
try:
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd)
except FileNotFoundError:
return None
except OSError as error:
raise DocForgeError(
"path_escape",
"Canonical target cannot be opened safely",
target=name,
) from error
with os.fdopen(descriptor, "rb") as handle:
before = os.fstat(handle.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_size > maximum:
raise DocForgeError(
"source_too_large",
"Canonical target is invalid or exceeds its configured limit",
target=name,
)
content = handle.read(maximum + 1)
after = os.fstat(handle.fileno())
if len(content) > maximum:
raise DocForgeError(
"source_too_large",
"Canonical target exceeds its configured limit",
target=name,
)
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
before_identity = (
before.st_dev,
before.st_ino,
before.st_mode,
before.st_uid,
before.st_gid,
before.st_size,
before.st_mtime_ns,
before.st_ctime_ns,
)
after_identity = (
after.st_dev,
after.st_ino,
after.st_mode,
after.st_uid,
after.st_gid,
after.st_size,
after.st_mtime_ns,
after.st_ctime_ns,
)
current_identity = (
current.st_dev,
current.st_ino,
current.st_mode,
current.st_uid,
current.st_gid,
current.st_size,
current.st_mtime_ns,
current.st_ctime_ns,
)
if before_identity != after_identity or before_identity != current_identity:
raise DocForgeError(
"base_conflict",
"Canonical target changed while it was captured",
target=name,
)
return _CanonicalFile(*before_identity, content)
def _recovery_conflict(
self,
publication: _CanonicalPublication,
reason: str,
) -> dict[str, object]:
return {
"source": publication.relative,
"reason": reason,
"target": self._relative(publication.target),
"retained": (
self._relative(publication.staged) if publication.staged.exists() else None
),
}
def _relative(self, path: Path) -> str:
return path.relative_to(self.project.descriptor.root).as_posix()
def _target(self, relative: str) -> Path:
candidate = Path(relative)
if candidate.is_absolute() or ".." in candidate.parts:
@ -162,29 +729,6 @@ class GenericCanonicalApplier:
finally:
os.close(descriptor)
def _restore(self, previous: dict[Path, bytes | None]) -> None:
for target in sorted(previous, key=str):
raw = previous[target]
if raw is None:
target.unlink(missing_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=".docforge-rollback-",
dir=target.parent,
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(raw)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
self._fsync_directory(target.parent)
except Exception:
temporary.unlink(missing_ok=True)
raise
@staticmethod
def _remove_empty_directories(paths: Sequence[Path]) -> None:
for path in reversed(paths):