feat(python): durable transaction subsystem and Kiro IDE 1.0 adapter

Land the crash-recoverable managed-file transaction engine (journal,
staging, commit, restart recovery, opaque snapshots, private artifacts,
Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with
legacy migration.

Repair five defects that kept the suite red (426 failures to zero):

- Anchor the transaction state directory at ~/.elydora/transactions
  instead of the shared system temp directory. The recovery journal now
  shares fate with the runtimes it repairs, survives temp cleaners, and
  isolates naturally under per-test fake homes.
- Skip pending-transaction recovery when no state directory exists so
  rejected installs never create runtime directories.
- Create the runtime root's parent before hardening it, matching the Go
  SDK's expand behavior for fresh homes.
- Stop using file timestamps as identity evidence: Windows exposes
  mtime/ctime with delayed cross-handle visibility. Reads now prove
  stability through a verification re-read; snapshots compare device,
  inode, size, and SHA-256.
- Stop comparing synthetic Windows permission bits across stat
  boundaries: path stats derive execute bits from file extensions while
  handle stats do not, which falsely flagged every managed .cmd wrapper.
  Snapshots store raw modes for restoration and compare portable bits.

Reservation cleanup now verifies content before discarding so a
concurrent writer's file at a rollback path is preserved, and the
install-failure tests assert the precise contract: no agent runtime
residue, with the durable state directory allowed to remain.

Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-21 18:53:06 -04:00
1 parent dce7d1288a
commit e37418c489
53 files changed
+11855 -531

No files matched your search

+236
View File
@@ -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
+132
View File
@@ -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"]
+99 -5
View File
@@ -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:
+64
View File
@@ -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"]
+436
View File
@@ -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",
]
+119
View File
@@ -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"]
+501
View File
@@ -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)
+278
View File
@@ -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"]
+274
View File
@@ -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)
+380 -296
View File
@@ -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:
+336
View File
@@ -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"]
+474
View File
@@ -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",
]
+272
View File
@@ -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",
]
+80
View File
@@ -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"]
+280
View File
@@ -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",
]
+322
View File
@@ -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"]
+453
View File
@@ -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
+60
View File
@@ -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
+498
View File
@@ -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",
]
+2 -2
View File
@@ -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."""
+164
View File
@@ -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"]
+9 -3
View File
@@ -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
@@ -53,6 +54,8 @@ ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
{PROTECTED_RUNTIME_READER}
{ERROR_LOG_RUNTIME}
def base64url_encode(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -137,9 +140,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: "
+112 -129
View File
@@ -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)
+262
View File
@@ -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)
+395
View File
@@ -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,
)
+271
View File
@@ -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)
+498
View File
@@ -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)
+492
View File
@@ -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"),
)
+2 -2
View File
@@ -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",