sdks/python: mirror Elydora-Python-SDK 2.0.2
Rebuilt from the standalone 54e65eb tree (durable transactions, Kiro IDE adapter, hook retry with a bounded chain lock, version 2.0.2) with the Open-Source API trims re-applied: no token rotation, token issuance, or agent integration updates, and the mirror's registration contract and OpenCode plugin tests kept. Claude-Session: https://claude.ai/code/session_01QoUjdc7HGKTsgj5GhG8HvF
This commit is contained in:
1 parent
b00932d047
commit
1656a94811
56 files changed
+12203
-588
No files matched your search
@@ -0,0 +1,236 @@
|
||||
"""Identity-bound removal for empty physical directories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import DirectorySnapshot, read_physical_directory
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import _remove_posix_private_directory
|
||||
|
||||
|
||||
def _mark_windows_directory_for_deletion(directory: PinnedDirectory) -> None:
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
import msvcrt as msvcrt_module
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
msvcrt: Any = msvcrt_module
|
||||
|
||||
class FileDispositionInformation(ctypes.Structure):
|
||||
_fields_ = [("delete_file", wintypes.BOOL)]
|
||||
|
||||
if directory.descriptor is None:
|
||||
raise OSError(f"Pinned {directory.label} is closed")
|
||||
handle = msvcrt.get_osfhandle(directory.descriptor)
|
||||
if handle == -1:
|
||||
raise OSError("Directory descriptor has no Windows handle")
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
set_information = kernel32.SetFileInformationByHandle
|
||||
set_information.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
set_information.restype = wintypes.BOOL
|
||||
disposition = FileDispositionInformation(True)
|
||||
if not set_information(
|
||||
handle,
|
||||
4,
|
||||
ctypes.byref(disposition),
|
||||
ctypes.sizeof(disposition),
|
||||
):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(
|
||||
f"Remove empty {directory.label} at {directory.path}: "
|
||||
f"{ctypes.WinError(error)}"
|
||||
)
|
||||
|
||||
|
||||
def _rename_posix_into_quarantine(
|
||||
source_parent_descriptor: int,
|
||||
source_name: str,
|
||||
quarantine_descriptor: int,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
import ctypes
|
||||
import errno
|
||||
|
||||
del source_path, destination_path
|
||||
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
renameat2 = getattr(libc, "renameat2", None)
|
||||
if renameat2 is not None:
|
||||
renameat2.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
renameat2.restype = ctypes.c_int
|
||||
result = renameat2(
|
||||
source_parent_descriptor,
|
||||
os.fsencode(source_name),
|
||||
quarantine_descriptor,
|
||||
b"entry",
|
||||
1,
|
||||
)
|
||||
if result == 0:
|
||||
return
|
||||
error = ctypes.get_errno()
|
||||
if error not in (errno.EINVAL, errno.ENOSYS, errno.EOPNOTSUPP):
|
||||
raise OSError(error, os.strerror(error), source_name, "entry")
|
||||
renameatx = getattr(libc, "renameatx_np", None)
|
||||
if renameatx is not None:
|
||||
renameatx.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
renameatx.restype = ctypes.c_int
|
||||
result = renameatx(
|
||||
source_parent_descriptor,
|
||||
os.fsencode(source_name),
|
||||
quarantine_descriptor,
|
||||
b"entry",
|
||||
0x00000004,
|
||||
)
|
||||
if result == 0:
|
||||
return
|
||||
error = ctypes.get_errno()
|
||||
raise OSError(error, os.strerror(error), source_name, "entry")
|
||||
raise OSError(
|
||||
"Host does not support atomic no-replace directory quarantine"
|
||||
)
|
||||
|
||||
|
||||
def _create_posix_quarantine(
|
||||
parent: PinnedDirectory,
|
||||
child_name: str,
|
||||
) -> PinnedDirectory:
|
||||
if parent.descriptor is None:
|
||||
raise OSError("Pinned directory is closed")
|
||||
quarantine_name = f".{child_name}.{uuid4().hex}.elydora-removed"
|
||||
try:
|
||||
os.mkdir(quarantine_name, mode=0o700, dir_fd=parent.descriptor)
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"Create private directory quarantine at "
|
||||
f"{parent.path_for(quarantine_name)}: {error}"
|
||||
) from error
|
||||
metadata = parent.stat_file(quarantine_name)
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
raise OSError(
|
||||
f"Directory quarantine is not physical: "
|
||||
f"{parent.path_for(quarantine_name)}"
|
||||
)
|
||||
snapshot = DirectorySnapshot(metadata.st_dev, metadata.st_ino)
|
||||
return PinnedDirectory.open(
|
||||
parent.path_for(quarantine_name),
|
||||
"Elydora directory quarantine",
|
||||
snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _quarantine_posix_directory(
|
||||
parent: PinnedDirectory,
|
||||
child: PinnedDirectory,
|
||||
child_name: str,
|
||||
) -> None:
|
||||
if parent.descriptor is None or child.descriptor is None:
|
||||
raise OSError("Pinned directory is closed")
|
||||
visible = parent.stat_file(child_name)
|
||||
opened = os.fstat(child.descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(visible.st_mode)
|
||||
or (visible.st_dev, visible.st_ino)
|
||||
!= (opened.st_dev, opened.st_ino)
|
||||
):
|
||||
raise OSError(f"{child.label} changed before quarantine: {child.path}")
|
||||
quarantine = _create_posix_quarantine(parent, child_name)
|
||||
quarantine_name = parent.name_for(quarantine.path)
|
||||
try:
|
||||
if quarantine.list_names():
|
||||
raise OSError(
|
||||
f"Directory quarantine is not empty: {quarantine.path}"
|
||||
)
|
||||
child.close()
|
||||
assert quarantine.descriptor is not None
|
||||
_rename_posix_into_quarantine(
|
||||
parent.descriptor,
|
||||
child_name,
|
||||
quarantine.descriptor,
|
||||
child.path,
|
||||
quarantine.path_for("entry"),
|
||||
)
|
||||
quarantined = quarantine.stat_file("entry")
|
||||
preserved_path = quarantine.path_for("entry")
|
||||
if (
|
||||
not stat.S_ISDIR(quarantined.st_mode)
|
||||
or (quarantined.st_dev, quarantined.st_ino)
|
||||
!= (opened.st_dev, opened.st_ino)
|
||||
):
|
||||
raise OSError(
|
||||
f"{child.label} changed while quarantining; preserved at "
|
||||
f"{preserved_path}"
|
||||
)
|
||||
try:
|
||||
parent.stat_file(child_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
raise OSError(
|
||||
f"A replacement appeared at {child.path}; original preserved at "
|
||||
f"{preserved_path}"
|
||||
)
|
||||
_remove_posix_private_directory(quarantine.descriptor, "entry")
|
||||
quarantine_snapshot = quarantine.snapshot
|
||||
quarantine.close()
|
||||
current = parent.stat_file(quarantine_name)
|
||||
if (
|
||||
not stat.S_ISDIR(current.st_mode)
|
||||
or (current.st_dev, current.st_ino)
|
||||
!= (quarantine_snapshot.device, quarantine_snapshot.inode)
|
||||
):
|
||||
raise OSError(
|
||||
"Directory quarantine changed before cleanup: "
|
||||
f"{parent.path_for(quarantine_name)}"
|
||||
)
|
||||
_remove_posix_private_directory(parent.descriptor, quarantine_name)
|
||||
finally:
|
||||
if quarantine.descriptor is not None:
|
||||
quarantine.close()
|
||||
|
||||
|
||||
def remove_pinned_empty_directory(
|
||||
parent: PinnedDirectory,
|
||||
child: PinnedDirectory,
|
||||
*,
|
||||
require_empty: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Remove or quarantine the exact child while both handles stay pinned."""
|
||||
if require_empty and child.list_names():
|
||||
raise OSError(
|
||||
f"{child.label} contains entries after managed cleanup: {child.path}"
|
||||
)
|
||||
child_name = parent.name_for(child.path)
|
||||
parent.assert_path_stable("directory removal")
|
||||
child.assert_path_stable("directory removal")
|
||||
if os.name == "nt":
|
||||
_mark_windows_directory_for_deletion(child)
|
||||
child.close()
|
||||
else:
|
||||
_quarantine_posix_directory(parent, child, child_name)
|
||||
parent.assert_path_stable("directory quarantine")
|
||||
if read_physical_directory(child.path, child.label) is not None:
|
||||
raise OSError(f"{child.label} remained after removal: {child.path}")
|
||||
parent.assert_path_stable("directory removal")
|
||||
return None
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Durable, identity-bound file copy and replacement primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
_COPY_CHUNK_BYTES = 1024 * 1024
|
||||
FileIdentity = Tuple[int, int]
|
||||
|
||||
|
||||
def _regular_identity(
|
||||
metadata: os.stat_result,
|
||||
expected: FileIdentity,
|
||||
path: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or stat.S_ISLNK(metadata.st_mode)
|
||||
or (metadata.st_dev, metadata.st_ino) != expected
|
||||
):
|
||||
raise OSError(f"{label} changed while copying: {path}")
|
||||
|
||||
|
||||
def _stable_metadata(metadata: os.stat_result) -> tuple[int, int, int]:
|
||||
# Timestamps are excluded: Windows exposes file times with delayed
|
||||
# cross-handle visibility, so they are unsound identity evidence.
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def copy_into_reserved(
|
||||
directory: PinnedDirectory,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
source_identity: FileIdentity,
|
||||
destination_identity: FileIdentity,
|
||||
mode: int,
|
||||
label: str,
|
||||
maximum_bytes: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Copy one physical file into an existing private reservation and fsync it."""
|
||||
source_name = directory.name_for(source_path)
|
||||
destination_name = directory.name_for(destination_path)
|
||||
read_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
||||
read_flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
write_flags = os.O_WRONLY | getattr(os, "O_BINARY", 0)
|
||||
write_flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
source_descriptor = -1
|
||||
destination_descriptor = -1
|
||||
try:
|
||||
source_descriptor = directory.open_file(source_name, read_flags)
|
||||
before = os.fstat(source_descriptor)
|
||||
_regular_identity(before, source_identity, source_path, label)
|
||||
destination_descriptor = directory.open_file(destination_name, write_flags)
|
||||
destination = os.fstat(destination_descriptor)
|
||||
_regular_identity(
|
||||
destination,
|
||||
destination_identity,
|
||||
destination_path,
|
||||
f"{label} destination",
|
||||
)
|
||||
os.ftruncate(destination_descriptor, 0)
|
||||
total = 0
|
||||
while True:
|
||||
chunk = os.read(source_descriptor, _COPY_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if maximum_bytes is not None and total > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {source_path}"
|
||||
)
|
||||
offset = 0
|
||||
while offset < len(chunk):
|
||||
written = os.write(destination_descriptor, chunk[offset:])
|
||||
if written <= 0:
|
||||
raise OSError(f"Copy {label} made no progress: {destination_path}")
|
||||
offset += written
|
||||
directory.chmod_descriptor(destination_descriptor, mode)
|
||||
os.fsync(destination_descriptor)
|
||||
after = os.fstat(source_descriptor)
|
||||
if _stable_metadata(after) != _stable_metadata(before):
|
||||
raise OSError(f"{label} changed while copying: {source_path}")
|
||||
finally:
|
||||
for descriptor in (destination_descriptor, source_descriptor):
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
visible = directory.stat_file(source_name)
|
||||
_regular_identity(visible, source_identity, source_path, label)
|
||||
directory.sync()
|
||||
|
||||
|
||||
def atomic_replace(
|
||||
directory: PinnedDirectory,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
"""Atomically replace one same-directory name and persist its entry."""
|
||||
if os.name == "nt":
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
source = directory.path_for(directory.name_for(source_path))
|
||||
destination = directory.path_for(directory.name_for(destination_path))
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
move = kernel32.MoveFileExW
|
||||
move.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
|
||||
move.restype = wintypes.BOOL
|
||||
replace_existing = 0x00000001
|
||||
write_through = 0x00000008
|
||||
if not move(source, destination, replace_existing | write_through):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Atomically replace {destination}: {ctypes.WinError(error)}")
|
||||
return
|
||||
directory.replace_file(
|
||||
directory.name_for(source_path),
|
||||
directory.name_for(destination_path),
|
||||
)
|
||||
directory.sync()
|
||||
|
||||
|
||||
__all__ = ["atomic_replace", "copy_into_reserved"]
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
MAX_SECRET_BYTES = 64 * 1024
|
||||
@@ -21,6 +21,83 @@ class FileSnapshot:
|
||||
mode: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectorySnapshot:
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
|
||||
def identity_mode_bits(mode: int) -> int:
|
||||
"""Collapse permission bits that are not identity evidence on Windows.
|
||||
|
||||
Windows synthesizes POSIX modes and derives execute bits from the file
|
||||
extension for path stats but not for handle stats, so the synthetic value
|
||||
cannot serve as identity there. Windows permissions are enforced through
|
||||
DACLs in _windows_security instead. Captured snapshots still store the
|
||||
raw bits because restoration re-applies them.
|
||||
"""
|
||||
return mode if os.name != "nt" else 0
|
||||
|
||||
|
||||
def portable_file_mode(metadata: os.stat_result) -> int:
|
||||
"""Return the identity-relevant permission bits for one stat result."""
|
||||
return identity_mode_bits(stat.S_IMODE(metadata.st_mode))
|
||||
|
||||
|
||||
def _stable_file_metadata(
|
||||
metadata: os.stat_result,
|
||||
) -> tuple[int, int, int, int]:
|
||||
# Timestamps are excluded deliberately: Windows exposes file times with
|
||||
# delayed cross-handle visibility, so they are unsound identity evidence.
|
||||
# Content changes are caught by the verification re-read instead.
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
portable_file_mode(metadata),
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def _same_file_metadata(
|
||||
first: os.stat_result,
|
||||
second: os.stat_result,
|
||||
) -> bool:
|
||||
return _stable_file_metadata(first) == _stable_file_metadata(second)
|
||||
|
||||
|
||||
def assert_content_stable(
|
||||
opener: Callable[[], int],
|
||||
file_path: str,
|
||||
label: str,
|
||||
expected: os.stat_result,
|
||||
raw: bytes,
|
||||
maximum_bytes: int,
|
||||
) -> None:
|
||||
"""Re-read a just-read file and require identical identity and bytes.
|
||||
|
||||
This is the deterministic replacement for timestamp comparisons: a
|
||||
concurrent in-place rewrite is proven by content divergence between the
|
||||
two reads, independent of filesystem timer visibility.
|
||||
"""
|
||||
descriptor = opener()
|
||||
try:
|
||||
current = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(current.st_mode)
|
||||
or current.st_dev != expected.st_dev
|
||||
or current.st_ino != expected.st_ino
|
||||
):
|
||||
raise OSError(f"{label} changed while reading: {file_path}")
|
||||
with os.fdopen(descriptor, "rb") as file:
|
||||
descriptor = -1
|
||||
reread = file.read(maximum_bytes + 1)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
if reread != raw:
|
||||
raise OSError(f"{label} changed while reading: {file_path}")
|
||||
|
||||
|
||||
def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result]:
|
||||
try:
|
||||
metadata = os.lstat(file_path)
|
||||
@@ -52,13 +129,24 @@ def read_physical_file(
|
||||
after = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(after.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||
if not _same_file_metadata(before, after):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
if after.st_size > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
with os.fdopen(descriptor, "rb") as file:
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, after):
|
||||
raise OSError(f"{label} changed while reading: {file_path}")
|
||||
assert_content_stable(
|
||||
lambda: os.open(file_path, flags),
|
||||
file_path,
|
||||
label,
|
||||
after,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
finally:
|
||||
@@ -83,16 +171,22 @@ def physical_file_exists(file_path: str, label: str) -> bool:
|
||||
return _inspect_regular_file(file_path, label) is not None
|
||||
|
||||
|
||||
def physical_directory_exists(directory: str, label: str) -> bool:
|
||||
def read_physical_directory(
|
||||
directory: str, label: str
|
||||
) -> Optional[DirectorySnapshot]:
|
||||
try:
|
||||
metadata = os.lstat(directory)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {directory}: {error}") from error
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} is not a physical directory: {directory}")
|
||||
return True
|
||||
return DirectorySnapshot(metadata.st_dev, metadata.st_ino)
|
||||
|
||||
|
||||
def physical_directory_exists(directory: str, label: str) -> bool:
|
||||
return read_physical_directory(directory, label) is not None
|
||||
|
||||
|
||||
def ensure_physical_directory(directory: str, label: str) -> None:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Identity-bound cleanup for captured opaque rollback files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._opaque_removal import (
|
||||
StagedOpaqueRemoval,
|
||||
_same_captured,
|
||||
stream_opaque_snapshot,
|
||||
)
|
||||
from ._private_artifact import QuarantinedFile
|
||||
|
||||
|
||||
def _capture_cleanup_physical(
|
||||
staged: StagedOpaqueRemoval,
|
||||
source_path: str,
|
||||
) -> None:
|
||||
del staged, source_path
|
||||
|
||||
|
||||
def cleanup_opaque_removal(staged: StagedOpaqueRemoval) -> None:
|
||||
if not staged.committed or staged.rollback_path is None:
|
||||
return
|
||||
expected = staged.rollback_snapshot
|
||||
if expected is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
rollback_path = staged.rollback_path
|
||||
_capture_cleanup_physical(staged, rollback_path)
|
||||
captured_file = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
rollback_path,
|
||||
f"{staged.change.label} rollback cleanup",
|
||||
)
|
||||
captured = stream_opaque_snapshot(
|
||||
captured_file.container,
|
||||
captured_file.entry_path,
|
||||
f"{staged.change.label} rollback cleanup",
|
||||
require_owner_only=False,
|
||||
)
|
||||
if not _same_captured(captured, expected):
|
||||
try:
|
||||
captured_file.restore(rollback_path)
|
||||
except Exception as error:
|
||||
preserved = (
|
||||
captured_file.preserve()
|
||||
if captured_file.active
|
||||
else rollback_path
|
||||
)
|
||||
staged.rollback_path = None
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at cleanup boundary; "
|
||||
f"concurrent data preserved at {preserved}: {error}"
|
||||
) from error
|
||||
staged.rollback_path = None
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at cleanup boundary; "
|
||||
f"concurrent data preserved at {rollback_path}"
|
||||
)
|
||||
captured_file.discard()
|
||||
staged.rollback_path = None
|
||||
staged.rollback_snapshot = None
|
||||
staged.committed = False
|
||||
|
||||
|
||||
__all__ = ["cleanup_opaque_removal"]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Streaming, identity-bound removal for arbitrarily large runtime files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ._durable_file import copy_into_reserved
|
||||
from ._managed_files import FileSnapshot, identity_mode_bits
|
||||
from ._opaque_snapshot import (
|
||||
OpaqueFileSnapshot,
|
||||
_require_regular,
|
||||
stream_opaque_snapshot,
|
||||
)
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile, reserve_private_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpaqueRemovalChange:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[OpaqueFileSnapshot]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagedOpaqueRemoval:
|
||||
change: OpaqueRemovalChange
|
||||
directory: PinnedDirectory
|
||||
rollback_path: Optional[str]
|
||||
rollback_reservation: Optional[FileSnapshot] = None
|
||||
rollback_snapshot: Optional[OpaqueFileSnapshot] = None
|
||||
committed: bool = False
|
||||
|
||||
|
||||
def prepare_opaque_removal(
|
||||
directory: PinnedDirectory,
|
||||
file_path: str,
|
||||
label: str,
|
||||
) -> OpaqueRemovalChange:
|
||||
return OpaqueRemovalChange(
|
||||
file_path,
|
||||
label,
|
||||
stream_opaque_snapshot(directory, file_path, label),
|
||||
)
|
||||
|
||||
|
||||
def _same_captured(
|
||||
current: Optional[OpaqueFileSnapshot],
|
||||
expected: Optional[OpaqueFileSnapshot],
|
||||
*,
|
||||
include_mode: bool = True,
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
and (
|
||||
identity_mode_bits(current.mode) == identity_mode_bits(expected.mode)
|
||||
or not include_mode
|
||||
)
|
||||
and current.size == expected.size
|
||||
and current.sha256 == expected.sha256
|
||||
)
|
||||
|
||||
|
||||
def assert_opaque_unchanged(
|
||||
change: OpaqueRemovalChange,
|
||||
directory: PinnedDirectory,
|
||||
operation: str,
|
||||
) -> None:
|
||||
current = stream_opaque_snapshot(
|
||||
directory,
|
||||
change.file_path,
|
||||
change.label,
|
||||
)
|
||||
if current != change.original:
|
||||
raise OSError(
|
||||
f"{change.label} changed during {operation}: {change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def stage_opaque_removal(
|
||||
change: OpaqueRemovalChange,
|
||||
directory: PinnedDirectory,
|
||||
operation: str,
|
||||
) -> StagedOpaqueRemoval:
|
||||
assert_opaque_unchanged(change, directory, operation)
|
||||
if change.original is None:
|
||||
return StagedOpaqueRemoval(change, directory, None)
|
||||
basename = os.path.basename(change.file_path)
|
||||
rollback_name = f".{basename}.{uuid4().hex}.opaque-rollback"
|
||||
rollback_path = directory.path_for(rollback_name)
|
||||
reservation = reserve_private_path(
|
||||
directory,
|
||||
rollback_path,
|
||||
f"{change.label} rollback",
|
||||
)
|
||||
return StagedOpaqueRemoval(change, directory, rollback_path, reservation)
|
||||
|
||||
|
||||
def _rollback_snapshot(staged: StagedOpaqueRemoval) -> Optional[OpaqueFileSnapshot]:
|
||||
if staged.rollback_path is None:
|
||||
return None
|
||||
return stream_opaque_snapshot(
|
||||
staged.directory,
|
||||
staged.rollback_path,
|
||||
f"{staged.change.label} rollback",
|
||||
require_owner_only=False,
|
||||
)
|
||||
|
||||
|
||||
def _target_snapshot(staged: StagedOpaqueRemoval) -> Optional[OpaqueFileSnapshot]:
|
||||
return stream_opaque_snapshot(
|
||||
staged.directory,
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
require_owner_only=False,
|
||||
)
|
||||
|
||||
|
||||
def _capture_physical(staged: StagedOpaqueRemoval) -> None:
|
||||
del staged
|
||||
|
||||
|
||||
def prepare_opaque_rollback(staged: StagedOpaqueRemoval) -> None:
|
||||
"""Copy and flush opaque rollback data while its public target stays present."""
|
||||
original = staged.change.original
|
||||
if original is None:
|
||||
return
|
||||
rollback_path = staged.rollback_path
|
||||
reservation = staged.rollback_reservation
|
||||
if rollback_path is None or reservation is None:
|
||||
raise OSError(f"Missing rollback reservation for {staged.change.label}")
|
||||
_capture_physical(staged)
|
||||
try:
|
||||
assert_opaque_unchanged(staged.change, staged.directory, "commit boundary")
|
||||
except OSError as error:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at commit boundary: "
|
||||
f"{staged.change.file_path}"
|
||||
) from error
|
||||
try:
|
||||
current_reservation = staged.directory.read_file(
|
||||
staged.directory.name_for(rollback_path),
|
||||
f"{staged.change.label} rollback reservation",
|
||||
0,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise OSError(
|
||||
f"{staged.change.label} existing rollback reservation changed: "
|
||||
f"{rollback_path}"
|
||||
) from error
|
||||
if current_reservation != reservation:
|
||||
raise OSError(
|
||||
f"{staged.change.label} existing rollback reservation changed: "
|
||||
f"{rollback_path}"
|
||||
)
|
||||
copy_into_reserved(
|
||||
staged.directory,
|
||||
staged.change.file_path,
|
||||
rollback_path,
|
||||
(original.device, original.inode),
|
||||
(reservation.device, reservation.inode),
|
||||
0o600,
|
||||
staged.change.label,
|
||||
)
|
||||
assert_opaque_unchanged(staged.change, staged.directory, "rollback preparation")
|
||||
prepared = _rollback_snapshot(staged)
|
||||
if (
|
||||
prepared is None
|
||||
or prepared.device != reservation.device
|
||||
or prepared.inode != reservation.inode
|
||||
or prepared.size != original.size
|
||||
or prepared.sha256 != original.sha256
|
||||
or (os.name != "nt" and prepared.mode != 0o600)
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed while preparing: {rollback_path}"
|
||||
)
|
||||
staged.rollback_snapshot = prepared
|
||||
staged.rollback_reservation = None
|
||||
|
||||
|
||||
def _secure_rollback(staged: StagedOpaqueRemoval) -> OpaqueFileSnapshot:
|
||||
captured = staged.rollback_snapshot
|
||||
if staged.rollback_path is None or captured is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
name = staged.directory.name_for(staged.rollback_path)
|
||||
flags = (os.O_RDWR if os.name == "nt" else os.O_RDONLY) | getattr(
|
||||
os, "O_BINARY", 0
|
||||
)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = staged.directory.open_file(name, flags)
|
||||
metadata = os.fstat(descriptor)
|
||||
_require_regular(metadata, staged.rollback_path, staged.change.label, False)
|
||||
if (metadata.st_dev, metadata.st_ino) != (
|
||||
captured.device,
|
||||
captured.inode,
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed before securing: "
|
||||
f"{staged.rollback_path}"
|
||||
)
|
||||
staged.directory.chmod_descriptor(descriptor, 0o600)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
secured = _rollback_snapshot(staged)
|
||||
if not _same_captured(secured, captured, include_mode=False):
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed while securing: "
|
||||
f"{staged.rollback_path}"
|
||||
)
|
||||
if os.name != "nt" and secured is not None and secured.mode != 0o600:
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback has an unexpected mode: "
|
||||
f"{staged.rollback_path}"
|
||||
)
|
||||
if secured is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
staged.rollback_snapshot = secured
|
||||
return secured
|
||||
|
||||
|
||||
def _preserve_rollback(staged: StagedOpaqueRemoval, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
rollback_path = staged.rollback_path
|
||||
staged.rollback_path = None
|
||||
return OSError(f"{error}; original content preserved at {rollback_path}")
|
||||
|
||||
|
||||
def _restore_snapshot(
|
||||
staged: StagedOpaqueRemoval,
|
||||
captured: OpaqueFileSnapshot,
|
||||
restore_mode: int,
|
||||
) -> None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
if _target_snapshot(staged) is not None:
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} changed before restoration: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
target_name = staged.directory.name_for(staged.change.file_path)
|
||||
rollback_path = staged.rollback_path
|
||||
rollback_file = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
rollback_path,
|
||||
f"{staged.change.label} rollback",
|
||||
)
|
||||
current = stream_opaque_snapshot(
|
||||
rollback_file.container,
|
||||
rollback_file.entry_path,
|
||||
f"{staged.change.label} rollback",
|
||||
require_owner_only=False,
|
||||
)
|
||||
if not _same_captured(current, captured):
|
||||
try:
|
||||
rollback_file.restore(rollback_path)
|
||||
except Exception as error:
|
||||
preserved = (
|
||||
rollback_file.preserve()
|
||||
if rollback_file.active
|
||||
else rollback_path
|
||||
)
|
||||
staged.rollback_path = None
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed before restoration: "
|
||||
f"{rollback_path}; concurrent data preserved at {preserved}: {error}"
|
||||
) from error
|
||||
staged.rollback_path = None
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed before restoration: "
|
||||
f"{rollback_path}"
|
||||
)
|
||||
try:
|
||||
rollback_file.restore(staged.change.file_path)
|
||||
except Exception as error:
|
||||
preserved = (
|
||||
rollback_file.preserve()
|
||||
if rollback_file.active
|
||||
else rollback_path
|
||||
)
|
||||
staged.rollback_path = None
|
||||
raise OSError(
|
||||
f"{error}; original content preserved at {preserved}"
|
||||
) from error
|
||||
staged.rollback_path = None
|
||||
restored = _target_snapshot(staged)
|
||||
if not _same_captured(restored, captured):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} changed while restoring: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
if restored is None:
|
||||
raise OSError(f"Missing restored data for {staged.change.label}")
|
||||
staged.directory.chmod_file(
|
||||
target_name,
|
||||
restore_mode,
|
||||
(restored.device, restored.inode),
|
||||
)
|
||||
restored = _target_snapshot(staged)
|
||||
if (
|
||||
not _same_captured(restored, captured, include_mode=False)
|
||||
or restored is None
|
||||
or restored.mode != restore_mode
|
||||
):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} metadata restoration failed: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
staged.rollback_snapshot = None
|
||||
staged.committed = False
|
||||
|
||||
|
||||
def commit_opaque_removal(staged: StagedOpaqueRemoval) -> None:
|
||||
original = staged.change.original
|
||||
if original is None:
|
||||
assert_opaque_unchanged(staged.change, staged.directory, "transaction")
|
||||
return
|
||||
assert_opaque_unchanged(staged.change, staged.directory, "transaction")
|
||||
if staged.rollback_path is None or staged.rollback_snapshot is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
captured_file = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
)
|
||||
captured = stream_opaque_snapshot(
|
||||
captured_file.container,
|
||||
captured_file.entry_path,
|
||||
staged.change.label,
|
||||
require_owner_only=False,
|
||||
)
|
||||
if captured is None:
|
||||
preserved = captured_file.preserve()
|
||||
raise OSError(
|
||||
f"Missing captured original for {staged.change.label}; "
|
||||
f"transaction data preserved at {preserved}"
|
||||
)
|
||||
if not _same_captured(captured, original):
|
||||
boundary_error = OSError(
|
||||
f"{staged.change.label} changed at commit 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
|
||||
)
|
||||
raise OSError(
|
||||
f"{boundary_error}; concurrent data preserved at {preserved}: "
|
||||
f"{restore_error}"
|
||||
) from boundary_error
|
||||
raise boundary_error
|
||||
captured_file.discard()
|
||||
staged.directory.sync()
|
||||
staged.committed = True
|
||||
if _target_snapshot(staged) is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} reappeared after capture: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def assert_staged_opaque(staged: StagedOpaqueRemoval, operation: str) -> None:
|
||||
if staged.change.original is None:
|
||||
assert_opaque_unchanged(staged.change, staged.directory, operation)
|
||||
return
|
||||
if not staged.committed:
|
||||
assert_opaque_unchanged(staged.change, staged.directory, operation)
|
||||
return
|
||||
if _target_snapshot(staged) is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during {operation}: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
current = _rollback_snapshot(staged)
|
||||
if current != staged.rollback_snapshot:
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed during {operation}: "
|
||||
f"{staged.rollback_path}"
|
||||
)
|
||||
|
||||
|
||||
def rollback_opaque_removal(staged: StagedOpaqueRemoval) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
original = staged.change.original
|
||||
if original is None or staged.rollback_snapshot is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
_restore_snapshot(staged, staged.rollback_snapshot, original.mode)
|
||||
restored = _target_snapshot(staged)
|
||||
if (
|
||||
not _same_captured(restored, original, include_mode=False)
|
||||
or restored is None
|
||||
or restored.mode != original.mode
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed after restoration: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OpaqueRemovalChange",
|
||||
"StagedOpaqueRemoval",
|
||||
"assert_opaque_unchanged",
|
||||
"assert_staged_opaque",
|
||||
"commit_opaque_removal",
|
||||
"prepare_opaque_removal",
|
||||
"prepare_opaque_rollback",
|
||||
"rollback_opaque_removal",
|
||||
"stage_opaque_removal",
|
||||
"stream_opaque_snapshot",
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Bounded-memory snapshots for identity-sensitive opaque files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
|
||||
from ._managed_files import portable_file_mode
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
_HASH_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpaqueFileSnapshot:
|
||||
"""Content and physical identity without retaining file bytes in memory.
|
||||
|
||||
Identity is content-based (device, inode, mode, size, SHA-256). File
|
||||
timestamps are excluded because Windows exposes them with delayed
|
||||
visibility across handles, which makes them unsound identity evidence.
|
||||
"""
|
||||
|
||||
device: int
|
||||
inode: int
|
||||
mode: int
|
||||
size: int
|
||||
sha256: str
|
||||
|
||||
|
||||
def _metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
portable_file_mode(metadata),
|
||||
metadata.st_size,
|
||||
)
|
||||
|
||||
|
||||
def _same_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||
return _metadata(first) == _metadata(second)
|
||||
|
||||
|
||||
def _require_regular(
|
||||
metadata: os.stat_result,
|
||||
file_path: str,
|
||||
label: str,
|
||||
require_owner_only: bool,
|
||||
) -> None:
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
if os.name != "nt" and require_owner_only and metadata.st_mode & 0o077:
|
||||
raise PermissionError(
|
||||
f"{label} must be accessible only by its owner: {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def stream_opaque_snapshot(
|
||||
directory: PinnedDirectory,
|
||||
file_path: str,
|
||||
label: str,
|
||||
*,
|
||||
require_owner_only: bool = True,
|
||||
) -> Optional[OpaqueFileSnapshot]:
|
||||
"""Hash one no-follow regular file in bounded chunks through a pinned parent."""
|
||||
name = directory.name_for(file_path)
|
||||
try:
|
||||
before = directory.stat_file(name)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {file_path}: {error}") from error
|
||||
_require_regular(before, file_path, label, require_owner_only)
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = directory.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
_require_regular(opened, file_path, label, require_owner_only)
|
||||
if not _same_metadata(opened, before):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
while True:
|
||||
chunk = os.read(descriptor, _HASH_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
_require_regular(finished, file_path, label, require_owner_only)
|
||||
if not _same_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {file_path}: {error}") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
try:
|
||||
visible = directory.stat_file(name)
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"{label} changed while hashing: {file_path}") from error
|
||||
_require_regular(visible, file_path, label, require_owner_only)
|
||||
if not _same_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return OpaqueFileSnapshot(
|
||||
finished.st_dev,
|
||||
finished.st_ino,
|
||||
stat.S_IMODE(finished.st_mode),
|
||||
finished.st_size,
|
||||
digest.hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OpaqueFileSnapshot", "stream_opaque_snapshot"]
|
||||
@@ -0,0 +1,501 @@
|
||||
"""Pinned physical directory handles for transactional file operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
assert_content_stable,
|
||||
read_physical_directory,
|
||||
)
|
||||
|
||||
|
||||
_FILE_ATTRIBUTE_DIRECTORY = 0x00000010
|
||||
_FILE_ATTRIBUTE_NORMAL = 0x00000080
|
||||
_FILE_ATTRIBUTE_READONLY = 0x00000001
|
||||
_FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400
|
||||
|
||||
|
||||
def _close_windows_handle(handle: int) -> None:
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
close_handle = kernel32.CloseHandle
|
||||
close_handle.argtypes = [wintypes.HANDLE]
|
||||
close_handle.restype = wintypes.BOOL
|
||||
if not close_handle(handle):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Close Windows handle: {ctypes.WinError(error)}")
|
||||
|
||||
|
||||
def _open_windows_directory(
|
||||
directory: str, label: str, delete_access: bool = False
|
||||
) -> Tuple[int, DirectorySnapshot]:
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
import msvcrt as msvcrt_module
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
msvcrt: Any = msvcrt_module
|
||||
|
||||
class ByHandleFileInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("file_attributes", wintypes.DWORD),
|
||||
("creation_time", wintypes.FILETIME),
|
||||
("last_access_time", wintypes.FILETIME),
|
||||
("last_write_time", wintypes.FILETIME),
|
||||
("volume_serial_number", wintypes.DWORD),
|
||||
("file_size_high", wintypes.DWORD),
|
||||
("file_size_low", wintypes.DWORD),
|
||||
("number_of_links", wintypes.DWORD),
|
||||
("file_index_high", wintypes.DWORD),
|
||||
("file_index_low", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
create_file = kernel32.CreateFileW
|
||||
create_file.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.HANDLE,
|
||||
]
|
||||
create_file.restype = wintypes.HANDLE
|
||||
get_information = kernel32.GetFileInformationByHandle
|
||||
get_information.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(ByHandleFileInformation),
|
||||
]
|
||||
get_information.restype = wintypes.BOOL
|
||||
|
||||
file_list_directory = 0x00000001
|
||||
file_read_attributes = 0x00000080
|
||||
delete = 0x00010000
|
||||
file_share_read = 0x00000001
|
||||
file_share_write = 0x00000002
|
||||
open_existing = 3
|
||||
file_flag_open_reparse_point = 0x00200000
|
||||
file_flag_backup_semantics = 0x02000000
|
||||
invalid_handle = ctypes.c_void_p(-1).value
|
||||
handle = create_file(
|
||||
directory,
|
||||
file_list_directory
|
||||
| file_read_attributes
|
||||
| (delete if delete_access else 0),
|
||||
file_share_read | file_share_write,
|
||||
None,
|
||||
open_existing,
|
||||
file_flag_open_reparse_point | file_flag_backup_semantics,
|
||||
None,
|
||||
)
|
||||
if handle == invalid_handle:
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Open {label} at {directory}: {ctypes.WinError(error)}")
|
||||
handle_value = int(handle)
|
||||
descriptor = -1
|
||||
try:
|
||||
information = ByHandleFileInformation()
|
||||
if not get_information(handle_value, ctypes.byref(information)):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(
|
||||
f"Inspect pinned {label} at {directory}: {ctypes.WinError(error)}"
|
||||
)
|
||||
attributes = information.file_attributes
|
||||
if not attributes & _FILE_ATTRIBUTE_DIRECTORY:
|
||||
raise OSError(f"{label} is not a physical directory: {directory}")
|
||||
if attributes & _FILE_ATTRIBUTE_REPARSE_POINT:
|
||||
raise OSError(f"{label} is not a physical directory: {directory}")
|
||||
descriptor = msvcrt.open_osfhandle(
|
||||
handle_value,
|
||||
os.O_RDONLY | getattr(os, "O_NOINHERIT", 0),
|
||||
)
|
||||
handle_value = 0
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
raise OSError(f"{label} is not a physical directory: {directory}")
|
||||
return descriptor, DirectorySnapshot(metadata.st_dev, metadata.st_ino)
|
||||
except Exception:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
elif handle_value:
|
||||
_close_windows_handle(handle_value)
|
||||
raise
|
||||
|
||||
|
||||
def _chmod_windows_descriptor(descriptor: int, mode: int) -> None:
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
import msvcrt as msvcrt_module
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
msvcrt: Any = msvcrt_module
|
||||
|
||||
class FileBasicInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("creation_time", ctypes.c_longlong),
|
||||
("last_access_time", ctypes.c_longlong),
|
||||
("last_write_time", ctypes.c_longlong),
|
||||
("change_time", ctypes.c_longlong),
|
||||
("file_attributes", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
get_information = kernel32.GetFileInformationByHandleEx
|
||||
get_information.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
get_information.restype = wintypes.BOOL
|
||||
set_information = kernel32.SetFileInformationByHandle
|
||||
set_information.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_int,
|
||||
wintypes.LPVOID,
|
||||
wintypes.DWORD,
|
||||
]
|
||||
set_information.restype = wintypes.BOOL
|
||||
|
||||
handle = msvcrt.get_osfhandle(descriptor)
|
||||
if handle == -1:
|
||||
raise OSError("File descriptor has no Windows handle")
|
||||
current = FileBasicInformation()
|
||||
file_basic_info = 0
|
||||
if not get_information(
|
||||
handle,
|
||||
file_basic_info,
|
||||
ctypes.byref(current),
|
||||
ctypes.sizeof(current),
|
||||
):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Inspect open file mode: {ctypes.WinError(error)}")
|
||||
|
||||
attributes = int(current.file_attributes)
|
||||
if mode & stat.S_IWRITE:
|
||||
attributes &= ~_FILE_ATTRIBUTE_READONLY
|
||||
if attributes == 0:
|
||||
attributes = _FILE_ATTRIBUTE_NORMAL
|
||||
else:
|
||||
attributes &= ~_FILE_ATTRIBUTE_NORMAL
|
||||
attributes |= _FILE_ATTRIBUTE_READONLY
|
||||
if attributes == current.file_attributes:
|
||||
return
|
||||
update = FileBasicInformation(0, 0, 0, 0, attributes)
|
||||
if not set_information(
|
||||
handle,
|
||||
file_basic_info,
|
||||
ctypes.byref(update),
|
||||
ctypes.sizeof(update),
|
||||
):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Set open file mode: {ctypes.WinError(error)}")
|
||||
|
||||
|
||||
def chmod_open_file(descriptor: int, mode: int) -> None:
|
||||
"""Apply a mode to the already-open physical file."""
|
||||
if os.name == "nt" and not hasattr(os, "fchmod"):
|
||||
_chmod_windows_descriptor(descriptor, mode)
|
||||
return
|
||||
getattr(os, "fchmod")(descriptor, mode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PinnedDirectory:
|
||||
"""Keep one physical directory stable while path mutations run."""
|
||||
|
||||
path: str
|
||||
label: str
|
||||
snapshot: DirectorySnapshot
|
||||
descriptor: Optional[int] = None
|
||||
path_bound: bool = True
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
directory: str,
|
||||
label: str,
|
||||
expected: Optional[DirectorySnapshot] = None,
|
||||
delete_access: bool = False,
|
||||
) -> "PinnedDirectory":
|
||||
normalized = os.path.abspath(directory)
|
||||
before = read_physical_directory(normalized, label)
|
||||
if before is None:
|
||||
raise OSError(f"{label} is missing: {normalized}")
|
||||
if expected is not None and before != expected:
|
||||
raise OSError(f"{label} changed before pinning: {normalized}")
|
||||
|
||||
descriptor: Optional[int] = None
|
||||
try:
|
||||
if os.name == "nt":
|
||||
if delete_access:
|
||||
descriptor, opened = _open_windows_directory(
|
||||
normalized, label, True
|
||||
)
|
||||
else:
|
||||
descriptor, opened = _open_windows_directory(
|
||||
normalized, label
|
||||
)
|
||||
if opened != before:
|
||||
raise OSError(f"{label} changed while pinning: {normalized}")
|
||||
else:
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_CLOEXEC", 0)
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
descriptor = os.open(normalized, flags)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or (metadata.st_dev, metadata.st_ino)
|
||||
!= (before.device, before.inode)
|
||||
):
|
||||
raise OSError(f"{label} changed while pinning: {normalized}")
|
||||
after = read_physical_directory(normalized, label)
|
||||
if after != before:
|
||||
raise OSError(f"{label} changed while pinning: {normalized}")
|
||||
return cls(normalized, label, before, descriptor)
|
||||
except Exception:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
failures = []
|
||||
if self.descriptor is not None:
|
||||
try:
|
||||
os.close(self.descriptor)
|
||||
except OSError as error:
|
||||
failures.append(str(error))
|
||||
self.descriptor = None
|
||||
if failures:
|
||||
raise OSError(f"Close {self.label}: {'; '.join(failures)}")
|
||||
|
||||
def _require_open(self) -> None:
|
||||
if self.descriptor is None:
|
||||
raise OSError(f"Pinned {self.label} is closed")
|
||||
|
||||
def assert_path_stable(self, operation: str = "transaction") -> None:
|
||||
"""Require the visible path to still name the pinned directory."""
|
||||
self._require_open()
|
||||
assert self.descriptor is not None
|
||||
metadata = os.fstat(self.descriptor)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or (metadata.st_dev, metadata.st_ino)
|
||||
!= (self.snapshot.device, self.snapshot.inode)
|
||||
):
|
||||
raise OSError(f"Pinned {self.label} changed during {operation}")
|
||||
if not self.path_bound:
|
||||
return
|
||||
current = read_physical_directory(self.path, self.label)
|
||||
if current != self.snapshot:
|
||||
raise OSError(
|
||||
f"{self.label} changed during {operation}: {self.path}"
|
||||
)
|
||||
|
||||
def chmod_descriptor(self, descriptor: int, mode: int) -> None:
|
||||
"""Apply a mode while this directory remains pinned."""
|
||||
self._require_open()
|
||||
chmod_open_file(descriptor, mode)
|
||||
|
||||
def name_for(self, file_path: str) -> str:
|
||||
absolute = os.path.abspath(file_path)
|
||||
if os.path.normcase(os.path.dirname(absolute)) != os.path.normcase(self.path):
|
||||
raise ValueError(f"File is outside pinned {self.label}: {file_path}")
|
||||
name = os.path.basename(absolute)
|
||||
if not name or name in {".", ".."}:
|
||||
raise ValueError(f"Invalid file name in pinned {self.label}: {file_path}")
|
||||
return name
|
||||
|
||||
def path_for(self, name: str) -> str:
|
||||
if not name or name in {".", ".."} or os.path.basename(name) != name:
|
||||
raise ValueError(f"Invalid file name in pinned {self.label}: {name}")
|
||||
return os.path.join(self.path, name)
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
"""List direct entries while retaining the physical directory handle."""
|
||||
self._require_open()
|
||||
self.assert_path_stable("directory enumeration")
|
||||
try:
|
||||
if os.name == "nt":
|
||||
names = os.listdir(self.path)
|
||||
else:
|
||||
assert self.descriptor is not None
|
||||
names = os.listdir(self.descriptor)
|
||||
except OSError as error:
|
||||
raise OSError(f"List {self.label} at {self.path}: {error}") from error
|
||||
self.assert_path_stable("directory enumeration")
|
||||
return sorted(names)
|
||||
|
||||
def open_file(self, name: str, flags: int, mode: int = 0o600) -> int:
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
return os.open(self.path_for(name), flags, mode)
|
||||
assert self.descriptor is not None
|
||||
return os.open(name, flags, mode, dir_fd=self.descriptor)
|
||||
|
||||
def stat_file(self, name: str) -> os.stat_result:
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
return os.lstat(self.path_for(name))
|
||||
assert self.descriptor is not None
|
||||
return os.stat(name, dir_fd=self.descriptor, follow_symlinks=False)
|
||||
|
||||
def remove_file(self, name: str) -> None:
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
os.remove(self.path_for(name))
|
||||
return
|
||||
assert self.descriptor is not None
|
||||
os.unlink(name, dir_fd=self.descriptor)
|
||||
|
||||
def replace_file(self, source_name: str, destination_name: str) -> None:
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
os.replace(self.path_for(source_name), self.path_for(destination_name))
|
||||
return
|
||||
assert self.descriptor is not None
|
||||
os.replace(
|
||||
source_name,
|
||||
destination_name,
|
||||
src_dir_fd=self.descriptor,
|
||||
dst_dir_fd=self.descriptor,
|
||||
)
|
||||
|
||||
def sync(self) -> None:
|
||||
"""Persist directory entries where the host exposes directory fsync."""
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
# NTFS journals atomic rename metadata. Windows directory handles do
|
||||
# not support FlushFileBuffers, while every staged file is flushed.
|
||||
return
|
||||
if os.name != "posix":
|
||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||
assert self.descriptor is not None
|
||||
os.fsync(self.descriptor)
|
||||
|
||||
def link_file(self, source_name: str, destination_name: str) -> None:
|
||||
"""Create a no-clobber hard link within the pinned directory."""
|
||||
self._require_open()
|
||||
if os.name == "nt":
|
||||
os.link(self.path_for(source_name), self.path_for(destination_name))
|
||||
return
|
||||
assert self.descriptor is not None
|
||||
os.link(
|
||||
source_name,
|
||||
destination_name,
|
||||
src_dir_fd=self.descriptor,
|
||||
dst_dir_fd=self.descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
name: str,
|
||||
label: str,
|
||||
maximum_bytes: int,
|
||||
) -> Optional[FileSnapshot]:
|
||||
try:
|
||||
before = self.stat_file(name)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as error:
|
||||
raise OSError(f"Inspect {label} at {self.path_for(name)}: {error}") from error
|
||||
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {self.path_for(name)}")
|
||||
if before.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = self.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(
|
||||
before,
|
||||
opened,
|
||||
):
|
||||
raise OSError(f"{label} changed while opening: {self.path_for(name)}")
|
||||
if opened.st_size > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
with os.fdopen(descriptor, "rb") as file:
|
||||
descriptor = -1
|
||||
raw = file.read(maximum_bytes + 1)
|
||||
finished = os.fstat(file.fileno())
|
||||
if not _same_file_metadata(finished, opened):
|
||||
raise OSError(
|
||||
f"{label} changed while reading: {self.path_for(name)}"
|
||||
)
|
||||
assert_content_stable(
|
||||
lambda: self.open_file(name, flags),
|
||||
self.path_for(name),
|
||||
label,
|
||||
opened,
|
||||
raw,
|
||||
maximum_bytes,
|
||||
)
|
||||
except OSError as error:
|
||||
raise OSError(f"Read {label} at {self.path_for(name)}: {error}") from error
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
if len(raw) > maximum_bytes:
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {self.path_for(name)}"
|
||||
)
|
||||
try:
|
||||
contents = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError(
|
||||
f"{label} at {self.path_for(name)} must contain UTF-8 text"
|
||||
) from error
|
||||
return FileSnapshot(
|
||||
contents,
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
stat.S_IMODE(opened.st_mode),
|
||||
)
|
||||
|
||||
def chmod_file(
|
||||
self,
|
||||
name: str,
|
||||
mode: int,
|
||||
expected_identity: Tuple[int, int],
|
||||
) -> None:
|
||||
flags = os.O_RDWR | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = self.open_file(name, flags)
|
||||
metadata = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or (metadata.st_dev, metadata.st_ino) != expected_identity
|
||||
):
|
||||
raise OSError(f"File changed before chmod: {self.path_for(name)}")
|
||||
chmod_open_file(descriptor, mode)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Identity-bound private artifacts for transactional file boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import stat
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import DirectorySnapshot, FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
|
||||
"""Remove one empty child through the platform's native *at semantics."""
|
||||
if os.rmdir not in os.supports_dir_fd:
|
||||
raise OSError("Host does not support identity-bound directory removal")
|
||||
os.rmdir(name, dir_fd=parent_descriptor)
|
||||
|
||||
|
||||
def reserve_private_path(
|
||||
directory: PinnedDirectory,
|
||||
file_path: str,
|
||||
label: str,
|
||||
) -> FileSnapshot:
|
||||
"""Reserve one unpredictable name with exclusive physical-file creation."""
|
||||
name = directory.name_for(file_path)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = directory.open_file(name, flags, 0o600)
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise OSError(f"{label} reservation is not a physical file: {file_path}")
|
||||
directory.chmod_descriptor(descriptor, 0o600)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
directory.sync()
|
||||
snapshot = directory.read_file(name, f"{label} reservation", 0)
|
||||
if snapshot is None or snapshot.contents:
|
||||
raise OSError(f"{label} reservation changed after creation: {file_path}")
|
||||
return snapshot
|
||||
|
||||
|
||||
def _open_container(
|
||||
parent: PinnedDirectory,
|
||||
name: str,
|
||||
path: str,
|
||||
label: str,
|
||||
expected: DirectorySnapshot,
|
||||
) -> PinnedDirectory:
|
||||
if os.name == "nt":
|
||||
return PinnedDirectory.open(path, label, expected)
|
||||
if parent.descriptor is None:
|
||||
raise OSError(f"Pinned {parent.label} is closed")
|
||||
descriptor = -1
|
||||
try:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(name, flags, dir_fd=parent.descriptor)
|
||||
metadata = os.fstat(descriptor)
|
||||
after = parent.stat_file(name)
|
||||
if (
|
||||
not stat.S_ISDIR(metadata.st_mode)
|
||||
or DirectorySnapshot(metadata.st_dev, metadata.st_ino) != expected
|
||||
or (after.st_dev, after.st_ino) != (expected.device, expected.inode)
|
||||
):
|
||||
raise OSError(f"Private {label} changed while pinning: {path}")
|
||||
pinned = PinnedDirectory(path, label, expected, descriptor, False)
|
||||
descriptor = -1
|
||||
return pinned
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _reported_entry_path(container: PinnedDirectory) -> str:
|
||||
if os.name == "nt" or container.path_bound or container.descriptor is None:
|
||||
return container.path_for("entry")
|
||||
for descriptor_path in (
|
||||
f"/proc/self/fd/{container.descriptor}",
|
||||
f"/dev/fd/{container.descriptor}",
|
||||
):
|
||||
resolved = os.path.realpath(descriptor_path)
|
||||
if os.path.isabs(resolved) and resolved != descriptor_path:
|
||||
return os.path.join(resolved, "entry")
|
||||
return container.path_for("entry")
|
||||
|
||||
|
||||
def _create_container(
|
||||
parent: PinnedDirectory,
|
||||
basename: str,
|
||||
label: str,
|
||||
) -> tuple[str, PinnedDirectory]:
|
||||
name = f".{basename}.{uuid4().hex}.elydora-quarantine"
|
||||
path = parent.path_for(name)
|
||||
try:
|
||||
if os.name == "nt":
|
||||
os.mkdir(path, 0o700)
|
||||
else:
|
||||
if parent.descriptor is None:
|
||||
raise OSError(f"Pinned {parent.label} is closed")
|
||||
os.mkdir(name, 0o700, dir_fd=parent.descriptor)
|
||||
except OSError as error:
|
||||
raise OSError(f"Create private {label} at {path}: {error}") from error
|
||||
metadata = parent.stat_file(name)
|
||||
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"Private {label} is not a physical directory: {path}")
|
||||
if os.name != "nt" and stat.S_IMODE(metadata.st_mode) != 0o700:
|
||||
raise OSError(f"Private {label} has an unexpected mode: {path}")
|
||||
snapshot = DirectorySnapshot(metadata.st_dev, metadata.st_ino)
|
||||
return path, _open_container(parent, name, path, label, snapshot)
|
||||
|
||||
|
||||
def _move_into_container(
|
||||
parent: PinnedDirectory,
|
||||
source_name: str,
|
||||
container: PinnedDirectory,
|
||||
) -> None:
|
||||
if os.name == "nt":
|
||||
os.rename(parent.path_for(source_name), container.path_for("entry"))
|
||||
return
|
||||
if parent.descriptor is None or container.descriptor is None:
|
||||
raise OSError("Pinned private artifact directory is closed")
|
||||
os.rename(
|
||||
source_name,
|
||||
"entry",
|
||||
src_dir_fd=parent.descriptor,
|
||||
dst_dir_fd=container.descriptor,
|
||||
)
|
||||
|
||||
|
||||
def _link_from_container(
|
||||
container: PinnedDirectory,
|
||||
parent: PinnedDirectory,
|
||||
destination_name: str,
|
||||
) -> None:
|
||||
if os.name == "nt":
|
||||
os.link(container.path_for("entry"), parent.path_for(destination_name))
|
||||
return
|
||||
if container.descriptor is None or parent.descriptor is None:
|
||||
raise OSError("Pinned private artifact directory is closed")
|
||||
os.link(
|
||||
"entry",
|
||||
destination_name,
|
||||
src_dir_fd=container.descriptor,
|
||||
dst_dir_fd=parent.descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuarantinedFile:
|
||||
parent: PinnedDirectory
|
||||
original_path: str
|
||||
label: str
|
||||
container_path: str
|
||||
container: PinnedDirectory
|
||||
active: bool = True
|
||||
|
||||
@classmethod
|
||||
def capture(
|
||||
cls,
|
||||
parent: PinnedDirectory,
|
||||
file_path: str,
|
||||
label: str,
|
||||
*,
|
||||
require_parent_path: bool = True,
|
||||
) -> "QuarantinedFile":
|
||||
if require_parent_path:
|
||||
parent.assert_path_stable("transaction")
|
||||
name = parent.name_for(file_path)
|
||||
container_path, container = _create_container(parent, name, label)
|
||||
try:
|
||||
_move_into_container(parent, name, container)
|
||||
except Exception:
|
||||
container.close()
|
||||
try:
|
||||
if os.name == "nt":
|
||||
os.rmdir(container_path)
|
||||
else:
|
||||
assert parent.descriptor is not None
|
||||
_remove_posix_private_directory(
|
||||
parent.descriptor,
|
||||
parent.name_for(container_path),
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
captured = cls(parent, file_path, label, container_path, container)
|
||||
if require_parent_path:
|
||||
try:
|
||||
parent.assert_path_stable("transaction")
|
||||
except Exception as boundary_error:
|
||||
try:
|
||||
captured.restore()
|
||||
except Exception as restore_error:
|
||||
preserved = (
|
||||
captured.preserve()
|
||||
if captured.active
|
||||
else file_path
|
||||
)
|
||||
raise OSError(
|
||||
f"{boundary_error}; {label} preserved at {preserved}; "
|
||||
f"restoration failed: {restore_error}"
|
||||
) from boundary_error
|
||||
raise
|
||||
return captured
|
||||
|
||||
@property
|
||||
def entry_path(self) -> str:
|
||||
return _reported_entry_path(self.container)
|
||||
|
||||
def text_snapshot(self, maximum_bytes: int) -> Optional[FileSnapshot]:
|
||||
return self.container.read_file("entry", self.label, maximum_bytes)
|
||||
|
||||
def link(self, destination_path: Optional[str] = None) -> None:
|
||||
"""Install a no-clobber link while retaining the private identity."""
|
||||
if not self.active:
|
||||
raise OSError(f"Private {self.label} capture is closed")
|
||||
target = self.original_path if destination_path is None else destination_path
|
||||
destination_name = self.parent.name_for(target)
|
||||
_link_from_container(self.container, self.parent, destination_name)
|
||||
linked = self.parent.stat_file(destination_name)
|
||||
captured = self.container.stat_file("entry")
|
||||
if (linked.st_dev, linked.st_ino) != (captured.st_dev, captured.st_ino):
|
||||
raise OSError(f"{self.label} changed while restoring: {target}")
|
||||
|
||||
def restore(self, destination_path: Optional[str] = None) -> None:
|
||||
self.link(destination_path)
|
||||
self.discard()
|
||||
|
||||
def discard(self) -> None:
|
||||
if not self.active:
|
||||
return
|
||||
self.container.remove_file("entry")
|
||||
self.active = False
|
||||
self._close_empty_container()
|
||||
|
||||
def preserve(self) -> str:
|
||||
entry_path = self.entry_path
|
||||
if self.container.descriptor is not None:
|
||||
self.container.close()
|
||||
self.active = False
|
||||
return entry_path
|
||||
|
||||
def _close_empty_container(self) -> None:
|
||||
if self.container.list_names():
|
||||
raise OSError(
|
||||
f"Private {self.label} is not empty: {self.container_path}"
|
||||
)
|
||||
snapshot = self.container.snapshot
|
||||
self.container.close()
|
||||
name = self.parent.name_for(self.container_path)
|
||||
current = self.parent.stat_file(name)
|
||||
if (
|
||||
not stat.S_ISDIR(current.st_mode)
|
||||
or (current.st_dev, current.st_ino)
|
||||
!= (snapshot.device, snapshot.inode)
|
||||
):
|
||||
raise OSError(
|
||||
f"Private {self.label} changed before cleanup: "
|
||||
f"{self.container_path}"
|
||||
)
|
||||
if os.name == "nt":
|
||||
os.rmdir(self.container_path)
|
||||
else:
|
||||
if self.parent.descriptor is None:
|
||||
raise OSError(f"Pinned {self.parent.label} is closed")
|
||||
_remove_posix_private_directory(self.parent.descriptor, name)
|
||||
|
||||
|
||||
__all__ = ["QuarantinedFile", "reserve_private_path"]
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Identity-bound removal of one Elydora agent runtime directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
read_physical_directory,
|
||||
)
|
||||
from ._directory_removal import remove_pinned_empty_directory
|
||||
from ._opaque_removal import OpaqueRemovalChange, prepare_opaque_removal
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from ._transaction import write_changes
|
||||
from ._transaction_types import (
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
)
|
||||
|
||||
|
||||
RuntimeConfigValidator = Callable[[JsonObject, str], None]
|
||||
RuntimeFileContract = Dict[str, Tuple[str, int]]
|
||||
|
||||
STANDARD_RUNTIME_FILES: RuntimeFileContract = {
|
||||
"chain-state.json": ("Elydora chain state", MAX_SOURCE_BYTES),
|
||||
"status-cache.json": ("Elydora status cache", MAX_SOURCE_BYTES),
|
||||
"error.log": ("Elydora error log", MAX_SOURCE_BYTES),
|
||||
"guard.py": ("Elydora guard runtime", MAX_SOURCE_BYTES),
|
||||
"hook.py": ("Elydora audit runtime", MAX_SOURCE_BYTES),
|
||||
"private.key": ("Elydora private key", MAX_SECRET_BYTES),
|
||||
"config.json": ("Elydora runtime config", MAX_CONFIG_BYTES),
|
||||
}
|
||||
|
||||
KIRO_IDE_RUNTIME_FILES: RuntimeFileContract = dict(STANDARD_RUNTIME_FILES)
|
||||
_OPAQUE_RUNTIME_FILE = "error.log"
|
||||
|
||||
|
||||
def _managed_runtime_files(agent_name: str) -> RuntimeFileContract:
|
||||
files = dict(STANDARD_RUNTIME_FILES)
|
||||
if agent_name == "augment":
|
||||
extension = ".cmd" if os.name == "nt" else ".sh"
|
||||
files.update(
|
||||
{
|
||||
f"augment-guard{extension}": (
|
||||
"Elydora Augment guard wrapper",
|
||||
MAX_SOURCE_BYTES,
|
||||
),
|
||||
f"augment-hook{extension}": (
|
||||
"Elydora Augment audit wrapper",
|
||||
MAX_SOURCE_BYTES,
|
||||
),
|
||||
}
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedRuntimeRemoval:
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
runtime_root: str
|
||||
agent_directory: str
|
||||
root_snapshot: DirectorySnapshot
|
||||
agent_snapshot: DirectorySnapshot
|
||||
changes: List[FileChange]
|
||||
opaque_removals: List[OpaqueRemovalChange]
|
||||
file_preconditions: List[FilePrecondition]
|
||||
directory_preconditions: List[DirectoryPrecondition]
|
||||
|
||||
|
||||
def _close_pinned(*directories: Optional[PinnedDirectory]) -> None:
|
||||
failures = []
|
||||
for directory in reversed(directories):
|
||||
if directory is None:
|
||||
continue
|
||||
try:
|
||||
directory.close()
|
||||
except OSError as error:
|
||||
failures.append(str(error))
|
||||
if failures:
|
||||
raise OSError("; ".join(failures))
|
||||
|
||||
|
||||
def _validate_identity(
|
||||
config: JsonObject,
|
||||
config_path: str,
|
||||
agent_id: str,
|
||||
agent_name: str,
|
||||
) -> None:
|
||||
if config.get("agent_id") != agent_id or config.get("agent_name") != agent_name:
|
||||
raise ValueError(
|
||||
f"Elydora runtime identity does not match {agent_name}: {config_path}"
|
||||
)
|
||||
|
||||
|
||||
def _removal_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
snapshot: FileSnapshot,
|
||||
maximum_bytes: int,
|
||||
) -> FileChange:
|
||||
return FileChange(
|
||||
file_path,
|
||||
label,
|
||||
snapshot.contents,
|
||||
None,
|
||||
snapshot.mode,
|
||||
snapshot.mode,
|
||||
snapshot.device,
|
||||
snapshot.inode,
|
||||
maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def prepare_runtime_removal(
|
||||
agent_id: str,
|
||||
agent_name: str,
|
||||
*,
|
||||
config_validator: Optional[RuntimeConfigValidator] = None,
|
||||
managed_files: Optional[RuntimeFileContract] = None,
|
||||
) -> Optional[PreparedRuntimeRemoval]:
|
||||
"""Inspect an exact managed runtime without mutating it."""
|
||||
root_path = runtime_root()
|
||||
agent_path = resolve_agent_directory(root_path, agent_id)
|
||||
runtime_files = (
|
||||
_managed_runtime_files(agent_name)
|
||||
if managed_files is None
|
||||
else managed_files
|
||||
)
|
||||
root_snapshot = read_physical_directory(root_path, "Elydora runtime directory")
|
||||
if root_snapshot is None:
|
||||
return None
|
||||
agent_snapshot = read_physical_directory(
|
||||
agent_path, "Elydora agent runtime directory"
|
||||
)
|
||||
if agent_snapshot is None:
|
||||
return None
|
||||
|
||||
root = None
|
||||
agent = None
|
||||
try:
|
||||
root = PinnedDirectory.open(
|
||||
root_path, "Elydora runtime directory", root_snapshot
|
||||
)
|
||||
agent = PinnedDirectory.open(
|
||||
agent_path, "Elydora agent runtime directory", agent_snapshot
|
||||
)
|
||||
names = agent.list_names()
|
||||
unknown = [name for name in names if name not in runtime_files]
|
||||
if unknown:
|
||||
raise OSError(
|
||||
"Elydora agent runtime contains unmanaged entries: "
|
||||
f"{agent_path}: {', '.join(unknown)}"
|
||||
)
|
||||
|
||||
opaque_path = os.path.join(agent_path, _OPAQUE_RUNTIME_FILE)
|
||||
opaque_removals = [
|
||||
prepare_opaque_removal(
|
||||
agent,
|
||||
opaque_path,
|
||||
runtime_files[_OPAQUE_RUNTIME_FILE][0],
|
||||
)
|
||||
]
|
||||
snapshots: Dict[str, Optional[FileSnapshot]] = {}
|
||||
for name, (label, maximum_bytes) in runtime_files.items():
|
||||
if name == _OPAQUE_RUNTIME_FILE:
|
||||
continue
|
||||
snapshots[name] = agent.read_file(name, label, maximum_bytes)
|
||||
|
||||
config_path = os.path.join(agent_path, "config.json")
|
||||
config_snapshot = snapshots.get("config.json")
|
||||
present = [name for name, snapshot in snapshots.items() if snapshot is not None]
|
||||
if opaque_removals[0].original is not None:
|
||||
present.append(_OPAQUE_RUNTIME_FILE)
|
||||
if config_snapshot is None:
|
||||
if present:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without "
|
||||
f"config.json: {agent_path}"
|
||||
)
|
||||
else:
|
||||
config = parse_json_object(
|
||||
config_snapshot.contents,
|
||||
f"Elydora runtime config at {config_path}",
|
||||
)
|
||||
_validate_identity(config, config_path, agent_id, agent_name)
|
||||
if config_validator is not None:
|
||||
config_validator(config, config_path)
|
||||
|
||||
changes = []
|
||||
preconditions = []
|
||||
for name, (label, maximum_bytes) in runtime_files.items():
|
||||
if name == _OPAQUE_RUNTIME_FILE:
|
||||
continue
|
||||
file_path = os.path.join(agent_path, name)
|
||||
snapshot = snapshots[name]
|
||||
if snapshot is None:
|
||||
preconditions.append(
|
||||
FilePrecondition(file_path, label, None, maximum_bytes)
|
||||
)
|
||||
else:
|
||||
changes.append(
|
||||
_removal_change(file_path, label, snapshot, maximum_bytes)
|
||||
)
|
||||
|
||||
root.assert_path_stable("runtime removal preparation")
|
||||
agent.assert_path_stable("runtime removal preparation")
|
||||
return PreparedRuntimeRemoval(
|
||||
agent_id,
|
||||
agent_name,
|
||||
root_path,
|
||||
agent_path,
|
||||
root_snapshot,
|
||||
agent_snapshot,
|
||||
changes,
|
||||
opaque_removals,
|
||||
preconditions,
|
||||
[
|
||||
DirectoryPrecondition(
|
||||
root_path, "Elydora runtime directory", root_snapshot
|
||||
),
|
||||
DirectoryPrecondition(
|
||||
agent_path,
|
||||
"Elydora agent runtime directory",
|
||||
agent_snapshot,
|
||||
),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
_close_pinned(root, agent)
|
||||
|
||||
|
||||
def finalize_runtime_removal(plan: PreparedRuntimeRemoval) -> Optional[str]:
|
||||
"""Remove the exact empty agent directory retained by a completed transaction."""
|
||||
root = None
|
||||
agent = None
|
||||
try:
|
||||
root = PinnedDirectory.open(
|
||||
plan.runtime_root,
|
||||
"Elydora runtime directory",
|
||||
plan.root_snapshot,
|
||||
)
|
||||
agent = PinnedDirectory.open(
|
||||
plan.agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
plan.agent_snapshot,
|
||||
delete_access=True,
|
||||
)
|
||||
preserved = remove_pinned_empty_directory(root, agent)
|
||||
finally:
|
||||
_close_pinned(root, agent)
|
||||
return preserved
|
||||
|
||||
|
||||
def commit_runtime_removal(plan: PreparedRuntimeRemoval) -> None:
|
||||
"""Delete all proven managed files, then the exact empty agent directory."""
|
||||
write_changes(
|
||||
plan.changes,
|
||||
f"Remove {plan.agent_name} runtime",
|
||||
plan.file_preconditions,
|
||||
plan.directory_preconditions,
|
||||
plan.opaque_removals,
|
||||
)
|
||||
finalize_runtime_removal(plan)
|
||||
@@ -2,65 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Sequence, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
FileSnapshot,
|
||||
MAX_SOURCE_BYTES,
|
||||
ensure_physical_directory,
|
||||
physical_file_exists,
|
||||
read_physical_file,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[str]
|
||||
next_source: Optional[str]
|
||||
mode: int
|
||||
original_mode: Optional[int]
|
||||
original_device: Optional[int]
|
||||
original_inode: Optional[int]
|
||||
maximum_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilePrecondition:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[FileSnapshot]
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StagedChange:
|
||||
change: FileChange
|
||||
temporary_path: Optional[str]
|
||||
rollback_path: Optional[str]
|
||||
committed_snapshot: Optional[FileSnapshot] = None
|
||||
committed: bool = False
|
||||
from ._opaque_cleanup import cleanup_opaque_removal
|
||||
from ._opaque_removal import (
|
||||
OpaqueRemovalChange,
|
||||
StagedOpaqueRemoval,
|
||||
assert_staged_opaque,
|
||||
commit_opaque_removal,
|
||||
prepare_opaque_rollback,
|
||||
rollback_opaque_removal,
|
||||
stage_opaque_removal,
|
||||
)
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._transaction_commit import commit_change, prepare_change, rollback_change
|
||||
from ._transaction_journal import (
|
||||
FileFingerprint,
|
||||
JournalRecord,
|
||||
TransactionJournal,
|
||||
active_journal_path,
|
||||
clear_journal,
|
||||
write_journal,
|
||||
)
|
||||
from ._transaction_lock import serialized_transactions
|
||||
from ._transaction_restart import (
|
||||
recover_pending_locked,
|
||||
recover_pending_transactions as _recover_pending_transactions,
|
||||
)
|
||||
from ._transaction_staging import (
|
||||
assert_transaction_preconditions,
|
||||
cleanup_created_directories,
|
||||
cleanup_staged,
|
||||
close_directories,
|
||||
directory_for,
|
||||
pin_directories,
|
||||
reserve_change,
|
||||
same_snapshot,
|
||||
stage_change,
|
||||
)
|
||||
from ._transaction_types import (
|
||||
DirectoryMap,
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
StagedChange,
|
||||
)
|
||||
|
||||
|
||||
_EXPECTED_SNAPSHOT_UNSET = object()
|
||||
|
||||
|
||||
def _same_snapshot(
|
||||
current: Optional[FileSnapshot],
|
||||
expected: Optional[FileSnapshot],
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
def recover_pending_transactions() -> None:
|
||||
"""Recover a journal left by a terminated transaction process."""
|
||||
_recover_pending_transactions()
|
||||
|
||||
|
||||
def read_optional(
|
||||
@@ -80,24 +82,22 @@ def source_change(
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
expected_snapshot: object = _EXPECTED_SNAPSHOT_UNSET,
|
||||
enforce_mode: bool = False,
|
||||
) -> Optional[FileChange]:
|
||||
if original == next_source:
|
||||
return None
|
||||
if (
|
||||
next_source is not None
|
||||
and len(next_source.encode("utf-8")) > maximum_bytes
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} exceeds {maximum_bytes} bytes: {file_path}"
|
||||
)
|
||||
if next_source is not None and len(next_source.encode("utf-8")) > maximum_bytes:
|
||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
if current != original:
|
||||
raise OSError(f"{label} changed before staging: {file_path}")
|
||||
if expected_snapshot is not _EXPECTED_SNAPSHOT_UNSET:
|
||||
expected = cast(Optional[FileSnapshot], expected_snapshot)
|
||||
if not _same_snapshot(snapshot, expected):
|
||||
if not same_snapshot(snapshot, expected):
|
||||
raise OSError(f"{label} changed before staging: {file_path}")
|
||||
if original == next_source and (
|
||||
not enforce_mode or snapshot is None or snapshot.mode == mode
|
||||
):
|
||||
return None
|
||||
return FileChange(
|
||||
file_path,
|
||||
label,
|
||||
@@ -117,6 +117,7 @@ def file_change(
|
||||
next_source: Optional[str],
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
enforce_mode: bool = False,
|
||||
) -> Optional[FileChange]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
return source_change(
|
||||
@@ -126,275 +127,358 @@ def file_change(
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
|
||||
|
||||
def _remove_optional(file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
def _replace_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _cleanup_paths(paths: Sequence[Optional[str]]) -> List[str]:
|
||||
def _restore_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _capture_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _after_replace_physical(
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
del source_path, destination_path, directory
|
||||
|
||||
|
||||
def _after_journal_commit() -> None:
|
||||
"""Fault-injection boundary after the committed marker is durable."""
|
||||
|
||||
|
||||
def _commit(staged: StagedChange) -> None:
|
||||
commit_change(
|
||||
staged,
|
||||
_replace_physical,
|
||||
_capture_physical,
|
||||
_after_replace_physical,
|
||||
)
|
||||
|
||||
|
||||
def _rollback(staged: StagedChange) -> None:
|
||||
rollback_change(staged, _restore_physical, _capture_physical)
|
||||
|
||||
|
||||
def _recover(
|
||||
staged: Sequence[StagedChange],
|
||||
opaque_staged: Sequence[StagedOpaqueRemoval],
|
||||
directories: DirectoryMap,
|
||||
directory_preconditions: Sequence[DirectoryPrecondition],
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
for path in paths:
|
||||
for staged_item in reversed(staged):
|
||||
try:
|
||||
_remove_optional(path)
|
||||
_rollback(staged_item)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in reversed(opaque_staged):
|
||||
try:
|
||||
rollback_opaque_removal(opaque_item)
|
||||
except Exception as error:
|
||||
errors.append(str(error))
|
||||
for staged_item in staged:
|
||||
try:
|
||||
cleanup_staged(staged_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in opaque_staged:
|
||||
try:
|
||||
cleanup_opaque_removal(opaque_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
errors.extend(
|
||||
cleanup_created_directories(directory_preconditions, directories)
|
||||
)
|
||||
errors.extend(close_directories(directories))
|
||||
return errors
|
||||
|
||||
|
||||
def _write_staged(
|
||||
directory: str,
|
||||
basename: str,
|
||||
suffix: str,
|
||||
content: str,
|
||||
mode: int,
|
||||
def _finish_committed(
|
||||
staged: Sequence[StagedChange],
|
||||
opaque_staged: Sequence[StagedOpaqueRemoval],
|
||||
directories: DirectoryMap,
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
for staged_item in staged:
|
||||
try:
|
||||
cleanup_staged(staged_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
for opaque_item in opaque_staged:
|
||||
try:
|
||||
cleanup_opaque_removal(opaque_item)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
errors.extend(close_directories(directories))
|
||||
if errors:
|
||||
return errors
|
||||
try:
|
||||
clear_journal()
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
return errors
|
||||
|
||||
|
||||
def _text_journal_record(staged: StagedChange) -> JournalRecord:
|
||||
original = None
|
||||
if staged.change.original is not None:
|
||||
if (
|
||||
staged.change.original_device is None
|
||||
or staged.change.original_inode is None
|
||||
or staged.change.original_mode is None
|
||||
):
|
||||
raise OSError(f"Missing original identity for {staged.change.label}")
|
||||
original = FileFingerprint.from_text(
|
||||
FileSnapshot(
|
||||
staged.change.original,
|
||||
staged.change.original_device,
|
||||
staged.change.original_inode,
|
||||
staged.change.original_mode,
|
||||
)
|
||||
)
|
||||
next_value = (
|
||||
None
|
||||
if staged.temporary_snapshot is None
|
||||
else FileFingerprint.from_text(staged.temporary_snapshot)
|
||||
)
|
||||
temporary_identity = (
|
||||
None
|
||||
if staged.temporary_reservation is None
|
||||
else FileFingerprint.from_text(staged.temporary_reservation)
|
||||
)
|
||||
rollback = (
|
||||
None
|
||||
if staged.rollback_reservation is None
|
||||
else FileFingerprint.from_text(staged.rollback_reservation)
|
||||
)
|
||||
return JournalRecord(
|
||||
"text",
|
||||
staged.change.label,
|
||||
os.path.abspath(staged.change.file_path),
|
||||
None if staged.temporary_path is None else os.path.abspath(staged.temporary_path),
|
||||
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
||||
staged.directory.path,
|
||||
staged.directory.snapshot,
|
||||
original,
|
||||
next_value,
|
||||
temporary_identity,
|
||||
rollback,
|
||||
)
|
||||
|
||||
|
||||
def _opaque_journal_record(staged: StagedOpaqueRemoval) -> JournalRecord:
|
||||
original = (
|
||||
None
|
||||
if staged.change.original is None
|
||||
else FileFingerprint.from_opaque(staged.change.original)
|
||||
)
|
||||
rollback = (
|
||||
None
|
||||
if staged.rollback_reservation is None
|
||||
else FileFingerprint.from_text(staged.rollback_reservation)
|
||||
)
|
||||
return JournalRecord(
|
||||
"opaque",
|
||||
staged.change.label,
|
||||
os.path.abspath(staged.change.file_path),
|
||||
None,
|
||||
None if staged.rollback_path is None else os.path.abspath(staged.rollback_path),
|
||||
staged.directory.path,
|
||||
staged.directory.snapshot,
|
||||
original,
|
||||
None,
|
||||
None,
|
||||
rollback,
|
||||
)
|
||||
|
||||
|
||||
def _write_changes_locked(
|
||||
changes: Sequence[FileChange],
|
||||
label: str,
|
||||
) -> str:
|
||||
descriptor = -1
|
||||
temporary_path = ""
|
||||
try:
|
||||
descriptor, temporary_path = tempfile.mkstemp(
|
||||
prefix=f".{basename}.",
|
||||
suffix=suffix,
|
||||
dir=directory,
|
||||
text=True,
|
||||
)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as file:
|
||||
descriptor = -1
|
||||
file.write(content)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
os.chmod(temporary_path, mode)
|
||||
return temporary_path
|
||||
except Exception as error:
|
||||
failures = []
|
||||
if descriptor >= 0:
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError as close_error:
|
||||
failures.append(f"close failed: {close_error}")
|
||||
failures.extend(_cleanup_paths((temporary_path,)))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _assert_unchanged(change: FileChange) -> None:
|
||||
snapshot = read_physical_file(
|
||||
change.file_path,
|
||||
change.label,
|
||||
change.maximum_bytes,
|
||||
)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
identity_changed = snapshot is not None and (
|
||||
snapshot.device != change.original_device
|
||||
or snapshot.inode != change.original_inode
|
||||
)
|
||||
if current != change.original or identity_changed:
|
||||
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
||||
|
||||
|
||||
def _assert_preconditions(
|
||||
preconditions: Sequence[FilePrecondition],
|
||||
operation: str,
|
||||
preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
) -> None:
|
||||
for condition in preconditions:
|
||||
current = read_physical_file(
|
||||
condition.file_path,
|
||||
condition.label,
|
||||
condition.maximum_bytes,
|
||||
)
|
||||
if not _same_snapshot(current, condition.original):
|
||||
raise OSError(
|
||||
f"{condition.label} changed during {operation}: "
|
||||
f"{condition.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _stage(change: FileChange) -> _StagedChange:
|
||||
_assert_unchanged(change)
|
||||
directory = os.path.dirname(change.file_path)
|
||||
ensure_physical_directory(directory, f"{change.label} directory")
|
||||
basename = os.path.basename(change.file_path)
|
||||
temporary_path = None
|
||||
rollback_path = None
|
||||
filtered = [
|
||||
change
|
||||
for change in changes
|
||||
if change.original != change.next_source
|
||||
or change.original_mode != change.mode
|
||||
]
|
||||
directories: DirectoryMap = {}
|
||||
staged: List[StagedChange] = []
|
||||
opaque_staged: List[StagedOpaqueRemoval] = []
|
||||
journal: Optional[TransactionJournal] = None
|
||||
committed_marker_durable = False
|
||||
try:
|
||||
if change.next_source is not None:
|
||||
temporary_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".tmp",
|
||||
change.next_source,
|
||||
change.mode,
|
||||
change.label,
|
||||
)
|
||||
if change.original is not None and change.next_source is not None:
|
||||
rollback_path = _write_staged(
|
||||
directory,
|
||||
basename,
|
||||
".rollback",
|
||||
change.original,
|
||||
change.original_mode or change.mode,
|
||||
f"{change.label} rollback",
|
||||
)
|
||||
elif change.original is not None:
|
||||
rollback_path = os.path.join(
|
||||
directory,
|
||||
f".{basename}.{uuid4().hex}.rollback",
|
||||
)
|
||||
if os.path.lexists(rollback_path):
|
||||
raise FileExistsError(f"Rollback path already exists: {rollback_path}")
|
||||
return _StagedChange(change, temporary_path, rollback_path)
|
||||
except Exception as error:
|
||||
failures = _cleanup_paths((temporary_path, rollback_path))
|
||||
suffix = f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"Stage {change.label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _commit(staged: _StagedChange) -> None:
|
||||
_assert_unchanged(staged.change)
|
||||
if staged.change.next_source is None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
os.replace(staged.change.file_path, staged.rollback_path)
|
||||
else:
|
||||
if staged.temporary_path is None:
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
os.replace(staged.temporary_path, staged.change.file_path)
|
||||
staged.committed = True
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} remained after removal: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
if current is None or current.contents != staged.change.next_source:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
f"{staged.change.file_path}"
|
||||
assert_transaction_preconditions(preconditions, directory_preconditions, label)
|
||||
if not filtered and not opaque_removals:
|
||||
return
|
||||
paths = [
|
||||
os.path.normcase(os.path.abspath(change.file_path)) for change in filtered
|
||||
]
|
||||
paths.extend(
|
||||
os.path.normcase(os.path.abspath(change.file_path))
|
||||
for change in opaque_removals
|
||||
)
|
||||
staged.committed_snapshot = current
|
||||
|
||||
|
||||
def _assert_committed_unchanged(staged: _StagedChange) -> None:
|
||||
current = read_physical_file(
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
if staged.change.next_source is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
return
|
||||
committed = staged.committed_snapshot
|
||||
if current is None or committed is None or (
|
||||
current.contents != committed.contents
|
||||
or current.device != committed.device
|
||||
or current.inode != committed.inode
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: _StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
rollback_path = staged.rollback_path
|
||||
staged.rollback_path = None
|
||||
return OSError(
|
||||
f"{error}; original content preserved at {rollback_path}"
|
||||
)
|
||||
|
||||
|
||||
def _rollback(staged: _StagedChange) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
try:
|
||||
_assert_committed_unchanged(staged)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
try:
|
||||
if staged.change.next_source is None or staged.change.original is not None:
|
||||
if staged.rollback_path is None:
|
||||
raise OSError(
|
||||
f"Missing rollback data for {staged.change.label}"
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"{label} contains duplicate file targets")
|
||||
directories = pin_directories(
|
||||
filtered,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
[
|
||||
(
|
||||
os.path.dirname(change.file_path),
|
||||
f"{change.label} directory",
|
||||
True,
|
||||
)
|
||||
os.replace(staged.rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
else:
|
||||
_remove_optional(staged.change.file_path)
|
||||
for change in opaque_removals
|
||||
],
|
||||
)
|
||||
for opaque_change in opaque_removals:
|
||||
opaque_staged.append(
|
||||
stage_opaque_removal(
|
||||
opaque_change,
|
||||
directory_for(opaque_change.file_path, directories),
|
||||
label,
|
||||
)
|
||||
)
|
||||
for file_change_item in filtered:
|
||||
staged.append(
|
||||
reserve_change(
|
||||
file_change_item,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
label,
|
||||
directories,
|
||||
)
|
||||
)
|
||||
records = [
|
||||
*(_opaque_journal_record(item) for item in opaque_staged),
|
||||
*(_text_journal_record(item) for item in staged),
|
||||
]
|
||||
journal = TransactionJournal(uuid4().hex, label, "preparing", records)
|
||||
write_journal(journal)
|
||||
for staged_item in staged:
|
||||
stage_change(staged_item)
|
||||
records = [
|
||||
*(_opaque_journal_record(item) for item in opaque_staged),
|
||||
*(_text_journal_record(item) for item in staged),
|
||||
]
|
||||
journal = TransactionJournal(
|
||||
journal.transaction_id,
|
||||
journal.label,
|
||||
journal.state,
|
||||
records,
|
||||
)
|
||||
write_journal(journal)
|
||||
assert_transaction_preconditions(
|
||||
preconditions, directory_preconditions, label, directories
|
||||
)
|
||||
for opaque_item in opaque_staged:
|
||||
assert_staged_opaque(opaque_item, label)
|
||||
for opaque_item in opaque_staged:
|
||||
prepare_opaque_rollback(opaque_item)
|
||||
for staged_item in staged:
|
||||
prepare_change(staged_item, _capture_physical)
|
||||
journal = journal.with_state("committing")
|
||||
write_journal(journal)
|
||||
for opaque_item in opaque_staged:
|
||||
commit_opaque_removal(opaque_item)
|
||||
for staged_item in staged:
|
||||
_commit(staged_item)
|
||||
assert_transaction_preconditions(
|
||||
preconditions, directory_preconditions, label, directories
|
||||
)
|
||||
for opaque_item in opaque_staged:
|
||||
assert_staged_opaque(opaque_item, label)
|
||||
committed_journal = journal.with_state("committed")
|
||||
write_journal(committed_journal)
|
||||
journal = committed_journal
|
||||
committed_marker_durable = True
|
||||
_after_journal_commit()
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
if committed_marker_durable:
|
||||
cleanup_errors = _finish_committed(
|
||||
staged,
|
||||
opaque_staged,
|
||||
directories,
|
||||
)
|
||||
suffix = (
|
||||
f"; cleanup pending in {active_journal_path()}: "
|
||||
f"{'; '.join(cleanup_errors)}"
|
||||
if cleanup_errors
|
||||
else ""
|
||||
)
|
||||
raise OSError(
|
||||
f"{label} committed before post-commit failure: {error}{suffix}"
|
||||
) from error
|
||||
recovery_errors = _recover(
|
||||
staged,
|
||||
opaque_staged,
|
||||
directories,
|
||||
directory_preconditions,
|
||||
)
|
||||
suffix = (
|
||||
f"; recovery failed: {'; '.join(recovery_errors)}"
|
||||
if recovery_errors
|
||||
else ""
|
||||
)
|
||||
if journal is not None and not recovery_errors:
|
||||
try:
|
||||
clear_journal()
|
||||
except OSError as journal_error:
|
||||
suffix += f"; journal cleanup failed: {journal_error}"
|
||||
elif journal is not None:
|
||||
suffix += f"; recovery journal preserved at {active_journal_path()}"
|
||||
raise OSError(f"{label}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def _cleanup(staged: _StagedChange) -> None:
|
||||
_remove_optional(staged.temporary_path)
|
||||
_remove_optional(staged.rollback_path)
|
||||
cleanup_errors = _finish_committed(staged, opaque_staged, directories)
|
||||
if cleanup_errors:
|
||||
raise OSError(
|
||||
f"{label} committed; cleanup pending in {active_journal_path()}: "
|
||||
f"{'; '.join(cleanup_errors)}"
|
||||
)
|
||||
|
||||
|
||||
def write_changes(
|
||||
changes: Sequence[FileChange],
|
||||
label: str,
|
||||
preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
) -> None:
|
||||
filtered = [change for change in changes if change.original != change.next_source]
|
||||
if not filtered:
|
||||
return
|
||||
paths = [os.path.normcase(os.path.abspath(change.file_path)) for change in filtered]
|
||||
if len(paths) != len(set(paths)):
|
||||
raise ValueError(f"{label} contains duplicate file targets")
|
||||
staged: List[_StagedChange] = []
|
||||
try:
|
||||
_assert_preconditions(preconditions, label)
|
||||
for change in filtered:
|
||||
staged.append(_stage(change))
|
||||
_assert_preconditions(preconditions, label)
|
||||
for item in staged:
|
||||
_assert_preconditions(preconditions, label)
|
||||
_commit(item)
|
||||
_assert_preconditions(preconditions, label)
|
||||
except Exception as error:
|
||||
recovery_errors = []
|
||||
for item in reversed(staged):
|
||||
try:
|
||||
_rollback(item)
|
||||
except Exception as rollback_error:
|
||||
recovery_errors.append(str(rollback_error))
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as cleanup_error:
|
||||
recovery_errors.append(str(cleanup_error))
|
||||
suffix = (
|
||||
f"; recovery failed: {'; '.join(recovery_errors)}"
|
||||
if recovery_errors
|
||||
else ""
|
||||
with serialized_transactions():
|
||||
recover_pending_locked()
|
||||
_write_changes_locked(
|
||||
changes,
|
||||
label,
|
||||
preconditions,
|
||||
directory_preconditions,
|
||||
opaque_removals,
|
||||
)
|
||||
raise OSError(f"{label}: {error}{suffix}") from error
|
||||
cleanup_errors = []
|
||||
for item in staged:
|
||||
try:
|
||||
_cleanup(item)
|
||||
except OSError as error:
|
||||
cleanup_errors.append(str(error))
|
||||
if cleanup_errors:
|
||||
raise OSError(f"{label} cleanup failed: {'; '.join(cleanup_errors)}")
|
||||
|
||||
|
||||
def regular_file_exists(file_path: str, label: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Durable preparation, atomic commit, and rollback for text-file changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ._durable_file import atomic_replace, copy_into_reserved
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile
|
||||
from ._transaction_recovery import remove_committed_creation
|
||||
from ._transaction_staging import (
|
||||
assert_transaction_preconditions,
|
||||
assert_unchanged,
|
||||
same_snapshot,
|
||||
)
|
||||
from ._transaction_types import FileChange, StagedChange
|
||||
|
||||
|
||||
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
||||
|
||||
|
||||
def _original_snapshot(change: FileChange) -> Optional[FileSnapshot]:
|
||||
if change.original is None:
|
||||
return None
|
||||
if (
|
||||
change.original_device is None
|
||||
or change.original_inode is None
|
||||
or change.original_mode is None
|
||||
):
|
||||
raise OSError(f"Missing original identity for {change.label}")
|
||||
return FileSnapshot(
|
||||
change.original,
|
||||
change.original_device,
|
||||
change.original_inode,
|
||||
change.original_mode,
|
||||
)
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
if staged.temporary_path is None:
|
||||
return None
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.temporary_path),
|
||||
f"staged {staged.change.label}",
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _read_rollback(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
if staged.rollback_path is None:
|
||||
return None
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.rollback_path),
|
||||
f"{staged.change.label} rollback",
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError:
|
||||
if staged.rollback_path is None:
|
||||
return error if isinstance(error, OSError) else OSError(str(error))
|
||||
rollback_path = staged.rollback_path
|
||||
staged.rollback_path = None
|
||||
return OSError(f"{error}; original content preserved at {rollback_path}")
|
||||
|
||||
|
||||
def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None:
|
||||
"""Create and flush rollback material while the public target stays visible."""
|
||||
original = _original_snapshot(staged.change)
|
||||
if original is None:
|
||||
return
|
||||
reservation = staged.rollback_reservation
|
||||
rollback_path = staged.rollback_path
|
||||
if reservation is None or rollback_path is None:
|
||||
raise OSError(f"Missing rollback reservation for {staged.change.label}")
|
||||
capture_file(staged.change.file_path, rollback_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at commit boundary: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
rollback = _read_rollback(staged)
|
||||
if not same_snapshot(rollback, reservation):
|
||||
raise OSError(
|
||||
f"{staged.change.label} existing rollback reservation changed: "
|
||||
f"{rollback_path}"
|
||||
)
|
||||
copy_into_reserved(
|
||||
staged.directory,
|
||||
staged.change.file_path,
|
||||
rollback_path,
|
||||
(original.device, original.inode),
|
||||
(reservation.device, reservation.inode),
|
||||
0o600,
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
current = _read_target(staged)
|
||||
if not same_snapshot(current, original):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed while preparing rollback: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
prepared = _read_rollback(staged)
|
||||
if (
|
||||
prepared is None
|
||||
or prepared.contents != original.contents
|
||||
or (prepared.device, prepared.inode)
|
||||
!= (reservation.device, reservation.inode)
|
||||
or (os.name != "nt" and prepared.mode != 0o600)
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} rollback changed while preparing: {rollback_path}"
|
||||
)
|
||||
staged.rollback_snapshot = prepared
|
||||
staged.rollback_reservation = None
|
||||
|
||||
|
||||
def _replace_next(
|
||||
staged: StagedChange,
|
||||
replace_file: ReplaceFile,
|
||||
after_replace: ReplaceFile,
|
||||
) -> None:
|
||||
temporary = staged.temporary_snapshot
|
||||
temporary_path = staged.temporary_path
|
||||
if temporary_path is None or temporary is None:
|
||||
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||
replace_file(temporary_path, staged.change.file_path, staged.directory)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
current = _read_target(staged)
|
||||
expected = _original_snapshot(staged.change)
|
||||
if not same_snapshot(current, expected):
|
||||
error = OSError(
|
||||
f"{staged.change.label} changed before atomic replacement: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
if expected is not None:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
raise error
|
||||
if not same_snapshot(_read_temporary(staged), temporary):
|
||||
raise OSError(
|
||||
f"Staged {staged.change.label} changed at install boundary: "
|
||||
f"{temporary_path}"
|
||||
)
|
||||
atomic_replace(staged.directory, temporary_path, staged.change.file_path)
|
||||
staged.temporary_path = None
|
||||
staged.committed_snapshot = temporary
|
||||
staged.committed = True
|
||||
after_replace(temporary_path, staged.change.file_path, staged.directory)
|
||||
|
||||
|
||||
def _remove_original(staged: StagedChange) -> None:
|
||||
original = _original_snapshot(staged.change)
|
||||
if original is None:
|
||||
raise OSError(f"Missing original data for {staged.change.label}")
|
||||
captured = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
staged.change.file_path,
|
||||
staged.change.label,
|
||||
)
|
||||
snapshot = captured.text_snapshot(staged.change.maximum_bytes)
|
||||
if not same_snapshot(snapshot, original):
|
||||
try:
|
||||
captured.restore()
|
||||
except Exception as error:
|
||||
preserved = (
|
||||
captured.preserve()
|
||||
if captured.active
|
||||
else staged.change.file_path
|
||||
)
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at removal boundary; "
|
||||
f"concurrent data preserved at {preserved}: {error}"
|
||||
) from error
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed at removal boundary: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
captured.discard()
|
||||
staged.directory.sync()
|
||||
staged.committed_snapshot = None
|
||||
staged.committed = True
|
||||
|
||||
|
||||
def commit_change(
|
||||
staged: StagedChange,
|
||||
replace_file: ReplaceFile,
|
||||
capture_file: ReplaceFile,
|
||||
after_replace: ReplaceFile,
|
||||
) -> None:
|
||||
del capture_file
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
assert_unchanged(staged.change, staged.directories)
|
||||
if staged.change.original is not None and staged.rollback_snapshot is None:
|
||||
raise OSError(f"Missing rollback data for {staged.change.label}")
|
||||
if staged.change.next_source is None:
|
||||
_remove_original(staged)
|
||||
after_replace(
|
||||
staged.change.file_path,
|
||||
staged.change.file_path,
|
||||
staged.directory,
|
||||
)
|
||||
else:
|
||||
_replace_next(staged, replace_file, after_replace)
|
||||
|
||||
current = _read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed immediately after commit: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
if os.name != "nt" and current is not None and current.mode != staged.change.mode:
|
||||
raise OSError(
|
||||
f"{staged.change.label} has an unexpected mode after commit: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
|
||||
|
||||
def _restore_rollback_file(
|
||||
staged: StagedChange,
|
||||
expected: FileSnapshot,
|
||||
expected_current: Optional[FileSnapshot],
|
||||
restore_file: ReplaceFile,
|
||||
) -> None:
|
||||
rollback_path = staged.rollback_path
|
||||
rollback = staged.rollback_snapshot
|
||||
if rollback_path is None or rollback is None:
|
||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} changed before restoration: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
try:
|
||||
restore_file(rollback_path, staged.change.file_path, staged.directory)
|
||||
except Exception as error:
|
||||
raise _preserve_rollback(staged, error) from error
|
||||
if not same_snapshot(_read_target(staged), expected_current):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} changed before atomic restoration: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
current_rollback = _read_rollback(staged)
|
||||
if not _same_identity_and_contents(current_rollback, rollback):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} recovery source changed; concurrent data "
|
||||
f"preserved at {rollback_path}"
|
||||
),
|
||||
)
|
||||
if expected.mode != rollback.mode:
|
||||
staged.directory.chmod_file(
|
||||
staged.directory.name_for(rollback_path),
|
||||
expected.mode,
|
||||
(rollback.device, rollback.inode),
|
||||
)
|
||||
atomic_replace(staged.directory, rollback_path, staged.change.file_path)
|
||||
staged.rollback_path = None
|
||||
restored = _read_target(staged)
|
||||
if (
|
||||
restored is None
|
||||
or restored.contents != expected.contents
|
||||
or restored.mode != expected.mode
|
||||
):
|
||||
raise OSError(
|
||||
f"{staged.change.label} changed while restoring: "
|
||||
f"{staged.change.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def rollback_change(
|
||||
staged: StagedChange,
|
||||
restore_file: ReplaceFile,
|
||||
capture_file: ReplaceFile,
|
||||
) -> None:
|
||||
if not staged.committed:
|
||||
return
|
||||
current = _read_target(staged)
|
||||
if not same_snapshot(current, staged.committed_snapshot):
|
||||
raise _preserve_rollback(
|
||||
staged,
|
||||
OSError(
|
||||
f"{staged.change.label} changed during transaction recovery: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
)
|
||||
if staged.change.original is None:
|
||||
remove_committed_creation(staged, restore_file, capture_file)
|
||||
return
|
||||
original = _original_snapshot(staged.change)
|
||||
if original is None:
|
||||
raise OSError(f"Missing original data for {staged.change.label}")
|
||||
_restore_rollback_file(
|
||||
staged,
|
||||
original,
|
||||
staged.committed_snapshot,
|
||||
restore_file,
|
||||
)
|
||||
staged.rollback_snapshot = None
|
||||
staged.committed_snapshot = None
|
||||
staged.committed = False
|
||||
|
||||
|
||||
__all__ = ["commit_change", "prepare_change", "rollback_change"]
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Validated persistent journal for crash-recoverable file transactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
_same_file_metadata,
|
||||
identity_mode_bits,
|
||||
)
|
||||
from ._opaque_snapshot import OpaqueFileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._transaction_lock import transaction_state_directory
|
||||
|
||||
|
||||
JOURNAL_VERSION = 1
|
||||
JOURNAL_STATES = {"preparing", "committing", "recovering", "committed"}
|
||||
MAX_JOURNAL_BYTES = 1024 * 1024
|
||||
MAX_JOURNAL_RECORDS = 256
|
||||
_ACTIVE_JOURNAL = "active-v1.json"
|
||||
_HASH_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileFingerprint:
|
||||
sha256: str
|
||||
size: int
|
||||
mode: int
|
||||
device: int
|
||||
inode: int
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, snapshot: FileSnapshot) -> "FileFingerprint":
|
||||
encoded = snapshot.contents.encode("utf-8")
|
||||
return cls(
|
||||
hashlib.sha256(encoded).hexdigest(),
|
||||
len(encoded),
|
||||
snapshot.mode,
|
||||
snapshot.device,
|
||||
snapshot.inode,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_opaque(cls, snapshot: OpaqueFileSnapshot) -> "FileFingerprint":
|
||||
return cls(
|
||||
snapshot.sha256,
|
||||
snapshot.size,
|
||||
snapshot.mode,
|
||||
snapshot.device,
|
||||
snapshot.inode,
|
||||
)
|
||||
|
||||
def same_contents(self, other: Optional["FileFingerprint"]) -> bool:
|
||||
return (
|
||||
other is not None
|
||||
and self.sha256 == other.sha256
|
||||
and self.size == other.size
|
||||
)
|
||||
|
||||
def same_visible(self, other: Optional["FileFingerprint"]) -> bool:
|
||||
return (
|
||||
self.same_contents(other)
|
||||
and other is not None
|
||||
and identity_mode_bits(self.mode) == identity_mode_bits(other.mode)
|
||||
)
|
||||
|
||||
def same_exact(self, other: Optional["FileFingerprint"]) -> bool:
|
||||
return (
|
||||
self.same_visible(other)
|
||||
and other is not None
|
||||
and self.device == other.device
|
||||
and self.inode == other.inode
|
||||
)
|
||||
|
||||
def to_object(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"mode": self.mode,
|
||||
"device": self.device,
|
||||
"inode": self.inode,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_object(cls, value: object, label: str) -> "FileFingerprint":
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"sha256",
|
||||
"size",
|
||||
"mode",
|
||||
"device",
|
||||
"inode",
|
||||
}:
|
||||
raise ValueError(f"Invalid {label} fingerprint")
|
||||
digest = value["sha256"]
|
||||
numbers = [value[key] for key in ("size", "mode", "device", "inode")]
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or len(digest) != 64
|
||||
or any(character not in "0123456789abcdef" for character in digest)
|
||||
or any(not isinstance(number, int) or number < 0 for number in numbers)
|
||||
):
|
||||
raise ValueError(f"Invalid {label} fingerprint")
|
||||
return cls(digest, numbers[0], numbers[1], numbers[2], numbers[3])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalRecord:
|
||||
kind: str
|
||||
label: str
|
||||
target: str
|
||||
temporary: Optional[str]
|
||||
rollback: Optional[str]
|
||||
directory: str
|
||||
directory_snapshot: DirectorySnapshot
|
||||
original: Optional[FileFingerprint]
|
||||
next: Optional[FileFingerprint]
|
||||
temporary_identity: Optional[FileFingerprint]
|
||||
rollback_identity: Optional[FileFingerprint]
|
||||
|
||||
def to_object(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
"target": self.target,
|
||||
"temporary": self.temporary,
|
||||
"rollback": self.rollback,
|
||||
"directory": self.directory,
|
||||
"directory_snapshot": {
|
||||
"device": self.directory_snapshot.device,
|
||||
"inode": self.directory_snapshot.inode,
|
||||
},
|
||||
"original": None if self.original is None else self.original.to_object(),
|
||||
"next": None if self.next is None else self.next.to_object(),
|
||||
"temporary_identity": (
|
||||
None
|
||||
if self.temporary_identity is None
|
||||
else self.temporary_identity.to_object()
|
||||
),
|
||||
"rollback_identity": (
|
||||
None
|
||||
if self.rollback_identity is None
|
||||
else self.rollback_identity.to_object()
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_object(cls, value: object, index: int) -> "JournalRecord":
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Invalid transaction record {index}")
|
||||
required = {
|
||||
"kind",
|
||||
"label",
|
||||
"target",
|
||||
"temporary",
|
||||
"rollback",
|
||||
"directory",
|
||||
"directory_snapshot",
|
||||
"original",
|
||||
"next",
|
||||
"temporary_identity",
|
||||
"rollback_identity",
|
||||
}
|
||||
if set(value) != required:
|
||||
raise ValueError(f"Invalid transaction record {index}")
|
||||
kind = value["kind"]
|
||||
label = value["label"]
|
||||
target = value["target"]
|
||||
temporary = value["temporary"]
|
||||
rollback = value["rollback"]
|
||||
directory = value["directory"]
|
||||
if (
|
||||
kind not in {"text", "opaque"}
|
||||
or not isinstance(label, str)
|
||||
or not label
|
||||
or not isinstance(target, str)
|
||||
or not isinstance(directory, str)
|
||||
or not os.path.isabs(target)
|
||||
or not os.path.isabs(directory)
|
||||
or os.path.normcase(os.path.dirname(target)) != os.path.normcase(directory)
|
||||
):
|
||||
raise ValueError(f"Invalid transaction record {index}")
|
||||
for path in (temporary, rollback):
|
||||
if path is not None and (
|
||||
not isinstance(path, str)
|
||||
or not os.path.isabs(path)
|
||||
or os.path.normcase(os.path.dirname(path))
|
||||
!= os.path.normcase(directory)
|
||||
):
|
||||
raise ValueError(f"Invalid transaction record {index} path")
|
||||
snapshot = value["directory_snapshot"]
|
||||
if (
|
||||
not isinstance(snapshot, dict)
|
||||
or set(snapshot) != {"device", "inode"}
|
||||
or not isinstance(snapshot["device"], int)
|
||||
or not isinstance(snapshot["inode"], int)
|
||||
):
|
||||
raise ValueError(f"Invalid transaction record {index} directory")
|
||||
|
||||
def fingerprint(key: str) -> Optional[FileFingerprint]:
|
||||
item = value[key]
|
||||
return (
|
||||
None
|
||||
if item is None
|
||||
else FileFingerprint.from_object(item, f"record {index} {key}")
|
||||
)
|
||||
|
||||
return cls(
|
||||
kind,
|
||||
label,
|
||||
target,
|
||||
temporary,
|
||||
rollback,
|
||||
directory,
|
||||
DirectorySnapshot(snapshot["device"], snapshot["inode"]),
|
||||
fingerprint("original"),
|
||||
fingerprint("next"),
|
||||
fingerprint("temporary_identity"),
|
||||
fingerprint("rollback_identity"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransactionJournal:
|
||||
transaction_id: str
|
||||
label: str
|
||||
state: str
|
||||
records: List[JournalRecord]
|
||||
|
||||
def with_state(self, state: str) -> "TransactionJournal":
|
||||
if state not in JOURNAL_STATES:
|
||||
raise ValueError(f"Invalid transaction state: {state}")
|
||||
return replace(self, state=state)
|
||||
|
||||
def to_object(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"version": JOURNAL_VERSION,
|
||||
"transaction_id": self.transaction_id,
|
||||
"label": self.label,
|
||||
"state": self.state,
|
||||
"records": [record.to_object() for record in self.records],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_object(cls, value: object) -> "TransactionJournal":
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"version",
|
||||
"transaction_id",
|
||||
"label",
|
||||
"state",
|
||||
"records",
|
||||
}:
|
||||
raise ValueError("Invalid transaction journal schema")
|
||||
transaction_id = value["transaction_id"]
|
||||
label = value["label"]
|
||||
state = value["state"]
|
||||
records = value["records"]
|
||||
if (
|
||||
value["version"] != JOURNAL_VERSION
|
||||
or not isinstance(transaction_id, str)
|
||||
or len(transaction_id) != 32
|
||||
or any(character not in "0123456789abcdef" for character in transaction_id)
|
||||
or not isinstance(label, str)
|
||||
or not label
|
||||
or state not in JOURNAL_STATES
|
||||
or not isinstance(records, list)
|
||||
or not records
|
||||
or len(records) > MAX_JOURNAL_RECORDS
|
||||
):
|
||||
raise ValueError("Invalid transaction journal")
|
||||
return cls(
|
||||
transaction_id,
|
||||
label,
|
||||
state,
|
||||
[JournalRecord.from_object(record, index) for index, record in enumerate(records)],
|
||||
)
|
||||
|
||||
|
||||
def inspect_fingerprint(
|
||||
directory: PinnedDirectory,
|
||||
file_path: str,
|
||||
label: str,
|
||||
) -> Optional[FileFingerprint]:
|
||||
name = directory.name_for(file_path)
|
||||
try:
|
||||
before = directory.stat_file(name)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
|
||||
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = directory.open_file(name, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode) or not _same_file_metadata(before, opened):
|
||||
raise OSError(f"{label} changed while opening: {file_path}")
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
while True:
|
||||
chunk = os.read(descriptor, _HASH_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
visible = directory.stat_file(name)
|
||||
if not _same_file_metadata(visible, finished):
|
||||
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||
return FileFingerprint(
|
||||
digest.hexdigest(),
|
||||
size,
|
||||
stat.S_IMODE(opened.st_mode),
|
||||
opened.st_dev,
|
||||
opened.st_ino,
|
||||
)
|
||||
|
||||
|
||||
def _journal_path() -> str:
|
||||
return os.path.join(transaction_state_directory(), _ACTIVE_JOURNAL)
|
||||
|
||||
|
||||
def active_journal_path() -> str:
|
||||
"""Return the stable recovery record path for explicit diagnostics."""
|
||||
return _journal_path()
|
||||
|
||||
|
||||
def _sync_state_directory() -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
descriptor = os.open(
|
||||
transaction_state_directory(),
|
||||
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
||||
)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _replace_journal(temporary: str, path: str) -> None:
|
||||
if os.name != "nt":
|
||||
os.replace(temporary, path)
|
||||
_sync_state_directory()
|
||||
return
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
move = kernel32.MoveFileExW
|
||||
move.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
|
||||
move.restype = wintypes.BOOL
|
||||
replace_existing = 0x00000001
|
||||
write_through = 0x00000008
|
||||
if not move(temporary, path, replace_existing | write_through):
|
||||
error = ctypes.get_last_error()
|
||||
raise OSError(f"Persist transaction journal: {ctypes.WinError(error)}")
|
||||
|
||||
|
||||
def write_journal(journal: TransactionJournal) -> None:
|
||||
encoded = (json.dumps(journal.to_object(), sort_keys=True) + "\n").encode("utf-8")
|
||||
if len(encoded) > MAX_JOURNAL_BYTES:
|
||||
raise ValueError("Transaction journal exceeds its size limit")
|
||||
root = transaction_state_directory()
|
||||
path = _journal_path()
|
||||
temporary = os.path.join(root, f".{_ACTIVE_JOURNAL}.{uuid4().hex}.tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
offset = 0
|
||||
while offset < len(encoded):
|
||||
written = os.write(descriptor, encoded[offset:])
|
||||
if written <= 0:
|
||||
raise OSError("Transaction journal write made no progress")
|
||||
offset += written
|
||||
if os.name == "posix":
|
||||
os.fchmod(descriptor, 0o600)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
_replace_journal(temporary, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def load_journal() -> Optional[TransactionJournal]:
|
||||
path = _journal_path()
|
||||
try:
|
||||
metadata = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
||||
raise OSError(f"Transaction journal is not a physical file: {path}")
|
||||
if os.name == "posix" and metadata.st_mode & 0o077:
|
||||
raise PermissionError(f"Transaction journal must be owner-only: {path}")
|
||||
if metadata.st_size > MAX_JOURNAL_BYTES:
|
||||
raise ValueError("Transaction journal exceeds its size limit")
|
||||
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not _same_file_metadata(opened, metadata):
|
||||
raise OSError(f"Transaction journal changed while opening: {path}")
|
||||
chunks = []
|
||||
remaining = MAX_JOURNAL_BYTES + 1
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, remaining)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
raw = b"".join(chunks)
|
||||
finished = os.fstat(descriptor)
|
||||
if not _same_file_metadata(finished, opened):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
visible = os.lstat(path)
|
||||
except FileNotFoundError as error:
|
||||
raise OSError(f"Transaction journal changed while reading: {path}") from error
|
||||
if not _same_file_metadata(visible, finished):
|
||||
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||
if len(raw) > MAX_JOURNAL_BYTES:
|
||||
raise ValueError("Transaction journal exceeds its size limit")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"Invalid transaction journal at {path}: {error}") from error
|
||||
try:
|
||||
return TransactionJournal.from_object(value)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"Invalid transaction journal at {path}: {error}") from error
|
||||
|
||||
|
||||
def clear_journal() -> None:
|
||||
try:
|
||||
os.remove(_journal_path())
|
||||
except FileNotFoundError:
|
||||
return
|
||||
_sync_state_directory()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FileFingerprint",
|
||||
"JournalRecord",
|
||||
"TransactionJournal",
|
||||
"active_journal_path",
|
||||
"clear_journal",
|
||||
"inspect_fingerprint",
|
||||
"load_journal",
|
||||
"write_journal",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Cross-process serialization for durable provider transactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
from types import TracebackType
|
||||
from typing import Any, Optional, Type
|
||||
|
||||
from elydora._runtime_paths import (
|
||||
ensure_private_directory as ensure_private_parent_directory,
|
||||
runtime_root,
|
||||
)
|
||||
|
||||
|
||||
def _effective_uid() -> int:
|
||||
getter: Any = getattr(os, "geteuid", None)
|
||||
if getter is None:
|
||||
raise OSError("Effective user identity is unavailable")
|
||||
return int(getter())
|
||||
|
||||
|
||||
def _ensure_private_posix_directory(path: str) -> None:
|
||||
try:
|
||||
os.mkdir(path, 0o700)
|
||||
except FileExistsError:
|
||||
pass
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as error:
|
||||
if error.errno in {errno.ELOOP, errno.ENOTDIR}:
|
||||
raise OSError(
|
||||
f"Transaction state path is not a physical directory: {path}"
|
||||
) from error
|
||||
raise
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
raise OSError(
|
||||
f"Transaction state path is not a physical directory: {path}"
|
||||
)
|
||||
if metadata.st_uid != _effective_uid():
|
||||
raise PermissionError(
|
||||
f"Transaction state directory is not owned by the current user: {path}"
|
||||
)
|
||||
os.fchmod(descriptor, 0o700)
|
||||
secured = os.fstat(descriptor)
|
||||
visible = os.lstat(path)
|
||||
if (
|
||||
not stat.S_ISDIR(visible.st_mode)
|
||||
or stat.S_ISLNK(visible.st_mode)
|
||||
or visible.st_dev != secured.st_dev
|
||||
or visible.st_ino != secured.st_ino
|
||||
):
|
||||
raise OSError(
|
||||
f"Transaction state path is not a physical directory: {path}"
|
||||
)
|
||||
if stat.S_IMODE(secured.st_mode) != 0o700:
|
||||
raise PermissionError(
|
||||
f"Transaction state directory must be owner-only: {path}"
|
||||
)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def transaction_state_path() -> str:
|
||||
"""Return the transaction state directory path without touching the filesystem."""
|
||||
return os.path.join(runtime_root(), "transactions")
|
||||
|
||||
|
||||
def transaction_state_directory() -> str:
|
||||
"""Return the private per-user state directory inside the Elydora runtime root.
|
||||
|
||||
The journal must share fate with the runtimes it repairs, so it lives next
|
||||
to the managed agent directories under ``~/.elydora`` rather than in a
|
||||
volatile location such as the system temporary directory.
|
||||
"""
|
||||
root = runtime_root()
|
||||
state = transaction_state_path()
|
||||
parent = os.path.dirname(root)
|
||||
if not os.path.lexists(parent):
|
||||
ensure_private_parent_directory(parent)
|
||||
if os.name == "nt":
|
||||
from ._windows_security import ensure_private_directory
|
||||
|
||||
ensure_private_directory(root)
|
||||
ensure_private_directory(state)
|
||||
return state
|
||||
if os.name != "posix":
|
||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||
_ensure_private_posix_directory(root)
|
||||
_ensure_private_posix_directory(state)
|
||||
return state
|
||||
|
||||
|
||||
class _WindowsFileLock(AbstractContextManager["_WindowsFileLock"]):
|
||||
def __init__(self) -> None:
|
||||
self._handle = 0
|
||||
self._overlapped: Any = None
|
||||
|
||||
def __enter__(self) -> "_WindowsFileLock":
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
from ._windows_security import close_handle, open_private_file
|
||||
|
||||
class Overlapped(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Internal", ctypes.c_void_p),
|
||||
("InternalHigh", ctypes.c_void_p),
|
||||
("Offset", wintypes.DWORD),
|
||||
("OffsetHigh", wintypes.DWORD),
|
||||
("hEvent", wintypes.HANDLE),
|
||||
]
|
||||
|
||||
lock = kernel32.LockFileEx
|
||||
lock.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(Overlapped),
|
||||
]
|
||||
lock.restype = wintypes.BOOL
|
||||
path = os.path.join(transaction_state_directory(), "transaction.lock")
|
||||
handle = open_private_file(path)
|
||||
overlapped = Overlapped()
|
||||
if not lock(handle, 0x00000002, 0, 1, 0, ctypes.byref(overlapped)):
|
||||
error = ctypes.get_last_error()
|
||||
close_handle(handle)
|
||||
raise OSError(f"Lock transaction file: {ctypes.WinError(error)}")
|
||||
self._handle = handle
|
||||
self._overlapped = overlapped
|
||||
return self
|
||||
|
||||
def _close(self) -> None:
|
||||
if not self._handle:
|
||||
return
|
||||
from ._windows_security import close_handle
|
||||
|
||||
handle = self._handle
|
||||
self._handle = 0
|
||||
self._overlapped = None
|
||||
close_handle(handle)
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
del exc_type, exc_value, traceback
|
||||
import ctypes as ctypes_module
|
||||
from ctypes import wintypes
|
||||
|
||||
ctypes: Any = ctypes_module
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
unlock = kernel32.UnlockFileEx
|
||||
unlock.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.LPVOID,
|
||||
]
|
||||
unlock.restype = wintypes.BOOL
|
||||
unlock_error: Optional[OSError] = None
|
||||
if self._handle and not unlock(
|
||||
self._handle,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
ctypes.byref(self._overlapped),
|
||||
):
|
||||
error = ctypes.get_last_error()
|
||||
unlock_error = OSError(
|
||||
f"Unlock transaction file: {ctypes.WinError(error)}"
|
||||
)
|
||||
self._close()
|
||||
if unlock_error is not None:
|
||||
raise unlock_error
|
||||
|
||||
|
||||
class _PosixLock(AbstractContextManager["_PosixLock"]):
|
||||
def __init__(self) -> None:
|
||||
self._descriptor = -1
|
||||
|
||||
def __enter__(self) -> "_PosixLock":
|
||||
import fcntl as fcntl_module
|
||||
|
||||
fcntl: Any = fcntl_module
|
||||
|
||||
path = os.path.join(transaction_state_directory(), "transaction.lock")
|
||||
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except OSError as error:
|
||||
if error.errno == errno.ELOOP:
|
||||
raise OSError(
|
||||
f"Transaction lock is not a physical file: {path}"
|
||||
) from error
|
||||
raise
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise OSError(f"Transaction lock is not a physical file: {path}")
|
||||
if metadata.st_uid != _effective_uid():
|
||||
raise PermissionError(
|
||||
f"Transaction lock is not owned by the current user: {path}"
|
||||
)
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
visible = os.lstat(path)
|
||||
if (
|
||||
not stat.S_ISREG(visible.st_mode)
|
||||
or stat.S_ISLNK(visible.st_mode)
|
||||
or visible.st_dev != metadata.st_dev
|
||||
or visible.st_ino != metadata.st_ino
|
||||
):
|
||||
raise OSError(f"Transaction lock is not a physical file: {path}")
|
||||
if visible.st_uid != _effective_uid():
|
||||
raise PermissionError(
|
||||
f"Transaction lock is not owned by the current user: {path}"
|
||||
)
|
||||
if stat.S_IMODE(visible.st_mode) != 0o600:
|
||||
raise PermissionError(f"Transaction lock must be owner-only: {path}")
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
self._descriptor = descriptor
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: Optional[Type[BaseException]],
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> None:
|
||||
del exc_type, exc_value, traceback
|
||||
import fcntl as fcntl_module
|
||||
|
||||
fcntl: Any = fcntl_module
|
||||
|
||||
descriptor = self._descriptor
|
||||
self._descriptor = -1
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def serialized_transactions() -> AbstractContextManager[Any]:
|
||||
if os.name == "nt":
|
||||
return _WindowsFileLock()
|
||||
if os.name == "posix":
|
||||
return _PosixLock()
|
||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"serialized_transactions",
|
||||
"transaction_state_directory",
|
||||
"transaction_state_path",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Identity-bound population of journaled transaction reservations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
def _write_all(descriptor: int, payload: bytes, label: str) -> None:
|
||||
offset = 0
|
||||
while offset < len(payload):
|
||||
written = os.write(descriptor, payload[offset:])
|
||||
if written <= 0:
|
||||
raise OSError(f"Stage {label} made no progress")
|
||||
offset += written
|
||||
|
||||
|
||||
def populate_reserved_text(
|
||||
directory: PinnedDirectory,
|
||||
file_path: str,
|
||||
reservation: FileSnapshot,
|
||||
content: str,
|
||||
mode: int,
|
||||
label: str,
|
||||
) -> FileSnapshot:
|
||||
"""Fill one journal-owned empty file without changing its identity."""
|
||||
name = directory.name_for(file_path)
|
||||
current = directory.read_file(name, f"{label} reservation", 0)
|
||||
if current != reservation or current.contents:
|
||||
raise OSError(f"{label} reservation changed before staging: {file_path}")
|
||||
|
||||
flags = os.O_WRONLY | getattr(os, "O_BINARY", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = -1
|
||||
payload = content.encode("utf-8")
|
||||
try:
|
||||
descriptor = directory.open_file(name, flags)
|
||||
opened = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(opened.st_mode)
|
||||
or stat.S_ISLNK(opened.st_mode)
|
||||
or (opened.st_dev, opened.st_ino)
|
||||
!= (reservation.device, reservation.inode)
|
||||
or opened.st_size != 0
|
||||
):
|
||||
raise OSError(f"{label} reservation changed while opening: {file_path}")
|
||||
os.ftruncate(descriptor, 0)
|
||||
_write_all(descriptor, payload, label)
|
||||
os.fsync(descriptor)
|
||||
directory.chmod_descriptor(descriptor, mode)
|
||||
os.fsync(descriptor)
|
||||
finished = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(finished.st_mode)
|
||||
or (finished.st_dev, finished.st_ino)
|
||||
!= (reservation.device, reservation.inode)
|
||||
or finished.st_size != len(payload)
|
||||
):
|
||||
raise OSError(f"{label} changed while staging: {file_path}")
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
|
||||
directory.sync()
|
||||
snapshot = directory.read_file(name, label, len(payload))
|
||||
if (
|
||||
snapshot is None
|
||||
or snapshot.contents != content
|
||||
or (snapshot.device, snapshot.inode)
|
||||
!= (reservation.device, reservation.inode)
|
||||
or (os.name != "nt" and snapshot.mode != mode)
|
||||
):
|
||||
raise OSError(f"{label} changed after staging: {file_path}")
|
||||
return snapshot
|
||||
|
||||
|
||||
__all__ = ["populate_reserved_text"]
|
||||
@@ -0,0 +1,280 @@
|
||||
"""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 same_snapshot
|
||||
from ._transaction_types import StagedChange
|
||||
|
||||
|
||||
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
||||
|
||||
|
||||
def _read_target(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||
return staged.directory.read_file(
|
||||
staged.directory.name_for(staged.change.file_path),
|
||||
staged.change.label,
|
||||
staged.change.maximum_bytes,
|
||||
)
|
||||
|
||||
|
||||
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 _same_identity_and_contents(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return (
|
||||
current.contents == expected.contents
|
||||
and current.device == expected.device
|
||||
and current.inode == expected.inode
|
||||
)
|
||||
|
||||
|
||||
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 _release_rollback_source(staged: StagedChange, source_path: str) -> bool:
|
||||
if staged.rollback_path is not None and os.path.normcase(
|
||||
os.path.abspath(staged.rollback_path)
|
||||
) == os.path.normcase(os.path.abspath(source_path)):
|
||||
staged.rollback_path = None
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _preserve_source_path(
|
||||
error: Exception,
|
||||
file_path: str,
|
||||
original_source: bool,
|
||||
) -> OSError:
|
||||
kind = "original content" if original_source else "transaction data"
|
||||
return OSError(f"{error}; {kind} preserved at {file_path}")
|
||||
|
||||
|
||||
def install_private_file(
|
||||
staged: StagedChange,
|
||||
source_path: str,
|
||||
expected: FileSnapshot,
|
||||
replace_file: ReplaceFile,
|
||||
) -> None:
|
||||
try:
|
||||
replace_file(source_path, staged.change.file_path, staged.directory)
|
||||
except Exception:
|
||||
raise
|
||||
captured = QuarantinedFile.capture(
|
||||
staged.directory,
|
||||
source_path,
|
||||
f"{staged.change.label} recovery source",
|
||||
)
|
||||
original_source = _release_rollback_source(staged, source_path)
|
||||
captured_snapshot = captured.text_snapshot(staged.change.maximum_bytes)
|
||||
if not same_snapshot(captured_snapshot, expected):
|
||||
try:
|
||||
captured.restore(source_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise OSError(
|
||||
f"{error}; concurrent data preserved at {preserved}"
|
||||
) from error
|
||||
raise OSError(
|
||||
f"{staged.change.label} recovery source changed: {source_path}; "
|
||||
f"concurrent data preserved at {source_path}"
|
||||
)
|
||||
try:
|
||||
captured.link(staged.change.file_path)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else source_path
|
||||
raise _preserve_source_path(
|
||||
error,
|
||||
preserved,
|
||||
original_source,
|
||||
) from error
|
||||
restored = _read_target(staged)
|
||||
if not same_snapshot(restored, expected):
|
||||
preserved = captured.preserve()
|
||||
raise _preserve_source_path(
|
||||
OSError(
|
||||
f"{staged.change.label} changed while installing recovery data: "
|
||||
f"{staged.change.file_path}"
|
||||
),
|
||||
preserved,
|
||||
original_source,
|
||||
)
|
||||
captured.discard()
|
||||
|
||||
|
||||
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",
|
||||
"install_private_file",
|
||||
"remove_committed_creation",
|
||||
]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Deterministic recovery of a transaction interrupted by process exit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ._durable_file import atomic_replace
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile
|
||||
from ._transaction_journal import (
|
||||
FileFingerprint,
|
||||
JournalRecord,
|
||||
TransactionJournal,
|
||||
clear_journal,
|
||||
inspect_fingerprint,
|
||||
load_journal,
|
||||
write_journal,
|
||||
)
|
||||
from ._transaction_lock import serialized_transactions, transaction_state_path
|
||||
|
||||
|
||||
def _directory_key(path: str) -> str:
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def _open_directories(journal: TransactionJournal) -> Dict[str, PinnedDirectory]:
|
||||
directories: Dict[str, PinnedDirectory] = {}
|
||||
try:
|
||||
for record in journal.records:
|
||||
key = _directory_key(record.directory)
|
||||
current = directories.get(key)
|
||||
if current is not None:
|
||||
if current.snapshot != record.directory_snapshot:
|
||||
raise OSError(
|
||||
f"Conflicting directory identities in transaction journal: "
|
||||
f"{record.directory}"
|
||||
)
|
||||
continue
|
||||
directories[key] = PinnedDirectory.open(
|
||||
record.directory,
|
||||
f"{record.label} recovery directory",
|
||||
record.directory_snapshot,
|
||||
)
|
||||
return directories
|
||||
except Exception:
|
||||
_close_directories(directories)
|
||||
raise
|
||||
|
||||
|
||||
def _close_directories(directories: Dict[str, PinnedDirectory]) -> None:
|
||||
failures = []
|
||||
for directory in reversed(list(directories.values())):
|
||||
try:
|
||||
directory.close()
|
||||
except OSError as error:
|
||||
failures.append(str(error))
|
||||
if failures:
|
||||
raise OSError("; ".join(failures))
|
||||
|
||||
|
||||
def _directory_for(
|
||||
record: JournalRecord,
|
||||
directories: Dict[str, PinnedDirectory],
|
||||
) -> PinnedDirectory:
|
||||
directory = directories.get(_directory_key(record.directory))
|
||||
if directory is None:
|
||||
raise OSError(f"Missing recovery directory for {record.label}")
|
||||
return directory
|
||||
|
||||
|
||||
def _remove_owned_file(
|
||||
directory: PinnedDirectory,
|
||||
path: Optional[str],
|
||||
expected_identity: Optional[FileFingerprint],
|
||||
label: str,
|
||||
) -> None:
|
||||
if path is None or expected_identity is None:
|
||||
return
|
||||
current = inspect_fingerprint(directory, path, label)
|
||||
if current is None:
|
||||
return
|
||||
if (
|
||||
current.device != expected_identity.device
|
||||
or current.inode != expected_identity.inode
|
||||
):
|
||||
raise OSError(f"{label} changed before recovery cleanup: {path}")
|
||||
captured = QuarantinedFile.capture(
|
||||
directory,
|
||||
path,
|
||||
label,
|
||||
require_parent_path=False,
|
||||
)
|
||||
captured_current = inspect_fingerprint(
|
||||
captured.container,
|
||||
captured.container.path_for("entry"),
|
||||
label,
|
||||
)
|
||||
if (
|
||||
captured_current is None
|
||||
or captured_current.device != expected_identity.device
|
||||
or captured_current.inode != expected_identity.inode
|
||||
):
|
||||
preserved = captured.preserve()
|
||||
raise OSError(
|
||||
f"{label} changed at recovery cleanup boundary; preserved at {preserved}"
|
||||
)
|
||||
captured.discard()
|
||||
directory.sync()
|
||||
|
||||
|
||||
def _cleanup_record(
|
||||
record: JournalRecord,
|
||||
directories: Dict[str, PinnedDirectory],
|
||||
) -> None:
|
||||
directory = _directory_for(record, directories)
|
||||
_remove_owned_file(
|
||||
directory,
|
||||
record.temporary,
|
||||
record.temporary_identity,
|
||||
f"staged {record.label}",
|
||||
)
|
||||
_remove_owned_file(
|
||||
directory,
|
||||
record.rollback,
|
||||
record.rollback_identity,
|
||||
f"{record.label} rollback",
|
||||
)
|
||||
|
||||
|
||||
def _restore_original(
|
||||
record: JournalRecord,
|
||||
directory: PinnedDirectory,
|
||||
) -> None:
|
||||
original = record.original
|
||||
rollback_path = record.rollback
|
||||
rollback_identity = record.rollback_identity
|
||||
if original is None or rollback_path is None or rollback_identity is None:
|
||||
raise OSError(f"Missing rollback data for {record.label}")
|
||||
rollback = inspect_fingerprint(
|
||||
directory,
|
||||
rollback_path,
|
||||
f"{record.label} rollback",
|
||||
)
|
||||
if (
|
||||
rollback is None
|
||||
or rollback.device != rollback_identity.device
|
||||
or rollback.inode != rollback_identity.inode
|
||||
or not original.same_contents(rollback)
|
||||
):
|
||||
raise OSError(
|
||||
f"{record.label} rollback changed during restart recovery: "
|
||||
f"{rollback_path}"
|
||||
)
|
||||
if rollback.mode != original.mode:
|
||||
directory.chmod_file(
|
||||
directory.name_for(rollback_path),
|
||||
original.mode,
|
||||
(rollback.device, rollback.inode),
|
||||
)
|
||||
atomic_replace(directory, rollback_path, record.target)
|
||||
restored = inspect_fingerprint(directory, record.target, record.label)
|
||||
if not original.same_visible(restored):
|
||||
raise OSError(
|
||||
f"{record.label} restoration failed during restart recovery: "
|
||||
f"{record.target}"
|
||||
)
|
||||
|
||||
|
||||
def _matches_original_state(
|
||||
record: JournalRecord,
|
||||
current: Optional[FileFingerprint],
|
||||
) -> bool:
|
||||
original = record.original
|
||||
if original is None or current is None or not original.same_visible(current):
|
||||
return False
|
||||
if original.same_exact(current):
|
||||
return True
|
||||
rollback = record.rollback_identity
|
||||
return (
|
||||
rollback is not None
|
||||
and current.device == rollback.device
|
||||
and current.inode == rollback.inode
|
||||
)
|
||||
|
||||
|
||||
def _remove_created_target(
|
||||
record: JournalRecord,
|
||||
directory: PinnedDirectory,
|
||||
current: FileFingerprint,
|
||||
) -> None:
|
||||
expected = record.next
|
||||
if expected is None or not expected.same_exact(current):
|
||||
raise OSError(
|
||||
f"{record.label} changed before creation recovery: {record.target}"
|
||||
)
|
||||
captured = QuarantinedFile.capture(directory, record.target, record.label)
|
||||
captured_current = inspect_fingerprint(
|
||||
captured.container,
|
||||
captured.container.path_for("entry"),
|
||||
record.label,
|
||||
)
|
||||
if not expected.same_exact(captured_current):
|
||||
try:
|
||||
captured.restore(record.target)
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else record.target
|
||||
raise OSError(
|
||||
f"{record.label} changed at creation recovery boundary; "
|
||||
f"preserved at {preserved}: {error}"
|
||||
) from error
|
||||
raise OSError(
|
||||
f"{record.label} changed at creation recovery boundary: {record.target}"
|
||||
)
|
||||
captured.discard()
|
||||
directory.sync()
|
||||
|
||||
|
||||
def _recover_record(
|
||||
record: JournalRecord,
|
||||
directories: Dict[str, PinnedDirectory],
|
||||
) -> None:
|
||||
directory = _directory_for(record, directories)
|
||||
current = inspect_fingerprint(directory, record.target, record.label)
|
||||
original = record.original
|
||||
next_value = record.next
|
||||
if original is None:
|
||||
if current is None:
|
||||
return
|
||||
_remove_created_target(record, directory, current)
|
||||
return
|
||||
if _matches_original_state(record, current):
|
||||
return
|
||||
if next_value is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{record.label} changed after transactional removal: {record.target}"
|
||||
)
|
||||
_restore_original(record, directory)
|
||||
return
|
||||
if current is None or not next_value.same_exact(current):
|
||||
raise OSError(
|
||||
f"{record.label} changed after atomic replacement; original content "
|
||||
f"preserved at {record.rollback}: {record.target}"
|
||||
)
|
||||
_restore_original(record, directory)
|
||||
|
||||
|
||||
def _verify_committed_record(
|
||||
record: JournalRecord,
|
||||
directories: Dict[str, PinnedDirectory],
|
||||
) -> None:
|
||||
directory = _directory_for(record, directories)
|
||||
current = inspect_fingerprint(directory, record.target, record.label)
|
||||
if record.next is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{record.label} changed after committed removal: {record.target}"
|
||||
)
|
||||
return
|
||||
if not record.next.same_exact(current):
|
||||
raise OSError(
|
||||
f"{record.label} changed after committed transaction: {record.target}"
|
||||
)
|
||||
|
||||
|
||||
def recover_pending_locked() -> None:
|
||||
"""Recover the active journal while the caller owns the transaction lock."""
|
||||
journal = load_journal()
|
||||
if journal is None:
|
||||
return
|
||||
directories = _open_directories(journal)
|
||||
try:
|
||||
if journal.state == "preparing":
|
||||
for record in journal.records:
|
||||
current = inspect_fingerprint(
|
||||
_directory_for(record, directories),
|
||||
record.target,
|
||||
record.label,
|
||||
)
|
||||
if record.original is None:
|
||||
if current is not None:
|
||||
raise OSError(
|
||||
f"{record.label} appeared during transaction preparation: "
|
||||
f"{record.target}"
|
||||
)
|
||||
elif not record.original.same_exact(current):
|
||||
raise OSError(
|
||||
f"{record.label} changed during transaction preparation: "
|
||||
f"{record.target}"
|
||||
)
|
||||
elif journal.state in {"committing", "recovering"}:
|
||||
if journal.state != "recovering":
|
||||
journal = journal.with_state("recovering")
|
||||
write_journal(journal)
|
||||
for record in reversed(journal.records):
|
||||
_recover_record(record, directories)
|
||||
elif journal.state == "committed":
|
||||
for record in journal.records:
|
||||
_verify_committed_record(record, directories)
|
||||
else:
|
||||
raise ValueError(f"Unsupported transaction state: {journal.state}")
|
||||
for record in journal.records:
|
||||
_cleanup_record(record, directories)
|
||||
clear_journal()
|
||||
finally:
|
||||
_close_directories(directories)
|
||||
|
||||
|
||||
def recover_pending_transactions() -> None:
|
||||
"""Serialize and recover any transaction left by a terminated process.
|
||||
|
||||
A missing state directory proves no journal exists, so this returns without
|
||||
creating runtime directories for commands that may still reject their input.
|
||||
"""
|
||||
if not os.path.isdir(transaction_state_path()):
|
||||
return
|
||||
with serialized_transactions():
|
||||
recover_pending_locked()
|
||||
|
||||
|
||||
__all__ = ["recover_pending_locked", "recover_pending_transactions"]
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Anchored staging and precondition checks for file transactions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from ._directory_removal import remove_pinned_empty_directory
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
ensure_physical_directory,
|
||||
read_physical_directory,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
from ._private_artifact import QuarantinedFile, reserve_private_path
|
||||
from ._transaction_payload import populate_reserved_text
|
||||
from ._transaction_types import (
|
||||
DirectoryMap,
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
StagedChange,
|
||||
)
|
||||
|
||||
|
||||
def directory_key(directory_path: str) -> str:
|
||||
return os.path.normcase(os.path.abspath(directory_path))
|
||||
|
||||
|
||||
def directory_for(file_path: str, directories: DirectoryMap) -> PinnedDirectory:
|
||||
directory = directories.get(directory_key(os.path.dirname(file_path)))
|
||||
if directory is None:
|
||||
raise OSError(f"File directory is not pinned: {file_path}")
|
||||
return directory
|
||||
|
||||
|
||||
def same_snapshot(
|
||||
current: Optional[FileSnapshot], expected: Optional[FileSnapshot]
|
||||
) -> bool:
|
||||
if current is None or expected is None:
|
||||
return current is expected
|
||||
return current == expected
|
||||
|
||||
|
||||
def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None:
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
directory.remove_file(directory.name_for(file_path))
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
|
||||
def cleanup_paths(
|
||||
directory: PinnedDirectory, paths: Sequence[Optional[str]]
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
for path in paths:
|
||||
try:
|
||||
remove_optional(directory, path)
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
return errors
|
||||
|
||||
|
||||
def write_staged(staged: StagedChange) -> None:
|
||||
"""Populate a reservation only after its identity is journaled."""
|
||||
temporary_path = staged.temporary_path
|
||||
reservation = staged.temporary_reservation
|
||||
next_source = staged.change.next_source
|
||||
if temporary_path is None or reservation is None or next_source is None:
|
||||
raise OSError(f"Missing staged reservation for {staged.change.label}")
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
assert_unchanged(staged.change, staged.directories)
|
||||
staged.temporary_snapshot = populate_reserved_text(
|
||||
staged.directory,
|
||||
temporary_path,
|
||||
reservation,
|
||||
next_source,
|
||||
staged.change.mode,
|
||||
f"staged {staged.change.label}",
|
||||
)
|
||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||
|
||||
|
||||
def assert_unchanged(
|
||||
change: FileChange, directories: DirectoryMap
|
||||
) -> Optional[FileSnapshot]:
|
||||
directory = directory_for(change.file_path, directories)
|
||||
snapshot = directory.read_file(
|
||||
directory.name_for(change.file_path),
|
||||
change.label,
|
||||
change.maximum_bytes,
|
||||
)
|
||||
current = None if snapshot is None else snapshot.contents
|
||||
identity_changed = snapshot is not None and (
|
||||
snapshot.device != change.original_device
|
||||
or snapshot.inode != change.original_inode
|
||||
or snapshot.mode != change.original_mode
|
||||
)
|
||||
if current != change.original or identity_changed:
|
||||
raise OSError(f"{change.label} changed during installation: {change.file_path}")
|
||||
return snapshot
|
||||
|
||||
|
||||
def _assert_file_preconditions(
|
||||
preconditions: Sequence[FilePrecondition],
|
||||
operation: str,
|
||||
directories: Optional[DirectoryMap],
|
||||
) -> None:
|
||||
for condition in preconditions:
|
||||
directory = (
|
||||
None
|
||||
if directories is None
|
||||
else directories.get(directory_key(os.path.dirname(condition.file_path)))
|
||||
)
|
||||
current = (
|
||||
read_physical_file(
|
||||
condition.file_path,
|
||||
condition.label,
|
||||
condition.maximum_bytes,
|
||||
)
|
||||
if directory is None
|
||||
else directory.read_file(
|
||||
directory.name_for(condition.file_path),
|
||||
condition.label,
|
||||
condition.maximum_bytes,
|
||||
)
|
||||
)
|
||||
if not same_snapshot(current, condition.original):
|
||||
raise OSError(
|
||||
f"{condition.label} changed during {operation}: "
|
||||
f"{condition.file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_directory_preconditions(
|
||||
preconditions: Sequence[DirectoryPrecondition], operation: str
|
||||
) -> None:
|
||||
for condition in preconditions:
|
||||
current = read_physical_directory(condition.directory_path, condition.label)
|
||||
if current != condition.original:
|
||||
raise OSError(
|
||||
f"{condition.label} changed during {operation}: "
|
||||
f"{condition.directory_path}"
|
||||
)
|
||||
|
||||
|
||||
def assert_transaction_preconditions(
|
||||
file_preconditions: Sequence[FilePrecondition],
|
||||
directory_preconditions: Sequence[DirectoryPrecondition],
|
||||
operation: str,
|
||||
directories: Optional[DirectoryMap] = None,
|
||||
) -> None:
|
||||
_assert_file_preconditions(file_preconditions, operation, directories)
|
||||
_assert_directory_preconditions(directory_preconditions, operation)
|
||||
if directories is not None:
|
||||
for directory in directories.values():
|
||||
directory.assert_path_stable()
|
||||
|
||||
|
||||
def pin_directories(
|
||||
changes: Sequence[FileChange],
|
||||
file_preconditions: Sequence[FilePrecondition],
|
||||
directory_preconditions: Sequence[DirectoryPrecondition],
|
||||
extra_file_targets: Sequence[Tuple[str, str, bool]] = (),
|
||||
) -> DirectoryMap:
|
||||
specifications: Dict[str, Tuple[str, str, Optional[DirectorySnapshot]]] = {}
|
||||
for condition in directory_preconditions:
|
||||
if condition.original is not None:
|
||||
specifications[directory_key(condition.directory_path)] = (
|
||||
condition.directory_path,
|
||||
condition.label,
|
||||
condition.original,
|
||||
)
|
||||
|
||||
file_directories = [
|
||||
(os.path.dirname(change.file_path), f"{change.label} directory", True)
|
||||
for change in changes
|
||||
]
|
||||
file_directories.extend(
|
||||
(
|
||||
os.path.dirname(condition.file_path),
|
||||
f"{condition.label} directory",
|
||||
False,
|
||||
)
|
||||
for condition in file_preconditions
|
||||
)
|
||||
file_directories.extend(extra_file_targets)
|
||||
for directory_path, label, required in file_directories:
|
||||
key = directory_key(directory_path)
|
||||
if key in specifications:
|
||||
continue
|
||||
if required:
|
||||
ensure_physical_directory(directory_path, label)
|
||||
snapshot = read_physical_directory(directory_path, label)
|
||||
if snapshot is not None:
|
||||
specifications[key] = (directory_path, label, snapshot)
|
||||
|
||||
ordered = sorted(
|
||||
specifications.items(),
|
||||
key=lambda item: (os.path.abspath(item[1][0]).count(os.sep), item[0]),
|
||||
)
|
||||
pinned: DirectoryMap = {}
|
||||
try:
|
||||
for key, (directory_path, label, expected) in ordered:
|
||||
pinned[key] = PinnedDirectory.open(directory_path, label, expected)
|
||||
return pinned
|
||||
except Exception:
|
||||
close_directories(pinned)
|
||||
raise
|
||||
|
||||
|
||||
def close_directories(directories: DirectoryMap) -> List[str]:
|
||||
errors = []
|
||||
for directory in reversed(list(directories.values())):
|
||||
try:
|
||||
directory.close()
|
||||
except OSError as error:
|
||||
errors.append(str(error))
|
||||
return errors
|
||||
|
||||
|
||||
def reserve_change(
|
||||
change: FileChange,
|
||||
file_preconditions: Sequence[FilePrecondition],
|
||||
directory_preconditions: Sequence[DirectoryPrecondition],
|
||||
operation: str,
|
||||
directories: DirectoryMap,
|
||||
) -> StagedChange:
|
||||
assert_transaction_preconditions(
|
||||
file_preconditions, directory_preconditions, operation, directories
|
||||
)
|
||||
assert_unchanged(change, directories)
|
||||
directory = directory_for(change.file_path, directories)
|
||||
assert_transaction_preconditions(
|
||||
file_preconditions, directory_preconditions, operation, directories
|
||||
)
|
||||
basename = os.path.basename(change.file_path)
|
||||
temporary_path = None
|
||||
temporary_reservation = None
|
||||
rollback_path = None
|
||||
rollback_snapshot = None
|
||||
rollback_reservation = None
|
||||
try:
|
||||
if change.next_source is not None:
|
||||
temporary_name = f".{basename}.{uuid4().hex}.tmp"
|
||||
temporary_path = directory.path_for(temporary_name)
|
||||
temporary_reservation = reserve_private_path(
|
||||
directory,
|
||||
temporary_path,
|
||||
f"staged {change.label}",
|
||||
)
|
||||
if change.original is not None:
|
||||
rollback_name = f".{basename}.{uuid4().hex}.rollback"
|
||||
rollback_path = directory.path_for(rollback_name)
|
||||
rollback_reservation = reserve_private_path(
|
||||
directory,
|
||||
rollback_path,
|
||||
f"{change.label} rollback",
|
||||
)
|
||||
return StagedChange(
|
||||
change=change,
|
||||
directory=directory,
|
||||
temporary_path=temporary_path,
|
||||
temporary_snapshot=None,
|
||||
temporary_reservation=temporary_reservation,
|
||||
rollback_path=rollback_path,
|
||||
rollback_snapshot=rollback_snapshot,
|
||||
rollback_reservation=rollback_reservation,
|
||||
preconditions=(file_preconditions, directory_preconditions, operation),
|
||||
directories=directories,
|
||||
)
|
||||
except Exception as error:
|
||||
failures = cleanup_paths(directory, (temporary_path, rollback_path))
|
||||
suffix_message = (
|
||||
f"; cleanup failed: {'; '.join(failures)}" if failures else ""
|
||||
)
|
||||
raise OSError(f"Stage {change.label}: {error}{suffix_message}") from error
|
||||
|
||||
|
||||
def stage_change(staged: StagedChange) -> None:
|
||||
if staged.change.next_source is None:
|
||||
return
|
||||
write_staged(staged)
|
||||
|
||||
|
||||
def cleanup_staged(staged: StagedChange) -> None:
|
||||
_remove_exact_text(
|
||||
staged.directory,
|
||||
staged.temporary_path,
|
||||
f"staged {staged.change.label}",
|
||||
(
|
||||
staged.temporary_snapshot
|
||||
if staged.temporary_snapshot is not None
|
||||
else staged.temporary_reservation
|
||||
),
|
||||
)
|
||||
expected_rollback = (
|
||||
staged.rollback_snapshot
|
||||
if staged.rollback_snapshot is not None
|
||||
else staged.rollback_reservation
|
||||
)
|
||||
_remove_exact_text(
|
||||
staged.directory,
|
||||
staged.rollback_path,
|
||||
f"{staged.change.label} rollback",
|
||||
expected_rollback,
|
||||
)
|
||||
|
||||
|
||||
def _remove_exact_text(
|
||||
directory: PinnedDirectory,
|
||||
file_path: Optional[str],
|
||||
label: str,
|
||||
expected: Optional[FileSnapshot],
|
||||
) -> None:
|
||||
if file_path is None or expected is None:
|
||||
return
|
||||
try:
|
||||
captured = QuarantinedFile.capture(
|
||||
directory,
|
||||
file_path,
|
||||
label,
|
||||
require_parent_path=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
metadata = captured.container.stat_file("entry")
|
||||
changed = (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or stat.S_ISLNK(metadata.st_mode)
|
||||
or (metadata.st_dev, metadata.st_ino)
|
||||
!= (expected.device, expected.inode)
|
||||
)
|
||||
if not changed:
|
||||
# An in-place rewrite keeps the inode, so the removal is only exact
|
||||
# once the bytes also match the owned snapshot.
|
||||
try:
|
||||
snapshot = captured.text_snapshot(
|
||||
len(expected.contents.encode("utf-8"))
|
||||
)
|
||||
except ValueError:
|
||||
snapshot = None
|
||||
changed = snapshot is None or snapshot.contents != expected.contents
|
||||
if changed:
|
||||
try:
|
||||
captured.restore()
|
||||
except Exception as error:
|
||||
preserved = captured.preserve() if captured.active else file_path
|
||||
raise OSError(
|
||||
f"{label} changed before cleanup; transaction data "
|
||||
f"preserved at {preserved}: {error}"
|
||||
) from error
|
||||
raise OSError(f"{label} changed before cleanup: {file_path}")
|
||||
captured.discard()
|
||||
|
||||
|
||||
def cleanup_created_directories(
|
||||
preconditions: Sequence[DirectoryPrecondition],
|
||||
directories: Optional[DirectoryMap] = None,
|
||||
) -> List[str]:
|
||||
errors = []
|
||||
created = sorted(
|
||||
(condition for condition in preconditions if condition.created),
|
||||
key=lambda condition: os.path.abspath(condition.directory_path).count(os.sep),
|
||||
reverse=True,
|
||||
)
|
||||
for condition in created:
|
||||
child = None
|
||||
parent = None
|
||||
close_child = False
|
||||
close_parent = False
|
||||
try:
|
||||
current = read_physical_directory(condition.directory_path, condition.label)
|
||||
if current is None:
|
||||
continue
|
||||
if current != condition.original:
|
||||
raise OSError(
|
||||
f"{condition.label} changed before recovery: "
|
||||
f"{condition.directory_path}"
|
||||
)
|
||||
child_key = directory_key(condition.directory_path)
|
||||
child = None if directories is None else directories.get(child_key)
|
||||
if child is not None and os.name == "nt":
|
||||
child.close()
|
||||
child = PinnedDirectory.open(
|
||||
condition.directory_path,
|
||||
condition.label,
|
||||
condition.original,
|
||||
delete_access=True,
|
||||
)
|
||||
close_child = True
|
||||
if child is None:
|
||||
child = PinnedDirectory.open(
|
||||
condition.directory_path,
|
||||
condition.label,
|
||||
condition.original,
|
||||
delete_access=True,
|
||||
)
|
||||
close_child = True
|
||||
|
||||
parent_path = os.path.dirname(os.path.abspath(condition.directory_path))
|
||||
parent_key = directory_key(parent_path)
|
||||
parent = None if directories is None else directories.get(parent_key)
|
||||
if parent is None:
|
||||
parent_snapshot = read_physical_directory(
|
||||
parent_path, f"{condition.label} parent"
|
||||
)
|
||||
if parent_snapshot is None:
|
||||
raise OSError(
|
||||
f"{condition.label} parent is missing: {parent_path}"
|
||||
)
|
||||
parent = PinnedDirectory.open(
|
||||
parent_path,
|
||||
f"{condition.label} parent",
|
||||
parent_snapshot,
|
||||
)
|
||||
close_parent = True
|
||||
if os.name != "nt" and directories is not None:
|
||||
child_prefix = directory_key(condition.directory_path) + os.sep
|
||||
for key, descendant in directories.items():
|
||||
if key.startswith(child_prefix):
|
||||
descendant.close()
|
||||
preserved = remove_pinned_empty_directory(
|
||||
parent,
|
||||
child,
|
||||
require_empty=True,
|
||||
)
|
||||
if preserved is not None:
|
||||
errors.append(
|
||||
f"Preserved created {condition.label} at {preserved} "
|
||||
"for identity-safe POSIX cleanup"
|
||||
)
|
||||
except OSError as error:
|
||||
errors.append(f"Remove created {condition.label}: {error}")
|
||||
finally:
|
||||
for directory, should_close in (
|
||||
(child, close_child),
|
||||
(parent, close_parent),
|
||||
):
|
||||
if directory is not None and should_close:
|
||||
try:
|
||||
directory.close()
|
||||
except OSError as error:
|
||||
errors.append(
|
||||
f"Close created {condition.label}: {error}"
|
||||
)
|
||||
return errors
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Data contracts for fail-fast transactional file changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Sequence, Tuple
|
||||
|
||||
from ._managed_files import DirectorySnapshot, FileSnapshot, MAX_SOURCE_BYTES
|
||||
from ._pinned_directory import PinnedDirectory
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[str]
|
||||
next_source: Optional[str]
|
||||
mode: int
|
||||
original_mode: Optional[int]
|
||||
original_device: Optional[int]
|
||||
original_inode: Optional[int]
|
||||
maximum_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilePrecondition:
|
||||
file_path: str
|
||||
label: str
|
||||
original: Optional[FileSnapshot]
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DirectoryPrecondition:
|
||||
directory_path: str
|
||||
label: str
|
||||
original: Optional[DirectorySnapshot]
|
||||
created: bool = False
|
||||
|
||||
|
||||
TransactionPreconditions = Tuple[
|
||||
Sequence[FilePrecondition], Sequence[DirectoryPrecondition], str
|
||||
]
|
||||
DirectoryMap = Dict[str, PinnedDirectory]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagedChange:
|
||||
change: FileChange
|
||||
directory: PinnedDirectory
|
||||
temporary_path: Optional[str]
|
||||
temporary_snapshot: Optional[FileSnapshot]
|
||||
temporary_reservation: Optional[FileSnapshot]
|
||||
rollback_path: Optional[str]
|
||||
rollback_snapshot: Optional[FileSnapshot]
|
||||
rollback_reservation: Optional[FileSnapshot]
|
||||
preconditions: TransactionPreconditions
|
||||
directories: DirectoryMap
|
||||
committed_snapshot: Optional[FileSnapshot] = None
|
||||
committed: bool = False
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Windows owner-only security boundary for durable transaction state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from typing import Any, Tuple
|
||||
|
||||
|
||||
_kernel32: Any = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
_advapi32: Any = ctypes.WinDLL("advapi32", use_last_error=True)
|
||||
|
||||
_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
||||
_ERROR_ALREADY_EXISTS = 183
|
||||
_TOKEN_QUERY = 0x0008
|
||||
_TOKEN_USER = 1
|
||||
_SE_FILE_OBJECT = 1
|
||||
_OWNER_SECURITY_INFORMATION = 0x00000001
|
||||
_DACL_SECURITY_INFORMATION = 0x00000004
|
||||
_PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
|
||||
_SE_DACL_PROTECTED = 0x1000
|
||||
_ACL_SIZE_INFORMATION_CLASS = 2
|
||||
_ACCESS_ALLOWED_ACE_TYPE = 0
|
||||
_OBJECT_INHERIT_ACE = 0x01
|
||||
_CONTAINER_INHERIT_ACE = 0x02
|
||||
_FILE_ALL_ACCESS = 0x001F01FF
|
||||
_SDDL_REVISION_1 = 1
|
||||
_READ_CONTROL = 0x00020000
|
||||
_WRITE_DAC = 0x00040000
|
||||
_FILE_READ_ATTRIBUTES = 0x0080
|
||||
_GENERIC_READ = 0x80000000
|
||||
_GENERIC_WRITE = 0x40000000
|
||||
_FILE_SHARE_READ = 0x00000001
|
||||
_FILE_SHARE_WRITE = 0x00000002
|
||||
_OPEN_EXISTING = 3
|
||||
_OPEN_ALWAYS = 4
|
||||
_FILE_ATTRIBUTE_DIRECTORY = 0x00000010
|
||||
_FILE_ATTRIBUTE_NORMAL = 0x00000080
|
||||
_FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400
|
||||
_FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
||||
_FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
||||
_FILE_TYPE_DISK = 1
|
||||
|
||||
|
||||
class _SecurityAttributes(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("nLength", wintypes.DWORD),
|
||||
("lpSecurityDescriptor", wintypes.LPVOID),
|
||||
("bInheritHandle", wintypes.BOOL),
|
||||
]
|
||||
|
||||
|
||||
class _SidAndAttributes(ctypes.Structure):
|
||||
_fields_ = [("Sid", wintypes.LPVOID), ("Attributes", wintypes.DWORD)]
|
||||
|
||||
|
||||
class _TokenUser(ctypes.Structure):
|
||||
_fields_ = [("User", _SidAndAttributes)]
|
||||
|
||||
|
||||
class _AclSizeInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("AceCount", wintypes.DWORD),
|
||||
("AclBytesInUse", wintypes.DWORD),
|
||||
("AclBytesFree", wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class _AceHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("AceType", wintypes.BYTE),
|
||||
("AceFlags", wintypes.BYTE),
|
||||
("AceSize", wintypes.WORD),
|
||||
]
|
||||
|
||||
|
||||
class _AccessAllowedAce(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Header", _AceHeader),
|
||||
("Mask", wintypes.DWORD),
|
||||
("SidStart", wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
class _ByHandleFileInformation(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwFileAttributes", wintypes.DWORD),
|
||||
("ftCreationTime", wintypes.FILETIME),
|
||||
("ftLastAccessTime", wintypes.FILETIME),
|
||||
("ftLastWriteTime", wintypes.FILETIME),
|
||||
("dwVolumeSerialNumber", wintypes.DWORD),
|
||||
("nFileSizeHigh", wintypes.DWORD),
|
||||
("nFileSizeLow", wintypes.DWORD),
|
||||
("nNumberOfLinks", wintypes.DWORD),
|
||||
("nFileIndexHigh", wintypes.DWORD),
|
||||
("nFileIndexLow", wintypes.DWORD),
|
||||
]
|
||||
|
||||
|
||||
def _declare(function: Any, arguments: list[Any], result: Any) -> None:
|
||||
function.argtypes = arguments
|
||||
function.restype = result
|
||||
|
||||
|
||||
_declare(_kernel32.GetCurrentProcess, [], wintypes.HANDLE)
|
||||
_declare(_advapi32.OpenProcessToken, [wintypes.HANDLE, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.HANDLE)], wintypes.BOOL)
|
||||
_declare(_advapi32.GetTokenInformation,
|
||||
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL)
|
||||
_declare(_advapi32.GetLengthSid, [wintypes.LPVOID], wintypes.DWORD)
|
||||
_declare(_advapi32.CopySid,
|
||||
[wintypes.DWORD, wintypes.LPVOID, wintypes.LPVOID], wintypes.BOOL)
|
||||
_declare(_advapi32.ConvertSidToStringSidW,
|
||||
[wintypes.LPVOID, ctypes.POINTER(wintypes.LPWSTR)], wintypes.BOOL)
|
||||
_declare(_advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW,
|
||||
[wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID),
|
||||
ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL)
|
||||
_declare(_advapi32.GetSecurityDescriptorDacl,
|
||||
[wintypes.LPVOID, ctypes.POINTER(wintypes.BOOL),
|
||||
ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.BOOL)],
|
||||
wintypes.BOOL)
|
||||
_declare(_advapi32.GetSecurityDescriptorControl,
|
||||
[wintypes.LPVOID, ctypes.POINTER(wintypes.WORD),
|
||||
ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL)
|
||||
_declare(
|
||||
_advapi32.GetSecurityInfo,
|
||||
[wintypes.HANDLE, ctypes.c_int, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID),
|
||||
ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.LPVOID),
|
||||
ctypes.POINTER(wintypes.LPVOID)],
|
||||
wintypes.DWORD,
|
||||
)
|
||||
_declare(
|
||||
_advapi32.SetSecurityInfo,
|
||||
[wintypes.HANDLE, ctypes.c_int, wintypes.DWORD, wintypes.LPVOID,
|
||||
wintypes.LPVOID, wintypes.LPVOID, wintypes.LPVOID],
|
||||
wintypes.DWORD,
|
||||
)
|
||||
_declare(_advapi32.GetAclInformation,
|
||||
[wintypes.LPVOID, wintypes.LPVOID, wintypes.DWORD, ctypes.c_int],
|
||||
wintypes.BOOL)
|
||||
_declare(_advapi32.GetAce,
|
||||
[wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID)],
|
||||
wintypes.BOOL)
|
||||
_declare(_advapi32.EqualSid, [wintypes.LPVOID, wintypes.LPVOID], wintypes.BOOL)
|
||||
_declare(_kernel32.LocalFree, [wintypes.HLOCAL], wintypes.HLOCAL)
|
||||
_declare(_kernel32.CloseHandle, [wintypes.HANDLE], wintypes.BOOL)
|
||||
_declare(_kernel32.CreateDirectoryW,
|
||||
[wintypes.LPCWSTR, ctypes.POINTER(_SecurityAttributes)], wintypes.BOOL)
|
||||
_declare(_kernel32.CreateFileW,
|
||||
[wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
ctypes.POINTER(_SecurityAttributes), wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.HANDLE], wintypes.HANDLE)
|
||||
_declare(_kernel32.GetFileType, [wintypes.HANDLE], wintypes.DWORD)
|
||||
_declare(_kernel32.GetFileInformationByHandle,
|
||||
[wintypes.HANDLE, ctypes.POINTER(_ByHandleFileInformation)], wintypes.BOOL)
|
||||
|
||||
|
||||
def _winerror(action: str, code: int | None = None) -> OSError:
|
||||
error = ctypes.get_last_error() if code is None else code
|
||||
return OSError(f"{action}: {ctypes.WinError(error)}")
|
||||
|
||||
|
||||
def close_handle(handle: int) -> None:
|
||||
if not _kernel32.CloseHandle(handle):
|
||||
raise _winerror("Close transaction state handle")
|
||||
|
||||
|
||||
def _current_user_sid() -> Tuple[Any, str]:
|
||||
token = wintypes.HANDLE()
|
||||
if not _advapi32.OpenProcessToken(
|
||||
_kernel32.GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)
|
||||
):
|
||||
raise _winerror("Open current process token")
|
||||
try:
|
||||
required = wintypes.DWORD()
|
||||
_advapi32.GetTokenInformation(
|
||||
token, _TOKEN_USER, None, 0, ctypes.byref(required)
|
||||
)
|
||||
token_data = ctypes.create_string_buffer(required.value)
|
||||
if not _advapi32.GetTokenInformation(
|
||||
token,
|
||||
_TOKEN_USER,
|
||||
token_data,
|
||||
required,
|
||||
ctypes.byref(required),
|
||||
):
|
||||
raise _winerror("Read current process token")
|
||||
sid = ctypes.cast(token_data, ctypes.POINTER(_TokenUser)).contents.User.Sid
|
||||
sid_length = _advapi32.GetLengthSid(sid)
|
||||
if not sid_length:
|
||||
raise _winerror("Measure current user SID")
|
||||
sid_copy = ctypes.create_string_buffer(sid_length)
|
||||
if not _advapi32.CopySid(sid_length, sid_copy, sid):
|
||||
raise _winerror("Copy current user SID")
|
||||
sid_text = wintypes.LPWSTR()
|
||||
if not _advapi32.ConvertSidToStringSidW(sid_copy, ctypes.byref(sid_text)):
|
||||
raise _winerror("Format current user SID")
|
||||
try:
|
||||
sid_string = sid_text.value
|
||||
if sid_string is None:
|
||||
raise OSError("Current user SID string is unavailable")
|
||||
return sid_copy, sid_string
|
||||
finally:
|
||||
_kernel32.LocalFree(sid_text)
|
||||
finally:
|
||||
token_value = token.value
|
||||
if token_value is not None:
|
||||
close_handle(int(token_value))
|
||||
|
||||
|
||||
def current_user_sid_string() -> str:
|
||||
"""Return the stable current-user SID used by the state namespace."""
|
||||
_sid, sid_text = _current_user_sid()
|
||||
return sid_text
|
||||
|
||||
|
||||
def _security_descriptor(directory: bool) -> Tuple[int, int]:
|
||||
_sid, sid_text = _current_user_sid()
|
||||
inheritance = "OICI" if directory else ""
|
||||
descriptor = wintypes.LPVOID()
|
||||
sddl = f"O:{sid_text}D:P(A;{inheritance};FA;;;{sid_text})"
|
||||
if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl,
|
||||
_SDDL_REVISION_1,
|
||||
ctypes.byref(descriptor),
|
||||
None,
|
||||
):
|
||||
raise _winerror("Build transaction security descriptor")
|
||||
present = wintypes.BOOL()
|
||||
defaulted = wintypes.BOOL()
|
||||
dacl = wintypes.LPVOID()
|
||||
if not _advapi32.GetSecurityDescriptorDacl(
|
||||
descriptor,
|
||||
ctypes.byref(present),
|
||||
ctypes.byref(dacl),
|
||||
ctypes.byref(defaulted),
|
||||
):
|
||||
_kernel32.LocalFree(descriptor)
|
||||
raise _winerror("Read transaction security descriptor")
|
||||
if not present.value or not dacl.value:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
raise OSError("Transaction security descriptor has no DACL")
|
||||
descriptor_value = descriptor.value
|
||||
dacl_value = dacl.value
|
||||
if descriptor_value is None or dacl_value is None:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
raise OSError("Transaction security descriptor has no DACL")
|
||||
return int(descriptor_value), int(dacl_value)
|
||||
|
||||
|
||||
def _file_information(handle: int, directory: bool) -> _ByHandleFileInformation:
|
||||
if _kernel32.GetFileType(handle) != _FILE_TYPE_DISK:
|
||||
raise OSError("Transaction state path is not a disk file")
|
||||
information = _ByHandleFileInformation()
|
||||
if not _kernel32.GetFileInformationByHandle(handle, ctypes.byref(information)):
|
||||
raise _winerror("Inspect transaction state path")
|
||||
attributes = information.dwFileAttributes
|
||||
expected_directory = bool(attributes & _FILE_ATTRIBUTE_DIRECTORY)
|
||||
if attributes & _FILE_ATTRIBUTE_REPARSE_POINT or expected_directory != directory:
|
||||
kind = "directory" if directory else "file"
|
||||
raise OSError(f"Transaction state path is not a physical {kind}")
|
||||
return information
|
||||
|
||||
|
||||
def _identity(information: _ByHandleFileInformation) -> Tuple[int, int, int]:
|
||||
return (
|
||||
int(information.dwVolumeSerialNumber),
|
||||
int(information.nFileIndexHigh),
|
||||
int(information.nFileIndexLow),
|
||||
)
|
||||
|
||||
|
||||
def _open_path(
|
||||
path: str,
|
||||
directory: bool,
|
||||
access: int,
|
||||
disposition: int,
|
||||
security: _SecurityAttributes | None = None,
|
||||
) -> int:
|
||||
flags = _FILE_FLAG_OPEN_REPARSE_POINT
|
||||
if directory:
|
||||
flags |= _FILE_FLAG_BACKUP_SEMANTICS
|
||||
else:
|
||||
flags |= _FILE_ATTRIBUTE_NORMAL
|
||||
security_pointer = None if security is None else ctypes.byref(security)
|
||||
handle = _kernel32.CreateFileW(
|
||||
path,
|
||||
access,
|
||||
_FILE_SHARE_READ | _FILE_SHARE_WRITE,
|
||||
security_pointer,
|
||||
disposition,
|
||||
flags,
|
||||
None,
|
||||
)
|
||||
if int(handle) == _INVALID_HANDLE_VALUE:
|
||||
raise _winerror("Open transaction state path")
|
||||
return int(handle)
|
||||
|
||||
|
||||
def _verify_owner_and_dacl(handle: int, directory: bool) -> None:
|
||||
current_sid, _sid_text = _current_user_sid()
|
||||
owner = wintypes.LPVOID()
|
||||
dacl = wintypes.LPVOID()
|
||||
descriptor = wintypes.LPVOID()
|
||||
result = _advapi32.GetSecurityInfo(
|
||||
handle,
|
||||
_SE_FILE_OBJECT,
|
||||
_OWNER_SECURITY_INFORMATION | _DACL_SECURITY_INFORMATION,
|
||||
ctypes.byref(owner),
|
||||
None,
|
||||
ctypes.byref(dacl),
|
||||
None,
|
||||
ctypes.byref(descriptor),
|
||||
)
|
||||
if result:
|
||||
raise _winerror("Read transaction state security", int(result))
|
||||
try:
|
||||
if not owner.value or not _advapi32.EqualSid(owner, current_sid):
|
||||
raise PermissionError("Transaction state path is not owned by current user")
|
||||
control = wintypes.WORD()
|
||||
revision = wintypes.DWORD()
|
||||
if not _advapi32.GetSecurityDescriptorControl(
|
||||
descriptor, ctypes.byref(control), ctypes.byref(revision)
|
||||
):
|
||||
raise _winerror("Read transaction DACL control")
|
||||
if not control.value & _SE_DACL_PROTECTED or not dacl.value:
|
||||
raise PermissionError("Transaction state DACL is not protected owner-only")
|
||||
size = _AclSizeInformation()
|
||||
if not _advapi32.GetAclInformation(
|
||||
dacl,
|
||||
ctypes.byref(size),
|
||||
ctypes.sizeof(size),
|
||||
_ACL_SIZE_INFORMATION_CLASS,
|
||||
):
|
||||
raise _winerror("Inspect transaction DACL")
|
||||
if size.AceCount != 1:
|
||||
raise PermissionError("Transaction state DACL is not protected owner-only")
|
||||
ace_pointer = wintypes.LPVOID()
|
||||
if not _advapi32.GetAce(dacl, 0, ctypes.byref(ace_pointer)):
|
||||
raise _winerror("Read transaction DACL entry")
|
||||
ace = ctypes.cast(ace_pointer, ctypes.POINTER(_AccessAllowedAce)).contents
|
||||
expected_flags = (
|
||||
_OBJECT_INHERIT_ACE | _CONTAINER_INHERIT_ACE if directory else 0
|
||||
)
|
||||
ace_address = ace_pointer.value
|
||||
if ace_address is None:
|
||||
raise OSError("Transaction DACL entry is unavailable")
|
||||
sid_pointer = ctypes.c_void_p(
|
||||
int(ace_address) + _AccessAllowedAce.SidStart.offset
|
||||
)
|
||||
if (
|
||||
ace.Header.AceType != _ACCESS_ALLOWED_ACE_TYPE
|
||||
or ace.Header.AceFlags != expected_flags
|
||||
or ace.Mask != _FILE_ALL_ACCESS
|
||||
or not _advapi32.EqualSid(sid_pointer, current_sid)
|
||||
):
|
||||
raise PermissionError("Transaction state DACL is not protected owner-only")
|
||||
finally:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
|
||||
|
||||
def _apply_private_dacl(handle: int, directory: bool) -> None:
|
||||
current_sid, _sid_text = _current_user_sid()
|
||||
owner = wintypes.LPVOID()
|
||||
descriptor = wintypes.LPVOID()
|
||||
result = _advapi32.GetSecurityInfo(
|
||||
handle,
|
||||
_SE_FILE_OBJECT,
|
||||
_OWNER_SECURITY_INFORMATION,
|
||||
ctypes.byref(owner),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ctypes.byref(descriptor),
|
||||
)
|
||||
if result:
|
||||
raise _winerror("Read transaction state owner", int(result))
|
||||
try:
|
||||
if not owner.value or not _advapi32.EqualSid(owner, current_sid):
|
||||
raise PermissionError("Transaction state path is not owned by current user")
|
||||
finally:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
|
||||
security_descriptor, dacl = _security_descriptor(directory)
|
||||
try:
|
||||
result = _advapi32.SetSecurityInfo(
|
||||
handle,
|
||||
_SE_FILE_OBJECT,
|
||||
_DACL_SECURITY_INFORMATION | _PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
None,
|
||||
None,
|
||||
dacl,
|
||||
None,
|
||||
)
|
||||
if result:
|
||||
raise _winerror("Protect transaction state DACL", int(result))
|
||||
finally:
|
||||
_kernel32.LocalFree(security_descriptor)
|
||||
_verify_owner_and_dacl(handle, directory)
|
||||
|
||||
|
||||
def _verify_path_identity(path: str, handle: int, directory: bool) -> None:
|
||||
expected = _identity(_file_information(handle, directory))
|
||||
visible = _open_path(
|
||||
path,
|
||||
directory,
|
||||
_READ_CONTROL | _FILE_READ_ATTRIBUTES,
|
||||
_OPEN_EXISTING,
|
||||
)
|
||||
try:
|
||||
if _identity(_file_information(visible, directory)) != expected:
|
||||
raise OSError("Transaction state path changed while opening")
|
||||
finally:
|
||||
close_handle(visible)
|
||||
|
||||
|
||||
def ensure_private_directory(path: str) -> None:
|
||||
"""Create or harden one physical current-user transaction directory."""
|
||||
descriptor, _dacl = _security_descriptor(True)
|
||||
security = _SecurityAttributes(
|
||||
ctypes.sizeof(_SecurityAttributes),
|
||||
descriptor,
|
||||
False,
|
||||
)
|
||||
try:
|
||||
if not _kernel32.CreateDirectoryW(path, ctypes.byref(security)):
|
||||
error = ctypes.get_last_error()
|
||||
if error != _ERROR_ALREADY_EXISTS:
|
||||
raise _winerror("Create transaction state directory", error)
|
||||
finally:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
handle = _open_path(
|
||||
path,
|
||||
True,
|
||||
_READ_CONTROL | _WRITE_DAC | _FILE_READ_ATTRIBUTES,
|
||||
_OPEN_EXISTING,
|
||||
)
|
||||
try:
|
||||
_file_information(handle, True)
|
||||
_apply_private_dacl(handle, True)
|
||||
_verify_path_identity(path, handle, True)
|
||||
finally:
|
||||
close_handle(handle)
|
||||
|
||||
|
||||
def open_private_file(path: str) -> int:
|
||||
"""Open one physical current-user file with a protected owner-only DACL."""
|
||||
descriptor, _dacl = _security_descriptor(False)
|
||||
security = _SecurityAttributes(
|
||||
ctypes.sizeof(_SecurityAttributes),
|
||||
descriptor,
|
||||
False,
|
||||
)
|
||||
try:
|
||||
handle = _open_path(
|
||||
path,
|
||||
False,
|
||||
_GENERIC_READ | _GENERIC_WRITE | _READ_CONTROL | _WRITE_DAC,
|
||||
_OPEN_ALWAYS,
|
||||
security,
|
||||
)
|
||||
finally:
|
||||
_kernel32.LocalFree(descriptor)
|
||||
try:
|
||||
_file_information(handle, False)
|
||||
_apply_private_dacl(handle, False)
|
||||
_verify_path_identity(path, handle, False)
|
||||
except Exception:
|
||||
close_handle(handle)
|
||||
raise
|
||||
return handle
|
||||
|
||||
|
||||
def verify_private_path(path: str, *, directory: bool) -> None:
|
||||
"""Verify a physical current-user path has the exact private DACL contract."""
|
||||
handle = _open_path(
|
||||
path,
|
||||
directory,
|
||||
_READ_CONTROL | _FILE_READ_ATTRIBUTES,
|
||||
_OPEN_EXISTING,
|
||||
)
|
||||
try:
|
||||
_file_information(handle, directory)
|
||||
_verify_owner_and_dacl(handle, directory)
|
||||
_verify_path_identity(path, handle, directory)
|
||||
finally:
|
||||
close_handle(handle)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"close_handle",
|
||||
"current_user_sid_string",
|
||||
"ensure_private_directory",
|
||||
"open_private_file",
|
||||
"verify_private_path",
|
||||
]
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import TypedDict
|
||||
else:
|
||||
@@ -33,6 +31,8 @@ class AgentPlugin(ABC):
|
||||
"""Base class for all agent plugins."""
|
||||
|
||||
manages_guard_runtime = False
|
||||
manages_runtime_directories = False
|
||||
manages_runtime_removal = False
|
||||
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
"""Validate provider sources before the CLI creates runtime files."""
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Bounded error-log primitives embedded in generated audit hooks."""
|
||||
|
||||
ERROR_LOG_RUNTIME = r"""
|
||||
MAX_ERROR_ENTRY_BYTES = 64 * 1024
|
||||
ERROR_LOG_LOCK_ATTEMPTS = 50
|
||||
ERROR_LOG_LOCK_DELAY_SECONDS = 0.01
|
||||
|
||||
|
||||
def validate_error_log_metadata(metadata, file_path, label):
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise ValueError(label + " path is not a physical file: " + file_path)
|
||||
if os.name != "nt" and metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
raise PermissionError(label + " permissions are too broad: " + file_path)
|
||||
|
||||
|
||||
def open_error_log(file_path, label):
|
||||
for _attempt in range(3):
|
||||
try:
|
||||
before = os.lstat(file_path)
|
||||
except FileNotFoundError:
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(file_path, flags, 0o600)
|
||||
except FileExistsError:
|
||||
continue
|
||||
try:
|
||||
validate_error_log_metadata(os.fstat(descriptor), file_path, label)
|
||||
return descriptor
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
if stat.S_ISLNK(before.st_mode):
|
||||
raise ValueError(label + " path is not a physical file: " + file_path)
|
||||
validate_error_log_metadata(before, file_path, label)
|
||||
flags = os.O_RDWR | os.O_APPEND
|
||||
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(file_path, flags)
|
||||
try:
|
||||
after = os.fstat(descriptor)
|
||||
validate_error_log_metadata(after, file_path, label)
|
||||
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||
raise OSError(label + " changed while opening: " + file_path)
|
||||
return descriptor
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
raise OSError(label + " changed repeatedly while opening: " + file_path)
|
||||
|
||||
|
||||
def lock_error_log(descriptor, file_path, label):
|
||||
import errno
|
||||
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
lock_kind = "windows"
|
||||
lock = lambda: msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
||||
retry_errors = {
|
||||
errno.EACCES,
|
||||
errno.EAGAIN,
|
||||
getattr(errno, "EDEADLK", -1),
|
||||
}
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
lock_kind = "posix"
|
||||
lock = lambda: fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
retry_errors = {errno.EACCES, errno.EAGAIN}
|
||||
for attempt in range(ERROR_LOG_LOCK_ATTEMPTS):
|
||||
try:
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
lock()
|
||||
return lock_kind
|
||||
except OSError as error:
|
||||
if error.errno not in retry_errors:
|
||||
raise
|
||||
if attempt + 1 == ERROR_LOG_LOCK_ATTEMPTS:
|
||||
raise TimeoutError(
|
||||
label + " remained locked: " + file_path
|
||||
) from error
|
||||
time.sleep(ERROR_LOG_LOCK_DELAY_SECONDS)
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def unlock_error_log(descriptor, lock_kind):
|
||||
if lock_kind == "windows":
|
||||
import msvcrt
|
||||
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def write_all(descriptor, value, label, file_path):
|
||||
offset = 0
|
||||
while offset < len(value):
|
||||
written = os.write(descriptor, value[offset:])
|
||||
if written <= 0:
|
||||
raise OSError(label + " write made no progress: " + file_path)
|
||||
offset += written
|
||||
|
||||
|
||||
def bounded_error_message(error):
|
||||
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
prefix = (timestamp + " [elydora-hook] ").encode("ascii")
|
||||
detail = repr(error).encode("utf-8", errors="backslashreplace")
|
||||
suffix = b"\n"
|
||||
if len(prefix) + len(detail) + len(suffix) <= MAX_ERROR_ENTRY_BYTES:
|
||||
return prefix + detail + suffix
|
||||
marker = b"... [error entry truncated]\n"
|
||||
available = MAX_ERROR_ENTRY_BYTES - len(prefix) - len(marker)
|
||||
detail = detail[:available].decode("utf-8", errors="ignore").encode("utf-8")
|
||||
return prefix + detail + marker
|
||||
|
||||
|
||||
def error_log_rollover_notice(max_bytes):
|
||||
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
return (
|
||||
"[elydora-hook] Error log truncated at the "
|
||||
+ str(max_bytes)
|
||||
+ "-byte limit at "
|
||||
+ timestamp
|
||||
+ "\n"
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def append_bounded_error_log(file_path, label, encoded, max_bytes):
|
||||
if len(encoded) > max_bytes:
|
||||
raise ValueError(label + " entry exceeds the size limit: " + file_path)
|
||||
descriptor = open_error_log(file_path, label)
|
||||
lock_kind = None
|
||||
try:
|
||||
lock_kind = lock_error_log(descriptor, file_path, label)
|
||||
metadata = os.fstat(descriptor)
|
||||
validate_error_log_metadata(metadata, file_path, label)
|
||||
if metadata.st_size + len(encoded) > max_bytes:
|
||||
value = error_log_rollover_notice(max_bytes) + encoded
|
||||
if len(value) > max_bytes:
|
||||
raise ValueError(label + " entry exceeds the size limit: " + file_path)
|
||||
os.ftruncate(descriptor, 0)
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
else:
|
||||
value = encoded
|
||||
os.lseek(descriptor, 0, os.SEEK_END)
|
||||
write_all(descriptor, value, label, file_path)
|
||||
os.fsync(descriptor)
|
||||
if os.name != "nt":
|
||||
os.fchmod(descriptor, 0o600)
|
||||
finally:
|
||||
try:
|
||||
if lock_kind is not None:
|
||||
unlock_error_log(descriptor, lock_kind)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
"""
|
||||
|
||||
__all__ = ["ERROR_LOG_RUNTIME"]
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from .error_log_template import ERROR_LOG_RUNTIME
|
||||
from .guard_template import generate_guard_script
|
||||
from .runtime_reader_template import PROTECTED_RUNTIME_READER
|
||||
|
||||
@@ -50,9 +51,13 @@ PRIVATE_KEY_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "private.key")
|
||||
CHAIN_STATE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "chain-state.json")
|
||||
ERROR_LOG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "error.log")
|
||||
ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
MAX_CHAIN_ATTEMPTS = 5
|
||||
SUBMIT_BUDGET_SECONDS = 4.0
|
||||
|
||||
{PROTECTED_RUNTIME_READER}
|
||||
|
||||
{ERROR_LOG_RUNTIME}
|
||||
|
||||
|
||||
def base64url_encode(data):
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
@@ -137,9 +142,12 @@ def generate_uuidv7():
|
||||
|
||||
def log_error(error):
|
||||
try:
|
||||
message = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
message += " [elydora-hook] " + repr(error) + "\\n"
|
||||
append_protected_text(ERROR_LOG_PATH, "Error log", message)
|
||||
append_bounded_error_log(
|
||||
ERROR_LOG_PATH,
|
||||
"Error log",
|
||||
bounded_error_message(error),
|
||||
MAX_PROTECTED_LOG_BYTES,
|
||||
)
|
||||
except Exception as log_failure:
|
||||
sys.stderr.write(
|
||||
"[Elydora audit] Failed to write error log: "
|
||||
@@ -177,6 +185,77 @@ def write_chain_state(chain_hash):
|
||||
)
|
||||
|
||||
|
||||
def lock_owner_alive(lock_path):
|
||||
if os.name == "nt":
|
||||
return False
|
||||
try:
|
||||
with open(lock_path, "r", encoding="ascii") as handle:
|
||||
owner = int(handle.read().strip() or "0")
|
||||
except (FileNotFoundError, ValueError):
|
||||
return False
|
||||
if owner <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(owner, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def with_chain_lock(update):
|
||||
lock_path = CHAIN_STATE_PATH + ".lock"
|
||||
deadline = time.monotonic() + 1.0
|
||||
while True:
|
||||
try:
|
||||
descriptor = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
os.write(descriptor, str(os.getpid()).encode("ascii"))
|
||||
except FileExistsError:
|
||||
if lock_owner_alive(lock_path):
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Chain state lock timed out: " + lock_path)
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
try:
|
||||
age = time.time() - os.stat(lock_path).st_mtime
|
||||
except FileNotFoundError:
|
||||
age = 0.0
|
||||
if age > 5.0:
|
||||
reclaimed = lock_path + "." + str(os.getpid()) + ".stale"
|
||||
try:
|
||||
os.rename(lock_path, reclaimed)
|
||||
os.unlink(reclaimed)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError("Chain state lock timed out: " + lock_path)
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
try:
|
||||
return update()
|
||||
finally:
|
||||
try:
|
||||
current = os.lstat(lock_path)
|
||||
except FileNotFoundError:
|
||||
current = None
|
||||
own = os.fstat(descriptor)
|
||||
os.close(descriptor)
|
||||
if current is not None and current.st_ino == own.st_ino and current.st_dev == own.st_dev:
|
||||
os.unlink(lock_path)
|
||||
|
||||
|
||||
def advance_chain_state(from_hash, to_hash):
|
||||
def update():
|
||||
if read_chain_state() != from_hash:
|
||||
return False
|
||||
write_chain_state(to_hash)
|
||||
return True
|
||||
|
||||
return with_chain_lock(update)
|
||||
|
||||
|
||||
def read_runtime_config():
|
||||
try:
|
||||
raw = read_protected_file(
|
||||
@@ -239,27 +318,44 @@ def read_event():
|
||||
return event
|
||||
|
||||
|
||||
def submit_operation(event, runtime):
|
||||
nested = event.get("tool_result")
|
||||
if not isinstance(nested, dict):
|
||||
nested = event.get("tool_call")
|
||||
if not isinstance(nested, dict):
|
||||
nested = {{}}
|
||||
tool_name = event.get("tool_name", event.get("toolName", event.get("name", nested.get("name", "unknown"))))
|
||||
tool_input = event.get("tool_input", event.get("toolInput", event.get("input", nested.get("input", {{}}))))
|
||||
session_id = event.get(
|
||||
"conversation_id",
|
||||
event.get("session_id", event.get("sessionId", event.get("session", event.get("taskId", "unknown")))),
|
||||
def post_operation(operation, runtime, deadline):
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise RuntimeError("Audit API retry budget exhausted")
|
||||
url = runtime.get("base_url", "https://api.elydora.com").rstrip("/") + "/v1/operations"
|
||||
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
|
||||
if runtime.get("token"):
|
||||
headers["Authorization"] = "Bearer " + runtime["token"]
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(operation).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
previous = read_chain_state()
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=min(5.0, remaining)):
|
||||
return None
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8")
|
||||
if error.code == 400 and body:
|
||||
try:
|
||||
failure = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as parse_error:
|
||||
raise RuntimeError("Audit API returned invalid error JSON: " + str(parse_error)) from parse_error
|
||||
details = failure.get("error", {{}}) if isinstance(failure, dict) else {{}}
|
||||
if details.get("code") == "PREV_HASH_MISMATCH":
|
||||
match = re.search(
|
||||
r'Expected prev_chain_hash "([A-Za-z0-9_-]{{43}})"',
|
||||
str(details.get("message", "")),
|
||||
)
|
||||
if match:
|
||||
return match.group(1)
|
||||
raise RuntimeError("Audit API returned HTTP " + str(error.code)) from error
|
||||
|
||||
|
||||
def build_operation(runtime, seed, payload, payload_hash, tool_name, session_id, previous):
|
||||
operation_id = generate_uuidv7()
|
||||
issued_at = int(time.time() * 1000)
|
||||
payload = event if NATIVE_PAYLOAD else {{
|
||||
"tool_name": tool_name,
|
||||
"tool_input": tool_input,
|
||||
"session_id": session_id,
|
||||
}}
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
chain_hash = compute_chain_hash(previous, payload_hash, operation_id, issued_at)
|
||||
unsigned = {{
|
||||
"op_version": "1.0",
|
||||
@@ -277,44 +373,47 @@ def submit_operation(event, runtime):
|
||||
"prev_chain_hash": previous,
|
||||
"agent_pubkey_kid": runtime["kid"],
|
||||
}}
|
||||
signature = sign_ed25519(
|
||||
read_private_key(),
|
||||
jcs_canonicalize(unsigned).encode("utf-8"),
|
||||
)
|
||||
signature = sign_ed25519(seed, jcs_canonicalize(unsigned).encode("utf-8"))
|
||||
operation = dict(unsigned)
|
||||
operation.update({{"chain_hash": chain_hash, "signature": signature}})
|
||||
url = runtime.get("base_url", "https://api.elydora.com").rstrip("/") + "/v1/operations"
|
||||
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
|
||||
if runtime.get("token"):
|
||||
headers["Authorization"] = "Bearer " + runtime["token"]
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=json.dumps(operation).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5):
|
||||
write_chain_state(chain_hash)
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8")
|
||||
if error.code == 400 and body:
|
||||
try:
|
||||
failure = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as parse_error:
|
||||
raise RuntimeError("Audit API returned invalid error JSON: " + str(parse_error)) from parse_error
|
||||
details = failure.get("error", {{}}) if isinstance(failure, dict) else {{}}
|
||||
if details.get("code") == "PREV_HASH_MISMATCH":
|
||||
match = re.search(
|
||||
r'Expected prev_chain_hash "([A-Za-z0-9_-]{{43}})"',
|
||||
str(details.get("message", "")),
|
||||
)
|
||||
if match:
|
||||
write_chain_state(match.group(1))
|
||||
log_error("Chain hash resynced to server: " + match.group(1))
|
||||
raise RuntimeError("Audit API returned HTTP " + str(error.code)) from error
|
||||
return operation, chain_hash
|
||||
|
||||
|
||||
def submit_operation(event, runtime):
|
||||
nested = event.get("tool_result")
|
||||
if not isinstance(nested, dict):
|
||||
nested = event.get("tool_call")
|
||||
if not isinstance(nested, dict):
|
||||
nested = {{}}
|
||||
tool_name = event.get("tool_name", event.get("toolName", event.get("name", nested.get("name", "unknown"))))
|
||||
tool_input = event.get("tool_input", event.get("toolInput", event.get("input", nested.get("input", {{}}))))
|
||||
session_id = event.get(
|
||||
"conversation_id",
|
||||
event.get("session_id", event.get("sessionId", event.get("session", event.get("taskId", "unknown")))),
|
||||
)
|
||||
payload = event if NATIVE_PAYLOAD else {{
|
||||
"tool_name": tool_name,
|
||||
"tool_input": tool_input,
|
||||
"session_id": session_id,
|
||||
}}
|
||||
payload_hash = compute_payload_hash(payload)
|
||||
seed = read_private_key()
|
||||
deadline = time.monotonic() + SUBMIT_BUDGET_SECONDS
|
||||
previous = read_chain_state()
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
operation, chain_hash = build_operation(runtime, seed, payload, payload_hash, tool_name, session_id, previous)
|
||||
expected = post_operation(operation, runtime, deadline)
|
||||
if expected is None:
|
||||
advance_chain_state(previous, chain_hash)
|
||||
return
|
||||
advance_chain_state(previous, expected)
|
||||
log_error("Chain hash resynced to server: " + expected)
|
||||
if attempt >= MAX_CHAIN_ATTEMPTS:
|
||||
raise RuntimeError("Audit API rejected prev_chain_hash " + str(attempt) + " times")
|
||||
previous = read_chain_state()
|
||||
|
||||
def main():
|
||||
try:
|
||||
submit_operation(read_event(), read_runtime_config())
|
||||
|
||||
@@ -1,150 +1,133 @@
|
||||
"""Kiro IDE plugin — writes .kiro/hooks/elydora-audit.kiro.hook file."""
|
||||
"""Kiro IDE 1.0 workspace hook integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ._file_io import write_json_atomic, write_text_atomic
|
||||
from .base import AgentPlugin, InstallConfig, PluginStatus
|
||||
from .hook_template import generate_hook_script
|
||||
|
||||
|
||||
KIRO_HOOK_DIR = os.path.join(os.path.expanduser("~"), ".kiro", "hooks")
|
||||
KIRO_HOOK_FILENAME = "elydora-audit.kiro.hook"
|
||||
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_NAME,
|
||||
GUARD_NAME,
|
||||
build_kiroide_hook,
|
||||
kiroide_runtime_contracts,
|
||||
managed_kiroide_hooks_present,
|
||||
render_kiroide_document,
|
||||
require_available_kiroide_hooks,
|
||||
without_managed_kiroide_hooks,
|
||||
)
|
||||
from .kiroide_installation import (
|
||||
commit_kiroide_installation,
|
||||
commit_kiroide_uninstall,
|
||||
preflight_kiroide_installation,
|
||||
prepare_kiroide_installation,
|
||||
prepare_kiroide_uninstall,
|
||||
)
|
||||
from .kiroide_command import same_kiroide_agent_id
|
||||
from .kiroide_io import (
|
||||
kiroide_runtime_files_exist,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
read_kiroide_sources,
|
||||
require_kiroide_workspace_owner,
|
||||
)
|
||||
|
||||
|
||||
class KiroIdePlugin(AgentPlugin):
|
||||
"""Install/uninstall Elydora audit hook for Kiro IDE."""
|
||||
"""Install Elydora into the active Kiro IDE workspace."""
|
||||
|
||||
def _kiro_hook_path(self) -> str:
|
||||
return os.path.join(KIRO_HOOK_DIR, KIRO_HOOK_FILENAME)
|
||||
manages_guard_runtime = True
|
||||
manages_runtime_directories = True
|
||||
manages_runtime_removal = True
|
||||
|
||||
@staticmethod
|
||||
def _hook_path_for(agent_id: str) -> str:
|
||||
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
|
||||
def preflight_install(self, config: InstallConfig) -> None:
|
||||
sources = read_kiroide_sources()
|
||||
require_available_kiroide_hooks(sources.document.hooks)
|
||||
preflight_kiroide_installation(config, sources)
|
||||
|
||||
def install(self, config: InstallConfig) -> None:
|
||||
agent_id = config.get("agent_id", "")
|
||||
agent_name = config.get("agent_name", "")
|
||||
|
||||
# Create per-agent directory
|
||||
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
|
||||
os.makedirs(agent_dir, exist_ok=True)
|
||||
|
||||
# Write config.json
|
||||
config_data = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"token": config.get("token", ""),
|
||||
"agent_name": agent_name,
|
||||
}
|
||||
config_path = os.path.join(agent_dir, "config.json")
|
||||
write_json_atomic(
|
||||
config_path,
|
||||
config_data,
|
||||
0o600,
|
||||
"Elydora runtime config",
|
||||
sources = read_kiroide_sources()
|
||||
require_available_kiroide_hooks(sources.document.hooks)
|
||||
paths = preflight_kiroide_installation(config, sources)
|
||||
hooks = without_managed_kiroide_hooks(sources.document.hooks)
|
||||
rendered = render_kiroide_document(
|
||||
sources.document,
|
||||
[
|
||||
*hooks,
|
||||
build_kiroide_hook(GUARD_NAME, paths.guard_path),
|
||||
build_kiroide_hook(AUDIT_NAME, paths.audit_path),
|
||||
],
|
||||
)
|
||||
|
||||
# Write private key
|
||||
private_key_path = os.path.join(agent_dir, "private.key")
|
||||
write_text_atomic(
|
||||
private_key_path,
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
"Elydora private key",
|
||||
prepared = prepare_kiroide_installation(
|
||||
config, paths, sources, rendered
|
||||
)
|
||||
|
||||
# Write the Python hook script
|
||||
script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
commit_kiroide_installation(prepared, sources, paths)
|
||||
print(f" Kiro IDE workspace hooks: {sources.paths.config_path}")
|
||||
print(
|
||||
" Kiro IDE verification: confirm Elydora hooks in the "
|
||||
"Agent Hooks panel."
|
||||
)
|
||||
script_path = self._hook_path_for(agent_id)
|
||||
write_text_atomic(
|
||||
script_path,
|
||||
script,
|
||||
0o700,
|
||||
"Elydora audit runtime",
|
||||
)
|
||||
|
||||
guard_script_path = config.get("guard_script_path", "")
|
||||
python_exe = sys.executable
|
||||
|
||||
# Write the Kiro hook definition file (JSON format matching Node SDK)
|
||||
hook_config = {
|
||||
"name": "Elydora Audit",
|
||||
"description": "Sends tool-use events to the Elydora tamper-evident audit platform",
|
||||
"version": "1.0.0",
|
||||
"hooks": {
|
||||
"pre_tool_use": {
|
||||
"command": f'"{python_exe}" {guard_script_path}' if guard_script_path else "",
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"post_tool_use": {
|
||||
"command": script_path,
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
hook_path = self._kiro_hook_path()
|
||||
write_json_atomic(
|
||||
hook_path,
|
||||
hook_config,
|
||||
0o600,
|
||||
"Kiro IDE hook definition",
|
||||
)
|
||||
|
||||
print("Elydora hook installed for Kiro IDE.")
|
||||
print(f" Hook script: {script_path}")
|
||||
print(f" Hook definition: {hook_path}")
|
||||
|
||||
def uninstall(self, agent_id: str = "") -> None:
|
||||
hook_path = self._kiro_hook_path()
|
||||
if os.path.exists(hook_path):
|
||||
os.remove(hook_path)
|
||||
|
||||
# Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir)
|
||||
print("Elydora hook uninstalled from Kiro IDE.")
|
||||
sources = read_kiroide_sources()
|
||||
contracts = kiroide_runtime_contracts(sources.document.hooks)
|
||||
if agent_id:
|
||||
discovered_agent_ids = [agent_id]
|
||||
else:
|
||||
discovered_agent_ids = [contract.agent_id for contract in contracts]
|
||||
legacy_contract = sources.legacy.contract
|
||||
if legacy_contract is not None:
|
||||
discovered_agent_ids.append(legacy_contract.agent_id)
|
||||
agent_ids: list[str] = []
|
||||
for discovered_agent_id in discovered_agent_ids:
|
||||
if not any(
|
||||
same_kiroide_agent_id(discovered_agent_id, existing_agent_id)
|
||||
for existing_agent_id in agent_ids
|
||||
):
|
||||
agent_ids.append(discovered_agent_id)
|
||||
for managed_agent_id in agent_ids:
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, managed_agent_id)
|
||||
for contract in contracts
|
||||
)
|
||||
require_kiroide_workspace_owner(
|
||||
managed_agent_id,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=owns_workspace_runtime,
|
||||
allow_legacy_ownerless_config=(
|
||||
legacy_kiroide_contract_matches_agent(
|
||||
sources.legacy, managed_agent_id
|
||||
)
|
||||
),
|
||||
)
|
||||
rendered = render_kiroide_document(
|
||||
sources.document,
|
||||
without_managed_kiroide_hooks(sources.document.hooks, agent_id),
|
||||
)
|
||||
prepared = prepare_kiroide_uninstall(
|
||||
sources, rendered, agent_id, agent_ids
|
||||
)
|
||||
commit_kiroide_uninstall(prepared, sources)
|
||||
print("Elydora hooks uninstalled from Kiro IDE.")
|
||||
|
||||
def status(self) -> PluginStatus:
|
||||
hook_path = self._kiro_hook_path()
|
||||
hook_exists = os.path.exists(hook_path)
|
||||
|
||||
# Scan ~/.elydora/*/hook.py for any installed hook script
|
||||
import glob as _glob
|
||||
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py")
|
||||
hook_files = _glob.glob(hook_pattern)
|
||||
script_exists = len(hook_files) > 0
|
||||
|
||||
hook_configured = False
|
||||
if hook_exists:
|
||||
try:
|
||||
with open(hook_path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
hooks = cfg.get("hooks", {})
|
||||
hook_configured = bool(
|
||||
hooks.get("pre_tool_use") and hooks.get("post_tool_use")
|
||||
)
|
||||
except Exception:
|
||||
hook_configured = hook_exists # Fallback
|
||||
|
||||
installed = hook_exists and script_exists and hook_configured
|
||||
sources = read_kiroide_sources()
|
||||
require_available_kiroide_hooks(sources.document.hooks)
|
||||
contracts = kiroide_runtime_contracts(sources.document.hooks)
|
||||
configured = bool(contracts)
|
||||
managed_present = managed_kiroide_hooks_present(sources.document.hooks)
|
||||
installed = configured and kiroide_runtime_files_exist(
|
||||
contracts, sources.paths.workspace_root
|
||||
)
|
||||
if installed:
|
||||
details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}"
|
||||
elif hook_exists:
|
||||
details = "Hook definition exists but script missing"
|
||||
elif script_exists:
|
||||
details = "Script exists but hook definition missing"
|
||||
details = f"Config: {sources.paths.config_path}"
|
||||
elif configured:
|
||||
details = (
|
||||
f"Configured at {sources.paths.config_path}; "
|
||||
"runtime files are missing or invalid"
|
||||
)
|
||||
elif managed_present:
|
||||
details = (
|
||||
f"Managed hooks are incomplete or invalid: "
|
||||
f"{sources.paths.config_path}"
|
||||
)
|
||||
else:
|
||||
details = "Not installed"
|
||||
|
||||
return PluginStatus(installed=installed, agent="kiroide", details=details)
|
||||
return PluginStatus(installed=installed, agent=AGENT_KEY, details=details)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Kiro IDE command rendering and exact runtime ownership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from elydora._runtime_paths import runtime_root
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimeReference:
|
||||
agent_id: str
|
||||
script_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ParsedArgument:
|
||||
value: str
|
||||
next_index: int
|
||||
|
||||
|
||||
def same_kiroide_path(left: str, right: str) -> bool:
|
||||
return os.path.normcase(os.path.abspath(left)) == os.path.normcase(
|
||||
os.path.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def same_kiroide_agent_id(left: str, right: str) -> bool:
|
||||
return os.path.normcase(left) == os.path.normcase(right)
|
||||
|
||||
|
||||
def _quote_posix(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _quote_powershell(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _windows_powershell_path() -> str:
|
||||
configured = os.environ.get("SystemRoot") if os.name == "nt" else None
|
||||
system_root = (
|
||||
configured
|
||||
if configured
|
||||
and ntpath.isabs(configured)
|
||||
and re.search(r'["%\r\n]', configured) is None
|
||||
else r"C:\Windows"
|
||||
)
|
||||
return ntpath.join(
|
||||
system_root,
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
)
|
||||
|
||||
|
||||
def _same_windows_path(left: str, right: str) -> bool:
|
||||
return ntpath.normcase(ntpath.abspath(left)) == ntpath.normcase(
|
||||
ntpath.abspath(right)
|
||||
)
|
||||
|
||||
|
||||
def _windows_command(script_path: str) -> str:
|
||||
source = (
|
||||
"$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; "
|
||||
f"& {_quote_powershell(sys.executable)} "
|
||||
f"{_quote_powershell(script_path)}; exit $LASTEXITCODE"
|
||||
)
|
||||
encoded = base64.b64encode(source.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
f'"{_windows_powershell_path()}" -NoLogo -NoProfile '
|
||||
f"-NonInteractive -EncodedCommand {encoded}"
|
||||
)
|
||||
|
||||
|
||||
def build_kiroide_command(script_path: str) -> str:
|
||||
if (
|
||||
not os.path.isabs(sys.executable)
|
||||
or not _is_python_executable(sys.executable)
|
||||
or not os.path.isabs(script_path)
|
||||
):
|
||||
raise ValueError(
|
||||
"Kiro IDE hook commands require an absolute Python executable "
|
||||
"and script path"
|
||||
)
|
||||
if os.name == "nt":
|
||||
return _windows_command(script_path)
|
||||
return f"{_quote_posix(sys.executable)} {_quote_posix(script_path)}"
|
||||
|
||||
|
||||
def _read_posix_argument(command: str, start: int) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
apostrophe = "'\"'\"'"
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command.startswith(apostrophe, index):
|
||||
value += "'"
|
||||
index += len(apostrophe)
|
||||
continue
|
||||
if command[index] == "'":
|
||||
return _ParsedArgument(value, index + 1)
|
||||
value += command[index]
|
||||
index += 1
|
||||
return None
|
||||
|
||||
|
||||
def _parse_posix_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
executable = _read_posix_argument(command, 0)
|
||||
if (
|
||||
executable is None
|
||||
or command[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_posix_argument(command, executable.next_index + 1)
|
||||
if script is None or script.next_index != len(command):
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _read_powershell_argument(
|
||||
command: str, start: int
|
||||
) -> Optional[_ParsedArgument]:
|
||||
if start >= len(command) or command[start] != "'":
|
||||
return None
|
||||
value = ""
|
||||
index = start + 1
|
||||
while index < len(command):
|
||||
if command[index] != "'":
|
||||
value += command[index]
|
||||
index += 1
|
||||
continue
|
||||
if index + 1 < len(command) and command[index + 1] == "'":
|
||||
value += "'"
|
||||
index += 2
|
||||
continue
|
||||
return _ParsedArgument(value, index + 1)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_powershell_source(source: str) -> Optional[Tuple[str, str]]:
|
||||
prefix = "$ErrorActionPreference = 'Stop'; $global:LASTEXITCODE = 1; & "
|
||||
if not source.startswith(prefix):
|
||||
return None
|
||||
executable = _read_powershell_argument(source, len(prefix))
|
||||
if (
|
||||
executable is None
|
||||
or source[executable.next_index : executable.next_index + 1] != " "
|
||||
):
|
||||
return None
|
||||
script = _read_powershell_argument(source, executable.next_index + 1)
|
||||
if script is None or source[script.next_index :] != "; exit $LASTEXITCODE":
|
||||
return None
|
||||
return executable.value, script.value
|
||||
|
||||
|
||||
def _parse_windows_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
match = re.fullmatch(
|
||||
r'"([^"\r\n]+)" -NoLogo -NoProfile -NonInteractive '
|
||||
r"-EncodedCommand ([A-Za-z0-9+/]+={0,2})",
|
||||
command,
|
||||
)
|
||||
if (
|
||||
match is None
|
||||
or not ntpath.isabs(match.group(1))
|
||||
or not _same_windows_path(match.group(1), _windows_powershell_path())
|
||||
):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(match.group(2), validate=True)
|
||||
if base64.b64encode(raw).decode("ascii") != match.group(2):
|
||||
return None
|
||||
source = raw.decode("utf-16le")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
return _parse_powershell_source(source)
|
||||
|
||||
|
||||
def _is_python_executable(file_path: str) -> bool:
|
||||
return (
|
||||
re.fullmatch(
|
||||
r"(?:python(?:[0-9]+(?:\.[0-9]+)*)?[td]?|"
|
||||
r"pypy(?:[0-9]+(?:\.[0-9]+)*)?)(?:\.exe)?",
|
||||
os.path.basename(file_path),
|
||||
re.IGNORECASE,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _runtime_reference(
|
||||
executable: str, script_path: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
if (
|
||||
not os.path.isabs(executable)
|
||||
or not _is_python_executable(executable)
|
||||
or not os.path.isabs(script_path)
|
||||
or os.path.basename(script_path) != script_name
|
||||
):
|
||||
return None
|
||||
agent_directory = os.path.dirname(script_path)
|
||||
if not same_kiroide_path(os.path.dirname(agent_directory), runtime_root()):
|
||||
return None
|
||||
agent_id = os.path.basename(agent_directory)
|
||||
if agent_id in {"", ".", ".."}:
|
||||
return None
|
||||
return KiroIdeRuntimeReference(agent_id, script_path)
|
||||
|
||||
|
||||
def _parse_kiroide_command(command: str) -> Optional[Tuple[str, str]]:
|
||||
return (
|
||||
_parse_windows_command(command)
|
||||
if os.name == "nt"
|
||||
else _parse_posix_command(command)
|
||||
)
|
||||
|
||||
|
||||
def owned_kiroide_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
parsed = _parse_kiroide_command(command)
|
||||
if parsed is None:
|
||||
return None
|
||||
return _runtime_reference(parsed[0], parsed[1], script_name)
|
||||
|
||||
|
||||
def kiroide_runtime_reference(
|
||||
command: str, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
parsed = _parse_kiroide_command(command)
|
||||
if parsed is None:
|
||||
return None
|
||||
if not same_kiroide_path(parsed[0], sys.executable):
|
||||
return None
|
||||
return _runtime_reference(parsed[0], parsed[1], script_name)
|
||||
|
||||
|
||||
def legacy_guard_reference(
|
||||
command: object, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
match = re.fullmatch(r'"([^"\r\n]+)" (.+)', command)
|
||||
if match is None:
|
||||
return None
|
||||
return _runtime_reference(match.group(1), match.group(2), script_name)
|
||||
|
||||
|
||||
def legacy_audit_reference(
|
||||
command: object, script_name: str
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
if not isinstance(command, str):
|
||||
return None
|
||||
return _runtime_reference(sys.executable, command, script_name)
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Kiro IDE v1 hook schema, ownership, and rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from ._managed_files import FileSnapshot
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .kiroide_command import (
|
||||
KiroIdeRuntimeReference,
|
||||
build_kiroide_command,
|
||||
kiroide_runtime_reference,
|
||||
legacy_audit_reference,
|
||||
legacy_guard_reference,
|
||||
owned_kiroide_runtime_reference,
|
||||
same_kiroide_agent_id,
|
||||
same_kiroide_path,
|
||||
)
|
||||
|
||||
|
||||
AGENT_KEY = "kiroide"
|
||||
AUDIT_SCRIPT = "hook.py"
|
||||
CONFIG_FILE = "elydora-audit.json"
|
||||
GUARD_SCRIPT = "guard.py"
|
||||
LEGACY_CONFIG_FILE = "elydora-audit.kiro.hook"
|
||||
HOOK_TIMEOUT_SECONDS = 10
|
||||
|
||||
GUARD_NAME = "elydora-guard"
|
||||
AUDIT_NAME = "elydora-audit"
|
||||
GUARD_DESCRIPTION = "Block tool use when the Elydora agent is frozen"
|
||||
AUDIT_DESCRIPTION = "Record tool use in the Elydora audit trail"
|
||||
KIRO_TRIGGERS = frozenset(
|
||||
{
|
||||
"SessionStart",
|
||||
"Stop",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PreTaskExec",
|
||||
"PostTaskExec",
|
||||
"UserPromptSubmit",
|
||||
"PostFileCreate",
|
||||
"PostFileSave",
|
||||
"PostFileDelete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeDocument:
|
||||
exists: bool
|
||||
file_path: str
|
||||
root: JsonObject
|
||||
hooks: List[JsonObject]
|
||||
raw: Optional[str] = None
|
||||
snapshot: Optional[FileSnapshot] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedKiroIdeDocument:
|
||||
document: KiroIdeDocument
|
||||
changed: bool
|
||||
next_source: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimeContract:
|
||||
agent_id: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
|
||||
|
||||
def _validate_action(value: Any, label: str) -> JsonObject:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} action must be an object")
|
||||
action = dict(value)
|
||||
action_type = action.get("type")
|
||||
if action_type == "command":
|
||||
if not isinstance(action.get("command"), str) or not action["command"]:
|
||||
raise ValueError(f"{label} command action requires a non-empty command")
|
||||
return action
|
||||
if action_type == "agent":
|
||||
if not isinstance(action.get("prompt"), str) or not action["prompt"]:
|
||||
raise ValueError(f"{label} agent action requires a non-empty prompt")
|
||||
return action
|
||||
raise ValueError(f'{label} action has unsupported type "{action_type}"')
|
||||
|
||||
|
||||
def _validate_hook(value: Any, index: int, document_label: str) -> JsonObject:
|
||||
label = f"{document_label} hooks[{index}]"
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
hook = dict(value)
|
||||
if not isinstance(hook.get("name"), str) or not hook["name"]:
|
||||
raise ValueError(f"{label} requires a name")
|
||||
trigger = hook.get("trigger")
|
||||
if not isinstance(trigger, str) or trigger not in KIRO_TRIGGERS:
|
||||
raise ValueError(f'{label} has unsupported trigger "{trigger}"')
|
||||
if "description" in hook and not isinstance(hook["description"], str):
|
||||
raise ValueError(f"{label} description must be a string")
|
||||
if "matcher" in hook and not isinstance(hook["matcher"], str):
|
||||
raise ValueError(f"{label} matcher must be a string")
|
||||
if "timeout" in hook and (
|
||||
type(hook["timeout"]) is not int or hook["timeout"] < 0
|
||||
):
|
||||
raise ValueError(f"{label} timeout must be a non-negative integer")
|
||||
if "enabled" in hook and not isinstance(hook["enabled"], bool):
|
||||
raise ValueError(f"{label} enabled must be a boolean")
|
||||
hook["action"] = _validate_action(hook.get("action"), label)
|
||||
return hook
|
||||
|
||||
|
||||
def parse_kiroide_document(
|
||||
file_path: str,
|
||||
raw: str,
|
||||
snapshot: Optional[FileSnapshot] = None,
|
||||
document_label: str = "Kiro IDE hooks",
|
||||
) -> KiroIdeDocument:
|
||||
root = parse_json_object(raw, f"{document_label} at {file_path}")
|
||||
if root.get("version") != "v1":
|
||||
raise ValueError(f'{document_label} version must be "v1": {file_path}')
|
||||
hooks = root.get("hooks")
|
||||
if not isinstance(hooks, list):
|
||||
raise ValueError(
|
||||
f'{document_label} field "hooks" must be an array: {file_path}'
|
||||
)
|
||||
return KiroIdeDocument(
|
||||
True,
|
||||
file_path,
|
||||
root,
|
||||
[_validate_hook(hook, index, document_label) for index, hook in enumerate(hooks)],
|
||||
raw,
|
||||
snapshot,
|
||||
)
|
||||
|
||||
|
||||
def create_kiroide_document(file_path: str) -> KiroIdeDocument:
|
||||
return KiroIdeDocument(False, file_path, {}, [])
|
||||
|
||||
|
||||
def _managed_specification(name: object) -> Optional[Tuple[str, str, str]]:
|
||||
if name == GUARD_NAME:
|
||||
return GUARD_DESCRIPTION, "PreToolUse", GUARD_SCRIPT
|
||||
if name == AUDIT_NAME:
|
||||
return AUDIT_DESCRIPTION, "PostToolUse", AUDIT_SCRIPT
|
||||
return None
|
||||
|
||||
|
||||
def _command_reference(
|
||||
hook: JsonObject, current_executable: bool
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
specification = _managed_specification(hook.get("name"))
|
||||
action = hook.get("action")
|
||||
if (
|
||||
specification is None
|
||||
or not isinstance(action, dict)
|
||||
or action.get("type") != "command"
|
||||
or not isinstance(action.get("command"), str)
|
||||
):
|
||||
return None
|
||||
reader = (
|
||||
kiroide_runtime_reference
|
||||
if current_executable
|
||||
else owned_kiroide_runtime_reference
|
||||
)
|
||||
return reader(action["command"], specification[2])
|
||||
|
||||
|
||||
def _owned_reference(hook: JsonObject) -> Optional[KiroIdeRuntimeReference]:
|
||||
return _command_reference(hook, False)
|
||||
|
||||
|
||||
def _exact_reference(
|
||||
hook: JsonObject, current_executable: bool
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
specification = _managed_specification(hook.get("name"))
|
||||
reference = _command_reference(hook, current_executable)
|
||||
action = hook.get("action")
|
||||
if (
|
||||
specification is None
|
||||
or reference is None
|
||||
or set(hook)
|
||||
!= {
|
||||
"name",
|
||||
"description",
|
||||
"trigger",
|
||||
"matcher",
|
||||
"action",
|
||||
"timeout",
|
||||
"enabled",
|
||||
}
|
||||
or hook.get("description") != specification[0]
|
||||
or hook.get("trigger") != specification[1]
|
||||
or hook.get("matcher") != ".*"
|
||||
or hook.get("timeout") != HOOK_TIMEOUT_SECONDS
|
||||
or hook.get("enabled") is not True
|
||||
or not isinstance(action, dict)
|
||||
or set(action) != {"type", "command"}
|
||||
):
|
||||
return None
|
||||
return reference
|
||||
|
||||
|
||||
def _managed_reference(hook: JsonObject) -> Optional[KiroIdeRuntimeReference]:
|
||||
return _exact_reference(hook, True)
|
||||
|
||||
|
||||
def _portable_managed_reference(
|
||||
hook: JsonObject,
|
||||
) -> Optional[KiroIdeRuntimeReference]:
|
||||
return _exact_reference(hook, False)
|
||||
|
||||
|
||||
_ReferenceReader = Callable[
|
||||
[JsonObject], Optional[KiroIdeRuntimeReference]
|
||||
]
|
||||
|
||||
|
||||
def _managed_pair(
|
||||
hooks: List[JsonObject], reader: _ReferenceReader
|
||||
) -> Optional[
|
||||
Tuple[
|
||||
Tuple[int, KiroIdeRuntimeReference],
|
||||
Tuple[int, KiroIdeRuntimeReference],
|
||||
]
|
||||
]:
|
||||
managed = [
|
||||
(index, hook)
|
||||
for index, hook in enumerate(hooks)
|
||||
if _managed_specification(hook.get("name")) is not None
|
||||
]
|
||||
guards = [item for item in managed if item[1].get("name") == GUARD_NAME]
|
||||
audits = [item for item in managed if item[1].get("name") == AUDIT_NAME]
|
||||
if len(managed) != 2 or len(guards) != 1 or len(audits) != 1:
|
||||
return None
|
||||
guard = reader(guards[0][1])
|
||||
audit = reader(audits[0][1])
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
):
|
||||
return None
|
||||
return (guards[0][0], guard), (audits[0][0], audit)
|
||||
|
||||
|
||||
def _owned_references(
|
||||
hooks: List[JsonObject],
|
||||
) -> Dict[int, KiroIdeRuntimeReference]:
|
||||
return {
|
||||
index: reference
|
||||
for index, hook in enumerate(hooks)
|
||||
if (reference := _owned_reference(hook)) is not None
|
||||
}
|
||||
|
||||
|
||||
def require_available_kiroide_hooks(hooks: List[JsonObject]) -> None:
|
||||
owned = _owned_references(hooks)
|
||||
for index, hook in enumerate(hooks):
|
||||
if _managed_specification(hook.get("name")) and index not in owned:
|
||||
raise ValueError(
|
||||
f'Kiro IDE hook name "{hook.get("name")}" conflicts with '
|
||||
"the Elydora contract"
|
||||
)
|
||||
|
||||
|
||||
def build_kiroide_hook(name: str, script_path: str) -> JsonObject:
|
||||
specification = _managed_specification(name)
|
||||
if specification is None:
|
||||
raise ValueError(f"Unsupported managed Kiro IDE hook name: {name}")
|
||||
return {
|
||||
"name": name,
|
||||
"description": specification[0],
|
||||
"trigger": specification[1],
|
||||
"matcher": ".*",
|
||||
"action": {"type": "command", "command": build_kiroide_command(script_path)},
|
||||
"timeout": HOOK_TIMEOUT_SECONDS,
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def without_managed_kiroide_hooks(
|
||||
hooks: List[JsonObject], agent_id: str = ""
|
||||
) -> List[JsonObject]:
|
||||
owned = _owned_references(hooks)
|
||||
result = []
|
||||
for index, hook in enumerate(hooks):
|
||||
reference = owned.get(index)
|
||||
remove = reference is not None and (
|
||||
not agent_id or same_kiroide_agent_id(reference.agent_id, agent_id)
|
||||
)
|
||||
if not remove:
|
||||
result.append(hook)
|
||||
return result
|
||||
|
||||
|
||||
def _entirely_managed(document: KiroIdeDocument) -> bool:
|
||||
return (
|
||||
document.exists
|
||||
and set(document.root) == {"version", "hooks"}
|
||||
and len(document.hooks) == 2
|
||||
and _managed_pair(document.hooks, _portable_managed_reference)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def render_kiroide_document(
|
||||
document: KiroIdeDocument, hooks: List[JsonObject]
|
||||
) -> RenderedKiroIdeDocument:
|
||||
if not document.exists and not hooks:
|
||||
return RenderedKiroIdeDocument(document, False)
|
||||
if len(hooks) == len(document.hooks) and all(
|
||||
hook is document.hooks[index] for index, hook in enumerate(hooks)
|
||||
):
|
||||
return RenderedKiroIdeDocument(document, False)
|
||||
if not hooks and _entirely_managed(document):
|
||||
return RenderedKiroIdeDocument(document, True)
|
||||
root = {**document.root, "version": "v1", "hooks": hooks}
|
||||
next_source = json.dumps(root, indent=2, ensure_ascii=False) + "\n"
|
||||
parse_kiroide_document(document.file_path, next_source)
|
||||
return RenderedKiroIdeDocument(
|
||||
document,
|
||||
next_source != document.raw,
|
||||
next_source,
|
||||
)
|
||||
|
||||
|
||||
def kiroide_runtime_contracts(
|
||||
hooks: List[JsonObject],
|
||||
) -> List[KiroIdeRuntimeContract]:
|
||||
pair = _managed_pair(hooks, _managed_reference)
|
||||
if pair is None:
|
||||
return []
|
||||
guard = pair[0][1]
|
||||
audit = pair[1][1]
|
||||
return [
|
||||
KiroIdeRuntimeContract(
|
||||
guard.agent_id,
|
||||
guard.script_path,
|
||||
audit.script_path,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def managed_kiroide_hooks_present(hooks: List[JsonObject]) -> bool:
|
||||
return any(
|
||||
_managed_specification(hook.get("name")) is not None for hook in hooks
|
||||
)
|
||||
|
||||
|
||||
def legacy_kiroide_runtime_contract(
|
||||
raw: str, file_path: str
|
||||
) -> Optional[KiroIdeRuntimeContract]:
|
||||
root = parse_json_object(raw, f"legacy Kiro IDE hook at {file_path}")
|
||||
if (
|
||||
set(root) != {"name", "description", "version", "hooks"}
|
||||
or root.get("name") != "Elydora Audit"
|
||||
or root.get("description")
|
||||
!= "Sends tool-use events to the Elydora tamper-evident audit platform"
|
||||
or root.get("version") != "1.0.0"
|
||||
or not isinstance(root.get("hooks"), dict)
|
||||
):
|
||||
return None
|
||||
hooks = root["hooks"]
|
||||
if set(hooks) != {"pre_tool_use", "post_tool_use"}:
|
||||
return None
|
||||
guard_hook = hooks["pre_tool_use"]
|
||||
audit_hook = hooks["post_tool_use"]
|
||||
if (
|
||||
not isinstance(guard_hook, dict)
|
||||
or not isinstance(audit_hook, dict)
|
||||
or set(guard_hook) != {"command", "timeout_ms"}
|
||||
or set(audit_hook) != {"command", "timeout_ms"}
|
||||
or guard_hook.get("timeout_ms") != 5000
|
||||
or audit_hook.get("timeout_ms") != 5000
|
||||
):
|
||||
return None
|
||||
guard = legacy_guard_reference(guard_hook.get("command"), GUARD_SCRIPT)
|
||||
audit = legacy_audit_reference(audit_hook.get("command"), AUDIT_SCRIPT)
|
||||
if (
|
||||
guard is None
|
||||
or audit is None
|
||||
or not same_kiroide_agent_id(guard.agent_id, audit.agent_id)
|
||||
or not same_kiroide_path(
|
||||
os.path.dirname(guard.script_path), os.path.dirname(audit.script_path)
|
||||
)
|
||||
):
|
||||
return None
|
||||
return KiroIdeRuntimeContract(
|
||||
guard.agent_id,
|
||||
guard.script_path,
|
||||
audit.script_path,
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Physical directory identity contracts for Kiro IDE installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import List, NoReturn, Optional
|
||||
|
||||
from ._managed_files import DirectorySnapshot, read_physical_directory
|
||||
from ._transaction import DirectoryPrecondition, cleanup_created_directories
|
||||
from .kiroide_io import KiroIdeSources, require_kiroide_directory_state
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimeDirectoryState:
|
||||
parent: DirectorySnapshot
|
||||
root: Optional[DirectorySnapshot]
|
||||
agent: Optional[DirectorySnapshot]
|
||||
|
||||
|
||||
def inspect_runtime_directories(
|
||||
runtime_parent: str,
|
||||
runtime_root: str,
|
||||
agent_directory: str,
|
||||
) -> KiroIdeRuntimeDirectoryState:
|
||||
parent = read_physical_directory(runtime_parent, "Elydora runtime parent")
|
||||
if parent is None:
|
||||
raise OSError(f"Elydora runtime parent is missing: {runtime_parent}")
|
||||
return KiroIdeRuntimeDirectoryState(
|
||||
parent,
|
||||
read_physical_directory(runtime_root, "Elydora runtime directory"),
|
||||
read_physical_directory(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _require_directory_snapshot(
|
||||
directory_path: str,
|
||||
label: str,
|
||||
expected: Optional[DirectorySnapshot],
|
||||
operation: str,
|
||||
) -> None:
|
||||
current = read_physical_directory(directory_path, label)
|
||||
if current != expected:
|
||||
raise OSError(f"{label} changed during {operation}: {directory_path}")
|
||||
|
||||
|
||||
def require_runtime_directory_state(
|
||||
runtime_parent: str,
|
||||
runtime_root: str,
|
||||
agent_directory: str,
|
||||
expected: KiroIdeRuntimeDirectoryState,
|
||||
operation: str,
|
||||
) -> None:
|
||||
for directory_path, label, snapshot in (
|
||||
(runtime_parent, "Elydora runtime parent", expected.parent),
|
||||
(runtime_root, "Elydora runtime directory", expected.root),
|
||||
(agent_directory, "Elydora agent runtime directory", expected.agent),
|
||||
):
|
||||
_require_directory_snapshot(
|
||||
directory_path, label, snapshot, operation
|
||||
)
|
||||
|
||||
|
||||
def _create_pinned_directory(
|
||||
directory_path: str,
|
||||
label: str,
|
||||
) -> DirectorySnapshot:
|
||||
try:
|
||||
os.mkdir(directory_path, mode=0o700)
|
||||
except OSError as error:
|
||||
raise OSError(f"Create {label} at {directory_path}: {error}") from error
|
||||
snapshot = read_physical_directory(directory_path, label)
|
||||
if snapshot is None:
|
||||
raise OSError(f"{label} is missing after creation: {directory_path}")
|
||||
return snapshot
|
||||
|
||||
|
||||
def _verify_preconditions(
|
||||
preconditions: List[DirectoryPrecondition], operation: str
|
||||
) -> List[DirectoryPrecondition]:
|
||||
for condition in preconditions:
|
||||
_require_directory_snapshot(
|
||||
condition.directory_path,
|
||||
condition.label,
|
||||
condition.original,
|
||||
operation,
|
||||
)
|
||||
return preconditions
|
||||
|
||||
|
||||
def _raise_after_directory_recovery(
|
||||
operation: str,
|
||||
error: Exception,
|
||||
preconditions: List[DirectoryPrecondition],
|
||||
) -> NoReturn:
|
||||
failures = cleanup_created_directories(preconditions)
|
||||
suffix = f"; recovery failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"{operation}: {error}{suffix}") from error
|
||||
|
||||
|
||||
def pin_workspace_directories(
|
||||
sources: KiroIdeSources,
|
||||
operation: str,
|
||||
create_missing: bool,
|
||||
) -> List[DirectoryPrecondition]:
|
||||
created: List[DirectoryPrecondition] = []
|
||||
try:
|
||||
require_kiroide_directory_state(
|
||||
sources.paths, sources.directories, operation
|
||||
)
|
||||
workspace = sources.directories.workspace
|
||||
_require_directory_snapshot(
|
||||
sources.paths.workspace_root,
|
||||
"Kiro IDE workspace",
|
||||
workspace,
|
||||
operation,
|
||||
)
|
||||
kiro = sources.directories.kiro
|
||||
kiro_created = kiro is None and create_missing
|
||||
if kiro_created:
|
||||
kiro = _create_pinned_directory(
|
||||
sources.paths.kiro_directory,
|
||||
"Kiro IDE configuration directory",
|
||||
)
|
||||
created.append(
|
||||
DirectoryPrecondition(
|
||||
sources.paths.kiro_directory,
|
||||
"Kiro IDE configuration directory",
|
||||
kiro,
|
||||
created=True,
|
||||
)
|
||||
)
|
||||
_require_directory_snapshot(
|
||||
sources.paths.workspace_root,
|
||||
"Kiro IDE workspace",
|
||||
workspace,
|
||||
operation,
|
||||
)
|
||||
_require_directory_snapshot(
|
||||
sources.paths.kiro_directory,
|
||||
"Kiro IDE configuration directory",
|
||||
kiro,
|
||||
operation,
|
||||
)
|
||||
hooks = sources.directories.hooks
|
||||
hooks_created = hooks is None and create_missing
|
||||
if hooks_created:
|
||||
hooks = _create_pinned_directory(
|
||||
sources.paths.hooks_directory,
|
||||
"Kiro IDE hooks directory",
|
||||
)
|
||||
created.append(
|
||||
DirectoryPrecondition(
|
||||
sources.paths.hooks_directory,
|
||||
"Kiro IDE hooks directory",
|
||||
hooks,
|
||||
created=True,
|
||||
)
|
||||
)
|
||||
return _verify_preconditions(
|
||||
[
|
||||
DirectoryPrecondition(
|
||||
sources.paths.workspace_root, "Kiro IDE workspace", workspace
|
||||
),
|
||||
DirectoryPrecondition(
|
||||
sources.paths.kiro_directory,
|
||||
"Kiro IDE configuration directory",
|
||||
kiro,
|
||||
created=kiro_created,
|
||||
),
|
||||
DirectoryPrecondition(
|
||||
sources.paths.hooks_directory,
|
||||
"Kiro IDE hooks directory",
|
||||
hooks,
|
||||
created=hooks_created,
|
||||
),
|
||||
],
|
||||
operation,
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_after_directory_recovery(operation, error, created)
|
||||
|
||||
|
||||
def pin_runtime_directories(
|
||||
runtime_parent: str,
|
||||
runtime_root: str,
|
||||
agent_directory: str,
|
||||
expected: KiroIdeRuntimeDirectoryState,
|
||||
operation: str,
|
||||
create_missing: bool,
|
||||
) -> List[DirectoryPrecondition]:
|
||||
created: List[DirectoryPrecondition] = []
|
||||
try:
|
||||
require_runtime_directory_state(
|
||||
runtime_parent,
|
||||
runtime_root,
|
||||
agent_directory,
|
||||
expected,
|
||||
operation,
|
||||
)
|
||||
root = expected.root
|
||||
root_created = root is None and create_missing
|
||||
if root_created:
|
||||
_require_directory_snapshot(
|
||||
runtime_parent,
|
||||
"Elydora runtime parent",
|
||||
expected.parent,
|
||||
operation,
|
||||
)
|
||||
root = _create_pinned_directory(
|
||||
runtime_root, "Elydora runtime directory"
|
||||
)
|
||||
created.append(
|
||||
DirectoryPrecondition(
|
||||
runtime_root,
|
||||
"Elydora runtime directory",
|
||||
root,
|
||||
created=True,
|
||||
)
|
||||
)
|
||||
_require_directory_snapshot(
|
||||
runtime_parent,
|
||||
"Elydora runtime parent",
|
||||
expected.parent,
|
||||
operation,
|
||||
)
|
||||
_require_directory_snapshot(
|
||||
runtime_root, "Elydora runtime directory", root, operation
|
||||
)
|
||||
agent = expected.agent
|
||||
agent_created = agent is None and create_missing
|
||||
if agent_created:
|
||||
if root is None:
|
||||
raise OSError(
|
||||
f"Elydora runtime directory is missing: {runtime_root}"
|
||||
)
|
||||
agent = _create_pinned_directory(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
)
|
||||
created.append(
|
||||
DirectoryPrecondition(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
agent,
|
||||
created=True,
|
||||
)
|
||||
)
|
||||
return _verify_preconditions(
|
||||
[
|
||||
DirectoryPrecondition(
|
||||
runtime_parent, "Elydora runtime parent", expected.parent
|
||||
),
|
||||
DirectoryPrecondition(
|
||||
runtime_root,
|
||||
"Elydora runtime directory",
|
||||
root,
|
||||
created=root_created,
|
||||
),
|
||||
DirectoryPrecondition(
|
||||
agent_directory,
|
||||
"Elydora agent runtime directory",
|
||||
agent,
|
||||
created=agent_created,
|
||||
),
|
||||
],
|
||||
operation,
|
||||
)
|
||||
except Exception as error:
|
||||
_raise_after_directory_recovery(operation, error, created)
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Transactional Kiro IDE workspace hook installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
MAX_SOURCE_BYTES,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._opaque_removal import OpaqueRemovalChange
|
||||
from ._transaction import (
|
||||
DirectoryPrecondition,
|
||||
FileChange,
|
||||
FilePrecondition,
|
||||
cleanup_created_directories,
|
||||
source_change,
|
||||
write_changes,
|
||||
)
|
||||
from ._strict_json import JsonObject
|
||||
from ._runtime_removal import (
|
||||
KIRO_IDE_RUNTIME_FILES,
|
||||
PreparedRuntimeRemoval,
|
||||
finalize_runtime_removal,
|
||||
prepare_runtime_removal,
|
||||
)
|
||||
from .base import InstallConfig
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
RenderedKiroIdeDocument,
|
||||
kiroide_runtime_contracts,
|
||||
)
|
||||
from .kiroide_directories import (
|
||||
KiroIdeRuntimeDirectoryState,
|
||||
inspect_runtime_directories,
|
||||
pin_runtime_directories,
|
||||
pin_workspace_directories,
|
||||
require_runtime_directory_state,
|
||||
)
|
||||
from .kiroide_io import (
|
||||
KiroIdeSources,
|
||||
LegacyKiroIdeDocument,
|
||||
legacy_kiroide_contract_matches_agent,
|
||||
require_physical_legacy_directory,
|
||||
validate_api_origin,
|
||||
validate_kiroide_runtime_config,
|
||||
validate_private_key,
|
||||
validate_runtime_tree,
|
||||
)
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeRuntimePaths:
|
||||
agent_id: str
|
||||
runtime_parent: str
|
||||
runtime_root: str
|
||||
agent_directory: str
|
||||
guard_path: str
|
||||
audit_path: str
|
||||
directories: KiroIdeRuntimeDirectoryState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeInstallation:
|
||||
changes: List[FileChange]
|
||||
runtime_preconditions: List[FilePrecondition]
|
||||
@dataclass(frozen=True)
|
||||
class PreparedKiroIdeUninstall:
|
||||
changes: List[FileChange]
|
||||
runtime_removals: List[PreparedRuntimeRemoval]
|
||||
|
||||
|
||||
def _require_runtime_state(
|
||||
paths: KiroIdeRuntimePaths, operation: str
|
||||
) -> None:
|
||||
require_runtime_directory_state(
|
||||
paths.runtime_parent,
|
||||
paths.runtime_root,
|
||||
paths.agent_directory,
|
||||
paths.directories,
|
||||
operation,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_config(
|
||||
config: InstallConfig, agent_id: str, workspace_root: str
|
||||
) -> dict:
|
||||
value = {
|
||||
"org_id": config.get("org_id", ""),
|
||||
"agent_id": agent_id,
|
||||
"kid": config.get("kid", ""),
|
||||
"base_url": config.get("base_url", "https://api.elydora.com"),
|
||||
"agent_name": AGENT_KEY,
|
||||
"workspace_root": workspace_root,
|
||||
}
|
||||
token = config.get("token")
|
||||
if token:
|
||||
value["token"] = token
|
||||
return value
|
||||
|
||||
|
||||
def _json_source(value: dict) -> str:
|
||||
return json.dumps(value, indent=2) + "\n"
|
||||
|
||||
|
||||
def _agent_paths(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
agent_id = config.get("agent_id", "")
|
||||
if not agent_id:
|
||||
raise ValueError("agent_id is required")
|
||||
root = runtime_root()
|
||||
runtime_parent = os.path.dirname(root)
|
||||
agent_directory = resolve_agent_directory(root, agent_id)
|
||||
guard_path = os.path.join(agent_directory, GUARD_SCRIPT)
|
||||
audit_path = os.path.join(agent_directory, AUDIT_SCRIPT)
|
||||
if not same_kiroide_path(config.get("guard_script_path", ""), guard_path):
|
||||
raise ValueError(
|
||||
"Elydora guard runtime must use the managed agent directory: "
|
||||
f"{guard_path}"
|
||||
)
|
||||
directories = inspect_runtime_directories(
|
||||
runtime_parent, root, agent_directory
|
||||
)
|
||||
return KiroIdeRuntimePaths(
|
||||
agent_id,
|
||||
runtime_parent,
|
||||
root,
|
||||
agent_directory,
|
||||
guard_path,
|
||||
audit_path,
|
||||
directories,
|
||||
)
|
||||
|
||||
|
||||
def _validate_install_config(config: InstallConfig) -> KiroIdeRuntimePaths:
|
||||
for field in ("org_id", "agent_id", "kid", "private_key", "base_url"):
|
||||
value = config.get(field)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"{field} is required")
|
||||
if config.get("agent_name") != AGENT_KEY:
|
||||
raise ValueError(f"Kiro IDE installation requires agent_name {AGENT_KEY}")
|
||||
validate_private_key(config["private_key"])
|
||||
validate_api_origin(config["base_url"])
|
||||
if "token" in config:
|
||||
token = config["token"]
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string when provided")
|
||||
return _agent_paths(config)
|
||||
|
||||
|
||||
def preflight_kiroide_installation(
|
||||
config: InstallConfig, sources: KiroIdeSources
|
||||
) -> KiroIdeRuntimePaths:
|
||||
if not sources.document.file_path:
|
||||
raise ValueError("Kiro IDE installation requires a workspace hook path")
|
||||
paths = _validate_install_config(config)
|
||||
owns_workspace_runtime = any(
|
||||
same_kiroide_agent_id(contract.agent_id, paths.agent_id)
|
||||
for contract in kiroide_runtime_contracts(sources.document.hooks)
|
||||
)
|
||||
owns_global_legacy_runtime = legacy_kiroide_contract_matches_agent(
|
||||
sources.legacy, paths.agent_id
|
||||
)
|
||||
validate_runtime_tree(
|
||||
paths.agent_directory,
|
||||
paths.agent_id,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=owns_workspace_runtime,
|
||||
allow_legacy_ownerless_config=owns_global_legacy_runtime,
|
||||
)
|
||||
_require_runtime_state(paths, "Kiro IDE installation preflight")
|
||||
return paths
|
||||
|
||||
|
||||
def _runtime_change(
|
||||
file_path: str,
|
||||
label: str,
|
||||
next_source: str,
|
||||
mode: int,
|
||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
||||
enforce_mode: bool = False,
|
||||
) -> Tuple[Optional[FileChange], Optional[FilePrecondition]]:
|
||||
snapshot = read_physical_file(file_path, label, maximum_bytes)
|
||||
change = source_change(
|
||||
file_path,
|
||||
label,
|
||||
None if snapshot is None else snapshot.contents,
|
||||
next_source,
|
||||
mode,
|
||||
maximum_bytes,
|
||||
expected_snapshot=snapshot,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
precondition = (
|
||||
None
|
||||
if change is not None
|
||||
else FilePrecondition(file_path, label, snapshot, maximum_bytes)
|
||||
)
|
||||
return change, precondition
|
||||
|
||||
|
||||
def _rendered_change(
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
) -> Optional[FileChange]:
|
||||
if not rendered.changed:
|
||||
return None
|
||||
return source_change(
|
||||
rendered.document.file_path,
|
||||
"Kiro IDE hooks",
|
||||
rendered.document.raw if rendered.document.exists else None,
|
||||
rendered.next_source,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
expected_snapshot=rendered.document.snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _removes_legacy(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str = ""
|
||||
) -> bool:
|
||||
return legacy.contract is not None and (
|
||||
not agent_id
|
||||
or same_kiroide_agent_id(legacy.contract.agent_id, agent_id)
|
||||
)
|
||||
|
||||
|
||||
def _legacy_change(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str = ""
|
||||
) -> Optional[FileChange]:
|
||||
if not _removes_legacy(legacy, agent_id):
|
||||
return None
|
||||
if legacy.raw is None:
|
||||
raise OSError(f"Legacy Kiro IDE hook source is missing: {legacy.file_path}")
|
||||
require_physical_legacy_directory(legacy)
|
||||
return source_change(
|
||||
legacy.file_path,
|
||||
"legacy Kiro IDE hook",
|
||||
legacy.raw,
|
||||
None,
|
||||
0o600,
|
||||
MAX_SOURCE_BYTES,
|
||||
expected_snapshot=legacy.snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _present(changes: Sequence[Optional[FileChange]]) -> List[FileChange]:
|
||||
return [change for change in changes if change is not None]
|
||||
|
||||
|
||||
def prepare_kiroide_installation(
|
||||
config: InstallConfig,
|
||||
paths: KiroIdeRuntimePaths,
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
) -> PreparedKiroIdeInstallation:
|
||||
operation = "Kiro IDE installation preparation"
|
||||
_require_runtime_state(paths, operation)
|
||||
guard_script = generate_guard_script(AGENT_KEY, paths.agent_id)
|
||||
audit_script = generate_hook_script(
|
||||
org_id=config.get("org_id", ""),
|
||||
agent_id=paths.agent_id,
|
||||
kid=config.get("kid", ""),
|
||||
base_url=config.get("base_url", "https://api.elydora.com"),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
runtime = [
|
||||
_runtime_change(
|
||||
paths.guard_path, "Elydora guard runtime", guard_script, 0o700
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "config.json"),
|
||||
"Elydora runtime config",
|
||||
_json_source(
|
||||
_runtime_config(
|
||||
config,
|
||||
paths.agent_id,
|
||||
sources.paths.workspace_root,
|
||||
)
|
||||
),
|
||||
0o600,
|
||||
MAX_CONFIG_BYTES,
|
||||
enforce_mode=os.name != "nt",
|
||||
),
|
||||
_runtime_change(
|
||||
os.path.join(paths.agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
config.get("private_key", ""),
|
||||
0o600,
|
||||
MAX_SECRET_BYTES,
|
||||
enforce_mode=os.name != "nt",
|
||||
),
|
||||
_runtime_change(
|
||||
paths.audit_path, "Elydora audit runtime", audit_script, 0o700
|
||||
),
|
||||
]
|
||||
changes = _present(
|
||||
[
|
||||
*(change for change, _condition in runtime),
|
||||
_rendered_change(rendered),
|
||||
_legacy_change(sources.legacy, paths.agent_id),
|
||||
]
|
||||
)
|
||||
_require_runtime_state(paths, operation)
|
||||
return PreparedKiroIdeInstallation(
|
||||
changes,
|
||||
[
|
||||
condition
|
||||
for _change, condition in runtime
|
||||
if condition is not None
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _provider_preconditions(
|
||||
sources: KiroIdeSources,
|
||||
changes: List[FileChange],
|
||||
) -> List[FilePrecondition]:
|
||||
changed = {
|
||||
os.path.normcase(os.path.abspath(change.file_path)) for change in changes
|
||||
}
|
||||
values = (
|
||||
(
|
||||
sources.document.file_path,
|
||||
"Kiro IDE hooks",
|
||||
sources.document.snapshot,
|
||||
),
|
||||
(
|
||||
sources.legacy.file_path,
|
||||
"legacy Kiro IDE hook",
|
||||
sources.legacy.snapshot,
|
||||
),
|
||||
)
|
||||
return [
|
||||
FilePrecondition(file_path, label, snapshot, MAX_SOURCE_BYTES)
|
||||
for file_path, label, snapshot in values
|
||||
if os.path.normcase(os.path.abspath(file_path)) not in changed
|
||||
]
|
||||
|
||||
|
||||
def _commit_kiroide_changes(
|
||||
changes: List[FileChange],
|
||||
sources: KiroIdeSources,
|
||||
operation: str,
|
||||
runtime_paths: Optional[KiroIdeRuntimePaths] = None,
|
||||
runtime_preconditions: Sequence[FilePrecondition] = (),
|
||||
directory_preconditions: Sequence[DirectoryPrecondition] = (),
|
||||
opaque_removals: Sequence[OpaqueRemovalChange] = (),
|
||||
create_workspace_directories: Optional[bool] = None,
|
||||
) -> None:
|
||||
directories = []
|
||||
try:
|
||||
directories.extend(
|
||||
pin_workspace_directories(
|
||||
sources,
|
||||
operation,
|
||||
bool(changes)
|
||||
if create_workspace_directories is None
|
||||
else create_workspace_directories,
|
||||
)
|
||||
)
|
||||
if runtime_paths is not None:
|
||||
directories.extend(
|
||||
pin_runtime_directories(
|
||||
runtime_paths.runtime_parent,
|
||||
runtime_paths.runtime_root,
|
||||
runtime_paths.agent_directory,
|
||||
runtime_paths.directories,
|
||||
operation,
|
||||
bool(changes),
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
failures = cleanup_created_directories(directories)
|
||||
suffix = f"; recovery failed: {'; '.join(failures)}" if failures else ""
|
||||
raise OSError(f"{operation}: {error}{suffix}") from error
|
||||
write_changes(
|
||||
changes,
|
||||
operation,
|
||||
[
|
||||
*_provider_preconditions(sources, changes),
|
||||
*runtime_preconditions,
|
||||
],
|
||||
[*directories, *directory_preconditions],
|
||||
opaque_removals,
|
||||
)
|
||||
|
||||
|
||||
def commit_kiroide_installation(
|
||||
prepared: PreparedKiroIdeInstallation,
|
||||
sources: KiroIdeSources,
|
||||
paths: KiroIdeRuntimePaths,
|
||||
) -> None:
|
||||
_commit_kiroide_changes(
|
||||
prepared.changes,
|
||||
sources,
|
||||
"Install Kiro IDE hooks",
|
||||
paths,
|
||||
prepared.runtime_preconditions,
|
||||
)
|
||||
|
||||
|
||||
def prepare_kiroide_uninstall(
|
||||
sources: KiroIdeSources,
|
||||
rendered: RenderedKiroIdeDocument,
|
||||
agent_id: str = "",
|
||||
runtime_agent_ids: Sequence[str] = (),
|
||||
) -> PreparedKiroIdeUninstall:
|
||||
runtime_removals = []
|
||||
selected_agent_ids: List[str] = []
|
||||
candidates = runtime_agent_ids or ((agent_id,) if agent_id else ())
|
||||
for runtime_agent_id in candidates:
|
||||
if any(
|
||||
same_kiroide_agent_id(runtime_agent_id, selected_agent_id)
|
||||
for selected_agent_id in selected_agent_ids
|
||||
):
|
||||
continue
|
||||
selected_agent_ids.append(runtime_agent_id)
|
||||
allow_legacy_ownerless_config = legacy_kiroide_contract_matches_agent(
|
||||
sources.legacy, runtime_agent_id
|
||||
)
|
||||
|
||||
def validate_config(config: JsonObject, config_path: str) -> None:
|
||||
validate_kiroide_runtime_config(
|
||||
config,
|
||||
runtime_agent_id,
|
||||
config_path,
|
||||
sources.paths.workspace_root,
|
||||
allow_missing_workspace_root=any(
|
||||
same_kiroide_agent_id(
|
||||
contract.agent_id, runtime_agent_id
|
||||
)
|
||||
for contract in kiroide_runtime_contracts(
|
||||
sources.document.hooks
|
||||
)
|
||||
),
|
||||
allow_legacy_ownerless_config=allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
runtime_removal = prepare_runtime_removal(
|
||||
runtime_agent_id,
|
||||
AGENT_KEY,
|
||||
config_validator=validate_config,
|
||||
managed_files=KIRO_IDE_RUNTIME_FILES,
|
||||
)
|
||||
if runtime_removal is not None:
|
||||
runtime_removals.append(runtime_removal)
|
||||
provider_changes = _present(
|
||||
[_rendered_change(rendered), _legacy_change(sources.legacy, agent_id)]
|
||||
)
|
||||
return PreparedKiroIdeUninstall(
|
||||
[
|
||||
*provider_changes,
|
||||
*(
|
||||
change
|
||||
for runtime_removal in runtime_removals
|
||||
for change in runtime_removal.changes
|
||||
),
|
||||
],
|
||||
runtime_removals,
|
||||
)
|
||||
|
||||
|
||||
def commit_kiroide_uninstall(
|
||||
prepared: PreparedKiroIdeUninstall, sources: KiroIdeSources
|
||||
) -> None:
|
||||
_commit_kiroide_changes(
|
||||
prepared.changes,
|
||||
sources,
|
||||
"Uninstall Kiro IDE hooks",
|
||||
runtime_preconditions=tuple(
|
||||
condition
|
||||
for removal in prepared.runtime_removals
|
||||
for condition in removal.file_preconditions
|
||||
),
|
||||
directory_preconditions=tuple(
|
||||
condition
|
||||
for removal in prepared.runtime_removals
|
||||
for condition in removal.directory_preconditions
|
||||
),
|
||||
opaque_removals=tuple(
|
||||
change
|
||||
for removal in prepared.runtime_removals
|
||||
for change in removal.opaque_removals
|
||||
),
|
||||
create_workspace_directories=False,
|
||||
)
|
||||
for removal in prepared.runtime_removals:
|
||||
finalize_runtime_removal(removal)
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Kiro IDE workspace discovery and managed runtime inspection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import Any, List, Optional
|
||||
import urllib.parse
|
||||
|
||||
from elydora._runtime_paths import resolve_agent_directory, runtime_root
|
||||
|
||||
from ._managed_files import (
|
||||
DirectorySnapshot,
|
||||
FileSnapshot,
|
||||
MAX_CONFIG_BYTES,
|
||||
MAX_SECRET_BYTES,
|
||||
physical_directory_exists,
|
||||
physical_file_exists,
|
||||
read_physical_directory,
|
||||
read_physical_file,
|
||||
)
|
||||
from ._strict_json import JsonObject, parse_json_object
|
||||
from .guard_template import generate_guard_script
|
||||
from .hook_template import generate_hook_script
|
||||
from .kiroide_command import same_kiroide_agent_id, same_kiroide_path
|
||||
from .kiroide_contract import (
|
||||
AGENT_KEY,
|
||||
CONFIG_FILE,
|
||||
LEGACY_CONFIG_FILE,
|
||||
KiroIdeDocument,
|
||||
KiroIdeRuntimeContract,
|
||||
create_kiroide_document,
|
||||
legacy_kiroide_runtime_contract,
|
||||
parse_kiroide_document,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdePaths:
|
||||
workspace_root: str
|
||||
kiro_directory: str
|
||||
hooks_directory: str
|
||||
config_path: str
|
||||
legacy_config_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyKiroIdeDocument:
|
||||
exists: bool
|
||||
file_path: str
|
||||
raw: Optional[str] = None
|
||||
snapshot: Optional[FileSnapshot] = None
|
||||
contract: Optional[KiroIdeRuntimeContract] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeSources:
|
||||
paths: KiroIdePaths
|
||||
directories: KiroIdeDirectoryState
|
||||
document: KiroIdeDocument
|
||||
legacy: LegacyKiroIdeDocument
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeDirectoryState:
|
||||
workspace: DirectorySnapshot
|
||||
kiro: Optional[DirectorySnapshot]
|
||||
hooks: Optional[DirectorySnapshot]
|
||||
|
||||
|
||||
def resolve_kiroide_paths(
|
||||
workspace_root: Optional[str] = None,
|
||||
home_directory: Optional[str] = None,
|
||||
) -> KiroIdePaths:
|
||||
workspace = os.path.abspath(workspace_root or os.getcwd())
|
||||
home = home_directory or os.path.expanduser("~")
|
||||
kiro_directory = os.path.join(workspace, ".kiro")
|
||||
hooks_directory = os.path.join(kiro_directory, "hooks")
|
||||
return KiroIdePaths(
|
||||
workspace,
|
||||
kiro_directory,
|
||||
hooks_directory,
|
||||
os.path.join(hooks_directory, CONFIG_FILE),
|
||||
os.path.join(home, ".kiro", "hooks", LEGACY_CONFIG_FILE),
|
||||
)
|
||||
|
||||
|
||||
def _inspect_workspace(paths: KiroIdePaths) -> KiroIdeDirectoryState:
|
||||
workspace = read_physical_directory(
|
||||
paths.workspace_root, "Kiro IDE workspace"
|
||||
)
|
||||
if workspace is None:
|
||||
raise OSError(f"Kiro IDE workspace is missing: {paths.workspace_root}")
|
||||
kiro = read_physical_directory(
|
||||
paths.kiro_directory, "Kiro IDE configuration directory"
|
||||
)
|
||||
hooks = read_physical_directory(
|
||||
paths.hooks_directory, "Kiro IDE hooks directory"
|
||||
)
|
||||
return KiroIdeDirectoryState(workspace, kiro, hooks)
|
||||
|
||||
|
||||
def require_kiroide_directory_state(
|
||||
paths: KiroIdePaths,
|
||||
expected: KiroIdeDirectoryState,
|
||||
operation: str,
|
||||
) -> None:
|
||||
current = _inspect_workspace(paths)
|
||||
for label, path, before, after in (
|
||||
(
|
||||
"Kiro IDE workspace",
|
||||
paths.workspace_root,
|
||||
expected.workspace,
|
||||
current.workspace,
|
||||
),
|
||||
(
|
||||
"Kiro IDE configuration directory",
|
||||
paths.kiro_directory,
|
||||
expected.kiro,
|
||||
current.kiro,
|
||||
),
|
||||
(
|
||||
"Kiro IDE hooks directory",
|
||||
paths.hooks_directory,
|
||||
expected.hooks,
|
||||
current.hooks,
|
||||
),
|
||||
):
|
||||
if before != after:
|
||||
raise OSError(f"{label} changed during {operation}: {path}")
|
||||
|
||||
|
||||
def _read_document(paths: KiroIdePaths) -> KiroIdeDocument:
|
||||
snapshot = read_physical_file(paths.config_path, "Kiro IDE hooks")
|
||||
if snapshot is None:
|
||||
return create_kiroide_document(paths.config_path)
|
||||
return parse_kiroide_document(
|
||||
paths.config_path,
|
||||
snapshot.contents,
|
||||
snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _read_legacy(paths: KiroIdePaths) -> LegacyKiroIdeDocument:
|
||||
snapshot = read_physical_file(paths.legacy_config_path, "legacy Kiro IDE hook")
|
||||
if snapshot is None:
|
||||
return LegacyKiroIdeDocument(False, paths.legacy_config_path)
|
||||
return LegacyKiroIdeDocument(
|
||||
True,
|
||||
paths.legacy_config_path,
|
||||
snapshot.contents,
|
||||
snapshot,
|
||||
legacy_kiroide_runtime_contract(
|
||||
snapshot.contents, paths.legacy_config_path
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def read_kiroide_sources() -> KiroIdeSources:
|
||||
paths = resolve_kiroide_paths()
|
||||
directories = _inspect_workspace(paths)
|
||||
document = _read_document(paths)
|
||||
legacy = _read_legacy(paths)
|
||||
require_kiroide_directory_state(
|
||||
paths, directories, "Kiro IDE source discovery"
|
||||
)
|
||||
return KiroIdeSources(paths, directories, document, legacy)
|
||||
|
||||
|
||||
def require_physical_legacy_directory(legacy: LegacyKiroIdeDocument) -> None:
|
||||
if not legacy.exists:
|
||||
return
|
||||
hooks_directory = os.path.dirname(legacy.file_path)
|
||||
kiro_directory = os.path.dirname(hooks_directory)
|
||||
if not physical_directory_exists(
|
||||
kiro_directory, "legacy Kiro IDE configuration directory"
|
||||
):
|
||||
raise OSError(
|
||||
f"Legacy Kiro IDE configuration directory is missing: {kiro_directory}"
|
||||
)
|
||||
if not physical_directory_exists(
|
||||
hooks_directory, "legacy Kiro IDE hooks directory"
|
||||
):
|
||||
raise OSError(
|
||||
f"Legacy Kiro IDE hooks directory is missing: {hooks_directory}"
|
||||
)
|
||||
|
||||
|
||||
def validate_api_origin(value: str, label: str = "base_url") -> None:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
hostname = parsed.hostname
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL") from error
|
||||
invalid_character = "\\" in value or any(
|
||||
character.isspace() or ord(character) < 32 for character in value
|
||||
)
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or hostname is None
|
||||
or invalid_character
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTP or HTTPS URL")
|
||||
if (
|
||||
parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(
|
||||
f"{label} must exclude credentials, query parameters, and fragments"
|
||||
)
|
||||
|
||||
|
||||
def validate_private_key(value: str, label: str = "private_key") -> None:
|
||||
try:
|
||||
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
||||
seed = base64.b64decode(
|
||||
padded.replace("-", "+").replace("_", "/"), validate=True
|
||||
)
|
||||
canonical = base64.urlsafe_b64encode(seed).rstrip(b"=").decode("ascii")
|
||||
except (ValueError, UnicodeEncodeError) as error:
|
||||
raise ValueError(
|
||||
f"{label} must be a canonical 32-byte base64url value"
|
||||
) from error
|
||||
if len(seed) != 32 or canonical != value:
|
||||
raise ValueError(f"{label} must be a canonical 32-byte base64url value")
|
||||
|
||||
|
||||
def _require_non_empty_string(value: Any, field: str, config_path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"Elydora runtime config {field} is invalid: {config_path}")
|
||||
return value
|
||||
|
||||
|
||||
def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
|
||||
snapshot = read_physical_file(
|
||||
file_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
if snapshot is None:
|
||||
return None
|
||||
return parse_json_object(
|
||||
snapshot.contents, f"Elydora runtime config at {file_path}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
expected_workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
supported = {
|
||||
"org_id",
|
||||
"agent_id",
|
||||
"kid",
|
||||
"base_url",
|
||||
"token",
|
||||
"agent_name",
|
||||
"workspace_root",
|
||||
}
|
||||
extra = next((key for key in config if key not in supported), None)
|
||||
if extra is not None:
|
||||
raise ValueError(
|
||||
f'Elydora runtime config has unsupported field "{extra}": {config_path}'
|
||||
)
|
||||
_require_non_empty_string(config.get("org_id"), "org_id", config_path)
|
||||
_require_non_empty_string(config.get("kid"), "kid", config_path)
|
||||
agent_id = _require_non_empty_string(
|
||||
config.get("agent_id"), "agent_id", config_path
|
||||
)
|
||||
if (
|
||||
not same_kiroide_agent_id(agent_id, expected_agent_id)
|
||||
or config.get("agent_name") != AGENT_KEY
|
||||
):
|
||||
raise ValueError(
|
||||
"Elydora runtime identity does not match Kiro IDE hooks: "
|
||||
f"{config_path}"
|
||||
)
|
||||
configured_workspace = config.get("workspace_root")
|
||||
legacy_ownerless_config = (
|
||||
allow_legacy_ownerless_config and configured_workspace is None
|
||||
)
|
||||
if "token" in config and not (
|
||||
legacy_ownerless_config and config.get("token") == ""
|
||||
):
|
||||
_require_non_empty_string(config.get("token"), "token", config_path)
|
||||
base_url = _require_non_empty_string(
|
||||
config.get("base_url"), "base_url", config_path
|
||||
)
|
||||
validate_api_origin(base_url, "Elydora runtime config base_url")
|
||||
if configured_workspace is None and (
|
||||
allow_missing_workspace_root or legacy_ownerless_config
|
||||
):
|
||||
return
|
||||
workspace_root = _require_non_empty_string(
|
||||
configured_workspace, "workspace_root", config_path
|
||||
)
|
||||
if not os.path.isabs(workspace_root):
|
||||
raise ValueError(
|
||||
f"Elydora runtime config workspace_root must be absolute: {config_path}"
|
||||
)
|
||||
if not same_kiroide_path(workspace_root, expected_workspace_root):
|
||||
raise ValueError(
|
||||
"Elydora Kiro IDE agent is bound to another workspace: "
|
||||
f"{workspace_root}"
|
||||
)
|
||||
|
||||
|
||||
def validate_kiroide_runtime_config(
|
||||
config: JsonObject,
|
||||
expected_agent_id: str,
|
||||
config_path: str,
|
||||
expected_workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
"""Validate a runtime config already read through a pinned directory."""
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
expected_agent_id,
|
||||
config_path,
|
||||
expected_workspace_root,
|
||||
allow_missing_workspace_root,
|
||||
allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
|
||||
def validate_runtime_tree(
|
||||
agent_directory: str,
|
||||
agent_id: str,
|
||||
workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
root = runtime_root()
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config = _read_runtime_config(config_path)
|
||||
artifact_exists = any(
|
||||
physical_file_exists(os.path.join(agent_directory, name), label)
|
||||
for name, label in (
|
||||
("private.key", "Elydora private key"),
|
||||
("guard.py", "Elydora guard runtime"),
|
||||
("hook.py", "Elydora audit runtime"),
|
||||
("chain-state.json", "Elydora chain state"),
|
||||
("status-cache.json", "Elydora status cache"),
|
||||
("error.log", "Elydora error log"),
|
||||
)
|
||||
)
|
||||
if config is None:
|
||||
if artifact_exists:
|
||||
raise ValueError(
|
||||
"Elydora runtime identity cannot be verified without config.json: "
|
||||
f"{agent_directory}"
|
||||
)
|
||||
return
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
agent_id,
|
||||
config_path,
|
||||
workspace_root,
|
||||
allow_missing_workspace_root,
|
||||
allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_contract_exists(
|
||||
contract: KiroIdeRuntimeContract, workspace_root: str
|
||||
) -> bool:
|
||||
root = runtime_root()
|
||||
agent_directory = os.path.dirname(contract.guard_path)
|
||||
if (
|
||||
not same_kiroide_path(os.path.dirname(agent_directory), root)
|
||||
or not same_kiroide_path(
|
||||
contract.audit_path, os.path.join(agent_directory, "hook.py")
|
||||
)
|
||||
):
|
||||
return False
|
||||
if not physical_directory_exists(root, "Elydora runtime directory"):
|
||||
return False
|
||||
if not physical_directory_exists(
|
||||
agent_directory, "Elydora agent runtime directory"
|
||||
):
|
||||
return False
|
||||
config_path = os.path.join(agent_directory, "config.json")
|
||||
config_snapshot = read_physical_file(
|
||||
config_path, "Elydora runtime config", MAX_CONFIG_BYTES
|
||||
)
|
||||
config = (
|
||||
None
|
||||
if config_snapshot is None
|
||||
else parse_json_object(
|
||||
config_snapshot.contents,
|
||||
f"Elydora runtime config at {config_path}",
|
||||
)
|
||||
)
|
||||
key = read_physical_file(
|
||||
os.path.join(agent_directory, "private.key"),
|
||||
"Elydora private key",
|
||||
MAX_SECRET_BYTES,
|
||||
)
|
||||
guard = read_physical_file(contract.guard_path, "Elydora guard runtime")
|
||||
audit = read_physical_file(contract.audit_path, "Elydora audit runtime")
|
||||
if config is None or key is None or guard is None or audit is None:
|
||||
return False
|
||||
_validate_runtime_config(
|
||||
config,
|
||||
contract.agent_id,
|
||||
config_path,
|
||||
workspace_root,
|
||||
)
|
||||
if os.name != "nt" and key.mode & 0o077:
|
||||
raise ValueError(
|
||||
f"Elydora private key must be accessible only by its owner: "
|
||||
f"{os.path.join(agent_directory, 'private.key')}"
|
||||
)
|
||||
if (
|
||||
os.name != "nt"
|
||||
and "token" in config
|
||||
and config_snapshot is not None
|
||||
and config_snapshot.mode & 0o077
|
||||
):
|
||||
raise ValueError(
|
||||
"Token-bearing Elydora runtime config must be accessible only by "
|
||||
f"its owner: {config_path}"
|
||||
)
|
||||
validate_private_key(key.contents, "Elydora private key")
|
||||
expected_guard = generate_guard_script(AGENT_KEY, contract.agent_id)
|
||||
expected_audit = generate_hook_script(
|
||||
org_id=str(config["org_id"]),
|
||||
agent_id=contract.agent_id,
|
||||
kid=str(config["kid"]),
|
||||
base_url=str(config["base_url"]),
|
||||
native_payload=True,
|
||||
agent_name=AGENT_KEY,
|
||||
)
|
||||
return guard.contents == expected_guard and audit.contents == expected_audit
|
||||
|
||||
|
||||
def kiroide_runtime_files_exist(
|
||||
contracts: List[KiroIdeRuntimeContract],
|
||||
workspace_root: str,
|
||||
) -> bool:
|
||||
return any(
|
||||
_runtime_contract_exists(contract, workspace_root) for contract in contracts
|
||||
)
|
||||
|
||||
|
||||
def require_kiroide_workspace_owner(
|
||||
agent_id: str,
|
||||
workspace_root: str,
|
||||
allow_missing_workspace_root: bool = False,
|
||||
allow_legacy_ownerless_config: bool = False,
|
||||
) -> None:
|
||||
root = runtime_root()
|
||||
agent_directory = resolve_agent_directory(root, agent_id)
|
||||
validate_runtime_tree(
|
||||
agent_directory,
|
||||
agent_id,
|
||||
workspace_root,
|
||||
allow_missing_workspace_root,
|
||||
allow_legacy_ownerless_config,
|
||||
)
|
||||
|
||||
|
||||
def legacy_kiroide_contract_matches_agent(
|
||||
legacy: LegacyKiroIdeDocument, agent_id: str
|
||||
) -> bool:
|
||||
contract = legacy.contract
|
||||
if contract is None or not same_kiroide_agent_id(
|
||||
contract.agent_id, agent_id
|
||||
):
|
||||
return False
|
||||
agent_directory = resolve_agent_directory(runtime_root(), agent_id)
|
||||
return same_kiroide_path(
|
||||
contract.guard_path,
|
||||
os.path.join(agent_directory, "guard.py"),
|
||||
) and same_kiroide_path(
|
||||
contract.audit_path,
|
||||
os.path.join(agent_directory, "hook.py"),
|
||||
)
|
||||
@@ -58,8 +58,8 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
|
||||
},
|
||||
"kiroide": {
|
||||
"name": "Kiro IDE",
|
||||
"hook_event": "PostToolUse",
|
||||
"config_path": "~/.kiro/hooks/",
|
||||
"hook_event": "PreToolUse/PostToolUse",
|
||||
"config_path": ".kiro/hooks/elydora-audit.json",
|
||||
},
|
||||
"kimi": {
|
||||
"name": "Kimi Code",
|
||||
|
||||
Reference in new issue
Block a user