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