Close canonical cleanup race windows
This commit is contained in:
parent
1a6f33e2de
commit
d2bb95fe61
4 changed files with 805 additions and 125 deletions
|
|
@ -14,6 +14,7 @@ from typing import Protocol, cast
|
||||||
|
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
|
|
||||||
|
RENAME_NOREPLACE = 1
|
||||||
RENAME_EXCHANGE = 2
|
RENAME_EXCHANGE = 2
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,9 +33,13 @@ class _RenameAt2(Protocol):
|
||||||
) -> int: ...
|
) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
def rename_exchange_at(directory_fd: int, first: str, second: str) -> None:
|
def _rename_at2(
|
||||||
"""Atomically exchange two names inside one already bound directory."""
|
old_directory_fd: int,
|
||||||
|
old_name: str,
|
||||||
|
new_directory_fd: int,
|
||||||
|
new_name: str,
|
||||||
|
flags: int,
|
||||||
|
) -> int:
|
||||||
library = ctypes.CDLL(None, use_errno=True)
|
library = ctypes.CDLL(None, use_errno=True)
|
||||||
try:
|
try:
|
||||||
rename_at2 = cast(_RenameAt2, library.renameat2)
|
rename_at2 = cast(_RenameAt2, library.renameat2)
|
||||||
|
|
@ -53,20 +58,40 @@ def rename_exchange_at(directory_fd: int, first: str, second: str) -> None:
|
||||||
rename_at2.restype = ctypes.c_int
|
rename_at2.restype = ctypes.c_int
|
||||||
ctypes.set_errno(0)
|
ctypes.set_errno(0)
|
||||||
result = rename_at2(
|
result = rename_at2(
|
||||||
directory_fd,
|
old_directory_fd,
|
||||||
os.fsencode(first),
|
os.fsencode(old_name),
|
||||||
directory_fd,
|
new_directory_fd,
|
||||||
os.fsencode(second),
|
os.fsencode(new_name),
|
||||||
RENAME_EXCHANGE,
|
flags,
|
||||||
)
|
)
|
||||||
if result == 0:
|
if result == 0:
|
||||||
return
|
return 0
|
||||||
error_number = ctypes.get_errno()
|
error_number = ctypes.get_errno()
|
||||||
if error_number in {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP}:
|
if error_number in {errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP}:
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"atomic_exchange_unavailable",
|
"atomic_exchange_unavailable",
|
||||||
"Atomic exchange is unavailable on this filesystem",
|
"Atomic exchange is unavailable on this filesystem",
|
||||||
)
|
)
|
||||||
|
return error_number
|
||||||
|
|
||||||
|
|
||||||
|
def rename_exchange_between_at(
|
||||||
|
first_directory_fd: int,
|
||||||
|
first: str,
|
||||||
|
second_directory_fd: int,
|
||||||
|
second: str,
|
||||||
|
) -> None:
|
||||||
|
"""Atomically exchange names between two bound directories on one filesystem."""
|
||||||
|
|
||||||
|
error_number = _rename_at2(
|
||||||
|
first_directory_fd,
|
||||||
|
first,
|
||||||
|
second_directory_fd,
|
||||||
|
second,
|
||||||
|
RENAME_EXCHANGE,
|
||||||
|
)
|
||||||
|
if error_number == 0:
|
||||||
|
return
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"publication_failure",
|
"publication_failure",
|
||||||
"Could not exchange atomic publication paths",
|
"Could not exchange atomic publication paths",
|
||||||
|
|
@ -74,6 +99,38 @@ def rename_exchange_at(directory_fd: int, first: str, second: str) -> None:
|
||||||
) from OSError(error_number, os.strerror(error_number))
|
) from OSError(error_number, os.strerror(error_number))
|
||||||
|
|
||||||
|
|
||||||
|
def rename_exchange_at(directory_fd: int, first: str, second: str) -> None:
|
||||||
|
"""Atomically exchange two names inside one already bound directory."""
|
||||||
|
|
||||||
|
rename_exchange_between_at(directory_fd, first, directory_fd, second)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_noreplace_between_at(
|
||||||
|
source_directory_fd: int,
|
||||||
|
source: str,
|
||||||
|
target_directory_fd: int,
|
||||||
|
target: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Atomically move one name without replacing a target that appeared."""
|
||||||
|
|
||||||
|
error_number = _rename_at2(
|
||||||
|
source_directory_fd,
|
||||||
|
source,
|
||||||
|
target_directory_fd,
|
||||||
|
target,
|
||||||
|
RENAME_NOREPLACE,
|
||||||
|
)
|
||||||
|
if error_number == 0:
|
||||||
|
return True
|
||||||
|
if error_number == errno.EEXIST:
|
||||||
|
return False
|
||||||
|
raise DocForgeError(
|
||||||
|
"publication_failure",
|
||||||
|
"Could not move an atomic publication path without replacement",
|
||||||
|
error_number=error_number,
|
||||||
|
) from OSError(error_number, os.strerror(error_number))
|
||||||
|
|
||||||
|
|
||||||
def open_bound_directory(path: Path) -> int:
|
def open_bound_directory(path: Path) -> int:
|
||||||
"""Open one real directory and bind its current inode for later operations."""
|
"""Open one real directory and bind its current inode for later operations."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,22 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Protocol, cast
|
from typing import Literal, Protocol, cast
|
||||||
|
|
||||||
from ._fs_safety import open_bound_directory, rename_exchange_at, require_bound_directory
|
from ._fs_safety import (
|
||||||
|
open_bound_directory,
|
||||||
|
rename_exchange_between_at,
|
||||||
|
rename_noreplace_between_at,
|
||||||
|
require_bound_directory,
|
||||||
|
)
|
||||||
from .changesets import ChangesetStore
|
from .changesets import ChangesetStore
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .index import ProjectIndex
|
from .index import ProjectIndex
|
||||||
|
|
@ -83,6 +89,9 @@ class _CanonicalPublication:
|
||||||
backup_snapshot: _CanonicalFile | None = None
|
backup_snapshot: _CanonicalFile | None = None
|
||||||
published_snapshot: _CanonicalFile | None = None
|
published_snapshot: _CanonicalFile | None = None
|
||||||
committed: bool = False
|
committed: bool = False
|
||||||
|
cleanup_conflicts: list[dict[str, object]] = field(
|
||||||
|
default_factory=lambda: list[dict[str, object]]()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GenericCanonicalApplier:
|
class GenericCanonicalApplier:
|
||||||
|
|
@ -105,8 +114,10 @@ class GenericCanonicalApplier:
|
||||||
projected_by_source = self._nodes_by_source(projected)
|
projected_by_source = self._nodes_by_source(projected)
|
||||||
publications: list[_CanonicalPublication] = []
|
publications: list[_CanonicalPublication] = []
|
||||||
created_directories: list[Path] = []
|
created_directories: list[Path] = []
|
||||||
|
transaction_root: Path | None = None
|
||||||
targets = {relative: self._target(relative) for relative in sorted(changed_sources)}
|
targets = {relative: self._target(relative) for relative in sorted(changed_sources)}
|
||||||
try:
|
try:
|
||||||
|
transaction_root = self._prepare_transaction_root()
|
||||||
for relative, target in targets.items():
|
for relative, target in targets.items():
|
||||||
self._prepare_parent(target.parent, created_directories)
|
self._prepare_parent(target.parent, created_directories)
|
||||||
expected = self._capture(target, base.descriptor.limits.max_source_bytes)
|
expected = self._capture(target, base.descriptor.limits.max_source_bytes)
|
||||||
|
|
@ -126,9 +137,12 @@ class GenericCanonicalApplier:
|
||||||
source=relative,
|
source=relative,
|
||||||
)
|
)
|
||||||
staged, staged_snapshot = self._stage(
|
staged, staged_snapshot = self._stage(
|
||||||
target,
|
transaction_root,
|
||||||
raw,
|
raw,
|
||||||
mode=(stat.S_IMODE(expected.mode) if expected is not None else 0o600),
|
mode=(stat.S_IMODE(expected.mode) if expected is not None else 0o600),
|
||||||
|
owner=(expected.owner if expected is not None else None),
|
||||||
|
group=(expected.group if expected is not None else None),
|
||||||
|
source=relative,
|
||||||
)
|
)
|
||||||
publications.append(
|
publications.append(
|
||||||
_CanonicalPublication(
|
_CanonicalPublication(
|
||||||
|
|
@ -169,7 +183,9 @@ class GenericCanonicalApplier:
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
recovery = self._rollback(publications)
|
recovery = self._rollback(publications)
|
||||||
self._discard_unowned_staging(publications)
|
recovery.extend(self._discard_unowned_staging(publications))
|
||||||
|
if transaction_root is not None:
|
||||||
|
recovery.extend(self._finish_transaction(transaction_root))
|
||||||
self._remove_empty_directories(created_directories)
|
self._remove_empty_directories(created_directories)
|
||||||
if recovery:
|
if recovery:
|
||||||
cause = error.code if isinstance(error, DocForgeError) else type(error).__name__
|
cause = error.code if isinstance(error, DocForgeError) else type(error).__name__
|
||||||
|
|
@ -192,18 +208,37 @@ class GenericCanonicalApplier:
|
||||||
"Canonical target raced publication but was restored without data loss",
|
"Canonical target raced publication but was restored without data loss",
|
||||||
) from error
|
) from error
|
||||||
raise
|
raise
|
||||||
retained = self._discard_backups(publications)
|
retained = [
|
||||||
|
conflict for publication in publications for conflict in publication.cleanup_conflicts
|
||||||
|
]
|
||||||
|
retained.extend(self._discard_backups(publications))
|
||||||
|
assert transaction_root is not None
|
||||||
|
retained.extend(self._finish_transaction(transaction_root))
|
||||||
|
recovery_status = "cleanup_required" if retained else "clean"
|
||||||
return {
|
return {
|
||||||
"applied_sources": sorted(changed_sources),
|
"applied_sources": sorted(changed_sources),
|
||||||
"removed_sources": sorted(
|
"removed_sources": sorted(
|
||||||
source for source in changed_sources if source not in projected_by_source
|
source for source in changed_sources if source not in projected_by_source
|
||||||
),
|
),
|
||||||
"retained_recovery_files": retained,
|
"retained_recovery_files": retained,
|
||||||
|
"application_recovery": {
|
||||||
|
"status": recovery_status,
|
||||||
|
"retained": retained,
|
||||||
|
"remediation": (
|
||||||
|
(
|
||||||
|
"Canonical content is committed. Preserve and inspect the retained "
|
||||||
|
"transaction files, then remove only confirmed DocForge-owned artifacts."
|
||||||
|
)
|
||||||
|
if retained
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _publish(self, publication: _CanonicalPublication) -> None:
|
def _publish(self, publication: _CanonicalPublication) -> None:
|
||||||
target = publication.target
|
target = publication.target
|
||||||
parent_fd = open_bound_directory(target.parent)
|
parent_fd = open_bound_directory(target.parent)
|
||||||
|
staging_fd = open_bound_directory(publication.staged.parent)
|
||||||
try:
|
try:
|
||||||
current = self._capture_at(
|
current = self._capture_at(
|
||||||
target.parent,
|
target.parent,
|
||||||
|
|
@ -225,8 +260,8 @@ class GenericCanonicalApplier:
|
||||||
source=publication.relative,
|
source=publication.relative,
|
||||||
)
|
)
|
||||||
staged = self._capture_at(
|
staged = self._capture_at(
|
||||||
target.parent,
|
publication.staged.parent,
|
||||||
parent_fd,
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
|
|
@ -237,22 +272,24 @@ class GenericCanonicalApplier:
|
||||||
source=publication.relative,
|
source=publication.relative,
|
||||||
)
|
)
|
||||||
if publication.action == "create":
|
if publication.action == "create":
|
||||||
self._publish_create(publication, parent_fd)
|
self._publish_create(publication, parent_fd, staging_fd)
|
||||||
else:
|
else:
|
||||||
self._publish_exchange(publication, parent_fd)
|
self._publish_exchange(publication, parent_fd, staging_fd)
|
||||||
finally:
|
finally:
|
||||||
|
os.close(staging_fd)
|
||||||
os.close(parent_fd)
|
os.close(parent_fd)
|
||||||
|
|
||||||
def _publish_create(
|
def _publish_create(
|
||||||
self,
|
self,
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
parent_fd: int,
|
parent_fd: int,
|
||||||
|
staging_fd: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
os.link(
|
os.link(
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
publication.target.name,
|
publication.target.name,
|
||||||
src_dir_fd=parent_fd,
|
src_dir_fd=staging_fd,
|
||||||
dst_dir_fd=parent_fd,
|
dst_dir_fd=parent_fd,
|
||||||
follow_symlinks=False,
|
follow_symlinks=False,
|
||||||
)
|
)
|
||||||
|
|
@ -281,8 +318,40 @@ class GenericCanonicalApplier:
|
||||||
"Published canonical create target does not match its staging file",
|
"Published canonical create target does not match its staging file",
|
||||||
source=publication.relative,
|
source=publication.relative,
|
||||||
)
|
)
|
||||||
|
assert published is not None
|
||||||
publication.published_snapshot = published
|
publication.published_snapshot = published
|
||||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
private_copy = self._capture_at(
|
||||||
|
publication.staged.parent,
|
||||||
|
staging_fd,
|
||||||
|
publication.staged.name,
|
||||||
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
|
)
|
||||||
|
if private_copy is None or not publication.staged_snapshot.renamed_to(private_copy):
|
||||||
|
publication.cleanup_conflicts.append(
|
||||||
|
self._recovery_conflict(publication, "private_create_link_changed")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conflict = self._remove_private(
|
||||||
|
publication,
|
||||||
|
publication.staged,
|
||||||
|
private_copy,
|
||||||
|
"private_create_link_cleanup_failed",
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
publication.cleanup_conflicts.append(conflict)
|
||||||
|
refreshed = self._capture_at(
|
||||||
|
publication.target.parent,
|
||||||
|
parent_fd,
|
||||||
|
publication.target.name,
|
||||||
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
|
)
|
||||||
|
if not published.renamed_to(refreshed):
|
||||||
|
raise DocForgeError(
|
||||||
|
"application_mismatch",
|
||||||
|
"Published canonical create target changed during private cleanup",
|
||||||
|
source=publication.relative,
|
||||||
|
)
|
||||||
|
publication.published_snapshot = refreshed
|
||||||
os.fsync(parent_fd)
|
os.fsync(parent_fd)
|
||||||
require_bound_directory(publication.target.parent, parent_fd)
|
require_bound_directory(publication.target.parent, parent_fd)
|
||||||
|
|
||||||
|
|
@ -290,12 +359,18 @@ class GenericCanonicalApplier:
|
||||||
self,
|
self,
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
parent_fd: int,
|
parent_fd: int,
|
||||||
|
staging_fd: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
rename_exchange_at(parent_fd, publication.staged.name, publication.target.name)
|
rename_exchange_between_at(
|
||||||
|
staging_fd,
|
||||||
|
publication.staged.name,
|
||||||
|
parent_fd,
|
||||||
|
publication.target.name,
|
||||||
|
)
|
||||||
publication.committed = True
|
publication.committed = True
|
||||||
displaced = self._capture_at(
|
displaced = self._capture_at(
|
||||||
publication.target.parent,
|
publication.staged.parent,
|
||||||
parent_fd,
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
|
|
@ -312,7 +387,7 @@ class GenericCanonicalApplier:
|
||||||
or not publication.expected.renamed_to(displaced)
|
or not publication.expected.renamed_to(displaced)
|
||||||
or not publication.staged_snapshot.renamed_to(published)
|
or not publication.staged_snapshot.renamed_to(published)
|
||||||
):
|
):
|
||||||
if not self._exchange_back(publication, parent_fd):
|
if not self._exchange_back(publication, parent_fd, staging_fd):
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"application_recovery_required",
|
"application_recovery_required",
|
||||||
"Canonical target raced atomic publication and displaced data was retained",
|
"Canonical target raced atomic publication and displaced data was retained",
|
||||||
|
|
@ -325,22 +400,98 @@ class GenericCanonicalApplier:
|
||||||
source=publication.relative,
|
source=publication.relative,
|
||||||
)
|
)
|
||||||
if publication.action == "delete":
|
if publication.action == "delete":
|
||||||
os.unlink(publication.target.name, dir_fd=parent_fd)
|
assert published is not None
|
||||||
publication.published_snapshot = None
|
conflict = self._detach_canonical(
|
||||||
|
publication,
|
||||||
|
parent_fd,
|
||||||
|
staging_fd,
|
||||||
|
published,
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
if publication.published_snapshot is None:
|
||||||
|
publication.cleanup_conflicts.append(conflict)
|
||||||
|
else:
|
||||||
|
raise DocForgeError(
|
||||||
|
"application_recovery_required",
|
||||||
|
"Canonical delete target raced final detachment and was retained",
|
||||||
|
source=publication.relative,
|
||||||
|
conflict=conflict,
|
||||||
|
)
|
||||||
os.fsync(parent_fd)
|
os.fsync(parent_fd)
|
||||||
|
os.fsync(staging_fd)
|
||||||
require_bound_directory(publication.target.parent, parent_fd)
|
require_bound_directory(publication.target.parent, parent_fd)
|
||||||
|
require_bound_directory(publication.staged.parent, staging_fd)
|
||||||
|
|
||||||
|
def _detach_canonical(
|
||||||
|
self,
|
||||||
|
publication: _CanonicalPublication,
|
||||||
|
parent_fd: int,
|
||||||
|
staging_fd: int,
|
||||||
|
expected: _CanonicalFile,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
detached_name: str | None = None
|
||||||
|
for _ in range(8):
|
||||||
|
candidate = f".detached-{secrets.token_hex(12)}"
|
||||||
|
if rename_noreplace_between_at(
|
||||||
|
parent_fd,
|
||||||
|
publication.target.name,
|
||||||
|
staging_fd,
|
||||||
|
candidate,
|
||||||
|
):
|
||||||
|
detached_name = candidate
|
||||||
|
break
|
||||||
|
if detached_name is None:
|
||||||
|
return self._recovery_conflict(publication, "private_name_collisions")
|
||||||
|
detached = publication.staged.parent / detached_name
|
||||||
|
moved: _CanonicalFile | None = None
|
||||||
|
if self._same_inode_at(staging_fd, detached_name, expected):
|
||||||
|
with suppress(DocForgeError, OSError):
|
||||||
|
moved = self._capture_at(
|
||||||
|
detached.parent,
|
||||||
|
staging_fd,
|
||||||
|
detached.name,
|
||||||
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
|
)
|
||||||
|
if moved is None or not expected.renamed_to(moved):
|
||||||
|
restored = rename_noreplace_between_at(
|
||||||
|
staging_fd,
|
||||||
|
detached_name,
|
||||||
|
parent_fd,
|
||||||
|
publication.target.name,
|
||||||
|
)
|
||||||
|
os.fsync(staging_fd)
|
||||||
|
os.fsync(parent_fd)
|
||||||
|
conflict = self._recovery_conflict(publication, "canonical_detach_raced")
|
||||||
|
conflict["raced_data"] = None if restored else self._relative(detached)
|
||||||
|
conflict["foreign_target_restored"] = restored
|
||||||
|
return conflict
|
||||||
|
publication.published_snapshot = None
|
||||||
|
conflict = self._remove_private(
|
||||||
|
publication,
|
||||||
|
detached,
|
||||||
|
moved,
|
||||||
|
"detached_cleanup_failed",
|
||||||
|
)
|
||||||
|
os.fsync(parent_fd)
|
||||||
|
return conflict
|
||||||
|
|
||||||
def _exchange_back(
|
def _exchange_back(
|
||||||
self,
|
self,
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
parent_fd: int,
|
parent_fd: int,
|
||||||
|
staging_fd: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
backup = publication.backup_snapshot
|
backup = publication.backup_snapshot
|
||||||
published = publication.published_snapshot
|
published = publication.published_snapshot
|
||||||
if backup is None or published is None:
|
if backup is None or published is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
rename_exchange_at(parent_fd, publication.staged.name, publication.target.name)
|
rename_exchange_between_at(
|
||||||
|
staging_fd,
|
||||||
|
publication.staged.name,
|
||||||
|
parent_fd,
|
||||||
|
publication.target.name,
|
||||||
|
)
|
||||||
restored = self._capture_at(
|
restored = self._capture_at(
|
||||||
publication.target.parent,
|
publication.target.parent,
|
||||||
parent_fd,
|
parent_fd,
|
||||||
|
|
@ -348,21 +499,36 @@ class GenericCanonicalApplier:
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
staged = self._capture_at(
|
staged = self._capture_at(
|
||||||
publication.target.parent,
|
publication.staged.parent,
|
||||||
parent_fd,
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
if not backup.renamed_to(restored) or not published.renamed_to(staged):
|
if not backup.renamed_to(restored) or not published.renamed_to(staged):
|
||||||
return False
|
return False
|
||||||
|
assert staged is not None
|
||||||
|
publication.staged_snapshot = staged
|
||||||
publication.backup_snapshot = None
|
publication.backup_snapshot = None
|
||||||
publication.published_snapshot = None
|
publication.published_snapshot = None
|
||||||
publication.committed = False
|
publication.committed = False
|
||||||
os.fsync(parent_fd)
|
os.fsync(parent_fd)
|
||||||
|
os.fsync(staging_fd)
|
||||||
return True
|
return True
|
||||||
except (DocForgeError, OSError):
|
except (DocForgeError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _same_inode_at(
|
||||||
|
directory_fd: int,
|
||||||
|
name: str,
|
||||||
|
expected: _CanonicalFile,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
current = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return current.st_dev == expected.device and current.st_ino == expected.inode
|
||||||
|
|
||||||
def _rollback(
|
def _rollback(
|
||||||
self,
|
self,
|
||||||
publications: Sequence[_CanonicalPublication],
|
publications: Sequence[_CanonicalPublication],
|
||||||
|
|
@ -392,6 +558,7 @@ class GenericCanonicalApplier:
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
parent_fd = open_bound_directory(publication.target.parent)
|
parent_fd = open_bound_directory(publication.target.parent)
|
||||||
|
staging_fd = open_bound_directory(publication.staged.parent)
|
||||||
try:
|
try:
|
||||||
current = self._capture_at(
|
current = self._capture_at(
|
||||||
publication.target.parent,
|
publication.target.parent,
|
||||||
|
|
@ -400,8 +567,8 @@ class GenericCanonicalApplier:
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
backup = self._capture_at(
|
backup = self._capture_at(
|
||||||
publication.target.parent,
|
publication.staged.parent,
|
||||||
parent_fd,
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
|
|
@ -412,12 +579,16 @@ class GenericCanonicalApplier:
|
||||||
or not publication.backup_snapshot.unchanged(backup)
|
or not publication.backup_snapshot.unchanged(backup)
|
||||||
):
|
):
|
||||||
return self._recovery_conflict(publication, "target_or_backup_changed")
|
return self._recovery_conflict(publication, "target_or_backup_changed")
|
||||||
if not self._exchange_back(publication, parent_fd):
|
if not self._exchange_back(publication, parent_fd, staging_fd):
|
||||||
return self._recovery_conflict(publication, "atomic_restore_unconfirmed")
|
return self._recovery_conflict(publication, "atomic_restore_unconfirmed")
|
||||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
return self._remove_private(
|
||||||
os.fsync(parent_fd)
|
publication,
|
||||||
return None
|
publication.staged,
|
||||||
|
publication.staged_snapshot,
|
||||||
|
"rollback_staging_cleanup_failed",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
|
os.close(staging_fd)
|
||||||
os.close(parent_fd)
|
os.close(parent_fd)
|
||||||
|
|
||||||
def _rollback_delete(
|
def _rollback_delete(
|
||||||
|
|
@ -425,6 +596,7 @@ class GenericCanonicalApplier:
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
parent_fd = open_bound_directory(publication.target.parent)
|
parent_fd = open_bound_directory(publication.target.parent)
|
||||||
|
staging_fd = open_bound_directory(publication.staged.parent)
|
||||||
try:
|
try:
|
||||||
current = self._capture_at(
|
current = self._capture_at(
|
||||||
publication.target.parent,
|
publication.target.parent,
|
||||||
|
|
@ -433,8 +605,8 @@ class GenericCanonicalApplier:
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
backup = self._capture_at(
|
backup = self._capture_at(
|
||||||
publication.target.parent,
|
publication.staged.parent,
|
||||||
parent_fd,
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
)
|
)
|
||||||
|
|
@ -444,15 +616,12 @@ class GenericCanonicalApplier:
|
||||||
backup
|
backup
|
||||||
):
|
):
|
||||||
return self._recovery_conflict(publication, "backup_changed")
|
return self._recovery_conflict(publication, "backup_changed")
|
||||||
try:
|
if not rename_noreplace_between_at(
|
||||||
os.link(
|
staging_fd,
|
||||||
publication.staged.name,
|
publication.staged.name,
|
||||||
publication.target.name,
|
parent_fd,
|
||||||
src_dir_fd=parent_fd,
|
publication.target.name,
|
||||||
dst_dir_fd=parent_fd,
|
):
|
||||||
follow_symlinks=False,
|
|
||||||
)
|
|
||||||
except FileExistsError:
|
|
||||||
return self._recovery_conflict(publication, "deleted_target_reappeared")
|
return self._recovery_conflict(publication, "deleted_target_reappeared")
|
||||||
restored = self._capture_at(
|
restored = self._capture_at(
|
||||||
publication.target.parent,
|
publication.target.parent,
|
||||||
|
|
@ -462,12 +631,13 @@ class GenericCanonicalApplier:
|
||||||
)
|
)
|
||||||
if not publication.backup_snapshot.renamed_to(restored):
|
if not publication.backup_snapshot.renamed_to(restored):
|
||||||
return self._recovery_conflict(publication, "restore_unconfirmed")
|
return self._recovery_conflict(publication, "restore_unconfirmed")
|
||||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
|
||||||
publication.backup_snapshot = None
|
publication.backup_snapshot = None
|
||||||
publication.committed = False
|
publication.committed = False
|
||||||
os.fsync(parent_fd)
|
os.fsync(parent_fd)
|
||||||
|
os.fsync(staging_fd)
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
|
os.close(staging_fd)
|
||||||
os.close(parent_fd)
|
os.close(parent_fd)
|
||||||
|
|
||||||
def _rollback_create(
|
def _rollback_create(
|
||||||
|
|
@ -475,7 +645,7 @@ class GenericCanonicalApplier:
|
||||||
publication: _CanonicalPublication,
|
publication: _CanonicalPublication,
|
||||||
) -> dict[str, object] | None:
|
) -> dict[str, object] | None:
|
||||||
parent_fd = open_bound_directory(publication.target.parent)
|
parent_fd = open_bound_directory(publication.target.parent)
|
||||||
tombstone: Path | None = None
|
staging_fd = open_bound_directory(publication.staged.parent)
|
||||||
try:
|
try:
|
||||||
current = self._capture_at(
|
current = self._capture_at(
|
||||||
publication.target.parent,
|
publication.target.parent,
|
||||||
|
|
@ -491,36 +661,19 @@ class GenericCanonicalApplier:
|
||||||
or not publication.published_snapshot.unchanged(current)
|
or not publication.published_snapshot.unchanged(current)
|
||||||
):
|
):
|
||||||
return self._recovery_conflict(publication, "created_target_changed")
|
return self._recovery_conflict(publication, "created_target_changed")
|
||||||
tombstone, tombstone_snapshot = self._stage(publication.target, b"", mode=0o600)
|
conflict = self._detach_canonical(
|
||||||
rename_exchange_at(parent_fd, tombstone.name, publication.target.name)
|
publication,
|
||||||
displaced = self._capture_at(
|
|
||||||
publication.target.parent,
|
|
||||||
parent_fd,
|
parent_fd,
|
||||||
tombstone.name,
|
staging_fd,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
current,
|
||||||
)
|
)
|
||||||
published_tombstone = self._capture_at(
|
if conflict is not None:
|
||||||
publication.target.parent,
|
return conflict
|
||||||
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.committed = False
|
||||||
publication.published_snapshot = None
|
publication.published_snapshot = None
|
||||||
os.fsync(parent_fd)
|
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if tombstone is not None:
|
os.close(staging_fd)
|
||||||
tombstone.unlink(missing_ok=True)
|
|
||||||
os.close(parent_fd)
|
os.close(parent_fd)
|
||||||
|
|
||||||
def _discard_backups(
|
def _discard_backups(
|
||||||
|
|
@ -529,55 +682,123 @@ class GenericCanonicalApplier:
|
||||||
) -> list[dict[str, object]]:
|
) -> list[dict[str, object]]:
|
||||||
retained: list[dict[str, object]] = []
|
retained: list[dict[str, object]] = []
|
||||||
for publication in publications:
|
for publication in publications:
|
||||||
if publication.action != "create" and publication.staged.exists():
|
if publication.action != "create" and publication.backup_snapshot is not None:
|
||||||
parent_fd = open_bound_directory(publication.target.parent)
|
|
||||||
try:
|
try:
|
||||||
backup = self._capture_at(
|
staging_fd = open_bound_directory(publication.staged.parent)
|
||||||
publication.target.parent,
|
try:
|
||||||
parent_fd,
|
backup = self._capture_at(
|
||||||
publication.staged.name,
|
publication.staged.parent,
|
||||||
self.project.descriptor.limits.max_source_bytes,
|
staging_fd,
|
||||||
)
|
publication.staged.name,
|
||||||
if (
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
publication.backup_snapshot is None
|
|
||||||
or not publication.backup_snapshot.unchanged(backup)
|
|
||||||
):
|
|
||||||
retained.append(
|
|
||||||
self._recovery_conflict(publication, "backup_cleanup_raced")
|
|
||||||
)
|
)
|
||||||
continue
|
finally:
|
||||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
os.close(staging_fd)
|
||||||
os.fsync(parent_fd)
|
except Exception as error:
|
||||||
finally:
|
conflict = self._recovery_conflict(
|
||||||
os.close(parent_fd)
|
publication,
|
||||||
|
"backup_cleanup_inspection_failed",
|
||||||
|
)
|
||||||
|
conflict["error"] = (
|
||||||
|
error.code if isinstance(error, DocForgeError) else type(error).__name__
|
||||||
|
)
|
||||||
|
retained.append(conflict)
|
||||||
|
continue
|
||||||
|
if not publication.backup_snapshot.unchanged(backup):
|
||||||
|
retained.append(self._recovery_conflict(publication, "backup_cleanup_raced"))
|
||||||
|
continue
|
||||||
|
conflict = self._remove_private(
|
||||||
|
publication,
|
||||||
|
publication.staged,
|
||||||
|
publication.backup_snapshot,
|
||||||
|
"backup_cleanup_failed",
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
retained.append(conflict)
|
||||||
|
continue
|
||||||
publication.committed = False
|
publication.committed = False
|
||||||
publication.backup_snapshot = None
|
publication.backup_snapshot = None
|
||||||
return retained
|
return retained
|
||||||
|
|
||||||
@staticmethod
|
def _discard_unowned_staging(
|
||||||
def _discard_unowned_staging(publications: Sequence[_CanonicalPublication]) -> None:
|
self,
|
||||||
|
publications: Sequence[_CanonicalPublication],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
retained: list[dict[str, object]] = []
|
||||||
for publication in publications:
|
for publication in publications:
|
||||||
if not publication.committed and publication.staged.exists():
|
if publication.committed:
|
||||||
publication.staged.unlink()
|
continue
|
||||||
|
conflict = self._remove_private(
|
||||||
|
publication,
|
||||||
|
publication.staged,
|
||||||
|
publication.staged_snapshot,
|
||||||
|
"staging_cleanup_failed",
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
retained.append(conflict)
|
||||||
|
return retained
|
||||||
|
|
||||||
def _stage(self, target: Path, content: bytes, *, mode: int) -> tuple[Path, _CanonicalFile]:
|
def _remove_private(
|
||||||
|
self,
|
||||||
|
publication: _CanonicalPublication,
|
||||||
|
path: Path,
|
||||||
|
expected: _CanonicalFile,
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
try:
|
||||||
|
directory_fd = open_bound_directory(path.parent)
|
||||||
|
try:
|
||||||
|
backup = self._capture_at(
|
||||||
|
path.parent,
|
||||||
|
directory_fd,
|
||||||
|
path.name,
|
||||||
|
self.project.descriptor.limits.max_source_bytes,
|
||||||
|
)
|
||||||
|
if backup is None:
|
||||||
|
return None
|
||||||
|
if not expected.unchanged(backup):
|
||||||
|
return self._private_recovery_conflict(publication, path, reason)
|
||||||
|
os.unlink(path.name, dir_fd=directory_fd)
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
except Exception as error:
|
||||||
|
conflict = self._private_recovery_conflict(publication, path, reason)
|
||||||
|
conflict["error"] = (
|
||||||
|
error.code if isinstance(error, DocForgeError) else type(error).__name__
|
||||||
|
)
|
||||||
|
return conflict
|
||||||
|
|
||||||
|
def _stage(
|
||||||
|
self,
|
||||||
|
staging_root: Path,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
mode: int,
|
||||||
|
owner: int | None,
|
||||||
|
group: int | None,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[Path, _CanonicalFile]:
|
||||||
descriptor, temporary_name = tempfile.mkstemp(
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
prefix=".docforge-apply-",
|
prefix="staged-",
|
||||||
dir=target.parent,
|
dir=staging_root,
|
||||||
)
|
)
|
||||||
temporary = Path(temporary_name)
|
temporary = Path(temporary_name)
|
||||||
try:
|
try:
|
||||||
os.fchmod(descriptor, mode)
|
|
||||||
with os.fdopen(descriptor, "wb") as handle:
|
with os.fdopen(descriptor, "wb") as handle:
|
||||||
handle.write(content)
|
handle.write(content)
|
||||||
handle.flush()
|
handle.flush()
|
||||||
|
if owner is not None and group is not None:
|
||||||
|
os.fchown(handle.fileno(), owner, group)
|
||||||
|
os.fchmod(handle.fileno(), mode)
|
||||||
os.fsync(handle.fileno())
|
os.fsync(handle.fileno())
|
||||||
snapshot = self._capture(temporary, max(1, len(content)))
|
snapshot = self._capture(temporary, max(1, len(content)))
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"application_mismatch",
|
"application_mismatch",
|
||||||
"Canonical staging file disappeared",
|
"Canonical staging file disappeared",
|
||||||
source=self._relative(target),
|
source=source,
|
||||||
)
|
)
|
||||||
return temporary, snapshot
|
return temporary, snapshot
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -680,6 +901,19 @@ class GenericCanonicalApplier:
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _private_recovery_conflict(
|
||||||
|
self,
|
||||||
|
publication: _CanonicalPublication,
|
||||||
|
path: Path,
|
||||||
|
reason: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"source": publication.relative,
|
||||||
|
"reason": reason,
|
||||||
|
"target": self._relative(publication.target),
|
||||||
|
"retained": self._relative(path) if path.exists() else None,
|
||||||
|
}
|
||||||
|
|
||||||
def _relative(self, path: Path) -> str:
|
def _relative(self, path: Path) -> str:
|
||||||
return path.relative_to(self.project.descriptor.root).as_posix()
|
return path.relative_to(self.project.descriptor.root).as_posix()
|
||||||
|
|
||||||
|
|
@ -707,33 +941,106 @@ class GenericCanonicalApplier:
|
||||||
|
|
||||||
def _prepare_parent(self, parent: Path, created: list[Path]) -> None:
|
def _prepare_parent(self, parent: Path, created: list[Path]) -> None:
|
||||||
root = self.project.descriptor.root
|
root = self.project.descriptor.root
|
||||||
|
created.extend(self._create_directories_durable(parent, root=root, mode=0o755))
|
||||||
|
|
||||||
|
def _prepare_transaction_root(self) -> Path:
|
||||||
|
root = self.project.descriptor.root
|
||||||
|
namespace = root / ".docforge/application"
|
||||||
|
self._create_directories_durable(namespace, root=root, mode=0o700)
|
||||||
|
transaction = Path(tempfile.mkdtemp(prefix="transaction-", dir=namespace))
|
||||||
|
transaction.chmod(0o700)
|
||||||
|
self._fsync_directory(transaction)
|
||||||
|
self._fsync_directory(namespace)
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
def _create_directories_durable(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
mode: int,
|
||||||
|
) -> list[Path]:
|
||||||
missing: list[Path] = []
|
missing: list[Path] = []
|
||||||
cursor = parent
|
cursor = path
|
||||||
while not cursor.exists():
|
while not cursor.exists():
|
||||||
missing.append(cursor)
|
missing.append(cursor)
|
||||||
cursor = cursor.parent
|
cursor = cursor.parent
|
||||||
if cursor.is_symlink() or cursor.resolve(strict=True) != cursor or not cursor.is_dir():
|
if cursor.is_symlink() or cursor.resolve(strict=True) != cursor or not cursor.is_dir():
|
||||||
raise DocForgeError("path_escape", "Canonical target parent is unsafe")
|
raise DocForgeError("path_escape", "Canonical target parent is unsafe")
|
||||||
if not cursor.is_relative_to(root):
|
if not cursor.is_relative_to(root) or not path.is_relative_to(root):
|
||||||
raise DocForgeError("path_escape", "Canonical target parent escaped the project root")
|
raise DocForgeError("path_escape", "Canonical target parent escaped the project root")
|
||||||
parent.mkdir(parents=True, exist_ok=True)
|
created: list[Path] = []
|
||||||
if parent.resolve(strict=True) != parent:
|
for directory in reversed(missing):
|
||||||
|
try:
|
||||||
|
directory.mkdir(mode=mode)
|
||||||
|
except FileExistsError as error:
|
||||||
|
if (
|
||||||
|
directory.is_symlink()
|
||||||
|
or not directory.is_dir()
|
||||||
|
or directory.resolve(strict=True) != directory
|
||||||
|
):
|
||||||
|
raise DocForgeError(
|
||||||
|
"path_escape",
|
||||||
|
"Canonical target parent became unsafe during creation",
|
||||||
|
) from error
|
||||||
|
continue
|
||||||
|
self._fsync_directory(directory)
|
||||||
|
self._fsync_directory(directory.parent)
|
||||||
|
created.append(directory)
|
||||||
|
if path.resolve(strict=True) != path:
|
||||||
raise DocForgeError("path_escape", "Canonical target parent resolves unexpectedly")
|
raise DocForgeError("path_escape", "Canonical target parent resolves unexpectedly")
|
||||||
created.extend(reversed(missing))
|
return created
|
||||||
|
|
||||||
|
def _finish_transaction(self, transaction: Path) -> list[dict[str, object]]:
|
||||||
|
try:
|
||||||
|
retained = sorted(
|
||||||
|
(self._relative(path) for path in transaction.iterdir()),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"reason": "transaction_inspection_failed",
|
||||||
|
"retained": self._relative(transaction),
|
||||||
|
"error": (
|
||||||
|
error.code if isinstance(error, DocForgeError) else type(error).__name__
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if retained:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"reason": "transaction_files_retained",
|
||||||
|
"retained": retained,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
transaction.rmdir()
|
||||||
|
self._fsync_directory(transaction.parent)
|
||||||
|
except OSError as error:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"reason": "transaction_cleanup_failed",
|
||||||
|
"retained": self._relative(transaction),
|
||||||
|
"error": type(error).__name__,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fsync_directory(path: Path) -> None:
|
def _fsync_directory(path: Path) -> None:
|
||||||
descriptor = os.open(path, os.O_RDONLY)
|
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||||
try:
|
try:
|
||||||
os.fsync(descriptor)
|
os.fsync(descriptor)
|
||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
|
||||||
@staticmethod
|
def _remove_empty_directories(self, paths: Sequence[Path]) -> None:
|
||||||
def _remove_empty_directories(paths: Sequence[Path]) -> None:
|
|
||||||
for path in reversed(paths):
|
for path in reversed(paths):
|
||||||
with suppress(OSError):
|
try:
|
||||||
path.rmdir()
|
path.rmdir()
|
||||||
|
self._fsync_directory(path.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _nodes_by_source(snapshot: ProjectSnapshot) -> dict[str, tuple[Node, ...]]:
|
def _nodes_by_source(snapshot: ProjectSnapshot) -> dict[str, tuple[Node, ...]]:
|
||||||
|
|
|
||||||
|
|
@ -649,14 +649,28 @@ class ChangesetStore:
|
||||||
tuple(cast(Mapping[str, object], item) for item in document["operations"]),
|
tuple(cast(Mapping[str, object], item) for item in document["operations"]),
|
||||||
)
|
)
|
||||||
current = self.project.load()
|
current = self.project.load()
|
||||||
|
lifecycle_payload: dict[str, object] = {
|
||||||
|
"status": "applied",
|
||||||
|
"changeset_hash": actual_hash,
|
||||||
|
"revision": current.revision,
|
||||||
|
"source_hash": current.source_hash,
|
||||||
|
}
|
||||||
|
application_recovery = payload.get("application_recovery")
|
||||||
|
if isinstance(application_recovery, Mapping):
|
||||||
|
recovery_payload = cast(Mapping[str, object], application_recovery)
|
||||||
|
if recovery_payload.get("status") != "clean":
|
||||||
|
retained = recovery_payload.get("retained")
|
||||||
|
lifecycle_payload["application_recovery"] = {
|
||||||
|
"status": recovery_payload.get("status"),
|
||||||
|
"retained_count": (
|
||||||
|
len(cast(list[object], retained)) if isinstance(retained, list) else 0
|
||||||
|
),
|
||||||
|
"retained_root": ".docforge/application",
|
||||||
|
"remediation": recovery_payload.get("remediation"),
|
||||||
|
}
|
||||||
lifecycle = self._write_state(
|
lifecycle = self._write_state(
|
||||||
changeset_id,
|
changeset_id,
|
||||||
{
|
lifecycle_payload,
|
||||||
"status": "applied",
|
|
||||||
"changeset_hash": actual_hash,
|
|
||||||
"revision": current.revision,
|
|
||||||
"source_hash": current.source_hash,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return self._result(
|
return self._result(
|
||||||
current,
|
current,
|
||||||
|
|
|
||||||
|
|
@ -293,19 +293,24 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
proposal_path = root / ".docforge/changesets/update-race.json"
|
proposal_path = root / ".docforge/changesets/update-race.json"
|
||||||
proposal_bytes = proposal_path.read_bytes()
|
proposal_bytes = proposal_path.read_bytes()
|
||||||
target = root / "docs/content/workflow.md"
|
target = root / "docs/content/workflow.md"
|
||||||
exchange = application_module.rename_exchange_at
|
exchange = application_module.rename_exchange_between_at
|
||||||
raced = False
|
raced = False
|
||||||
|
|
||||||
def race(directory_fd: int, first: str, second: str) -> None:
|
def race(
|
||||||
|
first_directory_fd: int,
|
||||||
|
first: str,
|
||||||
|
second_directory_fd: int,
|
||||||
|
second: str,
|
||||||
|
) -> None:
|
||||||
nonlocal raced
|
nonlocal raced
|
||||||
if second == target.name and not raced:
|
if second == target.name and not raced:
|
||||||
raced = True
|
raced = True
|
||||||
target.write_bytes(target.read_bytes() + b"\nExternal edit at exchange.\n")
|
target.write_bytes(target.read_bytes() + b"\nExternal edit at exchange.\n")
|
||||||
exchange(directory_fd, first, second)
|
exchange(first_directory_fd, first, second_directory_fd, second)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"docforge.application.rename_exchange_at",
|
"docforge.application.rename_exchange_between_at",
|
||||||
side_effect=race,
|
side_effect=race,
|
||||||
),
|
),
|
||||||
self.assertRaises(DocForgeError) as captured,
|
self.assertRaises(DocForgeError) as captured,
|
||||||
|
|
@ -403,21 +408,26 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
delete_target = delete_root / "docs/content/proof.toml"
|
delete_target = delete_root / "docs/content/proof.toml"
|
||||||
exchange = application_module.rename_exchange_at
|
exchange = application_module.rename_exchange_between_at
|
||||||
deleted_race = False
|
deleted_race = False
|
||||||
|
|
||||||
def race_delete(directory_fd: int, first: str, second: str) -> None:
|
def race_delete(
|
||||||
|
first_directory_fd: int,
|
||||||
|
first: str,
|
||||||
|
second_directory_fd: int,
|
||||||
|
second: str,
|
||||||
|
) -> None:
|
||||||
nonlocal deleted_race
|
nonlocal deleted_race
|
||||||
if second == delete_target.name and not deleted_race:
|
if second == delete_target.name and not deleted_race:
|
||||||
deleted_race = True
|
deleted_race = True
|
||||||
delete_target.write_bytes(
|
delete_target.write_bytes(
|
||||||
delete_target.read_bytes() + b"\n# foreign delete edit\n"
|
delete_target.read_bytes() + b"\n# foreign delete edit\n"
|
||||||
)
|
)
|
||||||
exchange(directory_fd, first, second)
|
exchange(first_directory_fd, first, second_directory_fd, second)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"docforge.application.rename_exchange_at",
|
"docforge.application.rename_exchange_between_at",
|
||||||
side_effect=race_delete,
|
side_effect=race_delete,
|
||||||
),
|
),
|
||||||
self.assertRaises(DocForgeError) as delete_error,
|
self.assertRaises(DocForgeError) as delete_error,
|
||||||
|
|
@ -432,6 +442,167 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
self.assertIn("# foreign delete edit", delete_target.read_text(encoding="utf-8"))
|
self.assertIn("# foreign delete edit", delete_target.read_text(encoding="utf-8"))
|
||||||
self.assertFalse(tuple(delete_target.parent.glob(".docforge-apply-*")))
|
self.assertFalse(tuple(delete_target.parent.glob(".docforge-apply-*")))
|
||||||
|
|
||||||
|
def test_delete_detach_race_restores_foreign_target_and_retains_original(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
proposal = store.register(
|
||||||
|
"delete-detach-race",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"operation": "delete",
|
||||||
|
"node_id": "proof.validation",
|
||||||
|
"relationship_changes": [
|
||||||
|
{
|
||||||
|
"action": "remove",
|
||||||
|
"source_id": "proof.validation",
|
||||||
|
"relation": "proves",
|
||||||
|
"target_id": "guide.workflow",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rationale": "Race the final no-replace canonical detachment.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
target = root / "docs/content/proof.toml"
|
||||||
|
original = target.read_bytes()
|
||||||
|
move = application_module.rename_noreplace_between_at
|
||||||
|
raced = False
|
||||||
|
|
||||||
|
def race_detach(
|
||||||
|
source_directory_fd: int,
|
||||||
|
source: str,
|
||||||
|
target_directory_fd: int,
|
||||||
|
destination: str,
|
||||||
|
) -> bool:
|
||||||
|
nonlocal raced
|
||||||
|
if source == target.name and destination.startswith(".detached-") and not raced:
|
||||||
|
raced = True
|
||||||
|
replacement = target.with_name(".foreign-delete")
|
||||||
|
replacement.write_bytes(b"foreign replacement at delete detach\n")
|
||||||
|
os.replace(replacement, target)
|
||||||
|
return move(
|
||||||
|
source_directory_fd,
|
||||||
|
source,
|
||||||
|
target_directory_fd,
|
||||||
|
destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"docforge.application.rename_noreplace_between_at",
|
||||||
|
side_effect=race_detach,
|
||||||
|
),
|
||||||
|
self.assertRaises(DocForgeError) as captured,
|
||||||
|
):
|
||||||
|
store.apply(
|
||||||
|
changeset_id="delete-detach-race",
|
||||||
|
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||||
|
applier_id="alpha-editor",
|
||||||
|
application=GenericCanonicalApplier(project).apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(raced)
|
||||||
|
self.assertEqual("application_recovery_required", captured.exception.code)
|
||||||
|
self.assertEqual(b"foreign replacement at delete detach\n", target.read_bytes())
|
||||||
|
conflicts = captured.exception.details["conflicts"]
|
||||||
|
retained = root / conflicts[0]["retained"]
|
||||||
|
self.assertEqual(original, retained.read_bytes())
|
||||||
|
self.assertFalse(
|
||||||
|
(root / ".docforge/changesets/.state/delete-detach-race.json").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_rollback_detach_race_never_unlinks_foreign_target(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
proposal = store.register(
|
||||||
|
"create-rollback-detach-race",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"operation": "create",
|
||||||
|
"node_id": "guide.created",
|
||||||
|
"target_source": "docs/content/a-created.md",
|
||||||
|
"metadata": self.new_metadata(),
|
||||||
|
"content": "Approved content that publishes first.",
|
||||||
|
"rationale": "Exercise create rollback detachment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operation": "update",
|
||||||
|
"node_id": "guide.workflow",
|
||||||
|
"metadata": {"summary": "Synthetic failing second publication."},
|
||||||
|
"rationale": "Trigger rollback after create publication.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
target = root / "docs/content/a-created.md"
|
||||||
|
publish = GenericCanonicalApplier._publish
|
||||||
|
move = application_module.rename_noreplace_between_at
|
||||||
|
publish_calls = 0
|
||||||
|
raced = False
|
||||||
|
|
||||||
|
def fail_second(
|
||||||
|
applier: GenericCanonicalApplier,
|
||||||
|
publication: Any,
|
||||||
|
) -> None:
|
||||||
|
nonlocal publish_calls
|
||||||
|
publish_calls += 1
|
||||||
|
if publish_calls == 1:
|
||||||
|
publish(applier, publication)
|
||||||
|
return
|
||||||
|
raise DocForgeError("application_failure", "Synthetic second publication failure")
|
||||||
|
|
||||||
|
def race_rollback_detach(
|
||||||
|
source_directory_fd: int,
|
||||||
|
source: str,
|
||||||
|
target_directory_fd: int,
|
||||||
|
destination: str,
|
||||||
|
) -> bool:
|
||||||
|
nonlocal raced
|
||||||
|
if source == target.name and destination.startswith(".detached-") and not raced:
|
||||||
|
raced = True
|
||||||
|
replacement = target.with_name(".foreign-create-rollback")
|
||||||
|
replacement.write_bytes(b"foreign replacement during create rollback\n")
|
||||||
|
os.replace(replacement, target)
|
||||||
|
return move(
|
||||||
|
source_directory_fd,
|
||||||
|
source,
|
||||||
|
target_directory_fd,
|
||||||
|
destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
GenericCanonicalApplier,
|
||||||
|
"_publish",
|
||||||
|
autospec=True,
|
||||||
|
side_effect=fail_second,
|
||||||
|
),
|
||||||
|
mock.patch(
|
||||||
|
"docforge.application.rename_noreplace_between_at",
|
||||||
|
side_effect=race_rollback_detach,
|
||||||
|
),
|
||||||
|
self.assertRaises(DocForgeError) as captured,
|
||||||
|
):
|
||||||
|
store.apply(
|
||||||
|
changeset_id="create-rollback-detach-race",
|
||||||
|
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||||
|
applier_id="alpha-editor",
|
||||||
|
application=GenericCanonicalApplier(project).apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(raced)
|
||||||
|
self.assertEqual("application_recovery_required", captured.exception.code)
|
||||||
|
self.assertEqual(
|
||||||
|
b"foreign replacement during create rollback\n",
|
||||||
|
target.read_bytes(),
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
(root / ".docforge/changesets/.state/create-rollback-detach-race.json").exists()
|
||||||
|
)
|
||||||
|
|
||||||
def test_rollback_never_clobbers_a_foreign_edit_and_retains_original_bytes(self) -> None:
|
def test_rollback_never_clobbers_a_foreign_edit_and_retains_original_bytes(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture(Path(directory))
|
root = self.copy_fixture(Path(directory))
|
||||||
|
|
@ -505,7 +676,8 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = self.copy_fixture(Path(directory))
|
root = self.copy_fixture(Path(directory))
|
||||||
target = root / "docs/content/workflow.md"
|
target = root / "docs/content/workflow.md"
|
||||||
target.chmod(0o640)
|
target.chmod(0o6750)
|
||||||
|
before = target.stat()
|
||||||
project = Project.open(root)
|
project = Project.open(root)
|
||||||
store = ChangesetStore(project, "alpha-editor")
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
proposal = store.register(
|
proposal = store.register(
|
||||||
|
|
@ -528,8 +700,138 @@ class DocForgeChangesetTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result["applied"])
|
self.assertTrue(result["applied"])
|
||||||
self.assertEqual(0o640, stat.S_IMODE(target.stat().st_mode))
|
after = target.stat()
|
||||||
|
self.assertEqual(0o6750, stat.S_IMODE(after.st_mode))
|
||||||
|
self.assertEqual(before.st_uid, after.st_uid)
|
||||||
|
self.assertEqual(before.st_gid, after.st_gid)
|
||||||
self.assertEqual([], result["retained_recovery_files"])
|
self.assertEqual([], result["retained_recovery_files"])
|
||||||
|
self.assertEqual("clean", result["application_recovery"]["status"])
|
||||||
|
self.assertFalse(tuple((root / ".docforge/application").glob("transaction-*")))
|
||||||
|
|
||||||
|
def test_nested_creation_fsyncs_each_new_directory_and_parent_entry(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
proposal = store.register(
|
||||||
|
"nested-durable",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"operation": "create",
|
||||||
|
"node_id": "guide.nested",
|
||||||
|
"target_source": "docs/content/nested/deeper/guide.md",
|
||||||
|
"metadata": self.new_metadata(),
|
||||||
|
"content": "Nested canonical content.",
|
||||||
|
"rationale": "Prove durable nested-directory creation.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
real_fsync = os.fsync
|
||||||
|
fsynced_directories: set[Path] = set()
|
||||||
|
|
||||||
|
def record_fsync(descriptor: int) -> None:
|
||||||
|
try:
|
||||||
|
path = Path(os.readlink(f"/proc/self/fd/{descriptor}"))
|
||||||
|
if path.is_dir():
|
||||||
|
fsynced_directories.add(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
real_fsync(descriptor)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"docforge.application.os.fsync",
|
||||||
|
side_effect=record_fsync,
|
||||||
|
):
|
||||||
|
result = store.apply(
|
||||||
|
changeset_id="nested-durable",
|
||||||
|
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||||
|
applier_id="alpha-editor",
|
||||||
|
application=GenericCanonicalApplier(project).apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["applied"])
|
||||||
|
for path in (
|
||||||
|
root / "docs/content",
|
||||||
|
root / "docs/content/nested",
|
||||||
|
root / "docs/content/nested/deeper",
|
||||||
|
):
|
||||||
|
self.assertIn(path, fsynced_directories)
|
||||||
|
|
||||||
|
def test_post_commit_cleanup_failure_closes_proposal_with_recovery_record(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = self.copy_fixture(Path(directory))
|
||||||
|
project = Project.open(root)
|
||||||
|
store = ChangesetStore(project, "alpha-editor")
|
||||||
|
proposal = store.register(
|
||||||
|
"cleanup-recovery",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"operation": "update",
|
||||||
|
"node_id": "guide.workflow",
|
||||||
|
"metadata": {"summary": "Committed despite private cleanup failure."},
|
||||||
|
"rationale": "Persist actionable post-commit recovery evidence.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
real_unlink = os.unlink
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
def fail_private_cleanup(
|
||||||
|
path: str | bytes,
|
||||||
|
*,
|
||||||
|
dir_fd: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
nonlocal failed
|
||||||
|
if (
|
||||||
|
isinstance(path, str)
|
||||||
|
and path.startswith("staged-")
|
||||||
|
and dir_fd is not None
|
||||||
|
and not failed
|
||||||
|
):
|
||||||
|
failed = True
|
||||||
|
raise PermissionError("synthetic private cleanup failure")
|
||||||
|
real_unlink(path, dir_fd=dir_fd)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"docforge.application.os.unlink",
|
||||||
|
side_effect=fail_private_cleanup,
|
||||||
|
):
|
||||||
|
result = store.apply(
|
||||||
|
changeset_id="cleanup-recovery",
|
||||||
|
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||||
|
applier_id="alpha-editor",
|
||||||
|
application=GenericCanonicalApplier(project).apply,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(failed)
|
||||||
|
self.assertTrue(result["applied"])
|
||||||
|
self.assertEqual("applied", result["lifecycle"]["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
"cleanup_required",
|
||||||
|
result["application_recovery"]["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"cleanup_required",
|
||||||
|
result["lifecycle"]["application_recovery"]["status"],
|
||||||
|
)
|
||||||
|
retained = result["application_recovery"]["retained"]
|
||||||
|
self.assertTrue(retained)
|
||||||
|
lifecycle_path = root / ".docforge/changesets/.state/cleanup-recovery.json"
|
||||||
|
lifecycle = json.loads(lifecycle_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(
|
||||||
|
"cleanup_required",
|
||||||
|
lifecycle["application_recovery"]["status"],
|
||||||
|
)
|
||||||
|
with self.assertRaises(DocForgeError) as closed:
|
||||||
|
store.apply(
|
||||||
|
changeset_id="cleanup-recovery",
|
||||||
|
expected_changeset_hash=str(proposal["changeset_hash"]),
|
||||||
|
applier_id="alpha-editor",
|
||||||
|
application=GenericCanonicalApplier(project).apply,
|
||||||
|
)
|
||||||
|
self.assertEqual("changeset_closed", closed.exception.code)
|
||||||
|
|
||||||
def test_changeset_rollback_fsyncs_the_parent_directory(self) -> None:
|
def test_changeset_rollback_fsyncs_the_parent_directory(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue