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
This commit is contained in:
1 parent
0f72a5e2f1
commit
ed34831c20
21 files changed
+104
-360
No files matched your search
+1
-1
@@ -20,7 +20,7 @@ from ._runtime_paths import (
|
|||||||
from .crypto import get_public_key_base64url
|
from .crypto import get_public_key_base64url
|
||||||
from .plugins.base import AgentPlugin, InstallConfig
|
from .plugins.base import AgentPlugin, InstallConfig
|
||||||
from .plugins.registry import SUPPORTED_AGENTS, get_agent_names
|
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.augment import AugmentPlugin
|
||||||
from .plugins.claudecode import ClaudeCodePlugin
|
from .plugins.claudecode import ClaudeCodePlugin
|
||||||
from .plugins.codex import CodexPlugin
|
from .plugins.codex import CodexPlugin
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
import stat
|
import stat
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
from ._managed_files import same_file_metadata
|
||||||
from ._pinned_directory import PinnedDirectory
|
from ._pinned_directory import PinnedDirectory
|
||||||
|
|
||||||
|
|
||||||
@@ -27,16 +28,6 @@ def _regular_identity(
|
|||||||
raise OSError(f"{label} changed while copying: {path}")
|
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(
|
def copy_into_reserved(
|
||||||
directory: PinnedDirectory,
|
directory: PinnedDirectory,
|
||||||
source_path: str,
|
source_path: str,
|
||||||
@@ -88,7 +79,7 @@ def copy_into_reserved(
|
|||||||
directory.chmod_descriptor(destination_descriptor, mode)
|
directory.chmod_descriptor(destination_descriptor, mode)
|
||||||
os.fsync(destination_descriptor)
|
os.fsync(destination_descriptor)
|
||||||
after = os.fstat(source_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}")
|
raise OSError(f"{label} changed while copying: {source_path}")
|
||||||
finally:
|
finally:
|
||||||
for descriptor in (destination_descriptor, source_descriptor):
|
for descriptor in (destination_descriptor, source_descriptor):
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import stat
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
JsonObject = Dict[str, Any]
|
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:
|
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)
|
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
|
|
||||||
@@ -28,14 +28,7 @@ class DirectorySnapshot:
|
|||||||
|
|
||||||
|
|
||||||
def identity_mode_bits(mode: int) -> int:
|
def identity_mode_bits(mode: int) -> int:
|
||||||
"""Collapse permission bits that are not identity evidence on Windows.
|
"""Windows synthesizes mode bits, so they are not identity there."""
|
||||||
|
|
||||||
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
|
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))
|
return identity_mode_bits(stat.S_IMODE(metadata.st_mode))
|
||||||
|
|
||||||
|
|
||||||
def _stable_file_metadata(
|
def stable_file_metadata(metadata: os.stat_result) -> tuple[int, int, int, int]:
|
||||||
metadata: os.stat_result,
|
"""Identity without timestamps; Windows file times lag across handles."""
|
||||||
) -> 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 (
|
return (
|
||||||
metadata.st_dev,
|
metadata.st_dev,
|
||||||
metadata.st_ino,
|
metadata.st_ino,
|
||||||
@@ -58,11 +47,8 @@ def _stable_file_metadata(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _same_file_metadata(
|
def same_file_metadata(first: os.stat_result, second: os.stat_result) -> bool:
|
||||||
first: os.stat_result,
|
return stable_file_metadata(first) == stable_file_metadata(second)
|
||||||
second: os.stat_result,
|
|
||||||
) -> bool:
|
|
||||||
return _stable_file_metadata(first) == _stable_file_metadata(second)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_content_stable(
|
def assert_content_stable(
|
||||||
@@ -73,12 +59,7 @@ def assert_content_stable(
|
|||||||
raw: bytes,
|
raw: bytes,
|
||||||
maximum_bytes: int,
|
maximum_bytes: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Re-read a just-read file and require identical identity and bytes.
|
"""Re-read the 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()
|
descriptor = opener()
|
||||||
try:
|
try:
|
||||||
current = os.fstat(descriptor)
|
current = os.fstat(descriptor)
|
||||||
@@ -110,26 +91,25 @@ def _inspect_regular_file(file_path: str, label: str) -> Optional[os.stat_result
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
def read_physical_file(
|
def read_file_snapshot(
|
||||||
|
before: os.stat_result,
|
||||||
|
opener: Callable[[int], int],
|
||||||
file_path: str,
|
file_path: str,
|
||||||
label: str,
|
label: str,
|
||||||
maximum_bytes: int = MAX_SOURCE_BYTES,
|
maximum_bytes: int,
|
||||||
) -> Optional[FileSnapshot]:
|
) -> FileSnapshot:
|
||||||
before = _inspect_regular_file(file_path, label)
|
"""Read one regular file through opener and prove it stayed stable."""
|
||||||
if before is None:
|
|
||||||
return None
|
|
||||||
if before.st_size > maximum_bytes:
|
if before.st_size > maximum_bytes:
|
||||||
raise ValueError(f"{label} exceeds {maximum_bytes} bytes: {file_path}")
|
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 = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||||
descriptor = -1
|
descriptor = -1
|
||||||
try:
|
try:
|
||||||
descriptor = os.open(file_path, flags)
|
descriptor = opener(flags)
|
||||||
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 not _same_file_metadata(before, after):
|
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}")
|
||||||
@@ -137,15 +117,10 @@ def read_physical_file(
|
|||||||
descriptor = -1
|
descriptor = -1
|
||||||
raw = file.read(maximum_bytes + 1)
|
raw = file.read(maximum_bytes + 1)
|
||||||
finished = os.fstat(file.fileno())
|
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}")
|
raise OSError(f"{label} changed while reading: {file_path}")
|
||||||
assert_content_stable(
|
assert_content_stable(
|
||||||
lambda: os.open(file_path, flags),
|
lambda: opener(flags), file_path, label, after, raw, maximum_bytes
|
||||||
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
|
||||||
@@ -159,11 +134,19 @@ def read_physical_file(
|
|||||||
contents = raw.decode("utf-8")
|
contents = raw.decode("utf-8")
|
||||||
except UnicodeDecodeError as error:
|
except UnicodeDecodeError as error:
|
||||||
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
|
raise ValueError(f"{label} at {file_path} must contain UTF-8 text") from error
|
||||||
return FileSnapshot(
|
return FileSnapshot(contents, after.st_dev, after.st_ino, stat.S_IMODE(after.st_mode))
|
||||||
contents=contents,
|
|
||||||
device=after.st_dev,
|
|
||||||
inode=after.st_ino,
|
def read_physical_file(
|
||||||
mode=stat.S_IMODE(after.st_mode),
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
import stat
|
import stat
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ._managed_files import portable_file_mode
|
from ._managed_files import same_file_metadata
|
||||||
from ._pinned_directory import PinnedDirectory
|
from ._pinned_directory import PinnedDirectory
|
||||||
|
|
||||||
|
|
||||||
@@ -17,12 +17,7 @@ _HASH_CHUNK_BYTES = 1024 * 1024
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OpaqueFileSnapshot:
|
class OpaqueFileSnapshot:
|
||||||
"""Content and physical identity without retaining file bytes in memory.
|
"""Device, inode, mode, size, and SHA-256 without retaining the bytes."""
|
||||||
|
|
||||||
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
|
device: int
|
||||||
inode: int
|
inode: int
|
||||||
@@ -31,19 +26,6 @@ class OpaqueFileSnapshot:
|
|||||||
sha256: str
|
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(
|
def _require_regular(
|
||||||
metadata: os.stat_result,
|
metadata: os.stat_result,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@@ -82,7 +64,7 @@ def stream_opaque_snapshot(
|
|||||||
descriptor = directory.open_file(name, flags)
|
descriptor = directory.open_file(name, flags)
|
||||||
opened = os.fstat(descriptor)
|
opened = os.fstat(descriptor)
|
||||||
_require_regular(opened, file_path, label, require_owner_only)
|
_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}")
|
raise OSError(f"{label} changed while opening: {file_path}")
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
while True:
|
while True:
|
||||||
@@ -92,7 +74,7 @@ def stream_opaque_snapshot(
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
finished = os.fstat(descriptor)
|
finished = os.fstat(descriptor)
|
||||||
_require_regular(finished, file_path, label, require_owner_only)
|
_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}")
|
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||||
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
|
||||||
@@ -105,7 +87,7 @@ def stream_opaque_snapshot(
|
|||||||
except FileNotFoundError as error:
|
except FileNotFoundError as error:
|
||||||
raise OSError(f"{label} changed while hashing: {file_path}") from error
|
raise OSError(f"{label} changed while hashing: {file_path}") from error
|
||||||
_require_regular(visible, file_path, label, require_owner_only)
|
_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}")
|
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||||
return OpaqueFileSnapshot(
|
return OpaqueFileSnapshot(
|
||||||
finished.st_dev,
|
finished.st_dev,
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ from typing import Any, Optional, Tuple
|
|||||||
from ._managed_files import (
|
from ._managed_files import (
|
||||||
DirectorySnapshot,
|
DirectorySnapshot,
|
||||||
FileSnapshot,
|
FileSnapshot,
|
||||||
_same_file_metadata,
|
read_file_snapshot,
|
||||||
assert_content_stable,
|
|
||||||
read_physical_directory,
|
read_physical_directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -381,8 +380,7 @@ class PinnedDirectory:
|
|||||||
"""Persist directory entries where the host exposes directory fsync."""
|
"""Persist directory entries where the host exposes directory fsync."""
|
||||||
self._require_open()
|
self._require_open()
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
# NTFS journals atomic rename metadata. Windows directory handles do
|
# Windows directory handles do not support FlushFileBuffers.
|
||||||
# not support FlushFileBuffers, while every staged file is flushed.
|
|
||||||
return
|
return
|
||||||
if os.name != "posix":
|
if os.name != "posix":
|
||||||
raise OSError(f"Unsupported transaction platform: {os.name}")
|
raise OSError(f"Unsupported transaction platform: {os.name}")
|
||||||
@@ -410,72 +408,22 @@ class PinnedDirectory:
|
|||||||
label: str,
|
label: str,
|
||||||
maximum_bytes: int,
|
maximum_bytes: int,
|
||||||
) -> Optional[FileSnapshot]:
|
) -> Optional[FileSnapshot]:
|
||||||
|
file_path = self.path_for(name)
|
||||||
try:
|
try:
|
||||||
before = self.stat_file(name)
|
before = self.stat_file(name)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
except OSError as error:
|
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):
|
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)}")
|
raise OSError(f"{label} path is not a physical file: {file_path}")
|
||||||
if before.st_size > maximum_bytes:
|
return read_file_snapshot(
|
||||||
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,
|
before,
|
||||||
opened,
|
lambda flags: self.open_file(name, flags),
|
||||||
):
|
file_path,
|
||||||
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,
|
label,
|
||||||
opened,
|
|
||||||
raw,
|
|
||||||
maximum_bytes,
|
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(
|
def chmod_file(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import os
|
|||||||
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, MAX_SOURCE_BYTES, read_physical_file
|
||||||
FileSnapshot,
|
|
||||||
MAX_SOURCE_BYTES,
|
|
||||||
physical_file_exists,
|
|
||||||
read_physical_file,
|
|
||||||
)
|
|
||||||
from ._opaque_cleanup import cleanup_opaque_removal
|
from ._opaque_cleanup import cleanup_opaque_removal
|
||||||
from ._opaque_removal import (
|
from ._opaque_removal import (
|
||||||
OpaqueRemovalChange,
|
OpaqueRemovalChange,
|
||||||
@@ -65,15 +60,6 @@ def recover_pending_transactions() -> None:
|
|||||||
_recover_pending_transactions()
|
_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(
|
def source_change(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
label: str,
|
label: str,
|
||||||
@@ -479,14 +465,3 @@ def write_changes(
|
|||||||
directory_preconditions,
|
directory_preconditions,
|
||||||
opaque_removals,
|
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}")
|
|
||||||
@@ -13,6 +13,8 @@ from ._transaction_recovery import remove_committed_creation
|
|||||||
from ._transaction_staging import (
|
from ._transaction_staging import (
|
||||||
assert_transaction_preconditions,
|
assert_transaction_preconditions,
|
||||||
assert_unchanged,
|
assert_unchanged,
|
||||||
|
read_target,
|
||||||
|
same_identity_and_contents,
|
||||||
same_snapshot,
|
same_snapshot,
|
||||||
)
|
)
|
||||||
from ._transaction_types import FileChange, StagedChange
|
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]:
|
def _read_temporary(staged: StagedChange) -> Optional[FileSnapshot]:
|
||||||
if staged.temporary_path is None:
|
if staged.temporary_path is None:
|
||||||
return 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:
|
def _preserve_rollback(staged: StagedChange, error: Exception) -> OSError:
|
||||||
if staged.rollback_path is None:
|
if staged.rollback_path is None:
|
||||||
return error if isinstance(error, OSError) else OSError(str(error))
|
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}")
|
raise OSError(f"Missing rollback reservation for {staged.change.label}")
|
||||||
capture_file(staged.change.file_path, rollback_path, staged.directory)
|
capture_file(staged.change.file_path, rollback_path, staged.directory)
|
||||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||||
current = _read_target(staged)
|
current = read_target(staged)
|
||||||
if not same_snapshot(current, original):
|
if not same_snapshot(current, original):
|
||||||
raise OSError(
|
raise OSError(
|
||||||
f"{staged.change.label} changed at commit boundary: "
|
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.label,
|
||||||
staged.change.maximum_bytes,
|
staged.change.maximum_bytes,
|
||||||
)
|
)
|
||||||
current = _read_target(staged)
|
current = read_target(staged)
|
||||||
if not same_snapshot(current, original):
|
if not same_snapshot(current, original):
|
||||||
raise OSError(
|
raise OSError(
|
||||||
f"{staged.change.label} changed while preparing rollback: "
|
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}")
|
raise OSError(f"Missing staged file for {staged.change.label}")
|
||||||
replace_file(temporary_path, staged.change.file_path, staged.directory)
|
replace_file(temporary_path, staged.change.file_path, staged.directory)
|
||||||
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
assert_transaction_preconditions(*staged.preconditions, staged.directories)
|
||||||
current = _read_target(staged)
|
current = read_target(staged)
|
||||||
expected = _original_snapshot(staged.change)
|
expected = _original_snapshot(staged.change)
|
||||||
if not same_snapshot(current, expected):
|
if not same_snapshot(current, expected):
|
||||||
error = OSError(
|
error = OSError(
|
||||||
@@ -227,7 +209,7 @@ def commit_change(
|
|||||||
else:
|
else:
|
||||||
_replace_next(staged, replace_file, after_replace)
|
_replace_next(staged, replace_file, after_replace)
|
||||||
|
|
||||||
current = _read_target(staged)
|
current = read_target(staged)
|
||||||
if not same_snapshot(current, staged.committed_snapshot):
|
if not same_snapshot(current, staged.committed_snapshot):
|
||||||
raise OSError(
|
raise OSError(
|
||||||
f"{staged.change.label} changed immediately after commit: "
|
f"{staged.change.label} changed immediately after commit: "
|
||||||
@@ -251,7 +233,7 @@ def _restore_rollback_file(
|
|||||||
rollback = staged.rollback_snapshot
|
rollback = staged.rollback_snapshot
|
||||||
if rollback_path is None or rollback is None:
|
if rollback_path is None or rollback is None:
|
||||||
raise OSError(f"Missing rollback path for {staged.change.label}")
|
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(
|
raise _preserve_rollback(
|
||||||
staged,
|
staged,
|
||||||
OSError(
|
OSError(
|
||||||
@@ -263,7 +245,7 @@ def _restore_rollback_file(
|
|||||||
restore_file(rollback_path, staged.change.file_path, staged.directory)
|
restore_file(rollback_path, staged.change.file_path, staged.directory)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise _preserve_rollback(staged, error) from 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(
|
raise _preserve_rollback(
|
||||||
staged,
|
staged,
|
||||||
OSError(
|
OSError(
|
||||||
@@ -272,7 +254,7 @@ def _restore_rollback_file(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
current_rollback = _read_rollback(staged)
|
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(
|
raise _preserve_rollback(
|
||||||
staged,
|
staged,
|
||||||
OSError(
|
OSError(
|
||||||
@@ -288,7 +270,7 @@ def _restore_rollback_file(
|
|||||||
)
|
)
|
||||||
atomic_replace(staged.directory, rollback_path, staged.change.file_path)
|
atomic_replace(staged.directory, rollback_path, staged.change.file_path)
|
||||||
staged.rollback_path = None
|
staged.rollback_path = None
|
||||||
restored = _read_target(staged)
|
restored = read_target(staged)
|
||||||
if (
|
if (
|
||||||
restored is None
|
restored is None
|
||||||
or restored.contents != expected.contents
|
or restored.contents != expected.contents
|
||||||
@@ -307,7 +289,7 @@ def rollback_change(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not staged.committed:
|
if not staged.committed:
|
||||||
return
|
return
|
||||||
current = _read_target(staged)
|
current = read_target(staged)
|
||||||
if not same_snapshot(current, staged.committed_snapshot):
|
if not same_snapshot(current, staged.committed_snapshot):
|
||||||
raise _preserve_rollback(
|
raise _preserve_rollback(
|
||||||
staged,
|
staged,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from uuid import uuid4
|
|||||||
from ._managed_files import (
|
from ._managed_files import (
|
||||||
DirectorySnapshot,
|
DirectorySnapshot,
|
||||||
FileSnapshot,
|
FileSnapshot,
|
||||||
_same_file_metadata,
|
same_file_metadata,
|
||||||
identity_mode_bits,
|
identity_mode_bits,
|
||||||
)
|
)
|
||||||
from ._opaque_snapshot import OpaqueFileSnapshot
|
from ._opaque_snapshot import OpaqueFileSnapshot
|
||||||
@@ -300,7 +300,7 @@ def inspect_fingerprint(
|
|||||||
descriptor = directory.open_file(name, flags)
|
descriptor = directory.open_file(name, flags)
|
||||||
try:
|
try:
|
||||||
opened = os.fstat(descriptor)
|
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}")
|
raise OSError(f"{label} changed while opening: {file_path}")
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
size = 0
|
size = 0
|
||||||
@@ -311,12 +311,12 @@ def inspect_fingerprint(
|
|||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
size += len(chunk)
|
size += len(chunk)
|
||||||
finished = os.fstat(descriptor)
|
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}")
|
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
visible = directory.stat_file(name)
|
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}")
|
raise OSError(f"{label} changed while hashing: {file_path}")
|
||||||
return FileFingerprint(
|
return FileFingerprint(
|
||||||
digest.hexdigest(),
|
digest.hexdigest(),
|
||||||
@@ -419,7 +419,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
|||||||
descriptor = os.open(path, flags)
|
descriptor = os.open(path, flags)
|
||||||
try:
|
try:
|
||||||
opened = os.fstat(descriptor)
|
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}")
|
raise OSError(f"Transaction journal changed while opening: {path}")
|
||||||
chunks = []
|
chunks = []
|
||||||
remaining = MAX_JOURNAL_BYTES + 1
|
remaining = MAX_JOURNAL_BYTES + 1
|
||||||
@@ -431,7 +431,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
|||||||
remaining -= len(chunk)
|
remaining -= len(chunk)
|
||||||
raw = b"".join(chunks)
|
raw = b"".join(chunks)
|
||||||
finished = os.fstat(descriptor)
|
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}")
|
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||||
finally:
|
finally:
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
@@ -439,7 +439,7 @@ def load_journal() -> Optional[TransactionJournal]:
|
|||||||
visible = os.lstat(path)
|
visible = os.lstat(path)
|
||||||
except FileNotFoundError as error:
|
except FileNotFoundError as error:
|
||||||
raise OSError(f"Transaction journal changed while reading: {path}") from 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}")
|
raise OSError(f"Transaction journal changed while reading: {path}")
|
||||||
if len(raw) > MAX_JOURNAL_BYTES:
|
if len(raw) > MAX_JOURNAL_BYTES:
|
||||||
raise ValueError("Transaction journal exceeds its size limit")
|
raise ValueError("Transaction journal exceeds its size limit")
|
||||||
|
|||||||
@@ -9,21 +9,13 @@ from uuid import uuid4
|
|||||||
from ._managed_files import FileSnapshot
|
from ._managed_files import FileSnapshot
|
||||||
from ._pinned_directory import PinnedDirectory
|
from ._pinned_directory import PinnedDirectory
|
||||||
from ._private_artifact import QuarantinedFile, reserve_private_path
|
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
|
from ._transaction_types import StagedChange
|
||||||
|
|
||||||
|
|
||||||
ReplaceFile = Callable[[str, str, PinnedDirectory], None]
|
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(
|
def _read_private_path(
|
||||||
staged: StagedChange,
|
staged: StagedChange,
|
||||||
file_path: str,
|
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]:
|
def _recovery_path(staged: StagedChange) -> tuple[str, FileSnapshot]:
|
||||||
basename = os.path.basename(staged.change.file_path)
|
basename = os.path.basename(staged.change.file_path)
|
||||||
path = staged.directory.path_for(f".{basename}.{uuid4().hex}.recovery")
|
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}")
|
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(
|
def capture_recovery_target(
|
||||||
staged: StagedChange,
|
staged: StagedChange,
|
||||||
expected: FileSnapshot,
|
expected: FileSnapshot,
|
||||||
@@ -263,7 +173,7 @@ def remove_committed_creation(
|
|||||||
replace_file,
|
replace_file,
|
||||||
capture_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)
|
discard_private_file(staged, recovery_path, recovery_snapshot)
|
||||||
raise OSError(
|
raise OSError(
|
||||||
f"{staged.change.label} changed after recovery capture: "
|
f"{staged.change.label} changed after recovery capture: "
|
||||||
@@ -275,6 +185,5 @@ def remove_committed_creation(
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"capture_recovery_target",
|
"capture_recovery_target",
|
||||||
"discard_private_file",
|
"discard_private_file",
|
||||||
"install_private_file",
|
|
||||||
"remove_committed_creation",
|
"remove_committed_creation",
|
||||||
]
|
]
|
||||||
@@ -46,6 +46,26 @@ def same_snapshot(
|
|||||||
return current == expected
|
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:
|
def remove_optional(directory: PinnedDirectory, file_path: Optional[str]) -> None:
|
||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
@@ -338,8 +358,7 @@ def _remove_exact_text(
|
|||||||
!= (expected.device, expected.inode)
|
!= (expected.device, expected.inode)
|
||||||
)
|
)
|
||||||
if not changed:
|
if not changed:
|
||||||
# An in-place rewrite keeps the inode, so the removal is only exact
|
# An in-place rewrite keeps the inode; bytes must match too.
|
||||||
# once the bytes also match the owned snapshot.
|
|
||||||
try:
|
try:
|
||||||
snapshot = captured.text_snapshot(
|
snapshot = captured.text_snapshot(
|
||||||
len(expected.contents.encode("utf-8"))
|
len(expected.contents.encode("utf-8"))
|
||||||
|
|||||||
@@ -210,12 +210,6 @@ def _current_user_sid() -> Tuple[Any, str]:
|
|||||||
close_handle(int(token_value))
|
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]:
|
def _security_descriptor(directory: bool) -> Tuple[int, int]:
|
||||||
_sid, sid_text = _current_user_sid()
|
_sid, sid_text = _current_user_sid()
|
||||||
inheritance = "OICI" if directory else ""
|
inheritance = "OICI" if directory else ""
|
||||||
@@ -491,7 +485,6 @@ def verify_private_path(path: str, *, directory: bool) -> None:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"close_handle",
|
"close_handle",
|
||||||
"current_user_sid_string",
|
|
||||||
"ensure_private_directory",
|
"ensure_private_directory",
|
||||||
"open_private_file",
|
"open_private_file",
|
||||||
"verify_private_path",
|
"verify_private_path",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ from .claudecode_io import (
|
|||||||
validate_private_key,
|
validate_private_key,
|
||||||
validate_runtime_tree,
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ from .codex_io import (
|
|||||||
validate_runtime_tree,
|
validate_runtime_tree,
|
||||||
write_codex_changes,
|
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:
|
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ from .cursor_io import (
|
|||||||
validate_runtime_identity,
|
validate_runtime_identity,
|
||||||
write_cursor_changes,
|
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:
|
def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ from .gemini_io import (
|
|||||||
validate_private_key,
|
validate_private_key,
|
||||||
validate_runtime_tree,
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ from .grok_io import (
|
|||||||
validate_private_key,
|
validate_private_key,
|
||||||
validate_runtime_tree,
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .error_log_template import ERROR_LOG_RUNTIME
|
from .error_log_template import ERROR_LOG_RUNTIME
|
||||||
from .guard_template import generate_guard_script
|
|
||||||
from .runtime_reader_template import PROTECTED_RUNTIME_READER
|
from .runtime_reader_template import PROTECTED_RUNTIME_READER
|
||||||
|
|
||||||
__all__ = ["generate_guard_script", "generate_hook_script"]
|
|
||||||
|
|
||||||
|
|
||||||
def generate_hook_script(
|
def generate_hook_script(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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 ._managed_files import MAX_CONFIG_BYTES, MAX_SECRET_BYTES, MAX_SOURCE_BYTES
|
||||||
from ._transaction import FileChange, file_change, source_change, write_changes
|
from ._transaction import FileChange, file_change, source_change, write_changes
|
||||||
from .base import InstallConfig
|
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_command import runtime_root, same_kimi_path
|
||||||
from .kimi_contract import (
|
from .kimi_contract import (
|
||||||
AGENT_KEY,
|
AGENT_KEY,
|
||||||
|
|||||||
@@ -47,3 +47,6 @@ elydora = ["py.typed"]
|
|||||||
|
|
||||||
[tool.setuptools.dynamic]
|
[tool.setuptools.dynamic]
|
||||||
version = {attr = "elydora._version.__version__"}
|
version = {attr = "elydora._version.__version__"}
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
platform = "win32"
|
||||||
@@ -9,7 +9,7 @@ import sys
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from elydora.plugins.hook_template import generate_guard_script
|
from elydora.plugins.guard_template import generate_guard_script
|
||||||
|
|
||||||
|
|
||||||
AGENT_ID = "agent 1"
|
AGENT_ID = "agent 1"
|
||||||
|
|||||||
Reference in new issue
Block a user