Close canonical cleanup race windows
This commit is contained in:
parent
1a6f33e2de
commit
d2bb95fe61
4 changed files with 805 additions and 125 deletions
|
|
@ -4,16 +4,22 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
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 .errors import DocForgeError
|
||||
from .index import ProjectIndex
|
||||
|
|
@ -83,6 +89,9 @@ class _CanonicalPublication:
|
|||
backup_snapshot: _CanonicalFile | None = None
|
||||
published_snapshot: _CanonicalFile | None = None
|
||||
committed: bool = False
|
||||
cleanup_conflicts: list[dict[str, object]] = field(
|
||||
default_factory=lambda: list[dict[str, object]]()
|
||||
)
|
||||
|
||||
|
||||
class GenericCanonicalApplier:
|
||||
|
|
@ -105,8 +114,10 @@ class GenericCanonicalApplier:
|
|||
projected_by_source = self._nodes_by_source(projected)
|
||||
publications: list[_CanonicalPublication] = []
|
||||
created_directories: list[Path] = []
|
||||
transaction_root: Path | None = None
|
||||
targets = {relative: self._target(relative) for relative in sorted(changed_sources)}
|
||||
try:
|
||||
transaction_root = self._prepare_transaction_root()
|
||||
for relative, target in targets.items():
|
||||
self._prepare_parent(target.parent, created_directories)
|
||||
expected = self._capture(target, base.descriptor.limits.max_source_bytes)
|
||||
|
|
@ -126,9 +137,12 @@ class GenericCanonicalApplier:
|
|||
source=relative,
|
||||
)
|
||||
staged, staged_snapshot = self._stage(
|
||||
target,
|
||||
transaction_root,
|
||||
raw,
|
||||
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(
|
||||
_CanonicalPublication(
|
||||
|
|
@ -169,7 +183,9 @@ class GenericCanonicalApplier:
|
|||
)
|
||||
except Exception as error:
|
||||
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)
|
||||
if recovery:
|
||||
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",
|
||||
) from error
|
||||
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 {
|
||||
"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,
|
||||
"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:
|
||||
target = publication.target
|
||||
parent_fd = open_bound_directory(target.parent)
|
||||
staging_fd = open_bound_directory(publication.staged.parent)
|
||||
try:
|
||||
current = self._capture_at(
|
||||
target.parent,
|
||||
|
|
@ -225,8 +260,8 @@ class GenericCanonicalApplier:
|
|||
source=publication.relative,
|
||||
)
|
||||
staged = self._capture_at(
|
||||
target.parent,
|
||||
parent_fd,
|
||||
publication.staged.parent,
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
|
|
@ -237,22 +272,24 @@ class GenericCanonicalApplier:
|
|||
source=publication.relative,
|
||||
)
|
||||
if publication.action == "create":
|
||||
self._publish_create(publication, parent_fd)
|
||||
self._publish_create(publication, parent_fd, staging_fd)
|
||||
else:
|
||||
self._publish_exchange(publication, parent_fd)
|
||||
self._publish_exchange(publication, parent_fd, staging_fd)
|
||||
finally:
|
||||
os.close(staging_fd)
|
||||
os.close(parent_fd)
|
||||
|
||||
def _publish_create(
|
||||
self,
|
||||
publication: _CanonicalPublication,
|
||||
parent_fd: int,
|
||||
staging_fd: int,
|
||||
) -> None:
|
||||
try:
|
||||
os.link(
|
||||
publication.staged.name,
|
||||
publication.target.name,
|
||||
src_dir_fd=parent_fd,
|
||||
src_dir_fd=staging_fd,
|
||||
dst_dir_fd=parent_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
|
|
@ -281,8 +318,40 @@ class GenericCanonicalApplier:
|
|||
"Published canonical create target does not match its staging file",
|
||||
source=publication.relative,
|
||||
)
|
||||
assert published is not None
|
||||
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)
|
||||
require_bound_directory(publication.target.parent, parent_fd)
|
||||
|
||||
|
|
@ -290,12 +359,18 @@ class GenericCanonicalApplier:
|
|||
self,
|
||||
publication: _CanonicalPublication,
|
||||
parent_fd: int,
|
||||
staging_fd: int,
|
||||
) -> 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
|
||||
displaced = self._capture_at(
|
||||
publication.target.parent,
|
||||
parent_fd,
|
||||
publication.staged.parent,
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
|
|
@ -312,7 +387,7 @@ class GenericCanonicalApplier:
|
|||
or not publication.expected.renamed_to(displaced)
|
||||
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(
|
||||
"application_recovery_required",
|
||||
"Canonical target raced atomic publication and displaced data was retained",
|
||||
|
|
@ -325,22 +400,98 @@ class GenericCanonicalApplier:
|
|||
source=publication.relative,
|
||||
)
|
||||
if publication.action == "delete":
|
||||
os.unlink(publication.target.name, dir_fd=parent_fd)
|
||||
publication.published_snapshot = None
|
||||
assert published is not 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(staging_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(
|
||||
self,
|
||||
publication: _CanonicalPublication,
|
||||
parent_fd: int,
|
||||
staging_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)
|
||||
rename_exchange_between_at(
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
parent_fd,
|
||||
publication.target.name,
|
||||
)
|
||||
restored = self._capture_at(
|
||||
publication.target.parent,
|
||||
parent_fd,
|
||||
|
|
@ -348,21 +499,36 @@ class GenericCanonicalApplier:
|
|||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
staged = self._capture_at(
|
||||
publication.target.parent,
|
||||
parent_fd,
|
||||
publication.staged.parent,
|
||||
staging_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
|
||||
assert staged is not None
|
||||
publication.staged_snapshot = staged
|
||||
publication.backup_snapshot = None
|
||||
publication.published_snapshot = None
|
||||
publication.committed = False
|
||||
os.fsync(parent_fd)
|
||||
os.fsync(staging_fd)
|
||||
return True
|
||||
except (DocForgeError, OSError):
|
||||
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(
|
||||
self,
|
||||
publications: Sequence[_CanonicalPublication],
|
||||
|
|
@ -392,6 +558,7 @@ class GenericCanonicalApplier:
|
|||
publication: _CanonicalPublication,
|
||||
) -> dict[str, object] | None:
|
||||
parent_fd = open_bound_directory(publication.target.parent)
|
||||
staging_fd = open_bound_directory(publication.staged.parent)
|
||||
try:
|
||||
current = self._capture_at(
|
||||
publication.target.parent,
|
||||
|
|
@ -400,8 +567,8 @@ class GenericCanonicalApplier:
|
|||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
backup = self._capture_at(
|
||||
publication.target.parent,
|
||||
parent_fd,
|
||||
publication.staged.parent,
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
|
|
@ -412,12 +579,16 @@ class GenericCanonicalApplier:
|
|||
or not publication.backup_snapshot.unchanged(backup)
|
||||
):
|
||||
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")
|
||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
||||
os.fsync(parent_fd)
|
||||
return None
|
||||
return self._remove_private(
|
||||
publication,
|
||||
publication.staged,
|
||||
publication.staged_snapshot,
|
||||
"rollback_staging_cleanup_failed",
|
||||
)
|
||||
finally:
|
||||
os.close(staging_fd)
|
||||
os.close(parent_fd)
|
||||
|
||||
def _rollback_delete(
|
||||
|
|
@ -425,6 +596,7 @@ class GenericCanonicalApplier:
|
|||
publication: _CanonicalPublication,
|
||||
) -> dict[str, object] | None:
|
||||
parent_fd = open_bound_directory(publication.target.parent)
|
||||
staging_fd = open_bound_directory(publication.staged.parent)
|
||||
try:
|
||||
current = self._capture_at(
|
||||
publication.target.parent,
|
||||
|
|
@ -433,8 +605,8 @@ class GenericCanonicalApplier:
|
|||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
backup = self._capture_at(
|
||||
publication.target.parent,
|
||||
parent_fd,
|
||||
publication.staged.parent,
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
|
|
@ -444,15 +616,12 @@ class GenericCanonicalApplier:
|
|||
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:
|
||||
if not rename_noreplace_between_at(
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
parent_fd,
|
||||
publication.target.name,
|
||||
):
|
||||
return self._recovery_conflict(publication, "deleted_target_reappeared")
|
||||
restored = self._capture_at(
|
||||
publication.target.parent,
|
||||
|
|
@ -462,12 +631,13 @@ class GenericCanonicalApplier:
|
|||
)
|
||||
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)
|
||||
os.fsync(staging_fd)
|
||||
return None
|
||||
finally:
|
||||
os.close(staging_fd)
|
||||
os.close(parent_fd)
|
||||
|
||||
def _rollback_create(
|
||||
|
|
@ -475,7 +645,7 @@ class GenericCanonicalApplier:
|
|||
publication: _CanonicalPublication,
|
||||
) -> dict[str, object] | None:
|
||||
parent_fd = open_bound_directory(publication.target.parent)
|
||||
tombstone: Path | None = None
|
||||
staging_fd = open_bound_directory(publication.staged.parent)
|
||||
try:
|
||||
current = self._capture_at(
|
||||
publication.target.parent,
|
||||
|
|
@ -491,36 +661,19 @@ class GenericCanonicalApplier:
|
|||
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,
|
||||
conflict = self._detach_canonical(
|
||||
publication,
|
||||
parent_fd,
|
||||
tombstone.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
staging_fd,
|
||||
current,
|
||||
)
|
||||
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
|
||||
if conflict is not None:
|
||||
return conflict
|
||||
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(staging_fd)
|
||||
os.close(parent_fd)
|
||||
|
||||
def _discard_backups(
|
||||
|
|
@ -529,55 +682,123 @@ class GenericCanonicalApplier:
|
|||
) -> 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)
|
||||
if publication.action != "create" and publication.backup_snapshot is not None:
|
||||
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")
|
||||
staging_fd = open_bound_directory(publication.staged.parent)
|
||||
try:
|
||||
backup = self._capture_at(
|
||||
publication.staged.parent,
|
||||
staging_fd,
|
||||
publication.staged.name,
|
||||
self.project.descriptor.limits.max_source_bytes,
|
||||
)
|
||||
continue
|
||||
os.unlink(publication.staged.name, dir_fd=parent_fd)
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
os.close(staging_fd)
|
||||
except Exception as error:
|
||||
conflict = self._recovery_conflict(
|
||||
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.backup_snapshot = None
|
||||
return retained
|
||||
|
||||
@staticmethod
|
||||
def _discard_unowned_staging(publications: Sequence[_CanonicalPublication]) -> None:
|
||||
def _discard_unowned_staging(
|
||||
self,
|
||||
publications: Sequence[_CanonicalPublication],
|
||||
) -> list[dict[str, object]]:
|
||||
retained: list[dict[str, object]] = []
|
||||
for publication in publications:
|
||||
if not publication.committed and publication.staged.exists():
|
||||
publication.staged.unlink()
|
||||
if publication.committed:
|
||||
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(
|
||||
prefix=".docforge-apply-",
|
||||
dir=target.parent,
|
||||
prefix="staged-",
|
||||
dir=staging_root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, mode)
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(content)
|
||||
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())
|
||||
snapshot = self._capture(temporary, max(1, len(content)))
|
||||
if snapshot is None:
|
||||
raise DocForgeError(
|
||||
"application_mismatch",
|
||||
"Canonical staging file disappeared",
|
||||
source=self._relative(target),
|
||||
source=source,
|
||||
)
|
||||
return temporary, snapshot
|
||||
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:
|
||||
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:
|
||||
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] = []
|
||||
cursor = parent
|
||||
cursor = path
|
||||
while not cursor.exists():
|
||||
missing.append(cursor)
|
||||
cursor = cursor.parent
|
||||
if cursor.is_symlink() or cursor.resolve(strict=True) != cursor or not cursor.is_dir():
|
||||
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")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
if parent.resolve(strict=True) != parent:
|
||||
created: list[Path] = []
|
||||
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")
|
||||
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
|
||||
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:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
@staticmethod
|
||||
def _remove_empty_directories(paths: Sequence[Path]) -> None:
|
||||
def _remove_empty_directories(self, paths: Sequence[Path]) -> None:
|
||||
for path in reversed(paths):
|
||||
with suppress(OSError):
|
||||
try:
|
||||
path.rmdir()
|
||||
self._fsync_directory(path.parent)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _nodes_by_source(snapshot: ProjectSnapshot) -> dict[str, tuple[Node, ...]]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue