diff --git a/AGENTS.md b/AGENTS.md index 12f3c11..c6700ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a - Resolve Factory Droid 0.175.0 user hooks through `~/.factory/hooks.json`, the legacy `~/.factory/hooks/hooks.json`, then the user `settings.local.json` and `settings.json` hook containers. Current hook files use a top-level `hooks` object; retain direct event maps as an explicit legacy read contract until Factory migrates them. Register exact `PreToolUse` and `PostToolUse` groups with ten-second absolute command handlers in one active source, preserve the native snake_case payload, and propagate freeze or revocation through exit code `2`. Use PowerShell's call operator with single-quoted absolute arguments and explicit `$LASTEXITCODE` propagation on Windows, and migrate the legacy double-quoted command form through exact ownership checks. Remove exact Elydora ownership from inactive user sources while preserving JSONC comments and unknown event arrays. Keep project, folder, plugin, and organization hook definitions read-only. Resolve local settings over their matching base file, then apply system, project, folder, and user `hooksDisabled` through Factory's extension-only scalar precedence; reject system-managed `allowManagedHooksOnly`. Commit user hook sources, generated runtimes, runtime config, and private key through one rollback-capable transaction, precondition every parsed source and policy file, require exact generated runtime sources during status checks, and require `/hooks` review for the session snapshot and remote organization policy. - Resolve Qwen Code 0.20.0 user settings through explicit `QWEN_HOME`, then the initial Qwen home `.env`, then the OS home `.env`; when bootstrap discovers a redirected home, read that home's `.env` as well. Treat empty overrides as unset while preserving their process-level ownership semantics. Read system defaults, user settings, the active trusted workspace, system overrides, and trusted-folder rules with Qwen's documented precedence; write only the user source. Validate the current twenty-one event names and command, HTTP, and prompt handler schemas while preserving future event payloads. Register exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups named `elydora-guard` and `elydora-audit` with ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate freeze and revocation through exit code `2`, and use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows. - Commit Qwen Code user settings, generated runtimes, runtime config, and private key through one rollback-capable transaction, with snapshot preconditions for every consulted routing, policy, trust, and settings source. Status requires physical files, exact runtime sources, a canonical private key, matching runtime identity, and an effective enabled state. Verify discovery with the official 0.20.0 loader, review `/hooks`, and restart active Qwen Code sessions. +- Resolve Kiro IDE 1.0 hooks through the active workspace `.kiro/hooks/elydora-audit.json`. Register one exact `PreToolUse` and `PostToolUse` command pair in the v1 JSON schema with `.*` matchers, ten-second timeouts, and enabled state. Bind healthy current commands to the active Python executable and the expected system PowerShell launcher. Bind each Kiro IDE `agent_id` to one absolute workspace root in runtime config; require the bound workspace before install, status, or uninstall can use that runtime. Recognize recoverable v1 ownership per reserved managed name through the exact safe command grammar, an absolute Python-like executable, the Elydora runtime root, and the matching managed script name; this permits repair after interpreter relocation or hook-field drift. Preserve Kiro's complete native payload and propagate frozen or revoked state through exit code `2`. Treat `~/.kiro/hooks/elydora-audit.kiro.hook` solely as exact Elydora legacy migration input. Accept an ownerless legacy runtime config with an empty optional `token` only when that exact global legacy contract proves the same agent. Claim the absolute active workspace and remove the exact legacy hook in one installation transaction; let CLI uninstall use the same proof before runtime removal. Pin the physical identities of the workspace, `.kiro`, hooks, runtime parent, `~/.elydora`, and agent runtime directories throughout prepare, stage, commit, and recovery. Keep every read provider source, runtime file, file mode, and directory identity as a transaction precondition, including no-op installs and runtime-only updates. Commit the workspace hook file, exact legacy migration, generated runtimes, runtime config, and private key through one rollback-capable transaction, then remove only transaction-created empty directories after a failed commit. Status requires one unique complete managed pair with every managed reference accounted for, exact current commands and runtime sources, canonical private keys, matching runtime and workspace identity, and owner-only private keys plus token-bearing runtime configs on POSIX. Verify both entries in the Agent Hooks panel. - Resolve Letta Code 0.28.13 global hooks through `${HOME:-~}/.letta/settings.json`, matching Letta's `HOME || homedir()` behavior. Keep Elydora runtimes under the shared CLI root from `os.path.expanduser("~")`, independent from Letta's configurable settings home. Keep the active workspace `.letta/settings.json` and `.letta/settings.local.json` sources read-only, deduplicate the project source when the workspace is the home directory, and apply the official `hooks.disabled` precedence: an explicit global `false` enables hooks, global `true` disables them, then project and project-local `true` values disable them. Validate all eleven current events plus command and prompt handler shapes while preserving future event payloads. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups with `*` matchers and ten-second millisecond timeouts. Preserve the complete native snake_case payload, propagate frozen or revoked state through exit code `2`, and surface audit delivery failures through stderr, owner-only `error.log`, and exit code `1`; Letta post events retain their documented nonblocking semantics. Use PowerShell's call operator with explicit `$LASTEXITCODE` propagation on Windows and exact two-argument shell commands on Unix. Commit global settings, generated runtimes, runtime config, and private key through one rollback-capable transaction guarded by every consulted source snapshot. Migrate only exact legacy Python command ownership, require physical sources, canonical private keys, exact runtime sources, executable identity, matching runtime identity, and an effective enabled state during status checks, then run `/hooks` and restart active sessions. ## Code Quality @@ -53,6 +54,9 @@ This repository owns the published `elydora` package, its CLI, synchronous and a - Persist the signing key once at `~/.elydora//private.key` with mode `0600`. Generated audit runtimes read and validate that physical file at execution time. - Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors. - Write runtime secrets, generated scripts, and provider configuration through same-directory temporary files followed by atomic replacement. Surface permission and replacement failures. +- Serialize provider transactions across processes, flush staged and rollback files before mutation, keep existing targets continuously visible through atomic replacement, and persist an owner-only journal under `~/.elydora/transactions` for deterministic restart recovery. The state directory shares fate with the runtimes it repairs and survives failed installs; recovery never creates it for commands that may still reject their input. Commit markers retain completed changes; earlier markers restore every original target. +- Compare file identity through device, inode, size, and content hashes. Exclude timestamps everywhere and synthetic permission bits on Windows: both are stat-boundary artifacts there, not evidence. Captured snapshots still store raw modes because restoration re-applies them. +- Use capability-checked parent-dirfd removal on POSIX and write-through atomic replacement on Windows. Delete verified empty POSIX quarantine entries and containers after their identity boundary is proven. - Interpret `max_retries` as retries after the initial attempt and reject negative or non-integer values. - Automatically retry RFC-idempotent methods; retry non-idempotent methods only when the transport proves the request was never sent. - Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping. diff --git a/README.md b/README.md index f860f31..3a25aee 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Factory Droid 0.175.0 installation resolves one active user hook source in this Qwen Code 0.20.0 installation writes exact, named `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` command hooks to `$QWEN_HOME/settings.json` (default `~/.qwen/settings.json`). Explicit environment values lead Qwen's home bootstrap, followed by `/.env` or `~/.qwen/.env`, then `~/.env`; a discovered home contributes its own `.env`. System defaults, trusted workspace settings, system overrides, and trust rules remain read-only and participate in effective `disableAllHooks` evaluation. User settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of every consulted source. Managed handlers use ten-second millisecond timeouts, preserve Qwen's complete native snake_case payload for success and failure events, and propagate frozen or revoked state through exit code `2`. Run `/hooks` after installation and restart active Qwen Code sessions. +Kiro IDE 1.0 installation writes one exact `PreToolUse` and `PostToolUse` command pair to the active workspace's `.kiro/hooks/elydora-audit.json`, following the [official v1 hook format](https://kiro.dev/docs/hooks/). Both entries use `.*` matchers, ten-second timeouts, enabled state, the active absolute Python executable, and the expected system PowerShell launcher on Windows. Each Kiro IDE `agent_id` is bound to one absolute workspace root in its runtime config; run install, status, and uninstall from that workspace, and uninstall before moving the workspace. Elydora preserves unrelated workspace hooks and removes `~/.kiro/hooks/elydora-audit.kiro.hook` only when it exactly matches the prior Elydora Python contract for the same agent. An ownerless legacy runtime config may contain an empty optional `token` while that exact global legacy contract proves the same agent; migration writes the absolute active workspace owner and deletes the legacy hook in the same transaction. CLI uninstall accepts the same proof, deletes the exact legacy hook, and then removes the matching runtime directory. Recoverable v1 ownership uses each reserved managed name plus the exact safe command grammar, an absolute Python-like executable, the Elydora runtime root, and the matching managed script name, allowing installation and uninstall to repair interpreter relocation and hook-field drift. The installer pins the physical workspace, `.kiro`, hooks, runtime parent, `~/.elydora`, and agent runtime directory identities through prepare, stage, and commit. Every consulted provider source, runtime file, file mode, and directory identity remains a transaction precondition during no-op installs and runtime-only updates. The workspace hook file, legacy migration, generated runtimes, runtime config, and private key commit through one rollback-capable transaction. Failed transactions remove newly created empty directories after physical identity checks. Status accepts one unique complete managed pair with every managed reference accounted for, exact current commands and runtime sources, canonical private keys, matching runtime and workspace identity, and owner-only private keys plus token-bearing runtime configs on POSIX. Managed runtimes preserve Kiro's complete native event payload and propagate frozen or revoked state through exit code `2`. Confirm both entries in Kiro's Agent Hooks panel after installation. + Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. Letta Code 0.28.13 installation writes exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` groups to `${HOME:-~}/.letta/settings.json`, following the [official hook source and event contract](https://docs.letta.com/letta-agent/hooks). Elydora runtime files stay under the shared CLI root resolved by `os.path.expanduser("~")`, so Windows `HOME` overrides affect Letta settings while `USERPROFILE` retains Elydora runtime ownership. Workspace `.letta/settings.json` and `.letta/settings.local.json` remain read-only and participate in effective `hooks.disabled` evaluation. An explicit global `false` enables hooks; global `true`, project `true`, and project-local `true` disable them according to Letta's precedence. Managed command handlers use ten-second millisecond timeouts, the current absolute Python executable, PowerShell exit-code propagation on Windows, and complete native snake_case payloads. Frozen and revoked state propagates through exit code `2`; audit delivery failures remain visible through stderr, owner-only `error.log`, and exit code `1`, while Letta post events remain nonblocking. Global settings, generated runtimes, runtime config, and private key commit through one rollback-capable transaction guarded by snapshots of all three settings sources. Status requires an effective enabled state, exact hook and executable identity, physical files, canonical private keys, and matching runtime identity. Run `/hooks` and restart active Letta Code sessions after installation. diff --git a/elydora/cli.py b/elydora/cli.py index 0d83d01..8176599 100644 --- a/elydora/cli.py +++ b/elydora/cli.py @@ -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(): diff --git a/elydora/plugins/_directory_removal.py b/elydora/plugins/_directory_removal.py new file mode 100644 index 0000000..0fe461e --- /dev/null +++ b/elydora/plugins/_directory_removal.py @@ -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 diff --git a/elydora/plugins/_durable_file.py b/elydora/plugins/_durable_file.py new file mode 100644 index 0000000..3ac08a9 --- /dev/null +++ b/elydora/plugins/_durable_file.py @@ -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"] diff --git a/elydora/plugins/_managed_files.py b/elydora/plugins/_managed_files.py index 6b176a7..a3ee1d8 100644 --- a/elydora/plugins/_managed_files.py +++ b/elydora/plugins/_managed_files.py @@ -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: diff --git a/elydora/plugins/_opaque_cleanup.py b/elydora/plugins/_opaque_cleanup.py new file mode 100644 index 0000000..8354b44 --- /dev/null +++ b/elydora/plugins/_opaque_cleanup.py @@ -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"] diff --git a/elydora/plugins/_opaque_removal.py b/elydora/plugins/_opaque_removal.py new file mode 100644 index 0000000..508f54d --- /dev/null +++ b/elydora/plugins/_opaque_removal.py @@ -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", +] diff --git a/elydora/plugins/_opaque_snapshot.py b/elydora/plugins/_opaque_snapshot.py new file mode 100644 index 0000000..a1ccefe --- /dev/null +++ b/elydora/plugins/_opaque_snapshot.py @@ -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"] diff --git a/elydora/plugins/_pinned_directory.py b/elydora/plugins/_pinned_directory.py new file mode 100644 index 0000000..1d63124 --- /dev/null +++ b/elydora/plugins/_pinned_directory.py @@ -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) diff --git a/elydora/plugins/_private_artifact.py b/elydora/plugins/_private_artifact.py new file mode 100644 index 0000000..2fe2a1f --- /dev/null +++ b/elydora/plugins/_private_artifact.py @@ -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"] diff --git a/elydora/plugins/_runtime_removal.py b/elydora/plugins/_runtime_removal.py new file mode 100644 index 0000000..c1d1c30 --- /dev/null +++ b/elydora/plugins/_runtime_removal.py @@ -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) diff --git a/elydora/plugins/_transaction.py b/elydora/plugins/_transaction.py index a8de7af..dbde1e0 100644 --- a/elydora/plugins/_transaction.py +++ b/elydora/plugins/_transaction.py @@ -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: diff --git a/elydora/plugins/_transaction_commit.py b/elydora/plugins/_transaction_commit.py new file mode 100644 index 0000000..00152eb --- /dev/null +++ b/elydora/plugins/_transaction_commit.py @@ -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"] diff --git a/elydora/plugins/_transaction_journal.py b/elydora/plugins/_transaction_journal.py new file mode 100644 index 0000000..9f4a4a9 --- /dev/null +++ b/elydora/plugins/_transaction_journal.py @@ -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", +] + diff --git a/elydora/plugins/_transaction_lock.py b/elydora/plugins/_transaction_lock.py new file mode 100644 index 0000000..5f8f966 --- /dev/null +++ b/elydora/plugins/_transaction_lock.py @@ -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", +] diff --git a/elydora/plugins/_transaction_payload.py b/elydora/plugins/_transaction_payload.py new file mode 100644 index 0000000..dcb13b6 --- /dev/null +++ b/elydora/plugins/_transaction_payload.py @@ -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"] diff --git a/elydora/plugins/_transaction_recovery.py b/elydora/plugins/_transaction_recovery.py new file mode 100644 index 0000000..2acc444 --- /dev/null +++ b/elydora/plugins/_transaction_recovery.py @@ -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", +] diff --git a/elydora/plugins/_transaction_restart.py b/elydora/plugins/_transaction_restart.py new file mode 100644 index 0000000..fdd9641 --- /dev/null +++ b/elydora/plugins/_transaction_restart.py @@ -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"] diff --git a/elydora/plugins/_transaction_staging.py b/elydora/plugins/_transaction_staging.py new file mode 100644 index 0000000..c21d919 --- /dev/null +++ b/elydora/plugins/_transaction_staging.py @@ -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 diff --git a/elydora/plugins/_transaction_types.py b/elydora/plugins/_transaction_types.py new file mode 100644 index 0000000..a306e09 --- /dev/null +++ b/elydora/plugins/_transaction_types.py @@ -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 diff --git a/elydora/plugins/_windows_security.py b/elydora/plugins/_windows_security.py new file mode 100644 index 0000000..33f5eae --- /dev/null +++ b/elydora/plugins/_windows_security.py @@ -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", +] diff --git a/elydora/plugins/base.py b/elydora/plugins/base.py index 5b513c9..eb4cf94 100644 --- a/elydora/plugins/base.py +++ b/elydora/plugins/base.py @@ -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.""" diff --git a/elydora/plugins/error_log_template.py b/elydora/plugins/error_log_template.py new file mode 100644 index 0000000..998da08 --- /dev/null +++ b/elydora/plugins/error_log_template.py @@ -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"] diff --git a/elydora/plugins/hook_template.py b/elydora/plugins/hook_template.py index 5d6838e..c3a09c1 100644 --- a/elydora/plugins/hook_template.py +++ b/elydora/plugins/hook_template.py @@ -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: " diff --git a/elydora/plugins/kiroide.py b/elydora/plugins/kiroide.py index 89eb4c0..03bdf9b 100644 --- a/elydora/plugins/kiroide.py +++ b/elydora/plugins/kiroide.py @@ -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) diff --git a/elydora/plugins/kiroide_command.py b/elydora/plugins/kiroide_command.py new file mode 100644 index 0000000..fbc85cb --- /dev/null +++ b/elydora/plugins/kiroide_command.py @@ -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) diff --git a/elydora/plugins/kiroide_contract.py b/elydora/plugins/kiroide_contract.py new file mode 100644 index 0000000..914fc13 --- /dev/null +++ b/elydora/plugins/kiroide_contract.py @@ -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, + ) diff --git a/elydora/plugins/kiroide_directories.py b/elydora/plugins/kiroide_directories.py new file mode 100644 index 0000000..ac9e0a5 --- /dev/null +++ b/elydora/plugins/kiroide_directories.py @@ -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) diff --git a/elydora/plugins/kiroide_installation.py b/elydora/plugins/kiroide_installation.py new file mode 100644 index 0000000..d2aeec9 --- /dev/null +++ b/elydora/plugins/kiroide_installation.py @@ -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) diff --git a/elydora/plugins/kiroide_io.py b/elydora/plugins/kiroide_io.py new file mode 100644 index 0000000..c8d97cf --- /dev/null +++ b/elydora/plugins/kiroide_io.py @@ -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"), + ) diff --git a/elydora/plugins/registry.py b/elydora/plugins/registry.py index c1bc6da..941203e 100644 --- a/elydora/plugins/registry.py +++ b/elydora/plugins/registry.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", diff --git a/tests/test_augment_transaction.py b/tests/test_augment_transaction.py index 6a7d3d3..02d7b2a 100644 --- a/tests/test_augment_transaction.py +++ b/tests/test_augment_transaction.py @@ -53,17 +53,21 @@ def test_transaction_rolls_back_all_seven_files( ) -> None: original = '{"telemetryEnabled":true}\n' fixture = prepare_fixture(monkeypatch, tmp_path, existing_settings=original) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_settings_once(source: str, destination: str) -> None: + def fail_settings_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected Auggie settings failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_settings_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once) with pytest.raises(OSError, match="injected Auggie settings failure"): fixture.install() diff --git a/tests/test_claudecode_transaction.py b/tests/test_claudecode_transaction.py index 2c882a2..d56b8c7 100644 --- a/tests/test_claudecode_transaction.py +++ b/tests/test_claudecode_transaction.py @@ -69,17 +69,21 @@ def test_transaction_rolls_back_all_five_files( fixture = prepare_fixture( monkeypatch, tmp_path, existing_settings=original ) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_settings_once(source: str, destination: str) -> None: + def fail_settings_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected Claude settings failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_settings_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once) with pytest.raises(OSError, match="injected Claude settings failure"): fixture.install() @@ -118,19 +122,23 @@ def test_recovery_preserves_original_after_committed_runtime_changes( fixture.config["org_id"] = "org-updated" fixture.config["private_key"] = base64url_encode(bytes(range(31, -1, -1))) changes = prepare_installation(fixture) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical calls = 0 - def change_first_then_fail(source: str, destination: str) -> None: + def change_first_then_fail( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal calls calls += 1 if calls == 1: - real_replace(source, destination) + real_replace(source, destination, directory) write_text(Path(destination), "external change\n") return raise OSError("injected later commit failure") - monkeypatch.setattr(_transaction.os, "replace", change_first_then_fail) + monkeypatch.setattr(_transaction, "_replace_physical", change_first_then_fail) with pytest.raises(OSError, match="original content preserved at"): claudecode_installation.commit_claude_installation(changes) diff --git a/tests/test_cli_security.py b/tests/test_cli_security.py index 3049bc5..6bae58a 100644 --- a/tests/test_cli_security.py +++ b/tests/test_cli_security.py @@ -17,7 +17,6 @@ from elydora.plugins._file_io import write_text_atomic from elydora.plugins import ( copilot, cursor, - kiroide, opencode, ) from elydora.plugins.hook_template import generate_hook_script @@ -373,7 +372,6 @@ def test_atomic_secret_write_preserves_existing_file_on_replace_failure( [ (copilot, copilot.CopilotPlugin, "copilot"), (cursor, cursor.CursorPlugin, "cursor"), - (kiroide, kiroide.KiroIdePlugin, "kiroide"), (opencode, opencode.OpenCodePlugin, "opencode"), ], ) @@ -402,8 +400,6 @@ def test_legacy_plugins_persist_one_owner_only_private_key( "SETTINGS_PATH", str(case_root / "provider" / "settings.json"), ) - elif module is kiroide: - monkeypatch.setattr(module, "KIRO_HOOK_DIR", str(case_root / "kiro-hooks")) elif module is opencode: monkeypatch.setattr(module, "PLUGIN_DIR", str(case_root / "plugins")) elif module is copilot: diff --git a/tests/test_cline_transaction.py b/tests/test_cline_transaction.py index 79cfa8d..a67d3ae 100644 --- a/tests/test_cline_transaction.py +++ b/tests/test_cline_transaction.py @@ -37,17 +37,21 @@ def test_transaction_rolls_back_all_six_files( tmp_path: Path, ) -> None: fixture = prepare_fixture(monkeypatch, tmp_path) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_audit_once(source: str, destination: str) -> None: + def fail_audit_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.audit_wrapper) and not failed: failed = True raise OSError("injected Cline audit hook failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_audit_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_audit_once) with pytest.raises(OSError, match="injected Cline audit hook failure"): fixture.install() @@ -64,17 +68,21 @@ def test_concurrent_hook_replacement_is_detected_before_final_commit( fixture = prepare_fixture(monkeypatch, tmp_path) changes = prepare_installation(fixture) concurrent = "// concurrently replaced\n" - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical injected = False - def inject_replacement(source: str, destination: str) -> None: + def inject_replacement( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal injected if not injected: injected = True write_text(fixture.audit_wrapper, concurrent, 0o700) - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", inject_replacement) + monkeypatch.setattr(_transaction, "_replace_physical", inject_replacement) with pytest.raises(OSError, match="changed during installation"): cline_installation.commit_cline_installation(changes) @@ -229,17 +237,21 @@ def test_uninstall_restores_both_hooks_when_second_removal_fails( ), AGENT_ID, ) - real_replace = _transaction.os.replace + real_capture = _transaction._capture_physical failed = False - def fail_audit_removal(source: str, destination: str) -> None: + def fail_audit_removal( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(source) == str(fixture.audit_wrapper) and not failed: failed = True raise OSError("injected Cline uninstall failure") - real_replace(source, destination) + real_capture(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_audit_removal) + monkeypatch.setattr(_transaction, "_capture_physical", fail_audit_removal) with pytest.raises(OSError, match="injected Cline uninstall failure"): cline_installation.commit_cline_uninstall(changes) diff --git a/tests/test_codex_transaction.py b/tests/test_codex_transaction.py index e83703d..39c81ac 100644 --- a/tests/test_codex_transaction.py +++ b/tests/test_codex_transaction.py @@ -16,7 +16,7 @@ from codex_support import ( write_json, write_text, ) -from elydora.plugins import codex +from elydora.plugins import _transaction, codex def assert_no_transaction_files(root: Path) -> None: @@ -34,17 +34,21 @@ def test_transaction_rolls_back_all_five_files_when_hooks_commit_fails( existing_config={"hooks": {"SessionStart": []}}, ) original = fixture.config_path.read_text(encoding="utf-8") - real_replace = os.replace + real_replace = _transaction._replace_physical failed = False - def fail_hooks_once(source: str, destination: str) -> None: + def fail_hooks_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected hooks commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(os, "replace", fail_hooks_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_hooks_once) with pytest.raises(OSError, match="injected hooks commit failure"): fixture.install() diff --git a/tests/test_cursor_security.py b/tests/test_cursor_security.py index 69790a7..8353089 100644 --- a/tests/test_cursor_security.py +++ b/tests/test_cursor_security.py @@ -13,6 +13,7 @@ from cursor_support import ( write_json, write_text, ) +from elydora.plugins import _transaction @pytest.mark.parametrize( @@ -194,17 +195,21 @@ def test_transaction_rolls_back_all_files_when_config_commit_fails( existing_config={"version": 1, "hooks": {"sessionStart": []}}, ) original = fixture.config_path.read_text(encoding="utf-8") - real_replace = os.replace + real_replace = _transaction._replace_physical failed = False - def fail_config_once(source: str, destination: str) -> None: + def fail_config_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected config commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(os, "replace", fail_config_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_config_once) with pytest.raises(OSError, match="injected config commit failure"): fixture.install() diff --git a/tests/test_droid_transaction.py b/tests/test_droid_transaction.py index 4806729..5d9284a 100644 --- a/tests/test_droid_transaction.py +++ b/tests/test_droid_transaction.py @@ -63,17 +63,21 @@ def test_install_rolls_back_runtime_after_late_hook_commit_failure( ) -> None: fixture = DroidFixture(monkeypatch, tmp_path) prepared = _prepare_installation(fixture) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_hook_commit(source: Any, destination: Any) -> None: + def fail_hook_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if not failed and Path(destination) == fixture.root_path: failed = True raise OSError("injected Droid hook commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_hook_commit) + monkeypatch.setattr(_transaction, "_replace_physical", fail_hook_commit) with pytest.raises(OSError, match="injected Droid hook commit failure"): commit_droid_installation(prepared) assert failed is True @@ -200,17 +204,21 @@ def test_uninstall_rolls_back_all_hook_documents( for document in source_documents(sources) ] prepared = prepare_droid_uninstall(rendered) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_second_source(source: Any, destination: Any) -> None: + def fail_second_source( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if not failed and Path(destination) == fixture.settings_path: failed = True raise OSError("injected Droid uninstall failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_second_source) + monkeypatch.setattr(_transaction, "_replace_physical", fail_second_source) with pytest.raises(OSError, match="injected Droid uninstall failure"): commit_droid_uninstall(prepared) assert failed is True diff --git a/tests/test_gemini_transaction.py b/tests/test_gemini_transaction.py index 8d77ad5..42e21ab 100644 --- a/tests/test_gemini_transaction.py +++ b/tests/test_gemini_transaction.py @@ -63,17 +63,21 @@ def test_transaction_rolls_back_all_five_files( fixture = prepare_fixture( monkeypatch, tmp_path, existing_settings=original ) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_settings_once(source: str, destination: str) -> None: + def fail_settings_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected Gemini settings failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_settings_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_once) with pytest.raises(OSError, match="injected Gemini settings failure"): fixture.install() diff --git a/tests/test_grok_transaction.py b/tests/test_grok_transaction.py index fd16c63..c77eb9a 100644 --- a/tests/test_grok_transaction.py +++ b/tests/test_grok_transaction.py @@ -73,17 +73,21 @@ def test_install_restores_config_and_four_runtimes_when_final_commit_fails( fixture = prepare_fixture( monkeypatch, tmp_path, existing_config=original ) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_config_once(source: str, destination: str) -> None: + def fail_config_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.config_path) and not failed: failed = True raise OSError("injected Grok config failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_config_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_config_once) with pytest.raises(OSError, match="injected Grok config failure"): fixture.install() @@ -132,10 +136,14 @@ def test_uninstall_preserves_config_when_commit_fails( ) changes = grok_installation.prepare_grok_uninstall(rendered) - def fail_replace(_source: str, _destination: str) -> None: + def fail_replace( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: raise OSError("injected Grok uninstall failure") - monkeypatch.setattr(_transaction.os, "replace", fail_replace) + monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) with pytest.raises(OSError, match="injected Grok uninstall failure"): grok_installation.commit_grok_uninstall(changes) @@ -152,20 +160,24 @@ def test_recovery_preserves_original_after_committed_file_changes( original = fixture.runtime_config_path.read_text(encoding="utf-8") fixture.config["org_id"] = "org-updated" changes = prepare_installation(fixture) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical calls = 0 - def change_first_then_fail(source: str, destination: str) -> None: + def change_first_then_fail( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal calls calls += 1 if calls == 1: - real_replace(source, destination) + real_replace(source, destination, directory) write_text(Path(destination), "external change\n") return raise OSError("injected later commit failure") monkeypatch.setattr( - _transaction.os, "replace", change_first_then_fail + _transaction, "_replace_physical", change_first_then_fail ) with pytest.raises(OSError, match="original content preserved at"): @@ -189,21 +201,29 @@ def test_recovery_preserves_original_after_restore_failure( bytes(range(31, -1, -1)) ) changes = prepare_installation(fixture) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical commits = 0 - def fail_commit_and_restore(source: str, destination: str) -> None: + def fail_later_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal commits - if source.endswith(".rollback"): - raise OSError("injected rollback failure") commits += 1 if commits == 2: raise OSError("injected later commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr( - _transaction.os, "replace", fail_commit_and_restore - ) + def fail_restore( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + raise OSError("injected rollback failure") + + monkeypatch.setattr(_transaction, "_replace_physical", fail_later_commit) + monkeypatch.setattr(_transaction, "_restore_physical", fail_restore) with pytest.raises(OSError, match="original content preserved at"): grok_installation.commit_grok_installation(changes) diff --git a/tests/test_hook_error_log.py b/tests/test_hook_error_log.py new file mode 100644 index 0000000..8cb3216 --- /dev/null +++ b/tests/test_hook_error_log.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import os +from pathlib import Path +import stat +import subprocess +import sys + +from elydora.plugins.hook_template import generate_hook_script + + +MAX_ERROR_LOG_BYTES = 2 * 1024 * 1024 +LOG_RUNNER = """ +import sys + +namespace = {"__name__": "elydora_generated_hook_test"} +with open(sys.argv[1], encoding="utf-8") as source: + exec(compile(source.read(), sys.argv[1], "exec"), namespace) +namespace["log_error"](RuntimeError(sys.argv[2])) +""" +LARGE_LOG_RUNNER = """ +import sys + +namespace = {"__name__": "elydora_generated_hook_test"} +with open(sys.argv[1], encoding="utf-8") as source: + exec(compile(source.read(), sys.argv[1], "exec"), namespace) +namespace["log_error"](RuntimeError("x" * (128 * 1024))) +""" + + +def prepare_hook(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]: + agent_directory = tmp_path / ".elydora" / "agent-1" + agent_directory.mkdir(parents=True) + script_path = tmp_path / "hook.py" + script_path.write_text( + generate_hook_script( + org_id="org-1", + agent_id="agent-1", + kid="kid-1", + base_url="https://api.elydora.test", + ), + encoding="utf-8", + ) + environment = { + **os.environ, + "HOME": str(tmp_path), + "USERPROFILE": str(tmp_path), + } + return script_path, agent_directory / "error.log", environment + + +def run_log_error( + script_path: Path, + environment: dict[str, str], + message: str, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", LOG_RUNNER, str(script_path), message], + capture_output=True, + check=False, + env=environment, + text=True, + ) + + +def write_private_log(path: Path, contents: bytes) -> None: + path.write_bytes(contents) + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + + +def test_error_log_truncates_at_the_limit_and_records_the_current_error( + tmp_path: Path, +) -> None: + script_path, error_log, environment = prepare_hook(tmp_path) + write_private_log(error_log, b"old error\n" + b"x" * MAX_ERROR_LOG_BYTES) + + result = run_log_error(script_path, environment, "current-delivery-error") + + assert result.returncode == 0 + assert result.stderr == "" + contents = error_log.read_bytes() + assert len(contents) <= MAX_ERROR_LOG_BYTES + assert b"old error" not in contents + assert b"Error log truncated at the 2097152-byte limit" in contents + assert b"current-delivery-error" in contents + + +def test_error_log_rolls_over_before_a_new_entry_would_cross_the_limit( + tmp_path: Path, +) -> None: + script_path, error_log, environment = prepare_hook(tmp_path) + write_private_log(error_log, b"x" * (MAX_ERROR_LOG_BYTES - 8)) + + result = run_log_error(script_path, environment, "newest-error") + + assert result.returncode == 0 + assert result.stderr == "" + contents = error_log.read_bytes() + assert len(contents) <= MAX_ERROR_LOG_BYTES + assert contents.startswith(b"[") + assert b"Error log truncated at the 2097152-byte limit" in contents + assert b"newest-error" in contents + + +def test_concurrent_error_writers_keep_the_log_bounded(tmp_path: Path) -> None: + script_path, error_log, environment = prepare_hook(tmp_path) + write_private_log(error_log, b"x" * (MAX_ERROR_LOG_BYTES - 64)) + processes = [ + subprocess.Popen( + [ + sys.executable, + "-c", + LOG_RUNNER, + str(script_path), + f"concurrent-error-{index}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + text=True, + ) + for index in range(12) + ] + + results = [process.communicate(timeout=15) for process in processes] + + assert all(process.returncode == 0 for process in processes) + assert all(stderr == "" for _stdout, stderr in results) + contents = error_log.read_bytes() + assert len(contents) <= MAX_ERROR_LOG_BYTES + assert b"Error log truncated at the 2097152-byte limit" in contents + for index in range(12): + assert f"concurrent-error-{index}".encode() in contents + + +def test_single_error_entry_is_truncated_to_a_bounded_utf8_record( + tmp_path: Path, +) -> None: + script_path, error_log, environment = prepare_hook(tmp_path) + + result = subprocess.run( + [sys.executable, "-c", LARGE_LOG_RUNNER, str(script_path)], + capture_output=True, + check=False, + env=environment, + text=True, + ) + + assert result.returncode == 0 + assert result.stderr == "" + contents = error_log.read_bytes() + assert len(contents) == 64 * 1024 + assert contents.decode("utf-8").endswith("... [error entry truncated]\n") diff --git a/tests/test_kimi_transaction.py b/tests/test_kimi_transaction.py index 09b4d12..0018e63 100644 --- a/tests/test_kimi_transaction.py +++ b/tests/test_kimi_transaction.py @@ -43,17 +43,21 @@ def test_install_restores_both_configs_when_final_commit_fails( stable_config=stable, legacy_config=legacy, ) - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_legacy_once(source: str, destination: str) -> None: + def fail_legacy_once( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if os.path.abspath(destination) == str(fixture.legacy_path) and not failed: failed = True raise OSError("injected legacy config failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_legacy_once) + monkeypatch.setattr(_transaction, "_replace_physical", fail_legacy_once) with pytest.raises(OSError, match="injected legacy config failure"): fixture.install() @@ -107,18 +111,22 @@ def test_uninstall_restores_first_config_when_second_removal_fails( fixture.legacy_path, ] before = snapshot_files(paths) - real_replace = _transaction.os.replace + real_capture = _transaction._capture_physical removal_commits = 0 - def fail_second_removal(source: str, destination: str) -> None: + def fail_second_removal( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal removal_commits if destination.endswith(".rollback"): removal_commits += 1 if removal_commits == 2: raise OSError("injected uninstall failure") - real_replace(source, destination) + real_capture(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_second_removal) + monkeypatch.setattr(_transaction, "_capture_physical", fail_second_removal) with pytest.raises(OSError, match="injected uninstall failure"): fixture.plugin.uninstall(AGENT_ID) diff --git a/tests/test_kiroide_plugin.py b/tests/test_kiroide_plugin.py new file mode 100644 index 0000000..59a6f97 --- /dev/null +++ b/tests/test_kiroide_plugin.py @@ -0,0 +1,1487 @@ +from __future__ import annotations + +import base64 +import copy +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import subprocess +import stat +import sys +import threading +import time +from typing import Any + +import pytest + +from elydora import cli +from elydora.plugins import kiroide +from elydora.plugins import ( + _transaction, + kiroide_command, + kiroide_contract, + kiroide_installation, + kiroide_io, +) +from elydora.plugins.base import InstallConfig +from elydora.plugins.registry import SUPPORTED_AGENTS + + +AGENT_ID = "agent-1" +PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(b"=").decode() +ROTATED_PRIVATE_KEY = ( + base64.urlsafe_b64encode(bytes([12]) * 32).rstrip(b"=").decode() +) + + +@dataclass(frozen=True) +class KiroIdeFixture: + home: Path + workspace: Path + config_path: Path + legacy_path: Path + agent_directory: Path + guard_path: Path + audit_path: Path + plugin: kiroide.KiroIdePlugin + config: InstallConfig + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def _install_config(home: Path) -> InstallConfig: + return { + "org_id": "org-1", + "agent_id": AGENT_ID, + "agent_name": "kiroide", + "private_key": PRIVATE_KEY, + "kid": "kid-1", + "token": "token-1", + "base_url": "https://api.elydora.test", + "guard_script_path": str(home / ".elydora" / AGENT_ID / "guard.py"), + } + + +def _prepare_fixture( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + current: Any = None, + legacy: Any = None, + base_url: str = "https://api.elydora.test", +) -> KiroIdeFixture: + home = tmp_path / "home with spaces and 'quote %KIRO%" + workspace = tmp_path / "workspace with spaces" + home.mkdir(parents=True) + workspace.mkdir(parents=True) + config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json" + legacy_path = home / ".kiro" / "hooks" / "elydora-audit.kiro.hook" + if current is not None: + if isinstance(current, str): + config_path.parent.mkdir(parents=True) + config_path.write_text(current, encoding="utf-8") + else: + _write_json(config_path, current) + if legacy is not None: + if isinstance(legacy, str): + legacy_path.parent.mkdir(parents=True) + legacy_path.write_text(legacy, encoding="utf-8") + else: + _write_json(legacy_path, legacy) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(workspace) + agent_directory = home / ".elydora" / AGENT_ID + config = _install_config(home) + config["base_url"] = base_url + return KiroIdeFixture( + home, + workspace, + config_path, + legacy_path, + agent_directory, + agent_directory / "guard.py", + agent_directory / "hook.py", + kiroide.KiroIdePlugin(), + config, + ) + + +def _find_hook(document: dict[str, Any], name: str) -> dict[str, Any]: + return next(hook for hook in document["hooks"] if hook.get("name") == name) + + +def _managed_document(guard_command: str, audit_command: str) -> dict[str, Any]: + return { + "version": "v1", + "hooks": [ + { + "name": "elydora-guard", + "description": "Block tool use when the Elydora agent is frozen", + "trigger": "PreToolUse", + "matcher": ".*", + "action": {"type": "command", "command": guard_command}, + "timeout": 10, + "enabled": True, + }, + { + "name": "elydora-audit", + "description": "Record tool use in the Elydora audit trail", + "trigger": "PostToolUse", + "matcher": ".*", + "action": {"type": "command", "command": audit_command}, + "timeout": 10, + "enabled": True, + }, + ], + } + + +def _legacy_document(fixture: KiroIdeFixture, agent_id: str = AGENT_ID) -> dict: + agent_directory = fixture.home / ".elydora" / agent_id + return { + "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'"{sys.executable}" {agent_directory / "guard.py"}', + "timeout_ms": 5000, + }, + "post_tool_use": { + "command": str(agent_directory / "hook.py"), + "timeout_ms": 5000, + }, + }, + } + + +def _legacy_runtime_config( + fixture: KiroIdeFixture, agent_id: str = AGENT_ID +) -> dict[str, str]: + return { + "org_id": "org-1", + "agent_id": agent_id, + "kid": "kid-1", + "base_url": "https://api.elydora.test", + "token": "", + "agent_name": "kiroide", + } + + +def _write_legacy_runtime(fixture: KiroIdeFixture, agent_id: str) -> Path: + agent_directory = fixture.home / ".elydora" / agent_id + _write_json( + agent_directory / "config.json", + _legacy_runtime_config(fixture, agent_id), + ) + (agent_directory / "private.key").write_text(PRIVATE_KEY, encoding="utf-8") + (agent_directory / "guard.py").write_text("guard\n", encoding="utf-8") + (agent_directory / "hook.py").write_text("audit\n", encoding="utf-8") + return agent_directory + + +def _write_large_error_log(agent_directory: Path, marker: bytes) -> Path: + error_log = agent_directory / "error.log" + with error_log.open("wb") as file: + for _index in range(48): + file.write(marker * 4096) + if os.name != "nt": + error_log.chmod(0o600) + assert error_log.stat().st_size > 2 * 1024 * 1024 + return error_log + + +def _run_command( + command: str, fixture: KiroIdeFixture, payload: dict[str, Any] +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + shell=True, + cwd=fixture.workspace, + env={ + **os.environ, + "HOME": str(fixture.home), + "USERPROFILE": str(fixture.home), + }, + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + ) + + +class _AuditHandler(BaseHTTPRequestHandler): + operations: list[dict[str, Any]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + self.operations.append(json.loads(raw)) + self.send_response(201) + self.end_headers() + + def log_message(self, _format: str, *_args: object) -> None: + return + + +@dataclass(frozen=True) +class _AuditServer: + server: ThreadingHTTPServer + thread: threading.Thread + + @property + def base_url(self) -> str: + host, port = self.server.server_address + return f"http://{host}:{port}" + + def close(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join() + + +def _start_audit_server() -> _AuditServer: + _AuditHandler.operations = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), _AuditHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return _AuditServer(server, thread) + + +def _prepare_installation( + fixture: KiroIdeFixture, + config: InstallConfig | None = None, +) -> tuple[ + kiroide_io.KiroIdeSources, + kiroide_installation.KiroIdeRuntimePaths, + kiroide_installation.PreparedKiroIdeInstallation, + kiroide_contract.RenderedKiroIdeDocument, +]: + selected = fixture.config if config is None else config + sources = kiroide_io.read_kiroide_sources() + kiroide_contract.require_available_kiroide_hooks(sources.document.hooks) + paths = kiroide_installation.preflight_kiroide_installation( + selected, sources + ) + hooks = kiroide_contract.without_managed_kiroide_hooks( + sources.document.hooks + ) + rendered = kiroide_contract.render_kiroide_document( + sources.document, + [ + *hooks, + kiroide_contract.build_kiroide_hook( + kiroide_contract.GUARD_NAME, paths.guard_path + ), + kiroide_contract.build_kiroide_hook( + kiroide_contract.AUDIT_NAME, paths.audit_path + ), + ], + ) + prepared = kiroide_installation.prepare_kiroide_installation( + selected, paths, sources, rendered + ) + return sources, paths, prepared, rendered + + +def test_kiroide_uses_current_workspace_v1_hook_contract( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home with spaces" + workspace = tmp_path / "workspace with spaces" + home.mkdir() + workspace.mkdir() + config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json" + existing_hook = { + "name": "workspace-context", + "trigger": "SessionStart", + "action": {"type": "agent", "prompt": "Read AGENTS.md"}, + "enabled": True, + } + _write_json( + config_path, + {"version": "v1", "owner": "workspace", "hooks": [existing_hook]}, + ) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(workspace) + + plugin = kiroide.KiroIdePlugin() + plugin.install(_install_config(home)) + first_source = config_path.read_text(encoding="utf-8") + plugin.install(_install_config(home)) + + assert SUPPORTED_AGENTS["kiroide"] == { + "name": "Kiro IDE", + "hook_event": "PreToolUse/PostToolUse", + "config_path": ".kiro/hooks/elydora-audit.json", + } + assert config_path.read_text(encoding="utf-8") == first_source + document = json.loads(first_source) + assert document["version"] == "v1" + assert document["owner"] == "workspace" + assert document["hooks"][0] == existing_hook + assert document["hooks"][1]["trigger"] == "PreToolUse" + assert document["hooks"][2]["trigger"] == "PostToolUse" + assert all(hook["matcher"] == ".*" for hook in document["hooks"][1:]) + assert all(hook["timeout"] == 10 for hook in document["hooks"][1:]) + assert all(hook["enabled"] is True for hook in document["hooks"][1:]) + assert not (home / ".kiro" / "hooks" / "elydora-audit.json").exists() + private_key_path = home / ".elydora" / AGENT_ID / "private.key" + audit_path = home / ".elydora" / AGENT_ID / "hook.py" + assert private_key_path.read_text(encoding="utf-8") == PRIVATE_KEY + assert PRIVATE_KEY not in audit_path.read_text(encoding="utf-8") + if os.name != "nt": + assert private_key_path.stat().st_mode & 0o777 == 0o600 + assert audit_path.stat().st_mode & 0o777 == 0o700 + + +def test_kiroide_commands_block_frozen_agents_and_forward_native_events( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + api = _start_audit_server() + try: + fixture = _prepare_fixture( + monkeypatch, tmp_path, base_url=api.base_url + ) + fixture.plugin.install(fixture.config) + document = json.loads(fixture.config_path.read_text(encoding="utf-8")) + payload = { + "hook_event_name": "preToolUse", + "cwd": str(fixture.workspace), + "session_id": "session-1", + "tool_name": "execute_bash", + "tool_input": {"command": "pytest -q"}, + "future_field": {"retained": True}, + } + status_cache_path = fixture.agent_directory / "status-cache.json" + _write_json( + status_cache_path, + {"status": "frozen", "cached_at": time.time()}, + ) + if os.name != "nt": + status_cache_path.chmod(0o600) + + blocked = _run_command( + _find_hook(document, "elydora-guard")["action"]["command"], + fixture, + payload, + ) + assert blocked.returncode == 2 + assert "Tool execution blocked" in blocked.stderr + + payload["hook_event_name"] = "postToolUse" + payload["tool_response"] = {"success": True, "result": "12 passed"} + audited = _run_command( + _find_hook(document, "elydora-audit")["action"]["command"], + fixture, + payload, + ) + assert audited.returncode == 0, audited.stderr + assert _AuditHandler.operations[-1]["payload"] == payload + assert _AuditHandler.operations[-1]["action"] == { + "tool": "execute_bash" + } + assert _AuditHandler.operations[-1]["subject"] == { + "session_id": "session-1" + } + finally: + api.close() + + +def test_kiroide_status_requires_exact_enabled_hooks_and_runtime_sources( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + assert fixture.plugin.status() == { + "installed": True, + "agent": "kiroide", + "details": f"Config: {fixture.config_path}", + } + + document = json.loads(fixture.config_path.read_text(encoding="utf-8")) + _find_hook(document, "elydora-guard")["enabled"] = False + _write_json(fixture.config_path, document) + assert fixture.plugin.status() == { + "installed": False, + "agent": "kiroide", + "details": ( + "Managed hooks are incomplete or invalid: " + f"{fixture.config_path}" + ), + } + + fixture.plugin.install(fixture.config) + fixture.guard_path.write_text("tampered\n", encoding="utf-8") + status = fixture.plugin.status() + assert status["installed"] is False + assert "runtime files are missing or invalid" in status["details"] + + +def test_kiroide_status_surfaces_malformed_runtime_metadata( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + (fixture.agent_directory / "config.json").write_text( + "{ malformed", encoding="utf-8" + ) + + with pytest.raises(ValueError, match="parse Elydora runtime config"): + fixture.plugin.status() + + +@pytest.mark.parametrize( + ("source", "message"), + [ + ("{ malformed", "parse Kiro IDE hooks"), + ( + '{"version":"v1","version":"v1","hooks":[]}', + 'duplicate field "version"', + ), + ({"version": "v0", "hooks": []}, 'version must be "v1"'), + ({"version": "v1", "hooks": None}, 'field "hooks" must be an array'), + ( + { + "version": "v1", + "hooks": [ + { + "name": "bad", + "trigger": "FutureEvent", + "action": {"type": "command", "command": "echo bad"}, + } + ], + }, + "unsupported trigger", + ), + ], +) +def test_kiroide_rejects_invalid_workspace_hooks_before_runtime_writes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + source: Any, + message: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path, current=source) + original = fixture.config_path.read_text(encoding="utf-8") + + with pytest.raises(ValueError, match=message): + fixture.plugin.install(fixture.config) + + assert fixture.config_path.read_text(encoding="utf-8") == original + assert not fixture.agent_directory.exists() + + +def test_kiroide_cli_preflight_rejects_workspace_before_runtime_creation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path, current="{ malformed") + private_key_file = tmp_path / "private.key" + token_file = tmp_path / "token" + private_key_file.write_text(PRIVATE_KEY, encoding="utf-8") + token_file.write_text("token-1", encoding="utf-8") + if os.name != "nt": + private_key_file.chmod(0o600) + token_file.chmod(0o600) + args = cli.build_parser().parse_args( + [ + "install", + "--agent", + "kiroide", + "--org_id", + "org-1", + "--agent_id", + AGENT_ID, + "--private_key_file", + str(private_key_file), + "--token_file", + str(token_file), + "--kid", + "kid-1", + ] + ) + + with pytest.raises(ValueError, match="parse Kiro IDE hooks"): + cli.cmd_install(args) + + assert fixture.config_path.read_text(encoding="utf-8") == "{ malformed" + assert not fixture.agent_directory.exists() + + +def test_kiroide_rejects_noncanonical_private_keys_before_writes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.config["private_key"] = PRIVATE_KEY + "=" + + with pytest.raises(ValueError, match="canonical 32-byte base64url"): + fixture.plugin.install(fixture.config) + + assert not fixture.config_path.exists() + assert not fixture.agent_directory.exists() + + +def test_kiroide_rejects_managed_name_collisions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + current = { + "version": "v1", + "hooks": [ + { + "name": "elydora-guard", + "trigger": "PreToolUse", + "action": {"type": "command", "command": "user-command"}, + } + ], + } + fixture = _prepare_fixture(monkeypatch, tmp_path, current=current) + + with pytest.raises(ValueError, match="conflicts with the Elydora contract"): + fixture.plugin.install(fixture.config) + + assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == current + assert not fixture.agent_directory.exists() + + +def test_kiroide_rejects_linked_workspace_configuration_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + external = tmp_path / "external-kiro" + external.mkdir() + try: + (fixture.workspace / ".kiro").symlink_to(external, target_is_directory=True) + except OSError as error: + pytest.skip(f"Directory symbolic links are unavailable: {error}") + + with pytest.raises(OSError, match="not a physical directory"): + fixture.plugin.install(fixture.config) + + assert not (external / "hooks" / "elydora-audit.json").exists() + assert not fixture.agent_directory.exists() + + +def test_kiroide_migrates_only_the_matching_legacy_python_hook( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + + fixture.plugin.install(fixture.config) + assert not fixture.legacy_path.exists() + + _write_json(fixture.legacy_path, _legacy_document(fixture, "agent-2")) + fixture.plugin.install(fixture.config) + assert fixture.legacy_path.exists() + + +def test_kiroide_preserves_unrelated_legacy_hook_and_rejects_malformed_legacy( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + unrelated = { + "name": "User Hook", + "version": "1.0.0", + "hooks": {"post_tool_use": {"command": "user-command"}}, + } + fixture = _prepare_fixture(monkeypatch, tmp_path, legacy=unrelated) + fixture.plugin.install(fixture.config) + assert json.loads(fixture.legacy_path.read_text(encoding="utf-8")) == unrelated + + fixture.plugin.uninstall(AGENT_ID) + fixture.legacy_path.write_text("{ malformed", encoding="utf-8") + with pytest.raises(ValueError, match="parse legacy Kiro IDE hook"): + fixture.plugin.install(fixture.config) + assert fixture.legacy_path.read_text(encoding="utf-8") == "{ malformed" + + +def test_kiroide_uninstall_removes_exact_ownership_and_preserves_user_hooks( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + user_hook = { + "name": "workspace-context", + "trigger": "SessionStart", + "action": {"type": "agent", "prompt": "Read AGENTS.md"}, + } + fixture = _prepare_fixture( + monkeypatch, + tmp_path, + current={"version": "v1", "owner": "workspace", "hooks": [user_hook]}, + ) + fixture.plugin.install(fixture.config) + + fixture.plugin.uninstall("agent-2") + assert len(json.loads(fixture.config_path.read_text())["hooks"]) == 3 + assert fixture.agent_directory.exists() + assert (fixture.agent_directory / "private.key").read_text( + encoding="utf-8" + ) == PRIVATE_KEY + fixture.plugin.uninstall(AGENT_ID) + assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == { + "version": "v1", + "owner": "workspace", + "hooks": [user_hook], + } + + owned = _prepare_fixture(monkeypatch, tmp_path / "owned") + owned.plugin.install(owned.config) + owned.plugin.uninstall(AGENT_ID) + assert not owned.config_path.exists() + + +def test_kiroide_uninstall_without_agent_id_removes_owned_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + + fixture.plugin.uninstall() + + assert not fixture.config_path.exists() + assert not fixture.agent_directory.exists() + assert not (fixture.agent_directory / "private.key").exists() + + +def test_kiroide_uninstall_without_agent_id_removes_current_and_legacy_runtimes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + legacy_agent_id = "agent-2" + legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) + _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) + + fixture.plugin.uninstall() + + assert not fixture.config_path.exists() + assert not fixture.legacy_path.exists() + assert not fixture.agent_directory.exists() + assert not legacy_directory.exists() + assert not (legacy_directory / "private.key").exists() + + +def test_kiroide_no_id_uninstall_streams_all_oversized_error_logs( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + current_error = _write_large_error_log( + fixture.agent_directory, + b"current runtime error\n", + ) + legacy_agent_id = "agent-2" + legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) + legacy_error = _write_large_error_log( + legacy_directory, + b"legacy runtime error\n", + ) + _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) + + fixture.plugin.uninstall() + + assert not current_error.exists() + assert not legacy_error.exists() + assert not fixture.agent_directory.exists() + assert not legacy_directory.exists() + + +def test_kiroide_uninstall_without_agent_id_deduplicates_current_and_legacy_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + + fixture.plugin.uninstall() + + assert not fixture.config_path.exists() + assert not fixture.legacy_path.exists() + assert not fixture.agent_directory.exists() + + +def test_kiroide_uninstall_without_agent_id_rejects_unknown_runtime_before_writes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + legacy_agent_id = "agent-2" + legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) + _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) + unknown = legacy_directory / "user-data.txt" + unknown.write_text("preserve\n", encoding="utf-8") + original_provider = fixture.config_path.read_bytes() + original_legacy = fixture.legacy_path.read_bytes() + original_key = (fixture.agent_directory / "private.key").read_bytes() + original_legacy_key = (legacy_directory / "private.key").read_bytes() + + with pytest.raises(OSError, match="contains unmanaged entries"): + fixture.plugin.uninstall() + + assert fixture.config_path.read_bytes() == original_provider + assert fixture.legacy_path.read_bytes() == original_legacy + assert (fixture.agent_directory / "private.key").read_bytes() == original_key + assert (legacy_directory / "private.key").read_bytes() == original_legacy_key + assert unknown.read_text(encoding="utf-8") == "preserve\n" + + +def test_kiroide_uninstall_without_agent_id_rolls_back_provider_and_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + runtime_paths = [ + fixture.guard_path, + fixture.audit_path, + fixture.agent_directory / "private.key", + fixture.agent_directory / "config.json", + ] + original_provider = fixture.config_path.read_bytes() + original_runtime = {path: path.read_bytes() for path in runtime_paths} + original_capture = _transaction._capture_physical + + def fail_private_key_removal( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if Path(source) == fixture.agent_directory / "private.key" and ( + destination.endswith(".rollback") + ): + raise OSError("injected private key removal failure") + original_capture(source, destination, directory) + + monkeypatch.setattr(_transaction, "_capture_physical", fail_private_key_removal) + + with pytest.raises(OSError, match="injected private key removal failure"): + fixture.plugin.uninstall() + + assert fixture.config_path.read_bytes() == original_provider + assert {path: path.read_bytes() for path in runtime_paths} == original_runtime + + +def test_kiroide_transaction_rolls_back_when_legacy_cleanup_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + original = {"version": "v1", "owner": "workspace", "hooks": []} + fixture = _prepare_fixture(monkeypatch, tmp_path, current=original) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + original_capture = _transaction._capture_physical + + def fail_legacy_removal( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if Path(source) == fixture.legacy_path and destination.endswith(".rollback"): + raise OSError("injected legacy cleanup failure") + original_capture(source, destination, directory) + + monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal) + + with pytest.raises(OSError, match="injected legacy cleanup failure"): + fixture.plugin.install(fixture.config) + + assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == original + assert fixture.legacy_path.exists() + for path in ( + fixture.guard_path, + fixture.audit_path, + fixture.agent_directory / "config.json", + fixture.agent_directory / "private.key", + ): + assert not path.exists() + leftovers = list(fixture.config_path.parent.glob(".*.tmp")) + leftovers += list(fixture.config_path.parent.glob(".*.rollback")) + assert leftovers == [] + + +@pytest.mark.parametrize("swapped", ["workspace", "kiro", "hooks"]) +def test_kiroide_transaction_rejects_provider_directory_identity_swaps( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + swapped: str, +) -> None: + fixture = _prepare_fixture( + monkeypatch, + tmp_path, + current={"version": "v1", "hooks": []}, + ) + sources, paths, prepared, _rendered = _prepare_installation(fixture) + monkeypatch.chdir(tmp_path) + original_paths = { + "workspace": fixture.workspace, + "kiro": fixture.workspace / ".kiro", + "hooks": fixture.workspace / ".kiro" / "hooks", + } + original = original_paths[swapped] + backup = original.with_name(original.name + "-original") + replacement = tmp_path / f"redirected-{swapped}" + if swapped == "workspace": + (replacement / ".kiro" / "hooks").mkdir(parents=True) + redirected_config = replacement / ".kiro" / "hooks" / "elydora-audit.json" + elif swapped == "kiro": + (replacement / "hooks").mkdir(parents=True) + redirected_config = replacement / "hooks" / "elydora-audit.json" + else: + replacement.mkdir() + redirected_config = replacement / "elydora-audit.json" + original.rename(backup) + try: + original.symlink_to(replacement, target_is_directory=True) + except OSError as error: + backup.rename(original) + pytest.skip(f"Directory symbolic links are unavailable: {error}") + + with pytest.raises(OSError, match="not a physical directory|changed during"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert not redirected_config.exists() + assert not fixture.agent_directory.exists() + + +def test_kiroide_key_rotation_pins_unchanged_provider_sources( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + original_key = (fixture.agent_directory / "private.key").read_text( + encoding="utf-8" + ) + rotated = InstallConfig(**fixture.config) + rotated["private_key"] = ROTATED_PRIVATE_KEY + sources, paths, prepared, rendered = _prepare_installation(fixture, rotated) + assert rendered.changed is False + document = json.loads(fixture.config_path.read_text(encoding="utf-8")) + document["concurrent_change"] = True + _write_json(fixture.config_path, document) + + with pytest.raises(OSError, match="Kiro IDE hooks changed during"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert (fixture.agent_directory / "private.key").read_text( + encoding="utf-8" + ) == original_key + assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[ + "concurrent_change" + ] is True + + +def test_kiroide_noop_install_rejects_concurrent_provider_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + sources, paths, prepared, rendered = _prepare_installation(fixture) + assert prepared.changes == [] + assert rendered.changed is False + document = json.loads(fixture.config_path.read_text(encoding="utf-8")) + _find_hook(document, "elydora-guard")["enabled"] = False + _write_json(fixture.config_path, document) + + with pytest.raises(OSError, match="Kiro IDE hooks changed during"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert _find_hook( + json.loads(fixture.config_path.read_text(encoding="utf-8")), + "elydora-guard", + )["enabled"] is False + + +@pytest.mark.parametrize( + "relative_path", + ["private.key", "config.json", "guard.py", "hook.py"], +) +def test_kiroide_noop_install_pins_every_runtime_source( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + relative_path: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + sources, paths, prepared, _rendered = _prepare_installation(fixture) + assert prepared.changes == [] + assert len(prepared.runtime_preconditions) == 4 + target = fixture.agent_directory / relative_path + concurrent = target.read_text(encoding="utf-8") + "\nconcurrent-change" + target.write_text(concurrent, encoding="utf-8") + + with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert target.read_text(encoding="utf-8") == concurrent + + +def test_kiroide_runtime_config_binds_agent_to_absolute_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + + runtime_config = json.loads( + (fixture.agent_directory / "config.json").read_text(encoding="utf-8") + ) + assert runtime_config["workspace_root"] == str(fixture.workspace.resolve()) + assert Path(runtime_config["workspace_root"]).is_absolute() + + +def test_kiroide_rejects_same_agent_install_from_another_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + original_hook = fixture.config_path.read_bytes() + runtime_config_path = fixture.agent_directory / "config.json" + original_runtime = runtime_config_path.read_bytes() + second_workspace = tmp_path / "second workspace" + second_workspace.mkdir() + monkeypatch.chdir(second_workspace) + + with pytest.raises(ValueError, match="bound to another workspace"): + fixture.plugin.install(fixture.config) + + assert fixture.config_path.read_bytes() == original_hook + assert runtime_config_path.read_bytes() == original_runtime + assert not (second_workspace / ".kiro").exists() + + +def test_kiroide_cli_uninstall_requires_bound_workspace( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + original_hook = fixture.config_path.read_bytes() + second_workspace = tmp_path / "second workspace" + second_workspace.mkdir() + args = cli.build_parser().parse_args( + [ + "uninstall", + "--agent", + "kiroide", + "--agent_id", + AGENT_ID, + ] + ) + monkeypatch.chdir(second_workspace) + + with pytest.raises(ValueError, match="bound to another workspace"): + cli.cmd_uninstall(args) + + assert fixture.config_path.read_bytes() == original_hook + assert fixture.agent_directory.exists() + assert not (second_workspace / ".kiro").exists() + + monkeypatch.chdir(fixture.workspace) + cli.cmd_uninstall(args) + assert not fixture.config_path.exists() + assert not fixture.agent_directory.exists() + + +def test_kiroide_install_claims_legacy_runtime_owner_from_matching_hooks( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + runtime_config_path = fixture.agent_directory / "config.json" + runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8")) + runtime_config.pop("workspace_root") + _write_json(runtime_config_path, runtime_config) + + fixture.plugin.install(fixture.config) + + claimed = json.loads(runtime_config_path.read_text(encoding="utf-8")) + assert claimed["workspace_root"] == str(fixture.workspace.resolve()) + + +def test_kiroide_install_migrates_exact_global_legacy_runtime_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + runtime_config_path = fixture.agent_directory / "config.json" + _write_json(runtime_config_path, _legacy_runtime_config(fixture)) + config = dict(fixture.config) + config.pop("token") + + fixture.plugin.install(config) + + migrated = json.loads(runtime_config_path.read_text(encoding="utf-8")) + assert migrated["workspace_root"] == str(fixture.workspace.resolve()) + assert "token" not in migrated + assert fixture.config_path.exists() + assert not fixture.legacy_path.exists() + + +def test_kiroide_legacy_owner_migration_rolls_back_runtime_claim( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + runtime_config_path = fixture.agent_directory / "config.json" + original_runtime = _legacy_runtime_config(fixture) + _write_json(runtime_config_path, original_runtime) + original_capture = _transaction._capture_physical + + def fail_legacy_removal( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if Path(source) == fixture.legacy_path and destination.endswith( + ".rollback" + ): + raise OSError("injected legacy migration failure") + original_capture(source, destination, directory) + + monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal) + + with pytest.raises(OSError, match="injected legacy migration failure"): + fixture.plugin.install(fixture.config) + + assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == ( + original_runtime + ) + assert fixture.legacy_path.exists() + assert not fixture.config_path.exists() + + +def test_kiroide_cli_uninstall_accepts_exact_global_legacy_ownership( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + _write_json( + fixture.agent_directory / "config.json", + _legacy_runtime_config(fixture), + ) + args = cli.build_parser().parse_args( + [ + "uninstall", + "--agent", + "kiroide", + "--agent_id", + AGENT_ID, + ] + ) + + cli.cmd_uninstall(args) + + assert not fixture.legacy_path.exists() + assert not fixture.agent_directory.exists() + assert not fixture.config_path.exists() + + +def test_kiroide_rejects_unproved_legacy_runtime_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture, "agent-2")) + runtime_config_path = fixture.agent_directory / "config.json" + original = _legacy_runtime_config(fixture) + _write_json(runtime_config_path, original) + + with pytest.raises(ValueError, match="token is invalid"): + fixture.plugin.install(fixture.config) + + assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == original + assert fixture.legacy_path.exists() + assert not fixture.config_path.exists() + + +def test_kiroide_rejects_invalid_ownerless_legacy_runtime_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + _write_json(fixture.legacy_path, _legacy_document(fixture)) + runtime_config_path = fixture.agent_directory / "config.json" + invalid = _legacy_runtime_config(fixture) + invalid["token"] = " " + _write_json(runtime_config_path, invalid) + + with pytest.raises(ValueError, match="token is invalid"): + fixture.plugin.install(fixture.config) + + assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == invalid + assert fixture.legacy_path.exists() + assert not fixture.config_path.exists() + + +@pytest.mark.parametrize( + ("owner", "message"), + [("relative", "must be absolute"), ("other", "bound to another workspace")], +) +def test_kiroide_status_rejects_invalid_runtime_workspace_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + owner: str, + message: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + runtime_config_path = fixture.agent_directory / "config.json" + runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8")) + runtime_config["workspace_root"] = ( + "relative" if owner == "relative" else str((tmp_path / "other").resolve()) + ) + _write_json(runtime_config_path, runtime_config) + + with pytest.raises(ValueError, match=message): + fixture.plugin.status() + + +def test_kiroide_failed_install_removes_created_workspace_and_runtime_directories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + + def fail_first_commit( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + raise OSError("injected Kiro IDE commit failure") + + monkeypatch.setattr(_transaction, "_replace_physical", fail_first_commit) + + with pytest.raises(OSError, match="injected Kiro IDE commit failure"): + fixture.plugin.install(fixture.config) + + assert not (fixture.workspace / ".kiro").exists() + # The durable transaction state directory survives failures by design; + # everything else under the runtime root must be rolled back. + elydora_root = fixture.home / ".elydora" + if elydora_root.exists(): + assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") +@pytest.mark.parametrize("relative_path", ["private.key", "config.json"]) +def test_kiroide_noop_install_pins_runtime_secret_modes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + relative_path: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + sources, paths, prepared, _rendered = _prepare_installation(fixture) + target = fixture.agent_directory / relative_path + target.chmod(0o640) + + with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert stat.S_IMODE(target.stat().st_mode) == 0o640 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") +def test_kiroide_key_rotation_rejects_concurrent_mode_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + rotated = InstallConfig(**fixture.config) + rotated["private_key"] = ROTATED_PRIVATE_KEY + sources, paths, prepared, _rendered = _prepare_installation( + fixture, rotated + ) + key_path = fixture.agent_directory / "private.key" + assert any(change.file_path == str(key_path) for change in prepared.changes) + key_path.chmod(0o640) + + with pytest.raises(OSError, match="changed during installation"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert key_path.read_text(encoding="utf-8") == PRIVATE_KEY + assert stat.S_IMODE(key_path.stat().st_mode) == 0o640 + + +def test_kiroide_install_rejects_runtime_root_link_created_after_prepare( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + sources, paths, prepared, _rendered = _prepare_installation(fixture) + runtime_root = fixture.home / ".elydora" + redirected_root = tmp_path / "redirected-runtime-root" + redirected_root.mkdir() + try: + runtime_root.symlink_to(redirected_root, target_is_directory=True) + except OSError as error: + pytest.skip(f"Directory symbolic links are unavailable: {error}") + + with pytest.raises(OSError, match="not a physical directory|changed during"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert not (redirected_root / AGENT_ID / "private.key").exists() + assert not fixture.config_path.exists() + + +def test_kiroide_install_rejects_agent_directory_identity_swap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.agent_directory.mkdir(parents=True) + sources, paths, prepared, _rendered = _prepare_installation(fixture) + original_directory = fixture.agent_directory.with_name("agent-original") + fixture.agent_directory.rename(original_directory) + fixture.agent_directory.mkdir() + + with pytest.raises(OSError, match="changed during"): + kiroide_installation.commit_kiroide_installation( + prepared, sources, paths + ) + + assert not (fixture.agent_directory / "private.key").exists() + assert not fixture.config_path.exists() + + +def test_kiroide_exact_managed_commands_survive_python_relocation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + executable_name = "python.exe" if os.name == "nt" else "python3" + fake_executable = tmp_path / "other-runtime" / executable_name + with monkeypatch.context() as command_context: + command_context.setattr( + kiroide_command.sys, "executable", str(fake_executable) + ) + guard_command = kiroide_command.build_kiroide_command( + str(fixture.guard_path) + ) + audit_command = kiroide_command.build_kiroide_command( + str(fixture.audit_path) + ) + stale_document = _managed_document(guard_command, audit_command) + _write_json(fixture.config_path, stale_document) + + assert fixture.plugin.status() == { + "installed": False, + "agent": "kiroide", + "details": ( + "Managed hooks are incomplete or invalid: " + f"{fixture.config_path}" + ), + } + + fixture.plugin.install(fixture.config) + repaired = json.loads(fixture.config_path.read_text(encoding="utf-8")) + assert kiroide_command.kiroide_runtime_reference( + _find_hook(repaired, "elydora-guard")["action"]["command"], + "guard.py", + ) is not None + assert kiroide_command.kiroide_runtime_reference( + _find_hook(repaired, "elydora-audit")["action"]["command"], + "hook.py", + ) is not None + + _write_json(fixture.config_path, stale_document) + fixture.plugin.uninstall(AGENT_ID) + + assert not fixture.config_path.exists() + + +@pytest.mark.parametrize("damage", ["field", "orphan"]) +def test_kiroide_stale_command_ownership_survives_contract_drift( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + damage: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + executable_name = "python.exe" if os.name == "nt" else "python3" + fake_executable = tmp_path / "other-runtime" / executable_name + with monkeypatch.context() as command_context: + command_context.setattr( + kiroide_command.sys, "executable", str(fake_executable) + ) + document = _managed_document( + kiroide_command.build_kiroide_command(str(fixture.guard_path)), + kiroide_command.build_kiroide_command(str(fixture.audit_path)), + ) + if damage == "field": + _find_hook(document, "elydora-guard")["timeout"] = 9 + else: + document["hooks"].remove(_find_hook(document, "elydora-audit")) + _write_json(fixture.config_path, document) + + fixture.plugin.install(fixture.config) + repaired = json.loads(fixture.config_path.read_text(encoding="utf-8")) + assert len(repaired["hooks"]) == 2 + assert all(hook["timeout"] == 10 for hook in repaired["hooks"]) + + _write_json(fixture.config_path, document) + fixture.plugin.uninstall(AGENT_ID) + + assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[ + "hooks" + ] == [] + + +@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract") +def test_kiroide_current_commands_require_the_system_powershell_launcher( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + command = kiroide_command.build_kiroide_command(str(fixture.guard_path)) + expected = f'"{kiroide_command._windows_powershell_path()}"' + altered = command.replace(expected, r'"C:\Other\powershell.exe"', 1) + + assert kiroide_command.kiroide_runtime_reference(altered, "guard.py") is None + + +@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract") +def test_kiroide_windows_launcher_fails_when_python_is_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + missing_python = tmp_path / "missing-runtime" / "python.exe" + script_path = tmp_path / "guard.py" + with monkeypatch.context() as command_context: + command_context.setattr( + kiroide_command.sys, "executable", str(missing_python) + ) + command = kiroide_command.build_kiroide_command(str(script_path)) + + result = subprocess.run( + command, + shell=True, + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 1 + assert result.stderr + + +@pytest.mark.parametrize( + "executable_name", + ["python3.13t", "python3.14t", "python3.13d.exe", "pypy3", "pypy3.10.exe"], +) +def test_kiroide_ownership_accepts_supported_python_runtime_names( + executable_name: str, +) -> None: + assert kiroide_command._is_python_executable(executable_name) + + +def test_kiroide_builder_rejects_unrecoverable_executable_names( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with monkeypatch.context() as command_context: + command_context.setattr( + kiroide_command.sys, + "executable", + str(tmp_path / "embedded-runtime"), + ) + with pytest.raises(ValueError, match="absolute Python executable"): + kiroide_command.build_kiroide_command(str(tmp_path / "guard.py")) + + +@pytest.mark.parametrize("damage", ["orphan", "duplicate", "altered"]) +def test_kiroide_status_rejects_every_incomplete_managed_hook_set( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + damage: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + document = json.loads(fixture.config_path.read_text(encoding="utf-8")) + audit = _find_hook(document, "elydora-audit") + if damage == "orphan": + document["hooks"].remove(audit) + elif damage == "duplicate": + document["hooks"].append(copy.deepcopy(audit)) + else: + _find_hook(document, "elydora-guard")["timeout"] = 9 + _write_json(fixture.config_path, document) + + status = fixture.plugin.status() + assert status["installed"] is False + assert status["details"] == ( + f"Managed hooks are incomplete or invalid: {fixture.config_path}" + ) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") +@pytest.mark.parametrize( + ("target", "message"), + [ + ("key", "private key must be accessible only by its owner"), + ("config", "Token-bearing Elydora runtime config must be accessible"), + ], +) +def test_kiroide_repairs_and_enforces_owner_only_runtime_secrets( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + target: str, + message: str, +) -> None: + fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + paths = { + "key": fixture.agent_directory / "private.key", + "config": fixture.agent_directory / "config.json", + } + paths[target].chmod(0o644) + + with pytest.raises(ValueError, match=message): + fixture.plugin.status() + + fixture.plugin.install(fixture.config) + assert stat.S_IMODE(paths[target].stat().st_mode) == 0o600 + assert fixture.plugin.status()["installed"] is True diff --git a/tests/test_letta_transaction.py b/tests/test_letta_transaction.py index 064c75f..393d147 100644 --- a/tests/test_letta_transaction.py +++ b/tests/test_letta_transaction.py @@ -3,8 +3,6 @@ from __future__ import annotations import json import os from pathlib import Path -from typing import Any - import pytest from elydora.plugins import _transaction @@ -56,17 +54,21 @@ def test_transaction_rolls_back_every_runtime_and_settings_change( monkeypatch, tmp_path, global_settings={"owner": "user"} ) original = fixture.source() - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_settings_commit(source: Any, destination: Any) -> None: + def fail_settings_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if not failed and Path(destination) == fixture.global_path: failed = True raise OSError("simulated Letta settings commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_settings_commit) + monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_commit) with pytest.raises(OSError, match="Install Letta Code hooks"): fixture.install() assert failed diff --git a/tests/test_pinned_directory.py b/tests/test_pinned_directory.py new file mode 100644 index 0000000..2158bba --- /dev/null +++ b/tests/test_pinned_directory.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import os +from pathlib import Path +import stat + +import pytest + +from elydora.plugins import _private_artifact +from elydora.plugins import _pinned_directory +from elydora.plugins._managed_files import DirectorySnapshot +from elydora.plugins._pinned_directory import PinnedDirectory + + +def test_darwin_private_directory_removal_uses_stdlib_dir_fd( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, int | None]] = [] + + def record_rmdir(path: str, *, dir_fd: int | None = None) -> None: + calls.append((path, dir_fd)) + + monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir) + monkeypatch.setattr(_private_artifact.os, "supports_dir_fd", {record_rmdir}) + + _private_artifact._remove_posix_private_directory(41, "private-container") + + assert calls == [("private-container", 41)] + + +def test_assert_path_stable_detects_visible_identity_change( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + directory = tmp_path / "managed" + directory.mkdir() + pinned = PinnedDirectory.open(str(directory), "managed directory") + changed = DirectorySnapshot( + pinned.snapshot.device + 1, + pinned.snapshot.inode, + ) + monkeypatch.setattr( + _pinned_directory, + "read_physical_directory", + lambda _path, _label: changed, + ) + + try: + with pytest.raises(OSError, match="changed during test operation"): + pinned.assert_path_stable("test operation") + finally: + pinned.close() + + +def test_closed_pin_rejects_file_operations(tmp_path: Path) -> None: + directory = tmp_path / "managed" + directory.mkdir() + pinned = PinnedDirectory.open(str(directory), "managed directory") + pinned.close() + + with pytest.raises(OSError, match="is closed"): + pinned.stat_file("state.json") + + +@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract") +def test_windows_pin_blocks_directory_swap_and_allows_child_replace( + tmp_path: Path, +) -> None: + directory = tmp_path / "managed" + directory.mkdir() + (directory / "source").write_text("new", encoding="utf-8") + (directory / "target").write_text("old", encoding="utf-8") + moved = tmp_path / "moved" + pinned = PinnedDirectory.open(str(directory), "managed directory") + + try: + pinned.replace_file("source", "target") + assert (directory / "target").read_text(encoding="utf-8") == "new" + with pytest.raises(OSError): + directory.rename(moved) + pinned.assert_path_stable("replace test") + finally: + pinned.close() + + directory.rename(moved) + moved.rename(directory) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract") +def test_windows_pin_compares_volume_and_inode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + directory = tmp_path / "managed" + directory.mkdir() + real_open = _pinned_directory._open_windows_directory + + def open_with_changed_volume( + path: str, label: str + ) -> tuple[int, DirectorySnapshot]: + descriptor, snapshot = real_open(path, label) + return descriptor, DirectorySnapshot( + snapshot.device + 1, + snapshot.inode, + ) + + monkeypatch.setattr( + _pinned_directory, + "_open_windows_directory", + open_with_changed_volume, + ) + + with pytest.raises(OSError, match="changed while pinning"): + PinnedDirectory.open(str(directory), "managed directory") + + moved = tmp_path / "moved" + directory.rename(moved) + moved.rename(directory) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract") +def test_windows_descriptor_chmod_fallback_changes_open_file_mode( + tmp_path: Path, +) -> None: + target = tmp_path / "secret" + target.write_text("secret", encoding="utf-8") + descriptor = os.open( + target, + os.O_RDWR | getattr(os, "O_BINARY", 0), + ) + try: + _pinned_directory._chmod_windows_descriptor(descriptor, stat.S_IREAD) + assert not os.fstat(descriptor).st_mode & stat.S_IWRITE + _pinned_directory._chmod_windows_descriptor( + descriptor, + stat.S_IREAD | stat.S_IWRITE, + ) + assert os.fstat(descriptor).st_mode & stat.S_IWRITE + finally: + os.close(descriptor) + + +@pytest.mark.skipif(os.name != "nt", reason="Windows handle contract") +def test_windows_chmod_file_uses_the_open_descriptor( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + directory = tmp_path / "managed" + directory.mkdir() + target = directory / "secret" + target.write_text("secret", encoding="utf-8") + pinned = PinnedDirectory.open(str(directory), "managed directory") + snapshot = pinned.read_file("secret", "secret", 1024) + assert snapshot is not None + restore_descriptor = os.open( + target, + os.O_RDWR | getattr(os, "O_BINARY", 0), + ) + + def reject_path_chmod(*_args: object, **_kwargs: object) -> None: + raise AssertionError("path chmod was called") + + monkeypatch.setattr(_pinned_directory.os, "chmod", reject_path_chmod) + try: + pinned.chmod_file( + "secret", + stat.S_IREAD, + (snapshot.device, snapshot.inode), + ) + assert not target.stat().st_mode & stat.S_IWRITE + _pinned_directory.chmod_open_file( + restore_descriptor, + stat.S_IREAD | stat.S_IWRITE, + ) + finally: + os.close(restore_descriptor) + pinned.close() diff --git a/tests/test_qwen_plugin.py b/tests/test_qwen_plugin.py index 2a2c045..7c9a9b6 100644 --- a/tests/test_qwen_plugin.py +++ b/tests/test_qwen_plugin.py @@ -1,10 +1,7 @@ from __future__ import annotations import json -import os from pathlib import Path -from typing import Any - import pytest from elydora import cli @@ -315,17 +312,21 @@ def test_transaction_rolls_back_every_runtime_and_settings_change( monkeypatch, tmp_path, existing_settings={"owner": "user"} ) original = fixture.source() - real_replace = _transaction.os.replace + real_replace = _transaction._replace_physical failed = False - def fail_settings_commit(source: Any, destination: Any) -> None: + def fail_settings_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: nonlocal failed if not failed and Path(destination) == fixture.config_path: failed = True raise OSError("simulated settings commit failure") - real_replace(source, destination) + real_replace(source, destination, directory) - monkeypatch.setattr(_transaction.os, "replace", fail_settings_commit) + monkeypatch.setattr(_transaction, "_replace_physical", fail_settings_commit) with pytest.raises(OSError, match="Install Qwen Code hooks"): fixture.install() assert failed diff --git a/tests/test_runtime_removal.py b/tests/test_runtime_removal.py new file mode 100644 index 0000000..af37e03 --- /dev/null +++ b/tests/test_runtime_removal.py @@ -0,0 +1,792 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +from typing import Any + +import pytest + +from elydora import cli +from elydora.plugins import ( + _directory_removal, + _opaque_cleanup, + _opaque_removal, + _transaction, +) +from elydora.plugins._runtime_removal import ( + commit_runtime_removal, + finalize_runtime_removal, + prepare_runtime_removal, +) + + +AGENT_ID = "agent-1" +AGENT_NAME = "opencode" +PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode() + + +def _set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None: + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + +def _write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def _write_secret(path: Path, value: str) -> None: + path.write_text(value, encoding="utf-8") + if os.name != "nt": + path.chmod(0o600) + + +def _write_error_log(path: Path, chunks: int = 1) -> str: + digest = hashlib.sha256() + block = b"opaque historical error\n" * 4096 + with path.open("wb") as file: + for _index in range(chunks): + file.write(block) + digest.update(block) + if os.name != "nt": + path.chmod(0o600) + return digest.hexdigest() + + +def _stream_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + while chunk := file.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + agent_name: str = AGENT_NAME, +) -> tuple[Path, Path]: + home = tmp_path / "home" + root = home / ".elydora" + agent = root / AGENT_ID + _set_home(monkeypatch, home) + _write_json( + agent / "config.json", + {"agent_id": AGENT_ID, "agent_name": agent_name}, + ) + return root, agent + + +def _uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace: + return cli.build_parser().parse_args( + [ + "uninstall", + "--agent", + agent_name, + "--agent_id", + AGENT_ID, + ] + ) + + +@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"]) +def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + entry_kind: str, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + unknown = agent / "user-owned" + if entry_kind == "file": + unknown.write_text("preserve", encoding="utf-8") + elif entry_kind == "directory": + unknown.mkdir() + (unknown / "marker").write_text("preserve", encoding="utf-8") + else: + target = tmp_path / "symlink-target" + target.write_text("preserve", encoding="utf-8") + try: + unknown.symlink_to(target) + except OSError as error: + pytest.skip(f"File symbolic links are unavailable: {error}") + + provider_calls: list[str] = [] + + class Plugin: + def uninstall(self, agent_id: str = "") -> None: + provider_calls.append(agent_id) + + monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin()) + + with pytest.raises(OSError, match="unmanaged entries"): + cli.cmd_uninstall(_uninstall_args()) + + assert provider_calls == [] + assert unknown.exists() or unknown.is_symlink() + assert (agent / "config.json").is_file() + + +def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide") + provider_calls: list[str] = [] + + class Plugin: + manages_runtime_removal = True + + def uninstall(self, agent_id: str = "") -> None: + provider_calls.append(agent_id) + + def reject_generic_prepare(*_args: object, **_kwargs: object) -> None: + raise AssertionError("generic runtime removal must stay unused") + + monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin()) + monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare) + + cli.cmd_uninstall(_uninstall_args("kiroide")) + + assert provider_calls == [AGENT_ID] + assert (agent / "config.json").is_file() + + +def test_runtime_removal_rejects_config_identity_drift( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"} + _write_json(agent / "config.json", concurrent) + + with pytest.raises(OSError, match="changed"): + commit_runtime_removal(plan) + + assert json.loads((agent / "config.json").read_text(encoding="utf-8")) == ( + concurrent + ) + + +def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh" + user_file = agent / wrapper_name + user_file.write_text("user-owned payload", encoding="utf-8") + + with pytest.raises(OSError, match="unmanaged entries"): + prepare_runtime_removal(AGENT_ID, AGENT_NAME) + + assert user_file.read_text(encoding="utf-8") == "user-owned payload" + + +@pytest.mark.parametrize("swap", ["agent", "root"]) +def test_runtime_removal_rejects_directory_swaps_without_deleting_replacements( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + swap: str, +) -> None: + root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + + if swap == "agent": + original = root / "original-agent" + agent.rename(original) + agent.mkdir() + replacement = agent + else: + original = root.with_name("original-root") + root.rename(original) + replacement = root / AGENT_ID + replacement.mkdir(parents=True) + marker = replacement / "replacement-marker" + marker.write_text("preserve", encoding="utf-8") + + with pytest.raises(OSError, match="changed"): + commit_runtime_removal(plan) + + assert marker.read_text(encoding="utf-8") == "preserve" + assert (original / "config.json").is_file() if swap == "agent" else ( + original / AGENT_ID / "config.json" + ).is_file() + + +def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + concurrent = agent / "status-cache.json" + concurrent.write_text('{"state":"concurrent"}\n', encoding="utf-8") + + with pytest.raises(OSError, match="changed"): + commit_runtime_removal(plan) + + assert concurrent.read_text(encoding="utf-8") == ( + '{"state":"concurrent"}\n' + ) + assert (agent / "config.json").is_file() + + +def test_runtime_removal_preserves_unknown_entry_created_before_finalize( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + _transaction.write_changes( + plan.changes, + "Remove test runtime", + plan.file_preconditions, + plan.directory_preconditions, + ) + concurrent = agent / "concurrent-user-file" + concurrent.write_text("preserve", encoding="utf-8") + + with pytest.raises(OSError, match="contains entries after managed cleanup"): + finalize_runtime_removal(plan) + + assert concurrent.read_text(encoding="utf-8") == "preserve" + assert agent.is_dir() + + +def test_runtime_removal_deletes_all_known_dynamic_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root, agent = _runtime(monkeypatch, tmp_path) + dynamic = { + "chain-state.json": '{"sequence":1}\n', + "status-cache.json": '{"status":"active"}\n', + "error.log": "recorded delivery error\n", + } + for name, contents in dynamic.items(): + (agent / name).write_text(contents, encoding="utf-8") + if os.name != "nt": + (agent / "error.log").chmod(0o600) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + + commit_runtime_removal(plan) + + assert not agent.exists() + assert root.is_dir() + + +def test_runtime_removal_streams_oversized_error_log( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root, agent = _runtime(monkeypatch, tmp_path) + expected_digest = _write_error_log(agent / "error.log", chunks=32) + assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024 + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + assert plan.opaque_removals[0].original is not None + assert plan.opaque_removals[0].original.sha256 == expected_digest + + commit_runtime_removal(plan) + + assert not agent.exists() + assert root.is_dir() + + +def test_oversized_error_log_rolls_back_after_later_text_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + expected_digest = _write_error_log(error_log, chunks=32) + expected_mode = stat.S_IMODE(error_log.stat().st_mode) + original_commit = _transaction._commit + + def fail_first_text_commit(_staged: object) -> None: + raise OSError("injected text commit failure") + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr(_transaction, "_commit", fail_first_text_commit) + + with pytest.raises(OSError, match="injected text commit failure"): + commit_runtime_removal(plan) + + monkeypatch.setattr(_transaction, "_commit", original_commit) + assert _stream_digest(error_log) == expected_digest + assert stat.S_IMODE(error_log.stat().st_mode) == expected_mode + assert (agent / "config.json").is_file() + assert list(agent.glob("*.opaque-rollback")) == [] + + +def test_process_exit_after_opaque_removal_restores_error_log_on_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + expected_digest = _write_error_log(error_log, chunks=4) + script = """ +import os +import sys +from elydora.plugins import _transaction +from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal + +agent_id, agent_name = sys.argv[1:] +plan = prepare_runtime_removal(agent_id, agent_name) +assert plan is not None +_transaction._commit = lambda staged: os._exit(78) +commit_runtime_removal(plan) +""" + environment = os.environ.copy() + environment["HOME"] = os.environ["HOME"] + environment["USERPROFILE"] = os.environ["USERPROFILE"] + + completed = subprocess.run( + [sys.executable, "-c", script, AGENT_ID, AGENT_NAME], + cwd=Path(__file__).resolve().parents[1], + env=environment, + check=False, + ) + + assert completed.returncode == 78 + assert not error_log.exists() + _transaction.recover_pending_transactions() + assert _stream_digest(error_log) == expected_digest + assert (agent / "config.json").is_file() + assert list(agent.glob(".*.opaque-rollback")) == [] + + +def test_error_log_append_at_capture_boundary_fails_and_preserves_append( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + _write_error_log(error_log) + appended = b"concurrent append\n" + original_capture = _opaque_removal._capture_physical + + def append_then_capture(staged: Any) -> None: + with error_log.open("ab") as file: + file.write(appended) + file.flush() + os.fsync(file.fileno()) + original_capture(staged) + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr( + _opaque_removal, + "_capture_physical", + append_then_capture, + ) + + with pytest.raises(OSError, match="changed at commit boundary"): + commit_runtime_removal(plan) + + assert error_log.read_bytes().endswith(appended) + assert (agent / "config.json").is_file() + + +def test_error_log_replace_at_capture_boundary_preserves_both_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + original_digest = _write_error_log(error_log) + displaced = agent / "displaced-error.log" + replacement = b"concurrent replacement\n" + original_capture = _opaque_removal._capture_physical + + def replace_then_capture(staged: Any) -> None: + error_log.rename(displaced) + error_log.write_bytes(replacement) + if os.name != "nt": + error_log.chmod(0o600) + original_capture(staged) + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr( + _opaque_removal, + "_capture_physical", + replace_then_capture, + ) + + with pytest.raises(OSError, match="changed at commit boundary"): + commit_runtime_removal(plan) + + assert error_log.read_bytes() == replacement + assert _stream_digest(displaced) == original_digest + assert (agent / "config.json").is_file() + + +def test_error_log_rollback_destination_collision_preserves_victim( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + original_digest = _write_error_log(error_log) + victim = b"concurrent opaque rollback destination\n" + rollback_path: Path | None = None + original_capture = _opaque_removal._capture_physical + + def collide_with_rollback_destination(staged: Any) -> None: + nonlocal rollback_path + assert staged.rollback_path is not None + rollback_path = Path(staged.rollback_path) + rollback_path.write_bytes(victim) + if os.name != "nt": + rollback_path.chmod(0o600) + original_capture(staged) + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr( + _opaque_removal, + "_capture_physical", + collide_with_rollback_destination, + ) + + with pytest.raises(OSError, match="exist"): + commit_runtime_removal(plan) + + assert rollback_path is not None + assert rollback_path.read_bytes() == victim + assert _stream_digest(error_log) == original_digest + assert (agent / "config.json").is_file() + + +def test_error_log_cleanup_boundary_append_is_preserved_and_reported( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + _write_error_log(error_log) + appended = b"cleanup boundary append\n" + original_capture = _opaque_cleanup._capture_cleanup_physical + + def append_then_capture( + staged: Any, + source_path: str, + ) -> None: + with Path(source_path).open("ab") as file: + file.write(appended) + file.flush() + os.fsync(file.fileno()) + original_capture(staged, source_path) + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr( + _opaque_cleanup, + "_capture_cleanup_physical", + append_then_capture, + ) + + with pytest.raises(OSError, match="changed at cleanup boundary"): + commit_runtime_removal(plan) + + preserved = list(agent.glob(".error.log.*.opaque-rollback")) + assert len(preserved) == 1 + assert preserved[0].read_bytes().endswith(appended) + assert list(agent.glob(".*.elydora-quarantine")) == [] + + +def test_error_log_cleanup_replacement_preserves_both_identities( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + original_digest = _write_error_log(error_log) + replacement = b"concurrent cleanup replacement\n" + displaced = agent / "displaced-opaque-rollback" + rollback_path: Path | None = None + original_capture = _opaque_cleanup._capture_cleanup_physical + + def replace_before_cleanup_capture(staged: Any, source_path: str) -> None: + nonlocal rollback_path + rollback_path = Path(source_path) + rollback_path.rename(displaced) + rollback_path.write_bytes(replacement) + if os.name != "nt": + rollback_path.chmod(0o600) + original_capture(staged, source_path) + + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + monkeypatch.setattr( + _opaque_cleanup, + "_capture_cleanup_physical", + replace_before_cleanup_capture, + ) + + with pytest.raises(OSError, match="changed at cleanup boundary") as raised: + commit_runtime_removal(plan) + + assert rollback_path is not None + assert f"concurrent data preserved at {rollback_path}" in str(raised.value) + assert rollback_path.read_bytes() == replacement + assert _stream_digest(displaced) == original_digest + assert list(agent.glob(".*.elydora-quarantine")) == [] + + +@pytest.mark.parametrize("entry_kind", ["directory", "symlink"]) +def test_error_log_rejects_nonphysical_entries_before_runtime_mutation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + entry_kind: str, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + original_config = (agent / "config.json").read_bytes() + if entry_kind == "directory": + error_log.mkdir() + (error_log / "marker").write_text("preserve", encoding="utf-8") + else: + target = tmp_path / "external-error.log" + target.write_text("preserve", encoding="utf-8") + try: + error_log.symlink_to(target) + except OSError as error: + pytest.skip(f"File symbolic links are unavailable: {error}") + + with pytest.raises(OSError, match="not a physical file"): + prepare_runtime_removal(AGENT_ID, AGENT_NAME) + + assert (agent / "config.json").read_bytes() == original_config + assert error_log.exists() or error_log.is_symlink() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract") +def test_error_log_requires_owner_only_permissions_during_prepare( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + error_log = agent / "error.log" + error_log.write_text("historical error\n", encoding="utf-8") + error_log.chmod(0o640) + + with pytest.raises(PermissionError, match="accessible only by its owner"): + prepare_runtime_removal(AGENT_ID, AGENT_NAME) + + assert error_log.read_text(encoding="utf-8") == "historical error\n" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract") +def test_posix_runtime_directory_removal_cleans_exact_quarantine( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + original_rename = _directory_removal._rename_posix_into_quarantine + calls: list[tuple[int, str, int, str, str]] = [] + + def record_rename( + parent_descriptor: int, + source_name: str, + quarantine_descriptor: int, + source_path: str, + destination_path: str, + ) -> None: + calls.append( + ( + parent_descriptor, + source_name, + quarantine_descriptor, + source_path, + destination_path, + ) + ) + original_rename( + parent_descriptor, + source_name, + quarantine_descriptor, + source_path, + destination_path, + ) + + monkeypatch.setattr( + _directory_removal, + "_rename_posix_into_quarantine", + record_rename, + ) + + commit_runtime_removal(plan) + + assert len(calls) == 1 + assert isinstance(calls[0][0], int) + assert calls[0][1] == AGENT_ID + assert isinstance(calls[0][2], int) + assert Path(calls[0][3]) == agent + assert Path(calls[0][4]).name == "entry" + quarantined = list( + Path(plan.runtime_root).glob( + f".{AGENT_ID}.*.elydora-removed/entry" + ) + ) + assert quarantined == [] + assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == [] + assert not agent.exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract") +def test_posix_runtime_quarantine_preserves_atomic_boundary_swap( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + replacement = root / "replacement" + replacement.mkdir() + marker = replacement / "user-owned" + marker.write_text("preserve", encoding="utf-8") + original = root / "agent-original" + real_rename = _directory_removal._rename_posix_into_quarantine + + def swap_then_quarantine( + parent_descriptor: int, + source_name: str, + quarantine_descriptor: int, + source_path: str, + destination_path: str, + ) -> None: + agent.rename(original) + replacement.rename(agent) + real_rename( + parent_descriptor, + source_name, + quarantine_descriptor, + source_path, + destination_path, + ) + + real_rmdir = _directory_removal.os.rmdir + + def reject_path_rmdir( + path: str, + *, + dir_fd: int | None = None, + ) -> None: + if dir_fd is None: + raise AssertionError("POSIX runtime cleanup must use a parent dirfd") + real_rmdir(path, dir_fd=dir_fd) + + monkeypatch.setattr( + _directory_removal, + "_rename_posix_into_quarantine", + swap_then_quarantine, + ) + monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir) + + with pytest.raises(OSError, match="changed while quarantining"): + commit_runtime_removal(plan) + + quarantined_markers = list( + root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned") + ) + assert len(quarantined_markers) == 1 + assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve" + assert original.is_dir() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract") +def test_windows_runtime_directory_removal_marks_exact_pinned_handle( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _root, agent = _runtime(monkeypatch, tmp_path) + plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) + assert plan is not None + original_mark = _directory_removal._mark_windows_directory_for_deletion + observed: list[tuple[int, int]] = [] + + def record_mark(directory: Any) -> None: + assert directory.descriptor is not None + opened = os.fstat(directory.descriptor) + observed.append((opened.st_dev, opened.st_ino)) + assert observed[-1] == ( + plan.agent_snapshot.device, + plan.agent_snapshot.inode, + ) + original_mark(directory) + + monkeypatch.setattr( + _directory_removal, + "_mark_windows_directory_for_deletion", + record_mark, + ) + + commit_runtime_removal(plan) + + assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)] + assert not agent.exists() + + +def test_kiroide_cli_install_failure_leaves_no_runtime_root( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + _set_home(monkeypatch, home) + monkeypatch.chdir(workspace) + key_path = tmp_path / "install-private.key" + _write_secret(key_path, PRIVATE_KEY) + args = cli.build_parser().parse_args( + [ + "install", + "--agent", + "kiroide", + "--org_id", + "org-1", + "--agent_id", + AGENT_ID, + "--private_key_file", + str(key_path), + "--kid", + "kid-1", + ] + ) + + def fail_commit(_staged: object) -> None: + raise OSError("injected Kiro IDE CLI commit failure") + + monkeypatch.setattr(_transaction, "_commit", fail_commit) + + with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"): + cli.cmd_install(args) + + # The durable transaction state directory survives failures by design; + # everything else under the runtime root must be rolled back. + elydora_root = home / ".elydora" + if elydora_root.exists(): + assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"] + assert not (workspace / ".kiro").exists() diff --git a/tests/test_transaction_initialization.py b/tests/test_transaction_initialization.py new file mode 100644 index 0000000..2c8055c --- /dev/null +++ b/tests/test_transaction_initialization.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from elydora.plugins import _transaction + + +def _subprocess_environment(home: Path) -> dict[str, str]: + environment = os.environ.copy() + environment["HOME"] = str(home) + environment["USERPROFILE"] = str(home) + return environment + + +def test_process_exit_after_secret_staging_recovers_owned_artifact( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "target.txt" + target.write_bytes(b"old\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + script = """ +import os +import sys +from elydora.plugins import _transaction, _transaction_staging + +target = sys.argv[1] +change = _transaction.source_change(target, "target", "old\\n", "secret-new\\n", 0o600) +assert change is not None +real_write_staged = _transaction_staging.write_staged + +def terminate_after_secret_staging(*args, **kwargs): + real_write_staged(*args, **kwargs) + os._exit(78) + +_transaction_staging.write_staged = terminate_after_secret_staging +_transaction.write_changes([change], "initialization crash recovery") +""" + + completed = subprocess.run( + [sys.executable, "-c", script, str(target)], + cwd=Path(__file__).resolve().parents[1], + env=_subprocess_environment(home), + check=False, + ) + + assert completed.returncode == 78 + staged = list(tmp_path.glob(".target.txt.*.tmp")) + assert len(staged) == 1 + assert staged[0].read_text(encoding="utf-8") == "secret-new\n" + try: + _transaction.recover_pending_transactions() + assert target.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".target.txt.*.tmp")) == [] + finally: + for artifact in tmp_path.glob(".target.txt.*.tmp"): + artifact.unlink() diff --git a/tests/test_transaction_journal_lifecycle.py b/tests/test_transaction_journal_lifecycle.py new file mode 100644 index 0000000..38bad0f --- /dev/null +++ b/tests/test_transaction_journal_lifecycle.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from elydora.plugins import _transaction +from elydora.plugins._pinned_directory import PinnedDirectory +from elydora.plugins._transaction_journal import active_journal_path + + +def _change(path: Path, original: str, next_source: str) -> _transaction.FileChange: + change = _transaction.source_change( + str(path), + path.name, + original, + next_source, + 0o600, + ) + assert change is not None + return change + + +def _no_op_file_boundary( + source: str, + destination: str, + directory: PinnedDirectory, +) -> None: + del source, destination, directory + + +def test_durable_commit_marker_keeps_change_after_post_commit_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "managed.json" + target.write_bytes(b"old\n") + + def fail_after_marker() -> None: + raise OSError("injected post-commit failure") + + monkeypatch.setattr(_transaction, "_after_journal_commit", fail_after_marker) + + with pytest.raises(OSError, match="committed before post-commit failure"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "post-commit boundary", + ) + + assert target.read_text(encoding="utf-8") == "new\n" + assert not Path(active_journal_path()).exists() + assert not list(tmp_path.glob(".*.rollback")) + + +def test_failed_in_process_recovery_preserves_journal_for_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + first = tmp_path / "first.json" + second = tmp_path / "second.json" + first.write_bytes(b"first-old\n") + second.write_bytes(b"second-old\n") + + def fail_second_commit( + source: str, + destination: str, + directory: PinnedDirectory, + ) -> None: + del source, directory + if Path(destination) == second: + raise OSError("injected second commit failure") + + def fail_first_restore( + source: str, + destination: str, + directory: PinnedDirectory, + ) -> None: + del source, destination, directory + raise OSError("injected restoration failure") + + monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) + monkeypatch.setattr(_transaction, "_restore_physical", fail_first_restore) + + with pytest.raises(OSError, match="recovery journal preserved"): + _transaction.write_changes( + [ + _change(first, "first-old\n", "first-new\n"), + _change(second, "second-old\n", "second-new\n"), + ], + "recoverable in-process failure", + ) + + assert Path(active_journal_path()).is_file() + assert first.read_text(encoding="utf-8") == "first-new\n" + assert second.read_text(encoding="utf-8") == "second-old\n" + + monkeypatch.setattr(_transaction, "_restore_physical", _no_op_file_boundary) + _transaction.recover_pending_transactions() + + assert first.read_text(encoding="utf-8") == "first-old\n" + assert second.read_text(encoding="utf-8") == "second-old\n" + assert not Path(active_journal_path()).exists() + + +def test_committed_cleanup_failure_leaves_restart_record( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "managed.json" + target.write_bytes(b"old\n") + real_cleanup = _transaction.cleanup_staged + calls = 0 + + def fail_first_cleanup(staged: _transaction.StagedChange) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("injected committed cleanup failure") + real_cleanup(staged) + + monkeypatch.setattr(_transaction, "cleanup_staged", fail_first_cleanup) + + with pytest.raises(OSError, match="cleanup pending"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "committed cleanup failure", + ) + + assert target.read_text(encoding="utf-8") == "new\n" + assert Path(active_journal_path()).is_file() + + monkeypatch.setattr(_transaction, "cleanup_staged", real_cleanup) + _transaction.recover_pending_transactions() + + assert target.read_text(encoding="utf-8") == "new\n" + assert not Path(active_journal_path()).exists() + assert not list(tmp_path.glob(".*.rollback")) diff --git a/tests/test_transaction_lock.py b/tests/test_transaction_lock.py new file mode 100644 index 0000000..43f0da3 --- /dev/null +++ b/tests/test_transaction_lock.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import os +from pathlib import Path +import stat +import subprocess # nosec B404 +import sys +import time + +import pytest + +from elydora.plugins import _transaction_lock + + +if os.name == "nt": + from elydora.plugins._windows_security import verify_private_path + + +def _use_isolated_state( + monkeypatch: pytest.MonkeyPatch, + root: Path, +) -> None: + monkeypatch.setenv("HOME", str(root)) + monkeypatch.setenv("USERPROFILE", str(root)) + + +def test_transaction_state_directory_has_owner_boundary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _use_isolated_state(monkeypatch, tmp_path) + + state = Path(_transaction_lock.transaction_state_directory()) + + assert state.is_dir() + if os.name == "nt": + verify_private_path(str(state), directory=True) + else: + metadata = state.stat() + assert metadata.st_uid == getattr(os, "geteuid")() + assert stat.S_IMODE(metadata.st_mode) == 0o700 + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX owner contract") +def test_transaction_state_directory_rejects_foreign_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _use_isolated_state(monkeypatch, tmp_path) + state = Path(_transaction_lock.transaction_state_directory()) + owner = state.stat().st_uid + monkeypatch.setattr(os, "geteuid", lambda: owner + 1) + + with pytest.raises(PermissionError, match="owned by the current user"): + _transaction_lock.transaction_state_directory() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX owner contract") +def test_posix_lock_rejects_foreign_owner( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + lock_path = tmp_path / "transaction.lock" + lock_path.write_bytes(b"") + owner = lock_path.stat().st_uid + monkeypatch.setattr( + _transaction_lock, + "transaction_state_directory", + lambda: str(tmp_path), + ) + monkeypatch.setattr(os, "geteuid", lambda: owner + 1) + + with pytest.raises(PermissionError, match="owned by the current user"): + _transaction_lock._PosixLock().__enter__() + + +def test_transaction_lock_rejects_reparse_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _use_isolated_state(monkeypatch, tmp_path) + state = Path(_transaction_lock.transaction_state_directory()) + target = tmp_path / "target.lock" + target.write_bytes(b"") + lock_path = state / "transaction.lock" + try: + lock_path.symlink_to(target) + except OSError as error: + pytest.skip(f"Symbolic links unavailable: {error}") + + with pytest.raises(OSError, match="physical file"): + with _transaction_lock.serialized_transactions(): + pass + + +@pytest.mark.skipif(os.name != "nt", reason="Windows DACL contract") +def test_windows_lock_file_has_owner_only_protected_dacl( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _use_isolated_state(monkeypatch, tmp_path) + state = Path(_transaction_lock.transaction_state_directory()) + + with _transaction_lock.serialized_transactions(): + lock_path = state / "transaction.lock" + assert lock_path.is_file() + verify_private_path(str(lock_path), directory=False) + + +def test_transaction_lock_serializes_independent_processes(tmp_path: Path) -> None: + state_root = tmp_path / "state-root" + state_root.mkdir() + entered = tmp_path / "entered" + release = tmp_path / "release" + second_entered = tmp_path / "second-entered" + script = """ +import pathlib +import sys +import time +from elydora.plugins._transaction_lock import serialized_transactions + +entered, release = map(pathlib.Path, sys.argv[1:3]) +with serialized_transactions(): + entered.write_text("entered", encoding="utf-8") + if len(sys.argv) == 4: + deadline = time.monotonic() + 10 + while not release.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("release gate timed out") + time.sleep(0.01) +""" + environment = os.environ.copy() + environment["HOME"] = str(state_root) + environment["USERPROFILE"] = str(state_root) + repository = Path(__file__).resolve().parents[1] + first = subprocess.Popen( # nosec B603 + [sys.executable, "-c", script, str(entered), str(release), "wait"], + cwd=repository, + env=environment, + ) + second: subprocess.Popen[bytes] | None = None + try: + deadline = time.monotonic() + 10 + while not entered.exists() and first.poll() is None: + if time.monotonic() >= deadline: + raise TimeoutError("first process did not acquire the lock") + time.sleep(0.01) + assert first.poll() is None + + second = subprocess.Popen( # nosec B603 + [sys.executable, "-c", script, str(second_entered), str(release)], + cwd=repository, + env=environment, + ) + time.sleep(0.25) + assert second.poll() is None + assert not second_entered.exists() + + release.write_text("release", encoding="utf-8") + assert first.wait(timeout=10) == 0 + assert second.wait(timeout=10) == 0 + assert second_entered.read_text(encoding="utf-8") == "entered" + finally: + if first.poll() is None: + first.kill() + first.wait() + if second is not None and second.poll() is None: + second.kill() + second.wait() diff --git a/tests/test_transaction_metadata.py b/tests/test_transaction_metadata.py new file mode 100644 index 0000000..1db8c09 --- /dev/null +++ b/tests/test_transaction_metadata.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +from pathlib import Path +from types import TracebackType +from typing import BinaryIO, Optional, Type + +import pytest + +from elydora.plugins import _managed_files +from elydora.plugins._pinned_directory import PinnedDirectory +from elydora.plugins._private_artifact import QuarantinedFile + + +class _MutatingReader: + def __init__(self, file: BinaryIO, target: Path, replacement: bytes) -> None: + self._file = file + self._target = target + self._replacement = replacement + + def __enter__(self) -> "_MutatingReader": + self._file.__enter__() + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> Optional[bool]: + return self._file.__exit__(exc_type, exc_value, traceback) + + def fileno(self) -> int: + return self._file.fileno() + + def read(self, size: int = -1) -> bytes: + value = self._file.read(size) + self._target.write_bytes(self._replacement) + return value + + +def _mutate_during_read( + monkeypatch: pytest.MonkeyPatch, + target: Path, + replacement: bytes, +) -> None: + real_fdopen = os.fdopen + + def mutating_fdopen( + descriptor: int, + mode: str = "r", + *args: object, + **kwargs: object, + ) -> _MutatingReader: + file = real_fdopen(descriptor, mode, *args, **kwargs) + return _MutatingReader(file, target, replacement) # type: ignore[arg-type] + + monkeypatch.setattr(_managed_files.os, "fdopen", mutating_fdopen) + + +def test_read_physical_file_rejects_in_place_change_during_read( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "managed.json" + target.write_bytes(b"original") + _mutate_during_read(monkeypatch, target, b"replaced") + + with pytest.raises(OSError, match="changed while reading"): + _managed_files.read_physical_file(str(target), "managed file") + + +def test_pinned_read_rejects_in_place_change_during_read( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "managed.json" + target.write_bytes(b"original") + directory = PinnedDirectory.open(str(tmp_path), "managed directory") + try: + _mutate_during_read(monkeypatch, target, b"replaced") + with pytest.raises(OSError, match="changed while reading"): + directory.read_file(target.name, "managed file", 1024) + finally: + directory.close() + + +def test_private_capture_restores_target_when_parent_boundary_changes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "managed.json" + target.write_text("original", encoding="utf-8") + directory = PinnedDirectory.open(str(tmp_path), "managed directory") + real_assert = directory.assert_path_stable + calls = 0 + + def fail_after_capture(operation: str = "transaction") -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("injected parent boundary change") + real_assert(operation) + + monkeypatch.setattr(directory, "assert_path_stable", fail_after_capture) + try: + with pytest.raises(OSError, match="injected parent boundary change"): + QuarantinedFile.capture(directory, str(target), "managed file") + assert target.read_text(encoding="utf-8") == "original" + assert not list(tmp_path.glob("*.elydora-quarantine")) + finally: + directory.close() diff --git a/tests/test_transaction_security.py b/tests/test_transaction_security.py new file mode 100644 index 0000000..2134da6 --- /dev/null +++ b/tests/test_transaction_security.py @@ -0,0 +1,1074 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time + +import pytest + +from elydora.plugins import _transaction, _transaction_staging +from elydora.plugins._managed_files import read_physical_directory, read_physical_file + + +def _write(path: Path, source: str) -> None: + path.write_bytes(source.encode("utf-8")) + + +def _change( + path: Path, + original: str | None, + next_source: str | None, + mode: int = 0o600, + enforce_mode: bool = False, +) -> _transaction.FileChange: + change = _transaction.source_change( + str(path), + path.name, + original, + next_source, + mode, + enforce_mode=enforce_mode, + ) + assert change is not None + return change + + +def test_post_replace_precondition_failure_restores_original_target( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + provider = tmp_path / "provider.txt" + _write(target, "old\n") + _write(provider, "stable\n") + provider_snapshot = read_physical_file(str(provider), "provider") + real_replace = _transaction._replace_physical + + def replace_then_change_provider( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + real_replace(source, destination, directory) + if os.path.abspath(destination) == os.path.abspath(str(target)): + _write(provider, "concurrent\n") + + monkeypatch.setattr( + _transaction, "_replace_physical", replace_then_change_provider + ) + + with pytest.raises(OSError, match="provider changed during"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "provider transaction", + [_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)], + ) + + assert target.read_text(encoding="utf-8") == "old\n" + assert provider.read_text(encoding="utf-8") == "concurrent\n" + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_commit_boundary_concurrent_write_is_restored_and_rejected( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + real_capture = _transaction._capture_physical + changed = False + + def write_before_capture( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal changed + if ( + not changed + and os.path.abspath(source) == os.path.abspath(str(target)) + and destination.endswith(".rollback") + ): + changed = True + _write(target, "concurrent\n") + real_capture(source, destination, directory) + + monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture) + + with pytest.raises(OSError, match="changed at commit boundary"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "commit boundary", + ) + + assert changed + assert target.read_text(encoding="utf-8") == "concurrent\n" + assert list(tmp_path.glob(".*.tmp")) == [] + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_existing_target_remains_visible_during_atomic_update( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + replace_started = threading.Event() + release_replace = threading.Event() + failures: list[BaseException] = [] + observations: list[str] = [] + + def pause_before_replace( + _source: str, + destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(target): + replace_started.set() + if not release_replace.wait(5): + raise TimeoutError("test replace gate timed out") + + def update() -> None: + try: + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "visible atomic update", + ) + except BaseException as error: + failures.append(error) + + monkeypatch.setattr(_transaction, "_replace_physical", pause_before_replace) + worker = threading.Thread(target=update) + worker.start() + assert replace_started.wait(5) + for _index in range(200): + observations.append(target.read_text(encoding="utf-8")) + release_replace.set() + worker.join(5) + + assert not worker.is_alive() + assert failures == [] + assert set(observations) <= {"old\n", "new\n"} + assert target.read_text(encoding="utf-8") == "new\n" + + +def _transaction_subprocess_environment(home: Path) -> dict[str, str]: + environment = os.environ.copy() + environment["HOME"] = str(home) + environment["USERPROFILE"] = str(home) + return environment + + +def test_process_exit_after_atomic_replace_recovers_on_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "target.txt" + _write(target, "old\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + script = """ +import os +import sys +from elydora.plugins import _transaction + +target = sys.argv[1] +change = _transaction.source_change(target, "target", "old\\n", "new\\n", 0o600) +assert change is not None + +def terminate_after_replace(source, destination, directory): + del source, destination, directory + os._exit(73) + +_transaction._after_replace_physical = terminate_after_replace +_transaction.write_changes([change], "crash recovery") +""" + + completed = subprocess.run( + [sys.executable, "-c", script, str(target)], + cwd=Path(__file__).resolve().parents[1], + env=_transaction_subprocess_environment(home), + check=False, + ) + + assert completed.returncode == 73 + assert target.read_text(encoding="utf-8") == "new\n" + _transaction.recover_pending_transactions() + assert target.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".*.tmp")) == [] + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_process_exit_after_committed_marker_keeps_atomic_update( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "target.txt" + _write(target, "old\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + script = """ +import os +import sys +from elydora.plugins import _transaction + +target = sys.argv[1] +change = _transaction.source_change(target, "target", "old\\n", "new\\n", 0o600) +assert change is not None +_transaction._after_journal_commit = lambda: os._exit(74) +_transaction.write_changes([change], "committed crash recovery") +""" + + completed = subprocess.run( + [sys.executable, "-c", script, str(target)], + cwd=Path(__file__).resolve().parents[1], + env=_transaction_subprocess_environment(home), + check=False, + ) + + assert completed.returncode == 74 + assert target.read_text(encoding="utf-8") == "new\n" + _transaction.recover_pending_transactions() + assert target.read_text(encoding="utf-8") == "new\n" + assert list(tmp_path.glob(".*.tmp")) == [] + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_process_exit_mid_transaction_restores_all_targets( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + home.mkdir() + first = tmp_path / "first.txt" + second = tmp_path / "second.txt" + _write(first, "first-old\n") + _write(second, "second-old\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + script = """ +import os +import sys +from elydora.plugins import _transaction + +first, second = sys.argv[1:] +changes = [ + _transaction.source_change(first, "first", "first-old\\n", "first-new\\n", 0o600), + _transaction.source_change(second, "second", "second-old\\n", "second-new\\n", 0o600), +] +assert all(change is not None for change in changes) +calls = 0 + +def terminate_after_first(source, destination, directory): + global calls + del source, destination, directory + calls += 1 + if calls == 1: + os._exit(75) + +_transaction._after_replace_physical = terminate_after_first +_transaction.write_changes(changes, "multi-file crash recovery") +""" + + completed = subprocess.run( + [sys.executable, "-c", script, str(first), str(second)], + cwd=Path(__file__).resolve().parents[1], + env=_transaction_subprocess_environment(home), + check=False, + ) + + assert completed.returncode == 75 + assert first.read_text(encoding="utf-8") == "first-new\n" + assert second.read_text(encoding="utf-8") == "second-old\n" + _transaction.recover_pending_transactions() + assert first.read_text(encoding="utf-8") == "first-old\n" + assert second.read_text(encoding="utf-8") == "second-old\n" + assert list(tmp_path.glob(".*.tmp")) == [] + assert list(tmp_path.glob(".*.rollback")) == [] + + +@pytest.mark.parametrize( + ("original", "next_source", "exit_code"), + [ + ("old\n", None, 76), + (None, "created\n", 77), + ], +) +def test_process_exit_recovers_atomic_create_and_remove( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + original: str | None, + next_source: str | None, + exit_code: int, +) -> None: + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "target.txt" + if original is not None: + _write(target, original) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + script = """ +import json +import os +import sys +from elydora.plugins import _transaction + +target = sys.argv[1] +original = json.loads(sys.argv[2]) +next_source = json.loads(sys.argv[3]) +exit_code = int(sys.argv[4]) +change = _transaction.source_change(target, "target", original, next_source, 0o600) +assert change is not None +_transaction._after_replace_physical = lambda source, destination, directory: os._exit(exit_code) +_transaction.write_changes([change], "create-remove crash recovery") +""" + + completed = subprocess.run( + [ + sys.executable, + "-c", + script, + str(target), + json.dumps(original), + json.dumps(next_source), + str(exit_code), + ], + cwd=Path(__file__).resolve().parents[1], + env=_transaction_subprocess_environment(home), + check=False, + ) + + assert completed.returncode == exit_code + assert target.exists() is (next_source is not None) + _transaction.recover_pending_transactions() + assert target.exists() is (original is not None) + if original is not None: + assert target.read_text(encoding="utf-8") == original + assert list(tmp_path.glob(".*.tmp")) == [] + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_cross_process_writers_are_serialized( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + home.mkdir() + target = tmp_path / "target.txt" + ready = tmp_path / "ready" + second_ready = tmp_path / "second-ready" + release = tmp_path / "release" + _write(target, "old\n") + environment = _transaction_subprocess_environment(home) + first_script = """ +import pathlib +import sys +import time +from elydora.plugins import _transaction + +target, ready, release = map(pathlib.Path, sys.argv[1:]) +change = _transaction.source_change(str(target), "target", "old\\n", "first\\n", 0o600) +assert change is not None + +def pause(source, destination, directory): + del source, destination, directory + ready.write_text("ready", encoding="utf-8") + deadline = time.monotonic() + 10 + while not release.exists(): + if time.monotonic() >= deadline: + raise TimeoutError("writer release timed out") + time.sleep(0.01) + +_transaction._replace_physical = pause +_transaction.write_changes([change], "first writer") +""" + second_script = """ +import pathlib +import sys +from elydora.plugins import _transaction + +target = sys.argv[1] +ready = pathlib.Path(sys.argv[2]) +change = _transaction.source_change(target, "target", "old\\n", "second\\n", 0o600) +assert change is not None +ready.write_text("ready", encoding="utf-8") +try: + _transaction.write_changes([change], "second writer") +except OSError as error: + print(error) + raise SystemExit(19) +""" + first = subprocess.Popen( + [sys.executable, "-c", first_script, str(target), str(ready), str(release)], + cwd=Path(__file__).resolve().parents[1], + env=environment, + ) + deadline = time.monotonic() + 10 + while not ready.exists() and first.poll() is None: + if time.monotonic() >= deadline: + first.kill() + raise TimeoutError("first writer did not reach the replace gate") + time.sleep(0.01) + assert first.poll() is None + second = subprocess.Popen( + [sys.executable, "-c", second_script, str(target), str(second_ready)], + cwd=Path(__file__).resolve().parents[1], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + deadline = time.monotonic() + 10 + while not second_ready.exists() and second.poll() is None: + if time.monotonic() >= deadline: + second.kill() + first.kill() + raise TimeoutError("second writer did not reach the transaction gate") + time.sleep(0.01) + assert second.poll() is None + time.sleep(0.2) + assert second.poll() is None + release.write_text("release", encoding="utf-8") + assert first.wait(timeout=10) == 0 + output, _stderr = second.communicate(timeout=10) + + assert second.returncode == 19, output + assert "changed during" in output + assert target.read_text(encoding="utf-8") == "first\n" + + +def test_final_replace_exception_after_success_restores_original( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + real_replace = _transaction._replace_physical + + def replace_then_raise( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + real_replace(source, destination, directory) + if os.path.abspath(destination) == os.path.abspath(str(target)): + raise OSError("injected post-replace failure") + + monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise) + + with pytest.raises(OSError, match="injected post-replace failure"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "post-replace failure", + ) + + assert target.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_capture_exception_after_success_restores_original( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + real_capture = _transaction._capture_physical + + def capture_then_raise( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + real_capture(source, destination, directory) + raise OSError("injected post-capture failure") + + monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise) + + with pytest.raises(OSError, match="injected post-capture failure"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "post-capture failure", + ) + + assert target.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".*.rollback")) == [] + + +def test_rollback_reservation_collision_preserves_victim_and_original( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + victim = "concurrent rollback destination\n" + collision_path: Path | None = None + + def collide_with_rollback_destination( + _source: str, + destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal collision_path + if destination.endswith(".rollback"): + collision_path = Path(destination) + _write(collision_path, victim) + + monkeypatch.setattr( + _transaction, + "_capture_physical", + collide_with_rollback_destination, + ) + + with pytest.raises(OSError, match="exist"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "rollback destination collision", + ) + + assert collision_path is not None + assert target.read_text(encoding="utf-8") == "old\n" + assert collision_path.read_text(encoding="utf-8") == victim + + +def test_final_install_does_not_overwrite_concurrent_target( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + _write(target, "old\n") + real_replace = _transaction._replace_physical + injected = False + + def create_target_before_install( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal injected + if not injected and os.path.abspath(destination) == os.path.abspath(target): + injected = True + _write(target, "concurrent\n") + real_replace(source, destination, directory) + + monkeypatch.setattr( + _transaction, + "_replace_physical", + create_target_before_install, + ) + + with pytest.raises(OSError, match="original content preserved"): + _transaction.write_changes( + [_change(target, "old\n", "new\n")], + "no-clobber install", + ) + + assert injected + assert target.read_text(encoding="utf-8") == "concurrent\n" + rollbacks = list(tmp_path.glob(".*.rollback")) + assert len(rollbacks) == 1 + assert rollbacks[0].read_text(encoding="utf-8") == "old\n" + + +def test_restore_does_not_overwrite_concurrent_target( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + second = tmp_path / "second.txt" + _write(target, "old\n") + _write(second, "second-old\n") + real_replace = _transaction._replace_physical + real_restore = _transaction._restore_physical + injected = False + + def fail_second_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(second): + raise OSError("injected second failure") + real_replace(source, destination, directory) + + def create_target_before_restore( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal injected + if not injected and os.path.abspath(destination) == os.path.abspath(target): + injected = True + _write(target, "concurrent recovery\n") + real_restore(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) + monkeypatch.setattr( + _transaction, + "_restore_physical", + create_target_before_restore, + ) + + with pytest.raises(OSError, match="original content preserved"): + _transaction.write_changes( + [ + _change(target, "old\n", "new\n"), + _change(second, "second-old\n", "second-new\n"), + ], + "no-clobber restoration", + ) + + assert injected + assert target.read_text(encoding="utf-8") == "concurrent recovery\n" + rollbacks = list(tmp_path.glob(".*.rollback")) + assert len(rollbacks) == 1 + assert rollbacks[0].read_text(encoding="utf-8") == "old\n" + assert Path(_transaction.active_journal_path()).is_file() + _transaction.clear_journal() + + +def test_restore_source_replacement_reports_existing_preserved_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + target = tmp_path / "target.txt" + second = tmp_path / "second.txt" + displaced = tmp_path / "displaced-original.txt" + _write(target, "old\n") + _write(second, "second-old\n") + real_replace = _transaction._replace_physical + real_restore = _transaction._restore_physical + rollback_path: Path | None = None + + def fail_second_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(second): + raise OSError("injected second failure") + real_replace(source, destination, directory) + + def replace_restore_source( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal rollback_path + if ( + rollback_path is None + and source.endswith(".rollback") + and os.path.abspath(destination) == os.path.abspath(target) + ): + rollback_path = Path(source) + rollback_path.rename(displaced) + _write(rollback_path, "concurrent recovery source\n") + real_restore(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) + monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source) + + with pytest.raises(OSError, match="recovery source changed") as raised: + _transaction.write_changes( + [ + _change(target, "old\n", "new\n"), + _change(second, "second-old\n", "second-new\n"), + ], + "restore source replacement", + ) + + assert rollback_path is not None + assert f"concurrent data preserved at {rollback_path}" in str(raised.value) + assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n" + assert displaced.read_text(encoding="utf-8") == "old\n" + assert target.read_text(encoding="utf-8") == "new\n" + assert second.read_text(encoding="utf-8") == "second-old\n" + assert Path(_transaction.active_journal_path()).is_file() + _transaction.clear_journal() + + +def test_creation_rollback_captures_and_preserves_concurrent_replacement( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + created = tmp_path / "created.txt" + second = tmp_path / "second.txt" + _write(second, "second-old\n") + real_replace = _transaction._replace_physical + real_capture = _transaction._capture_physical + injected = False + + def fail_second_commit( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(second): + raise OSError("injected second failure") + real_replace(source, destination, directory) + + def replace_created_before_recovery_capture( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal injected + if ( + not injected + and os.path.abspath(source) == os.path.abspath(created) + and destination.endswith(".recovery") + ): + injected = True + _write(created, "concurrent replacement\n") + real_capture(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) + monkeypatch.setattr( + _transaction, + "_capture_physical", + replace_created_before_recovery_capture, + ) + + with pytest.raises(OSError, match="changed at recovery boundary"): + _transaction.write_changes( + [ + _change(created, None, "generated\n"), + _change(second, "second-old\n", "second-new\n"), + ], + "creation recovery capture", + ) + + assert injected + assert created.read_text(encoding="utf-8") == "concurrent replacement\n" + assert Path(_transaction.active_journal_path()).is_file() + _transaction.clear_journal() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract") +def test_mode_repair_uses_private_rollback_and_restores_original_mode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + secret = tmp_path / "private.key" + second = tmp_path / "config.json" + _write(secret, "secret\n") + _write(second, "old\n") + secret.chmod(0o644) + real_replace = _transaction._replace_physical + rollback_modes: list[int] = [] + + def inspect_then_fail_second( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(str(secret)): + rollback = next(tmp_path.glob(".private.key.*.rollback")) + rollback_modes.append(rollback.stat().st_mode & 0o777) + if os.path.abspath(destination) == os.path.abspath(str(second)): + raise OSError("injected second commit failure") + real_replace(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second) + + with pytest.raises(OSError, match="injected second commit failure"): + _transaction.write_changes( + [ + _change(secret, "secret\n", "secret\n", enforce_mode=True), + _change(second, "old\n", "new\n"), + ], + "mode repair", + ) + + assert rollback_modes + assert set(rollback_modes) == {0o600} + assert secret.read_text(encoding="utf-8") == "secret\n" + assert secret.stat().st_mode & 0o777 == 0o644 + assert second.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".*.rollback")) == [] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract") +def test_deletion_uses_private_rollback_and_restores_original_mode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + removed = tmp_path / "removed.txt" + second = tmp_path / "second.txt" + _write(removed, "remove me\n") + _write(second, "old\n") + removed.chmod(0o644) + real_replace = _transaction._replace_physical + rollback_modes: list[int] = [] + + def inspect_then_fail_second( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + if os.path.abspath(destination) == os.path.abspath(str(second)): + rollback = next(tmp_path.glob(".removed.txt.*.rollback")) + rollback_modes.append(rollback.stat().st_mode & 0o777) + raise OSError("injected second commit failure") + real_replace(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second) + + with pytest.raises(OSError, match="injected second commit failure"): + _transaction.write_changes( + [ + _change(removed, "remove me\n", None), + _change(second, "old\n", "new\n"), + ], + "deletion rollback mode", + ) + + assert rollback_modes == [0o600] + assert removed.read_text(encoding="utf-8") == "remove me\n" + assert removed.stat().st_mode & 0o777 == 0o644 + assert second.read_text(encoding="utf-8") == "old\n" + assert list(tmp_path.glob(".*.rollback")) == [] + + +def _swap_directory(parent: Path, backup: Path, redirected: Path) -> None: + parent.rename(backup) + redirected.rename(parent) + + +@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") +def test_staging_directory_swap_never_writes_secret_to_redirected_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + parent = tmp_path / "runtime" + backup = tmp_path / "runtime-original" + redirected = tmp_path / "runtime-redirected" + parent.mkdir() + redirected.mkdir() + target = parent / "private.key" + real_write = _transaction_staging.write_staged + swapped = False + + def swap_then_stage(*args: object, **kwargs: object): + nonlocal swapped + if not swapped: + swapped = True + _swap_directory(parent, backup, redirected) + return real_write(*args, **kwargs) + + monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage) + + with pytest.raises(OSError, match="changed during transaction"): + _transaction.write_changes( + [_change(target, None, "secret\n")], + "staging swap", + ) + + assert not (parent / "private.key").exists() + assert not (backup / "private.key").exists() + assert list(parent.iterdir()) == [] + assert list(backup.iterdir()) == [] + + +@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") +def test_commit_directory_swap_rolls_back_inside_pinned_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + parent = tmp_path / "runtime" + backup = tmp_path / "runtime-original" + redirected = tmp_path / "runtime-redirected" + parent.mkdir() + redirected.mkdir() + target = parent / "private.key" + real_replace = _transaction._replace_physical + swapped = False + + def swap_then_replace( + source: str, + destination: str, + directory: _transaction.PinnedDirectory, + ) -> None: + nonlocal swapped + if not swapped: + swapped = True + _swap_directory(parent, backup, redirected) + real_replace(source, destination, directory) + + monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace) + + with pytest.raises(OSError, match="changed during transaction"): + _transaction.write_changes( + [_change(target, None, "secret\n")], + "commit swap", + ) + + assert not (parent / "private.key").exists() + assert not (backup / "private.key").exists() + assert list(parent.iterdir()) == [] + assert list(backup.iterdir()) == [] + + +def test_failed_transaction_removes_only_created_empty_directories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + kiro = tmp_path / ".kiro" + hooks = kiro / "hooks" + hooks.mkdir(parents=True) + kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory") + hooks_snapshot = read_physical_directory(str(hooks), "hooks directory") + target = hooks / "elydora-audit.json" + + def fail_replace( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + raise OSError("injected commit failure") + + monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) + + with pytest.raises(OSError, match="injected commit failure"): + _transaction.write_changes( + [_change(target, None, "{}\n")], + "created directory recovery", + directory_preconditions=[ + _transaction.DirectoryPrecondition( + str(kiro), "Kiro directory", kiro_snapshot, created=True + ), + _transaction.DirectoryPrecondition( + str(hooks), "hooks directory", hooks_snapshot, created=True + ), + ], + ) + + assert not hooks.exists() + assert not kiro.exists() + + +def test_created_directory_recovery_keeps_parent_and_child_pinned( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + kiro = tmp_path / ".kiro" + hooks = kiro / "hooks" + hooks.mkdir(parents=True) + kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory") + hooks_snapshot = read_physical_directory(str(hooks), "hooks directory") + target = hooks / "elydora-audit.json" + observed: list[tuple[Path, Path]] = [] + real_remove = _transaction_staging.remove_pinned_empty_directory + + def observe_pins( + parent: object, + child: object, + *, + require_empty: bool = True, + ) -> None: + assert isinstance(parent, _transaction.PinnedDirectory) + assert isinstance(child, _transaction.PinnedDirectory) + assert parent.descriptor is not None + assert child.descriptor is not None + assert os.fstat(child.descriptor).st_ino == child.snapshot.inode + observed.append((Path(parent.path), Path(child.path))) + real_remove(parent, child, require_empty=require_empty) + + def fail_replace( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + raise OSError("injected pinned cleanup failure") + + monkeypatch.setattr( + _transaction_staging, + "remove_pinned_empty_directory", + observe_pins, + ) + monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) + + with pytest.raises(OSError, match="injected pinned cleanup failure"): + _transaction.write_changes( + [_change(target, None, "{}\n")], + "pinned directory recovery", + directory_preconditions=[ + _transaction.DirectoryPrecondition( + str(kiro), "Kiro directory", kiro_snapshot, created=True + ), + _transaction.DirectoryPrecondition( + str(hooks), "hooks directory", hooks_snapshot, created=True + ), + ], + ) + + expected = [(kiro, hooks), (tmp_path, kiro)] + assert observed == expected + assert not kiro.exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") +def test_created_directory_recovery_preserves_swapped_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + created = tmp_path / "created" + created.mkdir() + created_snapshot = read_physical_directory(str(created), "created directory") + target = created / "managed.json" + replacement = tmp_path / "replacement" + replacement.mkdir() + marker = replacement / "user-owned" + marker.write_text("preserve", encoding="utf-8") + original = tmp_path / "created-original" + real_remove = _transaction_staging.remove_pinned_empty_directory + + def swap_before_removal( + parent: _transaction.PinnedDirectory, + child: _transaction.PinnedDirectory, + *, + require_empty: bool = True, + ) -> None: + created.rename(original) + replacement.rename(created) + real_remove(parent, child, require_empty=require_empty) + + def fail_replace( + _source: str, + _destination: str, + _directory: _transaction.PinnedDirectory, + ) -> None: + raise OSError("injected swap cleanup failure") + + monkeypatch.setattr( + _transaction_staging, + "remove_pinned_empty_directory", + swap_before_removal, + ) + monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) + + with pytest.raises(OSError, match="recovery failed"): + _transaction.write_changes( + [_change(target, None, "{}\n")], + "swapped directory recovery", + directory_preconditions=[ + _transaction.DirectoryPrecondition( + str(created), + "created directory", + created_snapshot, + created=True, + ) + ], + ) + + assert (created / marker.name).read_text(encoding="utf-8") == "preserve" + assert original.is_dir() + assert Path(_transaction.active_journal_path()).is_file() + _transaction.clear_journal()