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

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

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

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

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

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

No files matched your search

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