From ed34831c20b266cbf2e3825a33a50263e0b4d624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 23:16:15 -0400 Subject: [PATCH] chore(python): audit plugins shared modules _managed_files owns the file snapshot reader and public metadata helpers used by pinned directories, durable copies, and opaque snapshots; transaction staging owns read_target and same_identity_and_contents; dead helpers removed; hook_template no longer re-exports the guard generator; mypy targets win32 so _windows_security type-checks. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq --- elydora/cli.py | 2 +- elydora/plugins/_durable_file.py | 13 +-- elydora/plugins/_file_io.py | 46 +---------- elydora/plugins/_managed_files.py | 75 +++++++---------- elydora/plugins/_opaque_snapshot.py | 28 ++----- elydora/plugins/_pinned_directory.py | 74 +++-------------- elydora/plugins/_transaction.py | 27 +----- elydora/plugins/_transaction_commit.py | 40 +++------ elydora/plugins/_transaction_journal.py | 14 ++-- elydora/plugins/_transaction_recovery.py | 95 +--------------------- elydora/plugins/_transaction_staging.py | 23 +++++- elydora/plugins/_windows_security.py | 7 -- elydora/plugins/claudecode_installation.py | 3 +- elydora/plugins/codex.py | 3 +- elydora/plugins/cursor.py | 3 +- elydora/plugins/gemini_installation.py | 3 +- elydora/plugins/grok_installation.py | 3 +- elydora/plugins/hook_template.py | 3 - elydora/plugins/kimi_installation.py | 3 +- pyproject.toml | 3 + tests/test_guard_template.py | 2 +- 21 files changed, 107 insertions(+), 363 deletions(-) diff --git a/elydora/cli.py b/elydora/cli.py index b9bf4da..fc3a4a4 100644 --- a/elydora/cli.py +++ b/elydora/cli.py @@ -20,7 +20,7 @@ from ._runtime_paths import ( from .crypto import get_public_key_base64url from .plugins.base import AgentPlugin, InstallConfig from .plugins.registry import SUPPORTED_AGENTS, get_agent_names -from .plugins.hook_template import generate_guard_script +from .plugins.guard_template import generate_guard_script from .plugins.augment import AugmentPlugin from .plugins.claudecode import ClaudeCodePlugin from .plugins.codex import CodexPlugin diff --git a/elydora/plugins/_durable_file.py b/elydora/plugins/_durable_file.py index 3ac08a9..69a5470 100644 --- a/elydora/plugins/_durable_file.py +++ b/elydora/plugins/_durable_file.py @@ -6,6 +6,7 @@ import os import stat from typing import Any, Optional, Tuple +from ._managed_files import same_file_metadata from ._pinned_directory import PinnedDirectory @@ -27,16 +28,6 @@ def _regular_identity( 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, @@ -88,7 +79,7 @@ def copy_into_reserved( directory.chmod_descriptor(destination_descriptor, mode) os.fsync(destination_descriptor) after = os.fstat(source_descriptor) - if _stable_metadata(after) != _stable_metadata(before): + if not same_file_metadata(after, before): raise OSError(f"{label} changed while copying: {source_path}") finally: for descriptor in (destination_descriptor, source_descriptor): diff --git a/elydora/plugins/_file_io.py b/elydora/plugins/_file_io.py index aa98325..13dd461 100644 --- a/elydora/plugins/_file_io.py +++ b/elydora/plugins/_file_io.py @@ -4,9 +4,8 @@ from __future__ import annotations import json import os -import stat import tempfile -from typing import Any, Dict, Optional +from typing import Any, Dict JsonObject = Dict[str, Any] @@ -64,46 +63,3 @@ def write_text_atomic(path: str, content: str, mode: int, label: str) -> None: def write_json_atomic(path: str, value: JsonObject, mode: int, label: str) -> None: write_text_atomic(path, json.dumps(value, indent=2) + "\n", mode, label) - - -def remove_file(path: str, label: str) -> None: - try: - os.remove(path) - except FileNotFoundError: - return - except OSError as error: - raise OSError(f"Remove {label} at {path}: {error}") from error - - -def regular_file_exists(path: str, label: str) -> bool: - try: - metadata = os.stat(path) - except FileNotFoundError: - return False - except OSError as error: - raise OSError(f"Read {label} at {path}: {error}") from error - return stat.S_ISREG(metadata.st_mode) - - -def require_runtime(path: str, label: str) -> None: - if not path: - raise ValueError(f"{label} path is required") - if not regular_file_exists(path, label): - raise FileNotFoundError(f"{label} is missing: {path}") - - -def read_json(path: str, label: str) -> Optional[JsonObject]: - try: - with open(path, "r", encoding="utf-8") as file: - raw = file.read() - except FileNotFoundError: - return None - except OSError as error: - raise OSError(f"Read {label} at {path}: {error}") from error - try: - value = json.loads(raw) - except json.JSONDecodeError as error: - raise ValueError(f"Failed to parse {label} at {path}: {error}") from error - if not isinstance(value, dict): - raise ValueError(f"{label} at {path} must contain a JSON object") - return value diff --git a/elydora/plugins/_managed_files.py b/elydora/plugins/_managed_files.py index a3ee1d8..5cd5f2e 100644 --- a/elydora/plugins/_managed_files.py +++ b/elydora/plugins/_managed_files.py @@ -28,14 +28,7 @@ class DirectorySnapshot: 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. - """ + """Windows synthesizes mode bits, so they are not identity there.""" return mode if os.name != "nt" else 0 @@ -44,12 +37,8 @@ def portable_file_mode(metadata: os.stat_result) -> int: 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. +def stable_file_metadata(metadata: os.stat_result) -> tuple[int, int, int, int]: + """Identity without timestamps; Windows file times lag across handles.""" return ( metadata.st_dev, metadata.st_ino, @@ -58,11 +47,8 @@ def _stable_file_metadata( ) -def _same_file_metadata( - first: os.stat_result, - second: os.stat_result, -) -> bool: - return _stable_file_metadata(first) == _stable_file_metadata(second) +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( @@ -73,12 +59,7 @@ def assert_content_stable( 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. - """ + """Re-read the file and require identical identity and bytes.""" descriptor = opener() try: current = os.fstat(descriptor) @@ -110,26 +91,25 @@ def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result return metadata -def read_physical_file( +def read_file_snapshot( + before: os.stat_result, + opener: Callable[[int], int], file_path: str, label: str, - maximum_bytes: int = MAX_SOURCE_BYTES, -) -> Optional[FileSnapshot]: - before = _inspect_regular_file(file_path, label) - if before is None: - return None + maximum_bytes: int, +) -> FileSnapshot: + """Read one regular file through opener and prove it stayed stable.""" if before.st_size > maximum_bytes: raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}") - flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0) flags |= getattr(os, "O_NOFOLLOW", 0) descriptor = -1 try: - descriptor = os.open(file_path, flags) + descriptor = opener(flags) after = os.fstat(descriptor) if not stat.S_ISREG(after.st_mode): raise OSError(f"{label} path is not a physical file: {file_path}") - if not _same_file_metadata(before, after): + if not same_file_metadata(before, after): raise OSError(f"{label} changed while opening: {file_path}") if after.st_size > maximum_bytes: raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}") @@ -137,15 +117,10 @@ def read_physical_file( descriptor = -1 raw = file.read(maximum_bytes + 1) finished = os.fstat(file.fileno()) - if not _same_file_metadata(finished, after): + 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, + lambda: opener(flags), file_path, label, after, raw, maximum_bytes ) except OSError as error: raise OSError(f"Read {label} at {file_path}: {error}") from error @@ -159,11 +134,19 @@ def read_physical_file( contents = raw.decode("utf-8") except UnicodeDecodeError as error: raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error - return FileSnapshot( - contents=contents, - device=after.st_dev, - inode=after.st_ino, - mode=stat.S_IMODE(after.st_mode), + return FileSnapshot(contents, after.st_dev, after.st_ino, stat.S_IMODE(after.st_mode)) + + +def read_physical_file( + file_path: str, + label: str, + maximum_bytes: int = MAX_SOURCE_BYTES, +) -> Optional[FileSnapshot]: + before = _inspect_regular_file(file_path, label) + if before is None: + return None + return read_file_snapshot( + before, lambda flags: os.open(file_path, flags), file_path, label, maximum_bytes ) diff --git a/elydora/plugins/_opaque_snapshot.py b/elydora/plugins/_opaque_snapshot.py index a1ccefe..3265fe0 100644 --- a/elydora/plugins/_opaque_snapshot.py +++ b/elydora/plugins/_opaque_snapshot.py @@ -8,7 +8,7 @@ import os import stat from typing import Optional -from ._managed_files import portable_file_mode +from ._managed_files import same_file_metadata from ._pinned_directory import PinnedDirectory @@ -17,12 +17,7 @@ _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, inode, mode, size, and SHA-256 without retaining the bytes.""" device: int inode: int @@ -31,19 +26,6 @@ class OpaqueFileSnapshot: 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, @@ -82,7 +64,7 @@ def stream_opaque_snapshot( 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): + if not same_file_metadata(opened, before): raise OSError(f"{label} changed while opening: {file_path}") digest = hashlib.sha256() while True: @@ -92,7 +74,7 @@ def stream_opaque_snapshot( digest.update(chunk) finished = os.fstat(descriptor) _require_regular(finished, file_path, label, require_owner_only) - if not _same_metadata(finished, opened): + if not same_file_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 @@ -105,7 +87,7 @@ def stream_opaque_snapshot( 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): + if not same_file_metadata(visible, finished): raise OSError(f"{label} changed while hashing: {file_path}") return OpaqueFileSnapshot( finished.st_dev, diff --git a/elydora/plugins/_pinned_directory.py b/elydora/plugins/_pinned_directory.py index 1d63124..522efdf 100644 --- a/elydora/plugins/_pinned_directory.py +++ b/elydora/plugins/_pinned_directory.py @@ -10,8 +10,7 @@ from typing import Any, Optional, Tuple from ._managed_files import ( DirectorySnapshot, FileSnapshot, - _same_file_metadata, - assert_content_stable, + read_file_snapshot, read_physical_directory, ) @@ -381,8 +380,7 @@ class PinnedDirectory: """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. + # Windows directory handles do not support FlushFileBuffers. return if os.name != "posix": raise OSError(f"Unsupported transaction platform: {os.name}") @@ -410,71 +408,21 @@ class PinnedDirectory: label: str, maximum_bytes: int, ) -> Optional[FileSnapshot]: + file_path = self.path_for(name) 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 + raise OSError(f"Inspect {label} at {file_path}: {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), + raise OSError(f"{label} path is not a physical file: {file_path}") + return read_file_snapshot( + before, + lambda flags: self.open_file(name, flags), + file_path, + label, + maximum_bytes, ) def chmod_file( diff --git a/elydora/plugins/_transaction.py b/elydora/plugins/_transaction.py index dbde1e0..cc445a7 100644 --- a/elydora/plugins/_transaction.py +++ b/elydora/plugins/_transaction.py @@ -6,12 +6,7 @@ import os from typing import List, Optional, Sequence, cast from uuid import uuid4 -from ._managed_files import ( - FileSnapshot, - MAX_SOURCE_BYTES, - physical_file_exists, - read_physical_file, -) +from ._managed_files import FileSnapshot, MAX_SOURCE_BYTES, read_physical_file from ._opaque_cleanup import cleanup_opaque_removal from ._opaque_removal import ( OpaqueRemovalChange, @@ -65,15 +60,6 @@ def recover_pending_transactions() -> None: _recover_pending_transactions() -def read_optional( - file_path: str, - label: str, - maximum_bytes: int = MAX_SOURCE_BYTES, -) -> Optional[str]: - snapshot = read_physical_file(file_path, label, maximum_bytes) - return None if snapshot is None else snapshot.contents - - def source_change( file_path: str, label: str, @@ -479,14 +465,3 @@ def write_changes( directory_preconditions, opaque_removals, ) - - -def regular_file_exists(file_path: str, label: str) -> bool: - return physical_file_exists(file_path, label) - - -def require_runtime(file_path: str, label: str) -> None: - if not file_path: - raise ValueError(f"{label} path is required") - if not regular_file_exists(file_path, label): - raise FileNotFoundError(f"{label} is missing: {file_path}") diff --git a/elydora/plugins/_transaction_commit.py b/elydora/plugins/_transaction_commit.py index 00152eb..24f0f2a 100644 --- a/elydora/plugins/_transaction_commit.py +++ b/elydora/plugins/_transaction_commit.py @@ -13,6 +13,8 @@ from ._transaction_recovery import remove_committed_creation from ._transaction_staging import ( assert_transaction_preconditions, assert_unchanged, + read_target, + same_identity_and_contents, same_snapshot, ) from ._transaction_types import FileChange, StagedChange @@ -38,14 +40,6 @@ def _original_snapshot(change: FileChange) -> Optional[FileSnapshot]: ) -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 @@ -66,18 +60,6 @@ def _read_rollback(staged: StagedChange) -> Optional[FileSnapshot]: ) -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)) @@ -97,7 +79,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> 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) + current = read_target(staged) if not same_snapshot(current, original): raise OSError( f"{staged.change.label} changed at commit boundary: " @@ -119,7 +101,7 @@ def prepare_change(staged: StagedChange, capture_file: ReplaceFile) -> None: staged.change.label, staged.change.maximum_bytes, ) - current = _read_target(staged) + current = read_target(staged) if not same_snapshot(current, original): raise OSError( f"{staged.change.label} changed while preparing rollback: " @@ -151,7 +133,7 @@ def _replace_next( 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) + current = read_target(staged) expected = _original_snapshot(staged.change) if not same_snapshot(current, expected): error = OSError( @@ -227,7 +209,7 @@ def commit_change( else: _replace_next(staged, replace_file, after_replace) - current = _read_target(staged) + current = read_target(staged) if not same_snapshot(current, staged.committed_snapshot): raise OSError( f"{staged.change.label} changed immediately after commit: " @@ -251,7 +233,7 @@ def _restore_rollback_file( 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): + if not same_snapshot(read_target(staged), expected_current): raise _preserve_rollback( staged, OSError( @@ -263,7 +245,7 @@ def _restore_rollback_file( 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): + if not same_snapshot(read_target(staged), expected_current): raise _preserve_rollback( staged, OSError( @@ -272,7 +254,7 @@ def _restore_rollback_file( ), ) current_rollback = _read_rollback(staged) - if not _same_identity_and_contents(current_rollback, rollback): + if not same_identity_and_contents(current_rollback, rollback): raise _preserve_rollback( staged, OSError( @@ -288,7 +270,7 @@ def _restore_rollback_file( ) atomic_replace(staged.directory, rollback_path, staged.change.file_path) staged.rollback_path = None - restored = _read_target(staged) + restored = read_target(staged) if ( restored is None or restored.contents != expected.contents @@ -307,7 +289,7 @@ def rollback_change( ) -> None: if not staged.committed: return - current = _read_target(staged) + current = read_target(staged) if not same_snapshot(current, staged.committed_snapshot): raise _preserve_rollback( staged, diff --git a/elydora/plugins/_transaction_journal.py b/elydora/plugins/_transaction_journal.py index 9f4a4a9..4595aec 100644 --- a/elydora/plugins/_transaction_journal.py +++ b/elydora/plugins/_transaction_journal.py @@ -13,7 +13,7 @@ from uuid import uuid4 from ._managed_files import ( DirectorySnapshot, FileSnapshot, - _same_file_metadata, + same_file_metadata, identity_mode_bits, ) from ._opaque_snapshot import OpaqueFileSnapshot @@ -300,7 +300,7 @@ def inspect_fingerprint( 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): + 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 @@ -311,12 +311,12 @@ def inspect_fingerprint( digest.update(chunk) size += len(chunk) finished = os.fstat(descriptor) - if not _same_file_metadata(finished, opened): + 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): + if not same_file_metadata(visible, finished): raise OSError(f"{label} changed while hashing: {file_path}") return FileFingerprint( digest.hexdigest(), @@ -419,7 +419,7 @@ def load_journal() -> Optional[TransactionJournal]: descriptor = os.open(path, flags) try: opened = os.fstat(descriptor) - if not _same_file_metadata(opened, metadata): + if not same_file_metadata(opened, metadata): raise OSError(f"Transaction journal changed while opening: {path}") chunks = [] remaining = MAX_JOURNAL_BYTES + 1 @@ -431,7 +431,7 @@ def load_journal() -> Optional[TransactionJournal]: remaining -= len(chunk) raw = b"".join(chunks) finished = os.fstat(descriptor) - if not _same_file_metadata(finished, opened): + if not same_file_metadata(finished, opened): raise OSError(f"Transaction journal changed while reading: {path}") finally: os.close(descriptor) @@ -439,7 +439,7 @@ def load_journal() -> Optional[TransactionJournal]: 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): + 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") diff --git a/elydora/plugins/_transaction_recovery.py b/elydora/plugins/_transaction_recovery.py index 2acc444..f3c564b 100644 --- a/elydora/plugins/_transaction_recovery.py +++ b/elydora/plugins/_transaction_recovery.py @@ -9,21 +9,13 @@ 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_staging import read_target, 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, @@ -36,18 +28,6 @@ def _read_private_path( ) -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") @@ -63,76 +43,6 @@ 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, @@ -263,7 +173,7 @@ def remove_committed_creation( replace_file, capture_file, ) - if _read_target(staged) is not None: + 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: " @@ -275,6 +185,5 @@ def remove_committed_creation( __all__ = [ "capture_recovery_target", "discard_private_file", - "install_private_file", "remove_committed_creation", ] diff --git a/elydora/plugins/_transaction_staging.py b/elydora/plugins/_transaction_staging.py index c21d919..48c9029 100644 --- a/elydora/plugins/_transaction_staging.py +++ b/elydora/plugins/_transaction_staging.py @@ -46,6 +46,26 @@ def same_snapshot( return current == expected +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 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 remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None: if not file_path: return @@ -338,8 +358,7 @@ def _remove_exact_text( != (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. + # An in-place rewrite keeps the inode; bytes must match too. try: snapshot = captured.text_snapshot( len(expected.contents.encode("utf-8")) diff --git a/elydora/plugins/_windows_security.py b/elydora/plugins/_windows_security.py index 33f5eae..2398c46 100644 --- a/elydora/plugins/_windows_security.py +++ b/elydora/plugins/_windows_security.py @@ -210,12 +210,6 @@ def _current_user_sid() -> Tuple[Any, str]: 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 "" @@ -491,7 +485,6 @@ def verify_private_path(path: str, *, directory: bool) -> None: __all__ = [ "close_handle", - "current_user_sid_string", "ensure_private_directory", "open_private_file", "verify_private_path", diff --git a/elydora/plugins/claudecode_installation.py b/elydora/plugins/claudecode_installation.py index 1f1fdd4..49cc92a 100644 --- a/elydora/plugins/claudecode_installation.py +++ b/elydora/plugins/claudecode_installation.py @@ -27,7 +27,8 @@ from .claudecode_io import ( validate_private_key, validate_runtime_tree, ) -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script @dataclass(frozen=True) diff --git a/elydora/plugins/codex.py b/elydora/plugins/codex.py index 05f1654..9fe7a97 100644 --- a/elydora/plugins/codex.py +++ b/elydora/plugins/codex.py @@ -36,7 +36,8 @@ from .codex_io import ( validate_runtime_tree, write_codex_changes, ) -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script def _runtime_config(config: InstallConfig, agent_id: str) -> dict: diff --git a/elydora/plugins/cursor.py b/elydora/plugins/cursor.py index 0770700..fae03b2 100644 --- a/elydora/plugins/cursor.py +++ b/elydora/plugins/cursor.py @@ -36,7 +36,8 @@ from .cursor_io import ( validate_runtime_identity, write_cursor_changes, ) -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script def _runtime_config(config: InstallConfig, agent_id: str) -> dict: diff --git a/elydora/plugins/gemini_installation.py b/elydora/plugins/gemini_installation.py index 36b4c49..f3a58e6 100644 --- a/elydora/plugins/gemini_installation.py +++ b/elydora/plugins/gemini_installation.py @@ -26,7 +26,8 @@ from .gemini_io import ( validate_private_key, validate_runtime_tree, ) -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script @dataclass(frozen=True) diff --git a/elydora/plugins/grok_installation.py b/elydora/plugins/grok_installation.py index 5367642..1c429fe 100644 --- a/elydora/plugins/grok_installation.py +++ b/elydora/plugins/grok_installation.py @@ -25,7 +25,8 @@ from .grok_io import ( validate_private_key, validate_runtime_tree, ) -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script @dataclass(frozen=True) diff --git a/elydora/plugins/hook_template.py b/elydora/plugins/hook_template.py index 8b00443..e0601f9 100644 --- a/elydora/plugins/hook_template.py +++ b/elydora/plugins/hook_template.py @@ -5,11 +5,8 @@ from __future__ import annotations import sys from .error_log_template import ERROR_LOG_RUNTIME -from .guard_template import generate_guard_script from .runtime_reader_template import PROTECTED_RUNTIME_READER -__all__ = ["generate_guard_script", "generate_hook_script"] - def generate_hook_script( *, diff --git a/elydora/plugins/kimi_installation.py b/elydora/plugins/kimi_installation.py index 6ae57df..07d8d80 100644 --- a/elydora/plugins/kimi_installation.py +++ b/elydora/plugins/kimi_installation.py @@ -12,7 +12,8 @@ from elydora._runtime_paths import resolve_agent_directory from ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES from ._transaction import FileChange, file_change, source_change, write_changes from .base import InstallConfig -from .hook_template import generate_guard_script, generate_hook_script +from .guard_template import generate_guard_script +from .hook_template import generate_hook_script from .kimi_command import runtime_root, same_kimi_path from .kimi_contract import ( AGENT_KEY, diff --git a/pyproject.toml b/pyproject.toml index 81ccbae..ba507e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,3 +47,6 @@ elydora = ["py.typed"] [tool.setuptools.dynamic] version = {attr = "elydora._version.__version__"} + +[tool.mypy] +platform = "win32" diff --git a/tests/test_guard_template.py b/tests/test_guard_template.py index e01f4bf..404cdc8 100644 --- a/tests/test_guard_template.py +++ b/tests/test_guard_template.py @@ -9,7 +9,7 @@ import sys from threading import Thread import time -from elydora.plugins.hook_template import generate_guard_script +from elydora.plugins.guard_template import generate_guard_script AGENT_ID = "agent 1"