_managed_files owns the file snapshot reader and public metadata helpers used by pinned directories, durable copies, and opaque snapshots; transaction staging owns read_target and same_identity_and_contents; dead helpers removed; hook_template no longer re-exports the guard generator; mypy targets win32 so _windows_security type-checks. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
190 lines
5.9 KiB
Python
190 lines
5.9 KiB
Python
"""No-clobber private-file capture and restoration primitives."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Callable, Optional
|
|
from uuid import uuid4
|
|
|
|
from ._managed_files import FileSnapshot
|
|
from ._pinned_directory import PinnedDirectory
|
|
from ._private_artifact import QuarantinedFile, reserve_private_path
|
|
from ._transaction_staging import read_target, same_snapshot
|
|
from ._transaction_types import StagedChange
|
|
|
|
|
|
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
|
|
|
|
|
def _read_private_path(
|
|
staged: StagedChange,
|
|
file_path: str,
|
|
label: str,
|
|
) -> Optional[FileSnapshot]:
|
|
return staged.directory.read_file(
|
|
staged.directory.name_for(file_path),
|
|
label,
|
|
staged.change.maximum_bytes,
|
|
)
|
|
|
|
|
|
def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]:
|
|
basename = os.path.basename(staged.change.file_path)
|
|
path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery")
|
|
reservation = reserve_private_path(
|
|
staged.directory,
|
|
path,
|
|
f"{staged.change.label} recovery",
|
|
)
|
|
return path, reservation
|
|
|
|
|
|
def _preserve_private_path(error: Exception, file_path: str) -> OSError:
|
|
return OSError(f"{error}; transaction data preserved at {file_path}")
|
|
|
|
|
|
def capture_recovery_target(
|
|
staged: StagedChange,
|
|
expected: FileSnapshot,
|
|
replace_file: ReplaceFile,
|
|
capture_file: ReplaceFile,
|
|
) -> tuple[str, FileSnapshot]:
|
|
path, reservation = _recovery_path(staged)
|
|
reserved = QuarantinedFile.capture(
|
|
staged.directory,
|
|
path,
|
|
f"{staged.change.label} recovery reservation",
|
|
)
|
|
reserved_snapshot = reserved.text_snapshot(staged.change.maximum_bytes)
|
|
if not same_snapshot(reserved_snapshot, reservation):
|
|
try:
|
|
reserved.restore(path)
|
|
except Exception as error:
|
|
preserved = reserved.preserve() if reserved.active else path
|
|
raise _preserve_private_path(error, preserved) from error
|
|
raise OSError(
|
|
f"{staged.change.label} recovery reservation changed: {path}"
|
|
)
|
|
try:
|
|
capture_file(staged.change.file_path, path, staged.directory)
|
|
except Exception:
|
|
reserved.discard()
|
|
raise
|
|
captured_file = QuarantinedFile.capture(
|
|
staged.directory,
|
|
staged.change.file_path,
|
|
f"{staged.change.label} recovery",
|
|
)
|
|
captured = captured_file.text_snapshot(staged.change.maximum_bytes)
|
|
if captured is None:
|
|
preserved = captured_file.preserve()
|
|
reserved.discard()
|
|
raise OSError(
|
|
f"Missing recovery data for {staged.change.label}; "
|
|
f"transaction data preserved at {preserved}"
|
|
)
|
|
if not same_snapshot(captured, expected):
|
|
boundary_error = OSError(
|
|
f"{staged.change.label} changed at recovery boundary: "
|
|
f"{staged.change.file_path}"
|
|
)
|
|
try:
|
|
captured_file.restore(staged.change.file_path)
|
|
except Exception as restore_error:
|
|
preserved = (
|
|
captured_file.preserve()
|
|
if captured_file.active
|
|
else staged.change.file_path
|
|
)
|
|
reserved.discard()
|
|
raise OSError(
|
|
f"{boundary_error}; concurrent data preserved at {preserved}: "
|
|
f"{restore_error}"
|
|
) from boundary_error
|
|
reserved.discard()
|
|
raise boundary_error
|
|
try:
|
|
captured_file.restore(path)
|
|
except Exception as error:
|
|
try:
|
|
captured_file.restore(staged.change.file_path)
|
|
except Exception as restore_error:
|
|
preserved = (
|
|
captured_file.preserve()
|
|
if captured_file.active
|
|
else staged.change.file_path
|
|
)
|
|
reserved.discard()
|
|
raise OSError(
|
|
f"{error}; transaction data preserved at {preserved}; "
|
|
f"restoration failed: {restore_error}"
|
|
) from error
|
|
reserved.discard()
|
|
raise
|
|
reserved.discard()
|
|
public_snapshot = _read_private_path(
|
|
staged,
|
|
path,
|
|
f"{staged.change.label} recovery",
|
|
)
|
|
if not same_snapshot(public_snapshot, captured):
|
|
raise OSError(f"{staged.change.label} recovery changed: {path}")
|
|
return path, captured
|
|
|
|
|
|
def discard_private_file(
|
|
staged: StagedChange,
|
|
file_path: str,
|
|
expected: FileSnapshot,
|
|
) -> None:
|
|
try:
|
|
captured = QuarantinedFile.capture(
|
|
staged.directory,
|
|
file_path,
|
|
f"{staged.change.label} recovery cleanup",
|
|
)
|
|
except FileNotFoundError:
|
|
return
|
|
current = captured.text_snapshot(staged.change.maximum_bytes)
|
|
if not same_snapshot(current, expected):
|
|
try:
|
|
captured.restore(file_path)
|
|
except Exception as error:
|
|
preserved = captured.preserve() if captured.active else file_path
|
|
raise _preserve_private_path(error, preserved) from error
|
|
raise OSError(
|
|
f"{staged.change.label} recovery changed before cleanup: "
|
|
f"{file_path}"
|
|
)
|
|
captured.discard()
|
|
|
|
|
|
def remove_committed_creation(
|
|
staged: StagedChange,
|
|
replace_file: ReplaceFile,
|
|
capture_file: ReplaceFile,
|
|
) -> None:
|
|
expected = staged.committed_snapshot
|
|
if expected is None:
|
|
raise OSError(f"Missing committed data for {staged.change.label}")
|
|
recovery_path, recovery_snapshot = capture_recovery_target(
|
|
staged,
|
|
expected,
|
|
replace_file,
|
|
capture_file,
|
|
)
|
|
if read_target(staged) is not None:
|
|
discard_private_file(staged, recovery_path, recovery_snapshot)
|
|
raise OSError(
|
|
f"{staged.change.label} changed after recovery capture: "
|
|
f"{staged.change.file_path}"
|
|
)
|
|
discard_private_file(staged, recovery_path, recovery_snapshot)
|
|
|
|
|
|
__all__ = [
|
|
"capture_recovery_target",
|
|
"discard_private_file",
|
|
"remove_committed_creation",
|
|
]
|