chore(python): audit tests
Split the three oversized test files into support modules and topic files, evaluate the rmdir dir_fd capability once at import, and drop unused imports. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
6ec3e902f5
commit
aa4b8a8bdf
20 files changed
+2610
-2417
No files matched your search
@@ -12,9 +12,12 @@ from ._managed_files import DirectorySnapshot, FileSnapshot
|
|||||||
from ._pinned_directory import PinnedDirectory
|
from ._pinned_directory import PinnedDirectory
|
||||||
|
|
||||||
|
|
||||||
|
_RMDIR_SUPPORTS_DIR_FD = os.rmdir in os.supports_dir_fd
|
||||||
|
|
||||||
|
|
||||||
def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
|
def _remove_posix_private_directory(parent_descriptor: int, name: str) -> None:
|
||||||
"""Remove one empty child through the platform's native *at semantics."""
|
"""Remove one empty child through the platform's native *at semantics."""
|
||||||
if os.rmdir not in os.supports_dir_fd:
|
if not _RMDIR_SUPPORTS_DIR_FD:
|
||||||
raise OSError("Host does not support identity-bound directory removal")
|
raise OSError("Host does not support identity-bound directory removal")
|
||||||
os.rmdir(name, dir_fd=parent_descriptor)
|
os.rmdir(name, dir_fd=parent_descriptor)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import tempfile
|
|||||||
from typing import Dict, List, Optional, Set, Tuple
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from ._dotenv import parse_dotenv
|
from ._dotenv import parse_dotenv
|
||||||
from ._managed_files import MAX_SOURCE_BYTES, FileSnapshot, read_physical_file
|
from ._managed_files import MAX_SOURCE_BYTES, read_physical_file
|
||||||
from ._runtime import same_path, unique_preconditions
|
from ._runtime import same_path, unique_preconditions
|
||||||
from ._transaction import FilePrecondition
|
from ._transaction import FilePrecondition
|
||||||
from .qwen_config import (
|
from .qwen_config import (
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins import kiroide
|
||||||
|
from elydora.plugins import (
|
||||||
|
kiroide_contract,
|
||||||
|
kiroide_installation,
|
||||||
|
kiroide_io,
|
||||||
|
)
|
||||||
|
from elydora.plugins.base import InstallConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_ID = "agent-1"
|
||||||
|
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(b"=").decode()
|
||||||
|
ROTATED_PRIVATE_KEY = (
|
||||||
|
base64.urlsafe_b64encode(bytes([12]) * 32).rstrip(b"=").decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KiroIdeFixture:
|
||||||
|
home: Path
|
||||||
|
workspace: Path
|
||||||
|
config_path: Path
|
||||||
|
legacy_path: Path
|
||||||
|
agent_directory: Path
|
||||||
|
guard_path: Path
|
||||||
|
audit_path: Path
|
||||||
|
plugin: kiroide.KiroIdePlugin
|
||||||
|
config: InstallConfig
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, value: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def install_config(home: Path) -> InstallConfig:
|
||||||
|
return {
|
||||||
|
"org_id": "org-1",
|
||||||
|
"agent_id": AGENT_ID,
|
||||||
|
"agent_name": "kiroide",
|
||||||
|
"private_key": PRIVATE_KEY,
|
||||||
|
"kid": "kid-1",
|
||||||
|
"token": "token-1",
|
||||||
|
"base_url": "https://api.elydora.test",
|
||||||
|
"guard_script_path": str(home / ".elydora" / AGENT_ID / "guard.py"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_fixture(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
current: Any = None,
|
||||||
|
legacy: Any = None,
|
||||||
|
base_url: str = "https://api.elydora.test",
|
||||||
|
) -> KiroIdeFixture:
|
||||||
|
home = tmp_path / "home with spaces and 'quote %KIRO%"
|
||||||
|
workspace = tmp_path / "workspace with spaces"
|
||||||
|
home.mkdir(parents=True)
|
||||||
|
workspace.mkdir(parents=True)
|
||||||
|
config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json"
|
||||||
|
legacy_path = home / ".kiro" / "hooks" / "elydora-audit.kiro.hook"
|
||||||
|
if current is not None:
|
||||||
|
if isinstance(current, str):
|
||||||
|
config_path.parent.mkdir(parents=True)
|
||||||
|
config_path.write_text(current, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
write_json(config_path, current)
|
||||||
|
if legacy is not None:
|
||||||
|
if isinstance(legacy, str):
|
||||||
|
legacy_path.parent.mkdir(parents=True)
|
||||||
|
legacy_path.write_text(legacy, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
write_json(legacy_path, legacy)
|
||||||
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(home))
|
||||||
|
monkeypatch.chdir(workspace)
|
||||||
|
agent_directory = home / ".elydora" / AGENT_ID
|
||||||
|
config = install_config(home)
|
||||||
|
config["base_url"] = base_url
|
||||||
|
return KiroIdeFixture(
|
||||||
|
home,
|
||||||
|
workspace,
|
||||||
|
config_path,
|
||||||
|
legacy_path,
|
||||||
|
agent_directory,
|
||||||
|
agent_directory / "guard.py",
|
||||||
|
agent_directory / "hook.py",
|
||||||
|
kiroide.KiroIdePlugin(),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def find_hook(document: dict[str, Any], name: str) -> dict[str, Any]:
|
||||||
|
return next(hook for hook in document["hooks"] if hook.get("name") == name)
|
||||||
|
|
||||||
|
|
||||||
|
def managed_document(guard_command: str, audit_command: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"version": "v1",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"name": "elydora-guard",
|
||||||
|
"description": "Block tool use when the Elydora agent is frozen",
|
||||||
|
"trigger": "PreToolUse",
|
||||||
|
"matcher": ".*",
|
||||||
|
"action": {"type": "command", "command": guard_command},
|
||||||
|
"timeout": 10,
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "elydora-audit",
|
||||||
|
"description": "Record tool use in the Elydora audit trail",
|
||||||
|
"trigger": "PostToolUse",
|
||||||
|
"matcher": ".*",
|
||||||
|
"action": {"type": "command", "command": audit_command},
|
||||||
|
"timeout": 10,
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_document(fixture: KiroIdeFixture, agent_id: str = AGENT_ID) -> dict:
|
||||||
|
agent_directory = fixture.home / ".elydora" / agent_id
|
||||||
|
return {
|
||||||
|
"name": "Elydora Audit",
|
||||||
|
"description": (
|
||||||
|
"Sends tool-use events to the Elydora tamper-evident audit platform"
|
||||||
|
),
|
||||||
|
"version": "1.0.0",
|
||||||
|
"hooks": {
|
||||||
|
"pre_tool_use": {
|
||||||
|
"command": f'"{sys.executable}" {agent_directory / "guard.py"}',
|
||||||
|
"timeout_ms": 5000,
|
||||||
|
},
|
||||||
|
"post_tool_use": {
|
||||||
|
"command": str(agent_directory / "hook.py"),
|
||||||
|
"timeout_ms": 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_runtime_config(
|
||||||
|
fixture: KiroIdeFixture, agent_id: str = AGENT_ID
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"org_id": "org-1",
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"kid": "kid-1",
|
||||||
|
"base_url": "https://api.elydora.test",
|
||||||
|
"token": "",
|
||||||
|
"agent_name": "kiroide",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_legacy_runtime(fixture: KiroIdeFixture, agent_id: str) -> Path:
|
||||||
|
agent_directory = fixture.home / ".elydora" / agent_id
|
||||||
|
write_json(
|
||||||
|
agent_directory / "config.json",
|
||||||
|
legacy_runtime_config(fixture, agent_id),
|
||||||
|
)
|
||||||
|
(agent_directory / "private.key").write_text(PRIVATE_KEY, encoding="utf-8")
|
||||||
|
(agent_directory / "guard.py").write_text("guard\n", encoding="utf-8")
|
||||||
|
(agent_directory / "hook.py").write_text("audit\n", encoding="utf-8")
|
||||||
|
return agent_directory
|
||||||
|
|
||||||
|
|
||||||
|
def write_large_error_log(agent_directory: Path, marker: bytes) -> Path:
|
||||||
|
error_log = agent_directory / "error.log"
|
||||||
|
with error_log.open("wb") as file:
|
||||||
|
for _index in range(48):
|
||||||
|
file.write(marker * 4096)
|
||||||
|
if os.name != "nt":
|
||||||
|
error_log.chmod(0o600)
|
||||||
|
assert error_log.stat().st_size > 2 * 1024 * 1024
|
||||||
|
return error_log
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(
|
||||||
|
command: str, fixture: KiroIdeFixture, payload: dict[str, Any]
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
cwd=fixture.workspace,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"HOME": str(fixture.home),
|
||||||
|
"USERPROFILE": str(fixture.home),
|
||||||
|
},
|
||||||
|
input=json.dumps(payload),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditHandler(BaseHTTPRequestHandler):
|
||||||
|
operations: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length).decode("utf-8")
|
||||||
|
self.operations.append(json.loads(raw))
|
||||||
|
self.send_response(201)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, _format: str, *_args: object) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuditServer:
|
||||||
|
server: ThreadingHTTPServer
|
||||||
|
thread: threading.Thread
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
host, port = self.server.server_address
|
||||||
|
return f"http://{host}:{port}"
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def start_audit_server() -> AuditServer:
|
||||||
|
AuditHandler.operations = []
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), AuditHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return AuditServer(server, thread)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_installation(
|
||||||
|
fixture: KiroIdeFixture,
|
||||||
|
config: InstallConfig | None = None,
|
||||||
|
) -> tuple[
|
||||||
|
kiroide_io.KiroIdeSources,
|
||||||
|
kiroide_installation.KiroIdeRuntimePaths,
|
||||||
|
kiroide_installation.PreparedKiroIdeInstallation,
|
||||||
|
kiroide_contract.RenderedKiroIdeDocument,
|
||||||
|
]:
|
||||||
|
selected = fixture.config if config is None else config
|
||||||
|
sources = kiroide_io.read_kiroide_sources()
|
||||||
|
kiroide_contract.require_available_kiroide_hooks(sources.document.hooks)
|
||||||
|
paths = kiroide_installation.preflight_kiroide_installation(
|
||||||
|
selected, sources
|
||||||
|
)
|
||||||
|
hooks = kiroide_contract.without_managed_kiroide_hooks(
|
||||||
|
sources.document.hooks
|
||||||
|
)
|
||||||
|
rendered = kiroide_contract.render_kiroide_document(
|
||||||
|
sources.document,
|
||||||
|
[
|
||||||
|
*hooks,
|
||||||
|
kiroide_contract.build_kiroide_hook(
|
||||||
|
kiroide_contract.GUARD_NAME, paths.guard_path
|
||||||
|
),
|
||||||
|
kiroide_contract.build_kiroide_hook(
|
||||||
|
kiroide_contract.AUDIT_NAME, paths.audit_path
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
prepared = kiroide_installation.prepare_kiroide_installation(
|
||||||
|
selected, paths, sources, rendered
|
||||||
|
)
|
||||||
|
return sources, paths, prepared, rendered
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora import cli
|
||||||
|
|
||||||
|
|
||||||
|
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 prepare_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,
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import stat
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins._shell_command import is_python_executable, windows_powershell_path
|
||||||
|
from elydora.plugins import (
|
||||||
|
kiroide_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
from kiroide_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
find_hook,
|
||||||
|
managed_document,
|
||||||
|
prepare_fixture,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_exact_managed_commands_survive_python_relocation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
executable_name = "python.exe" if os.name == "nt" else "python3"
|
||||||
|
fake_executable = tmp_path / "other-runtime" / executable_name
|
||||||
|
with monkeypatch.context() as command_context:
|
||||||
|
command_context.setattr(
|
||||||
|
kiroide_command.sys, "executable", str(fake_executable)
|
||||||
|
)
|
||||||
|
guard_command = kiroide_command.build_kiroide_command(
|
||||||
|
str(fixture.guard_path)
|
||||||
|
)
|
||||||
|
audit_command = kiroide_command.build_kiroide_command(
|
||||||
|
str(fixture.audit_path)
|
||||||
|
)
|
||||||
|
stale_document = managed_document(guard_command, audit_command)
|
||||||
|
write_json(fixture.config_path, stale_document)
|
||||||
|
|
||||||
|
assert fixture.plugin.status() == {
|
||||||
|
"installed": False,
|
||||||
|
"agent": "kiroide",
|
||||||
|
"details": (
|
||||||
|
"Managed hooks are incomplete or invalid: "
|
||||||
|
f"{fixture.config_path}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||||
|
assert kiroide_command.kiroide_runtime_reference(
|
||||||
|
find_hook(repaired, "elydora-guard")["action"]["command"],
|
||||||
|
"guard.py",
|
||||||
|
) is not None
|
||||||
|
assert kiroide_command.kiroide_runtime_reference(
|
||||||
|
find_hook(repaired, "elydora-audit")["action"]["command"],
|
||||||
|
"hook.py",
|
||||||
|
) is not None
|
||||||
|
|
||||||
|
write_json(fixture.config_path, stale_document)
|
||||||
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
|
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("damage", ["field", "orphan"])
|
||||||
|
def test_kiroide_stale_command_ownership_survives_contract_drift(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
damage: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
executable_name = "python.exe" if os.name == "nt" else "python3"
|
||||||
|
fake_executable = tmp_path / "other-runtime" / executable_name
|
||||||
|
with monkeypatch.context() as command_context:
|
||||||
|
command_context.setattr(
|
||||||
|
kiroide_command.sys, "executable", str(fake_executable)
|
||||||
|
)
|
||||||
|
document = managed_document(
|
||||||
|
kiroide_command.build_kiroide_command(str(fixture.guard_path)),
|
||||||
|
kiroide_command.build_kiroide_command(str(fixture.audit_path)),
|
||||||
|
)
|
||||||
|
if damage == "field":
|
||||||
|
find_hook(document, "elydora-guard")["timeout"] = 9
|
||||||
|
else:
|
||||||
|
document["hooks"].remove(find_hook(document, "elydora-audit"))
|
||||||
|
write_json(fixture.config_path, document)
|
||||||
|
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||||
|
assert len(repaired["hooks"]) == 2
|
||||||
|
assert all(hook["timeout"] == 10 for hook in repaired["hooks"])
|
||||||
|
|
||||||
|
write_json(fixture.config_path, document)
|
||||||
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
|
|
||||||
|
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
|
||||||
|
"hooks"
|
||||||
|
] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
|
||||||
|
def test_kiroide_current_commands_require_the_system_powershell_launcher(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
command = kiroide_command.build_kiroide_command(str(fixture.guard_path))
|
||||||
|
expected = f'"{windows_powershell_path()}"'
|
||||||
|
altered = command.replace(expected, r'"C:\Other\powershell.exe"', 1)
|
||||||
|
|
||||||
|
assert kiroide_command.kiroide_runtime_reference(altered, "guard.py") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
|
||||||
|
def test_kiroide_windows_launcher_fails_when_python_is_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
missing_python = tmp_path / "missing-runtime" / "python.exe"
|
||||||
|
script_path = tmp_path / "guard.py"
|
||||||
|
with monkeypatch.context() as command_context:
|
||||||
|
command_context.setattr(
|
||||||
|
kiroide_command.sys, "executable", str(missing_python)
|
||||||
|
)
|
||||||
|
command = kiroide_command.build_kiroide_command(str(script_path))
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"executable_name",
|
||||||
|
["python3.13t", "python3.14t", "python3.13d.exe", "pypy3", "pypy3.10.exe"],
|
||||||
|
)
|
||||||
|
def test_kiroide_ownership_accepts_supported_python_runtime_names(
|
||||||
|
executable_name: str,
|
||||||
|
) -> None:
|
||||||
|
assert is_python_executable(executable_name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_builder_rejects_unrecoverable_executable_names(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
with monkeypatch.context() as command_context:
|
||||||
|
command_context.setattr(
|
||||||
|
kiroide_command.sys,
|
||||||
|
"executable",
|
||||||
|
str(tmp_path / "embedded-runtime"),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="absolute Python executable"):
|
||||||
|
kiroide_command.build_kiroide_command(str(tmp_path / "guard.py"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("damage", ["orphan", "duplicate", "altered"])
|
||||||
|
def test_kiroide_status_rejects_every_incomplete_managed_hook_set(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
damage: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||||
|
audit = find_hook(document, "elydora-audit")
|
||||||
|
if damage == "orphan":
|
||||||
|
document["hooks"].remove(audit)
|
||||||
|
elif damage == "duplicate":
|
||||||
|
document["hooks"].append(copy.deepcopy(audit))
|
||||||
|
else:
|
||||||
|
find_hook(document, "elydora-guard")["timeout"] = 9
|
||||||
|
write_json(fixture.config_path, document)
|
||||||
|
|
||||||
|
status = fixture.plugin.status()
|
||||||
|
assert status["installed"] is False
|
||||||
|
assert status["details"] == (
|
||||||
|
f"Managed hooks are incomplete or invalid: {fixture.config_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("target", "message"),
|
||||||
|
[
|
||||||
|
("key", "private key must be accessible only by its owner"),
|
||||||
|
("config", "Token-bearing Elydora runtime config must be accessible"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_kiroide_repairs_and_enforces_owner_only_runtime_secrets(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
target: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
paths = {
|
||||||
|
"key": fixture.agent_directory / "private.key",
|
||||||
|
"config": fixture.agent_directory / "config.json",
|
||||||
|
}
|
||||||
|
paths[target].chmod(0o644)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
fixture.plugin.status()
|
||||||
|
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
assert stat.S_IMODE(paths[target].stat().st_mode) == 0o600
|
||||||
|
assert fixture.plugin.status()["installed"] is True
|
||||||
+37
-1178
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,359 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins import (
|
||||||
|
_transaction,
|
||||||
|
kiroide_installation,
|
||||||
|
)
|
||||||
|
from elydora.plugins.base import InstallConfig
|
||||||
|
|
||||||
|
from kiroide_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
PRIVATE_KEY,
|
||||||
|
ROTATED_PRIVATE_KEY,
|
||||||
|
find_hook,
|
||||||
|
legacy_document,
|
||||||
|
prepare_fixture,
|
||||||
|
prepare_installation,
|
||||||
|
write_json,
|
||||||
|
write_large_error_log,
|
||||||
|
write_legacy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_removes_exact_ownership_and_preserves_user_hooks(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
user_hook = {
|
||||||
|
"name": "workspace-context",
|
||||||
|
"trigger": "SessionStart",
|
||||||
|
"action": {"type": "agent", "prompt": "Read AGENTS.md"},
|
||||||
|
}
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
current={"version": "v1", "owner": "workspace", "hooks": [user_hook]},
|
||||||
|
)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
fixture.plugin.uninstall("agent-2")
|
||||||
|
assert len(json.loads(fixture.config_path.read_text())["hooks"]) == 3
|
||||||
|
assert fixture.agent_directory.exists()
|
||||||
|
assert (fixture.agent_directory / "private.key").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == PRIVATE_KEY
|
||||||
|
fixture.plugin.uninstall(AGENT_ID)
|
||||||
|
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == {
|
||||||
|
"version": "v1",
|
||||||
|
"owner": "workspace",
|
||||||
|
"hooks": [user_hook],
|
||||||
|
}
|
||||||
|
|
||||||
|
owned = prepare_fixture(monkeypatch, tmp_path / "owned")
|
||||||
|
owned.plugin.install(owned.config)
|
||||||
|
owned.plugin.uninstall(AGENT_ID)
|
||||||
|
assert not owned.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_without_agent_id_removes_owned_runtime(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
assert not (fixture.agent_directory / "private.key").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_without_agent_id_removes_current_and_legacy_runtimes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
legacy_agent_id = "agent-2"
|
||||||
|
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||||
|
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
assert not fixture.legacy_path.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
assert not legacy_directory.exists()
|
||||||
|
assert not (legacy_directory / "private.key").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_no_id_uninstall_streams_all_oversized_error_logs(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
current_error = write_large_error_log(
|
||||||
|
fixture.agent_directory,
|
||||||
|
b"current runtime error\n",
|
||||||
|
)
|
||||||
|
legacy_agent_id = "agent-2"
|
||||||
|
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||||
|
legacy_error = write_large_error_log(
|
||||||
|
legacy_directory,
|
||||||
|
b"legacy runtime error\n",
|
||||||
|
)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||||
|
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert not current_error.exists()
|
||||||
|
assert not legacy_error.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
assert not legacy_directory.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_without_agent_id_deduplicates_current_and_legacy_owner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
assert not fixture.legacy_path.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_without_agent_id_rejects_unknown_runtime_before_writes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
legacy_agent_id = "agent-2"
|
||||||
|
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||||
|
unknown = legacy_directory / "user-data.txt"
|
||||||
|
unknown.write_text("preserve\n", encoding="utf-8")
|
||||||
|
original_provider = fixture.config_path.read_bytes()
|
||||||
|
original_legacy = fixture.legacy_path.read_bytes()
|
||||||
|
original_key = (fixture.agent_directory / "private.key").read_bytes()
|
||||||
|
original_legacy_key = (legacy_directory / "private.key").read_bytes()
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="contains unmanaged entries"):
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert fixture.config_path.read_bytes() == original_provider
|
||||||
|
assert fixture.legacy_path.read_bytes() == original_legacy
|
||||||
|
assert (fixture.agent_directory / "private.key").read_bytes() == original_key
|
||||||
|
assert (legacy_directory / "private.key").read_bytes() == original_legacy_key
|
||||||
|
assert unknown.read_text(encoding="utf-8") == "preserve\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_uninstall_without_agent_id_rolls_back_provider_and_runtime(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
runtime_paths = [
|
||||||
|
fixture.guard_path,
|
||||||
|
fixture.audit_path,
|
||||||
|
fixture.agent_directory / "private.key",
|
||||||
|
fixture.agent_directory / "config.json",
|
||||||
|
]
|
||||||
|
original_provider = fixture.config_path.read_bytes()
|
||||||
|
original_runtime = {path: path.read_bytes() for path in runtime_paths}
|
||||||
|
original_capture = _transaction._capture_physical
|
||||||
|
|
||||||
|
def fail_private_key_removal(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if Path(source) == fixture.agent_directory / "private.key" and (
|
||||||
|
destination.endswith(".rollback")
|
||||||
|
):
|
||||||
|
raise OSError("injected private key removal failure")
|
||||||
|
original_capture(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_capture_physical", fail_private_key_removal)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected private key removal failure"):
|
||||||
|
fixture.plugin.uninstall()
|
||||||
|
|
||||||
|
assert fixture.config_path.read_bytes() == original_provider
|
||||||
|
assert {path: path.read_bytes() for path in runtime_paths} == original_runtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_transaction_rolls_back_when_legacy_cleanup_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
original = {"version": "v1", "owner": "workspace", "hooks": []}
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path, current=original)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
original_capture = _transaction._capture_physical
|
||||||
|
|
||||||
|
def fail_legacy_removal(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if Path(source) == fixture.legacy_path and destination.endswith(".rollback"):
|
||||||
|
raise OSError("injected legacy cleanup failure")
|
||||||
|
original_capture(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected legacy cleanup failure"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == original
|
||||||
|
assert fixture.legacy_path.exists()
|
||||||
|
for path in (
|
||||||
|
fixture.guard_path,
|
||||||
|
fixture.audit_path,
|
||||||
|
fixture.agent_directory / "config.json",
|
||||||
|
fixture.agent_directory / "private.key",
|
||||||
|
):
|
||||||
|
assert not path.exists()
|
||||||
|
leftovers = list(fixture.config_path.parent.glob(".*.tmp"))
|
||||||
|
leftovers += list(fixture.config_path.parent.glob(".*.rollback"))
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("swapped", ["workspace", "kiro", "hooks"])
|
||||||
|
def test_kiroide_transaction_rejects_provider_directory_identity_swaps(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
swapped: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
current={"version": "v1", "hooks": []},
|
||||||
|
)
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
original_paths = {
|
||||||
|
"workspace": fixture.workspace,
|
||||||
|
"kiro": fixture.workspace / ".kiro",
|
||||||
|
"hooks": fixture.workspace / ".kiro" / "hooks",
|
||||||
|
}
|
||||||
|
original = original_paths[swapped]
|
||||||
|
backup = original.with_name(original.name + "-original")
|
||||||
|
replacement = tmp_path / f"redirected-{swapped}"
|
||||||
|
if swapped == "workspace":
|
||||||
|
(replacement / ".kiro" / "hooks").mkdir(parents=True)
|
||||||
|
redirected_config = replacement / ".kiro" / "hooks" / "elydora-audit.json"
|
||||||
|
elif swapped == "kiro":
|
||||||
|
(replacement / "hooks").mkdir(parents=True)
|
||||||
|
redirected_config = replacement / "hooks" / "elydora-audit.json"
|
||||||
|
else:
|
||||||
|
replacement.mkdir()
|
||||||
|
redirected_config = replacement / "elydora-audit.json"
|
||||||
|
original.rename(backup)
|
||||||
|
try:
|
||||||
|
original.symlink_to(replacement, target_is_directory=True)
|
||||||
|
except OSError as error:
|
||||||
|
backup.rename(original)
|
||||||
|
pytest.skip(f"Directory symbolic links are unavailable: {error}")
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="not a physical directory|changed during"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not redirected_config.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_key_rotation_pins_unchanged_provider_sources(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
original_key = (fixture.agent_directory / "private.key").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
rotated = InstallConfig(**fixture.config)
|
||||||
|
rotated["private_key"] = ROTATED_PRIVATE_KEY
|
||||||
|
sources, paths, prepared, rendered = prepare_installation(fixture, rotated)
|
||||||
|
assert rendered.changed is False
|
||||||
|
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||||
|
document["concurrent_change"] = True
|
||||||
|
write_json(fixture.config_path, document)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (fixture.agent_directory / "private.key").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
) == original_key
|
||||||
|
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
|
||||||
|
"concurrent_change"
|
||||||
|
] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_noop_install_rejects_concurrent_provider_changes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
sources, paths, prepared, rendered = prepare_installation(fixture)
|
||||||
|
assert prepared.changes == []
|
||||||
|
assert rendered.changed is False
|
||||||
|
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||||
|
find_hook(document, "elydora-guard")["enabled"] = False
|
||||||
|
write_json(fixture.config_path, document)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert find_hook(
|
||||||
|
json.loads(fixture.config_path.read_text(encoding="utf-8")),
|
||||||
|
"elydora-guard",
|
||||||
|
)["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"relative_path",
|
||||||
|
["private.key", "config.json", "guard.py", "hook.py"],
|
||||||
|
)
|
||||||
|
def test_kiroide_noop_install_pins_every_runtime_source(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
relative_path: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||||
|
assert prepared.changes == []
|
||||||
|
assert len(prepared.runtime_preconditions) == 4
|
||||||
|
target = fixture.agent_directory / relative_path
|
||||||
|
concurrent = target.read_text(encoding="utf-8") + "\nconcurrent-change"
|
||||||
|
target.write_text(concurrent, encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == concurrent
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora import cli
|
||||||
|
from elydora.plugins import (
|
||||||
|
_transaction,
|
||||||
|
kiroide_installation,
|
||||||
|
)
|
||||||
|
from elydora.plugins.base import InstallConfig
|
||||||
|
|
||||||
|
from kiroide_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
PRIVATE_KEY,
|
||||||
|
ROTATED_PRIVATE_KEY,
|
||||||
|
legacy_document,
|
||||||
|
legacy_runtime_config,
|
||||||
|
prepare_fixture,
|
||||||
|
prepare_installation,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_runtime_config_binds_agent_to_absolute_workspace(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
runtime_config = json.loads(
|
||||||
|
(fixture.agent_directory / "config.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert runtime_config["workspace_root"] == str(fixture.workspace.resolve())
|
||||||
|
assert Path(runtime_config["workspace_root"]).is_absolute()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_rejects_same_agent_install_from_another_workspace(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
original_hook = fixture.config_path.read_bytes()
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
original_runtime = runtime_config_path.read_bytes()
|
||||||
|
second_workspace = tmp_path / "second workspace"
|
||||||
|
second_workspace.mkdir()
|
||||||
|
monkeypatch.chdir(second_workspace)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="bound to another workspace"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert fixture.config_path.read_bytes() == original_hook
|
||||||
|
assert runtime_config_path.read_bytes() == original_runtime
|
||||||
|
assert not (second_workspace / ".kiro").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_cli_uninstall_requires_bound_workspace(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
original_hook = fixture.config_path.read_bytes()
|
||||||
|
second_workspace = tmp_path / "second workspace"
|
||||||
|
second_workspace.mkdir()
|
||||||
|
args = cli.build_parser().parse_args(
|
||||||
|
[
|
||||||
|
"uninstall",
|
||||||
|
"--agent",
|
||||||
|
"kiroide",
|
||||||
|
"--agent_id",
|
||||||
|
AGENT_ID,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
monkeypatch.chdir(second_workspace)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="bound to another workspace"):
|
||||||
|
cli.cmd_uninstall(args)
|
||||||
|
|
||||||
|
assert fixture.config_path.read_bytes() == original_hook
|
||||||
|
assert fixture.agent_directory.exists()
|
||||||
|
assert not (second_workspace / ".kiro").exists()
|
||||||
|
|
||||||
|
monkeypatch.chdir(fixture.workspace)
|
||||||
|
cli.cmd_uninstall(args)
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_install_claims_legacy_runtime_owner_from_matching_hooks(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
runtime_config.pop("workspace_root")
|
||||||
|
write_json(runtime_config_path, runtime_config)
|
||||||
|
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
claimed = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
assert claimed["workspace_root"] == str(fixture.workspace.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_install_migrates_exact_global_legacy_runtime_owner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
write_json(runtime_config_path, legacy_runtime_config(fixture))
|
||||||
|
config = dict(fixture.config)
|
||||||
|
config.pop("token")
|
||||||
|
|
||||||
|
fixture.plugin.install(config)
|
||||||
|
|
||||||
|
migrated = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
assert migrated["workspace_root"] == str(fixture.workspace.resolve())
|
||||||
|
assert "token" not in migrated
|
||||||
|
assert fixture.config_path.exists()
|
||||||
|
assert not fixture.legacy_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_legacy_owner_migration_rolls_back_runtime_claim(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
original_runtime = legacy_runtime_config(fixture)
|
||||||
|
write_json(runtime_config_path, original_runtime)
|
||||||
|
original_capture = _transaction._capture_physical
|
||||||
|
|
||||||
|
def fail_legacy_removal(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if Path(source) == fixture.legacy_path and destination.endswith(
|
||||||
|
".rollback"
|
||||||
|
):
|
||||||
|
raise OSError("injected legacy migration failure")
|
||||||
|
original_capture(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected legacy migration failure"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == (
|
||||||
|
original_runtime
|
||||||
|
)
|
||||||
|
assert fixture.legacy_path.exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_cli_uninstall_accepts_exact_global_legacy_ownership(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
write_json(
|
||||||
|
fixture.agent_directory / "config.json",
|
||||||
|
legacy_runtime_config(fixture),
|
||||||
|
)
|
||||||
|
args = cli.build_parser().parse_args(
|
||||||
|
[
|
||||||
|
"uninstall",
|
||||||
|
"--agent",
|
||||||
|
"kiroide",
|
||||||
|
"--agent_id",
|
||||||
|
AGENT_ID,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
cli.cmd_uninstall(args)
|
||||||
|
|
||||||
|
assert not fixture.legacy_path.exists()
|
||||||
|
assert not fixture.agent_directory.exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_rejects_unproved_legacy_runtime_owner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture, "agent-2"))
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
original = legacy_runtime_config(fixture)
|
||||||
|
write_json(runtime_config_path, original)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="token is invalid"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == original
|
||||||
|
assert fixture.legacy_path.exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_rejects_invalid_ownerless_legacy_runtime_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
invalid = legacy_runtime_config(fixture)
|
||||||
|
invalid["token"] = " "
|
||||||
|
write_json(runtime_config_path, invalid)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="token is invalid"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == invalid
|
||||||
|
assert fixture.legacy_path.exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("owner", "message"),
|
||||||
|
[("relative", "must be absolute"), ("other", "bound to another workspace")],
|
||||||
|
)
|
||||||
|
def test_kiroide_status_rejects_invalid_runtime_workspace_owner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
owner: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
runtime_config_path = fixture.agent_directory / "config.json"
|
||||||
|
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||||
|
runtime_config["workspace_root"] = (
|
||||||
|
"relative" if owner == "relative" else str((tmp_path / "other").resolve())
|
||||||
|
)
|
||||||
|
write_json(runtime_config_path, runtime_config)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
fixture.plugin.status()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_failed_install_removes_created_workspace_and_runtime_directories(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
def fail_first_commit(
|
||||||
|
_source: str,
|
||||||
|
_destination: str,
|
||||||
|
_directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
raise OSError("injected Kiro IDE commit failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_first_commit)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected Kiro IDE commit failure"):
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
|
||||||
|
assert not (fixture.workspace / ".kiro").exists()
|
||||||
|
# The durable transaction state directory survives failures by design;
|
||||||
|
# everything else under the runtime root must be rolled back.
|
||||||
|
elydora_root = fixture.home / ".elydora"
|
||||||
|
if elydora_root.exists():
|
||||||
|
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||||
|
@pytest.mark.parametrize("relative_path", ["private.key", "config.json"])
|
||||||
|
def test_kiroide_noop_install_pins_runtime_secret_modes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
relative_path: str,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||||
|
target = fixture.agent_directory / relative_path
|
||||||
|
target.chmod(0o640)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(target.stat().st_mode) == 0o640
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||||
|
def test_kiroide_key_rotation_rejects_concurrent_mode_changes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.plugin.install(fixture.config)
|
||||||
|
rotated = InstallConfig(**fixture.config)
|
||||||
|
rotated["private_key"] = ROTATED_PRIVATE_KEY
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(
|
||||||
|
fixture, rotated
|
||||||
|
)
|
||||||
|
key_path = fixture.agent_directory / "private.key"
|
||||||
|
assert any(change.file_path == str(key_path) for change in prepared.changes)
|
||||||
|
key_path.chmod(0o640)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during installation"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert key_path.read_text(encoding="utf-8") == PRIVATE_KEY
|
||||||
|
assert stat.S_IMODE(key_path.stat().st_mode) == 0o640
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_install_rejects_runtime_root_link_created_after_prepare(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||||
|
runtime_root = fixture.home / ".elydora"
|
||||||
|
redirected_root = tmp_path / "redirected-runtime-root"
|
||||||
|
redirected_root.mkdir()
|
||||||
|
try:
|
||||||
|
runtime_root.symlink_to(redirected_root, target_is_directory=True)
|
||||||
|
except OSError as error:
|
||||||
|
pytest.skip(f"Directory symbolic links are unavailable: {error}")
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="not a physical directory|changed during"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (redirected_root / AGENT_ID / "private.key").exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kiroide_install_rejects_agent_directory_identity_swap(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||||
|
fixture.agent_directory.mkdir(parents=True)
|
||||||
|
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||||
|
original_directory = fixture.agent_directory.with_name("agent-original")
|
||||||
|
fixture.agent_directory.rename(original_directory)
|
||||||
|
fixture.agent_directory.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during"):
|
||||||
|
kiroide_installation.commit_kiroide_installation(
|
||||||
|
prepared, sources, paths
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (fixture.agent_directory / "private.key").exists()
|
||||||
|
assert not fixture.config_path.exists()
|
||||||
@@ -3,7 +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
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def test_darwin_private_directory_removal_uses_stdlib_dir_fd(
|
|||||||
calls.append((path, dir_fd))
|
calls.append((path, dir_fd))
|
||||||
|
|
||||||
monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir)
|
monkeypatch.setattr(_private_artifact.os, "rmdir", record_rmdir)
|
||||||
monkeypatch.setattr(_private_artifact.os, "supports_dir_fd", {record_rmdir})
|
monkeypatch.setattr(_private_artifact, "_RMDIR_SUPPORTS_DIR_FD", True)
|
||||||
|
|
||||||
_private_artifact._remove_posix_private_directory(41, "private-container")
|
_private_artifact._remove_posix_private_directory(41, "private-container")
|
||||||
|
|
||||||
|
|||||||
+24
-551
@@ -1,23 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from elydora import cli
|
from elydora import cli
|
||||||
from elydora.plugins import (
|
from elydora.plugins import (
|
||||||
_directory_removal,
|
|
||||||
_opaque_cleanup,
|
|
||||||
_opaque_removal,
|
|
||||||
_transaction,
|
_transaction,
|
||||||
)
|
)
|
||||||
from elydora.plugins._runtime_removal import (
|
from elydora.plugins._runtime_removal import (
|
||||||
@@ -25,76 +16,15 @@ from elydora.plugins._runtime_removal import (
|
|||||||
finalize_runtime_removal,
|
finalize_runtime_removal,
|
||||||
prepare_runtime_removal,
|
prepare_runtime_removal,
|
||||||
)
|
)
|
||||||
|
from runtime_removal_support import (
|
||||||
|
|
||||||
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,
|
AGENT_ID,
|
||||||
]
|
AGENT_NAME,
|
||||||
)
|
prepare_runtime,
|
||||||
|
stream_digest,
|
||||||
|
uninstall_args,
|
||||||
|
write_error_log,
|
||||||
|
write_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"])
|
@pytest.mark.parametrize("entry_kind", ["file", "directory", "symlink"])
|
||||||
@@ -103,7 +33,7 @@ def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
entry_kind: str,
|
entry_kind: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
unknown = agent / "user-owned"
|
unknown = agent / "user-owned"
|
||||||
if entry_kind == "file":
|
if entry_kind == "file":
|
||||||
unknown.write_text("preserve", encoding="utf-8")
|
unknown.write_text("preserve", encoding="utf-8")
|
||||||
@@ -127,7 +57,7 @@ def test_cli_runtime_removal_rejects_unknown_entries_before_provider_mutation(
|
|||||||
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
||||||
|
|
||||||
with pytest.raises(OSError, match="unmanaged entries"):
|
with pytest.raises(OSError, match="unmanaged entries"):
|
||||||
cli.cmd_uninstall(_uninstall_args())
|
cli.cmd_uninstall(uninstall_args())
|
||||||
|
|
||||||
assert provider_calls == []
|
assert provider_calls == []
|
||||||
assert unknown.exists() or unknown.is_symlink()
|
assert unknown.exists() or unknown.is_symlink()
|
||||||
@@ -138,7 +68,7 @@ def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide")
|
_root, agent = prepare_runtime(monkeypatch, tmp_path, agent_name="kiroide")
|
||||||
provider_calls: list[str] = []
|
provider_calls: list[str] = []
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
@@ -153,7 +83,7 @@ def test_cli_skips_generic_cleanup_for_plugin_managed_runtime_removal(
|
|||||||
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
monkeypatch.setattr(cli, "_get_plugin", lambda _name: Plugin())
|
||||||
monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare)
|
monkeypatch.setattr(cli, "prepare_runtime_removal", reject_generic_prepare)
|
||||||
|
|
||||||
cli.cmd_uninstall(_uninstall_args("kiroide"))
|
cli.cmd_uninstall(uninstall_args("kiroide"))
|
||||||
|
|
||||||
assert provider_calls == [AGENT_ID]
|
assert provider_calls == [AGENT_ID]
|
||||||
assert (agent / "config.json").is_file()
|
assert (agent / "config.json").is_file()
|
||||||
@@ -163,11 +93,11 @@ def test_runtime_removal_rejects_config_identity_drift(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||||
assert plan is not None
|
assert plan is not None
|
||||||
concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"}
|
concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"}
|
||||||
_write_json(agent / "config.json", concurrent)
|
write_json(agent / "config.json", concurrent)
|
||||||
|
|
||||||
with pytest.raises(OSError, match="changed"):
|
with pytest.raises(OSError, match="changed"):
|
||||||
commit_runtime_removal(plan)
|
commit_runtime_removal(plan)
|
||||||
@@ -181,7 +111,7 @@ def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh"
|
wrapper_name = "augment-guard.cmd" if os.name == "nt" else "augment-guard.sh"
|
||||||
user_file = agent / wrapper_name
|
user_file = agent / wrapper_name
|
||||||
user_file.write_text("user-owned payload", encoding="utf-8")
|
user_file.write_text("user-owned payload", encoding="utf-8")
|
||||||
@@ -198,7 +128,7 @@ def test_runtime_removal_rejects_directory_swaps_without_deleting_replacements(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
swap: str,
|
swap: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
root, agent = _runtime(monkeypatch, tmp_path)
|
root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||||
assert plan is not None
|
assert plan is not None
|
||||||
|
|
||||||
@@ -228,7 +158,7 @@ def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||||
assert plan is not None
|
assert plan is not None
|
||||||
concurrent = agent / "status-cache.json"
|
concurrent = agent / "status-cache.json"
|
||||||
@@ -247,7 +177,7 @@ def test_runtime_removal_preserves_unknown_entry_created_before_finalize(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||||
assert plan is not None
|
assert plan is not None
|
||||||
_transaction.write_changes(
|
_transaction.write_changes(
|
||||||
@@ -270,7 +200,7 @@ def test_runtime_removal_deletes_all_known_dynamic_files(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
root, agent = _runtime(monkeypatch, tmp_path)
|
root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
dynamic = {
|
dynamic = {
|
||||||
"chain-state.json": '{"sequence":1}\n',
|
"chain-state.json": '{"sequence":1}\n',
|
||||||
"status-cache.json": '{"status":"active"}\n',
|
"status-cache.json": '{"status":"active"}\n',
|
||||||
@@ -293,8 +223,8 @@ def test_runtime_removal_streams_oversized_error_log(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
root, agent = _runtime(monkeypatch, tmp_path)
|
root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
expected_digest = _write_error_log(agent / "error.log", chunks=32)
|
expected_digest = write_error_log(agent / "error.log", chunks=32)
|
||||||
assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024
|
assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024
|
||||||
|
|
||||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||||
@@ -312,9 +242,9 @@ def test_oversized_error_log_rolls_back_after_later_text_failure(
|
|||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
_root, agent = _runtime(monkeypatch, tmp_path)
|
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||||
error_log = agent / "error.log"
|
error_log = agent / "error.log"
|
||||||
expected_digest = _write_error_log(error_log, chunks=32)
|
expected_digest = write_error_log(error_log, chunks=32)
|
||||||
expected_mode = stat.S_IMODE(error_log.stat().st_mode)
|
expected_mode = stat.S_IMODE(error_log.stat().st_mode)
|
||||||
original_commit = _transaction._commit
|
original_commit = _transaction._commit
|
||||||
|
|
||||||
@@ -329,464 +259,7 @@ def test_oversized_error_log_rolls_back_after_later_text_failure(
|
|||||||
commit_runtime_removal(plan)
|
commit_runtime_removal(plan)
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_commit", original_commit)
|
monkeypatch.setattr(_transaction, "_commit", original_commit)
|
||||||
assert _stream_digest(error_log) == expected_digest
|
assert stream_digest(error_log) == expected_digest
|
||||||
assert stat.S_IMODE(error_log.stat().st_mode) == expected_mode
|
assert stat.S_IMODE(error_log.stat().st_mode) == expected_mode
|
||||||
assert (agent / "config.json").is_file()
|
assert (agent / "config.json").is_file()
|
||||||
assert list(agent.glob("*.opaque-rollback")) == []
|
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()
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora import cli
|
||||||
|
from elydora.plugins import (
|
||||||
|
_directory_removal,
|
||||||
|
_transaction,
|
||||||
|
)
|
||||||
|
from elydora.plugins._runtime_removal import (
|
||||||
|
commit_runtime_removal,
|
||||||
|
prepare_runtime_removal,
|
||||||
|
)
|
||||||
|
from runtime_removal_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
AGENT_NAME,
|
||||||
|
PRIVATE_KEY,
|
||||||
|
prepare_runtime,
|
||||||
|
set_home,
|
||||||
|
write_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 = prepare_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 = prepare_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 = prepare_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()
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins import (
|
||||||
|
_opaque_cleanup,
|
||||||
|
_opaque_removal,
|
||||||
|
_transaction,
|
||||||
|
)
|
||||||
|
from elydora.plugins._runtime_removal import (
|
||||||
|
commit_runtime_removal,
|
||||||
|
prepare_runtime_removal,
|
||||||
|
)
|
||||||
|
from runtime_removal_support import (
|
||||||
|
AGENT_ID,
|
||||||
|
AGENT_NAME,
|
||||||
|
prepare_runtime,
|
||||||
|
stream_digest,
|
||||||
|
write_error_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_exit_after_opaque_removal_restores_error_log_on_restart(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
_root, agent = prepare_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 = prepare_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 = prepare_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 = prepare_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 = prepare_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 = prepare_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 = prepare_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 = prepare_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"
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins import _transaction, _transaction_staging
|
||||||
|
from elydora.plugins._managed_files import read_physical_directory
|
||||||
|
from transaction_support import (
|
||||||
|
make_change,
|
||||||
|
swap_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||||
|
def test_staging_directory_swap_never_writes_secret_to_redirected_path(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
parent = tmp_path / "runtime"
|
||||||
|
backup = tmp_path / "runtime-original"
|
||||||
|
redirected = tmp_path / "runtime-redirected"
|
||||||
|
parent.mkdir()
|
||||||
|
redirected.mkdir()
|
||||||
|
target = parent / "private.key"
|
||||||
|
real_write = _transaction_staging.write_staged
|
||||||
|
swapped = False
|
||||||
|
|
||||||
|
def swap_then_stage(*args: object, **kwargs: object):
|
||||||
|
nonlocal swapped
|
||||||
|
if not swapped:
|
||||||
|
swapped = True
|
||||||
|
swap_directory(parent, backup, redirected)
|
||||||
|
return real_write(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during transaction"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, None, "secret\n")],
|
||||||
|
"staging swap",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (parent / "private.key").exists()
|
||||||
|
assert not (backup / "private.key").exists()
|
||||||
|
assert list(parent.iterdir()) == []
|
||||||
|
assert list(backup.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||||
|
def test_commit_directory_swap_rolls_back_inside_pinned_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
parent = tmp_path / "runtime"
|
||||||
|
backup = tmp_path / "runtime-original"
|
||||||
|
redirected = tmp_path / "runtime-redirected"
|
||||||
|
parent.mkdir()
|
||||||
|
redirected.mkdir()
|
||||||
|
target = parent / "private.key"
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
swapped = False
|
||||||
|
|
||||||
|
def swap_then_replace(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal swapped
|
||||||
|
if not swapped:
|
||||||
|
swapped = True
|
||||||
|
swap_directory(parent, backup, redirected)
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed during transaction"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, None, "secret\n")],
|
||||||
|
"commit swap",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (parent / "private.key").exists()
|
||||||
|
assert not (backup / "private.key").exists()
|
||||||
|
assert list(parent.iterdir()) == []
|
||||||
|
assert list(backup.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_transaction_removes_only_created_empty_directories(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
kiro = tmp_path / ".kiro"
|
||||||
|
hooks = kiro / "hooks"
|
||||||
|
hooks.mkdir(parents=True)
|
||||||
|
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
||||||
|
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
||||||
|
target = hooks / "elydora-audit.json"
|
||||||
|
|
||||||
|
def fail_replace(
|
||||||
|
_source: str,
|
||||||
|
_destination: str,
|
||||||
|
_directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
raise OSError("injected commit failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected commit failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, None, "{}\n")],
|
||||||
|
"created directory recovery",
|
||||||
|
directory_preconditions=[
|
||||||
|
_transaction.DirectoryPrecondition(
|
||||||
|
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
||||||
|
),
|
||||||
|
_transaction.DirectoryPrecondition(
|
||||||
|
str(hooks), "hooks directory", hooks_snapshot, created=True
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not hooks.exists()
|
||||||
|
assert not kiro.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_directory_recovery_keeps_parent_and_child_pinned(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
kiro = tmp_path / ".kiro"
|
||||||
|
hooks = kiro / "hooks"
|
||||||
|
hooks.mkdir(parents=True)
|
||||||
|
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
||||||
|
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
||||||
|
target = hooks / "elydora-audit.json"
|
||||||
|
observed: list[tuple[Path, Path]] = []
|
||||||
|
real_remove = _transaction_staging.remove_pinned_empty_directory
|
||||||
|
|
||||||
|
def observe_pins(
|
||||||
|
parent: object,
|
||||||
|
child: object,
|
||||||
|
*,
|
||||||
|
require_empty: bool = True,
|
||||||
|
) -> None:
|
||||||
|
assert isinstance(parent, _transaction.PinnedDirectory)
|
||||||
|
assert isinstance(child, _transaction.PinnedDirectory)
|
||||||
|
assert parent.descriptor is not None
|
||||||
|
assert child.descriptor is not None
|
||||||
|
assert os.fstat(child.descriptor).st_ino == child.snapshot.inode
|
||||||
|
observed.append((Path(parent.path), Path(child.path)))
|
||||||
|
real_remove(parent, child, require_empty=require_empty)
|
||||||
|
|
||||||
|
def fail_replace(
|
||||||
|
_source: str,
|
||||||
|
_destination: str,
|
||||||
|
_directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
raise OSError("injected pinned cleanup failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction_staging,
|
||||||
|
"remove_pinned_empty_directory",
|
||||||
|
observe_pins,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected pinned cleanup failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, None, "{}\n")],
|
||||||
|
"pinned directory recovery",
|
||||||
|
directory_preconditions=[
|
||||||
|
_transaction.DirectoryPrecondition(
|
||||||
|
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
||||||
|
),
|
||||||
|
_transaction.DirectoryPrecondition(
|
||||||
|
str(hooks), "hooks directory", hooks_snapshot, created=True
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = [(kiro, hooks), (tmp_path, kiro)]
|
||||||
|
assert observed == expected
|
||||||
|
assert not kiro.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||||
|
def test_created_directory_recovery_preserves_swapped_directory(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
created = tmp_path / "created"
|
||||||
|
created.mkdir()
|
||||||
|
created_snapshot = read_physical_directory(str(created), "created directory")
|
||||||
|
target = created / "managed.json"
|
||||||
|
replacement = tmp_path / "replacement"
|
||||||
|
replacement.mkdir()
|
||||||
|
marker = replacement / "user-owned"
|
||||||
|
marker.write_text("preserve", encoding="utf-8")
|
||||||
|
original = tmp_path / "created-original"
|
||||||
|
real_remove = _transaction_staging.remove_pinned_empty_directory
|
||||||
|
|
||||||
|
def swap_before_removal(
|
||||||
|
parent: _transaction.PinnedDirectory,
|
||||||
|
child: _transaction.PinnedDirectory,
|
||||||
|
*,
|
||||||
|
require_empty: bool = True,
|
||||||
|
) -> None:
|
||||||
|
created.rename(original)
|
||||||
|
replacement.rename(created)
|
||||||
|
real_remove(parent, child, require_empty=require_empty)
|
||||||
|
|
||||||
|
def fail_replace(
|
||||||
|
_source: str,
|
||||||
|
_destination: str,
|
||||||
|
_directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
raise OSError("injected swap cleanup failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction_staging,
|
||||||
|
"remove_pinned_empty_directory",
|
||||||
|
swap_before_removal,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="recovery failed"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, None, "{}\n")],
|
||||||
|
"swapped directory recovery",
|
||||||
|
directory_preconditions=[
|
||||||
|
_transaction.DirectoryPrecondition(
|
||||||
|
str(created),
|
||||||
|
"created directory",
|
||||||
|
created_snapshot,
|
||||||
|
created=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (created / marker.name).read_text(encoding="utf-8") == "preserve"
|
||||||
|
assert original.is_dir()
|
||||||
|
assert Path(_transaction.active_journal_path()).is_file()
|
||||||
|
_transaction.clear_journal()
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from elydora.plugins import _transaction
|
||||||
|
from transaction_support import (
|
||||||
|
make_change,
|
||||||
|
write_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_replace_exception_after_success_restores_original(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
|
||||||
|
def replace_then_raise(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
||||||
|
raise OSError("injected post-replace failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected post-replace failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, "old\n", "new\n")],
|
||||||
|
"post-replace failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert list(tmp_path.glob(".*.rollback")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_exception_after_success_restores_original(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
real_capture = _transaction._capture_physical
|
||||||
|
|
||||||
|
def capture_then_raise(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
real_capture(source, destination, directory)
|
||||||
|
raise OSError("injected post-capture failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected post-capture failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, "old\n", "new\n")],
|
||||||
|
"post-capture failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert list(tmp_path.glob(".*.rollback")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollback_reservation_collision_preserves_victim_and_original(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
victim = "concurrent rollback destination\n"
|
||||||
|
collision_path: Path | None = None
|
||||||
|
|
||||||
|
def collide_with_rollback_destination(
|
||||||
|
_source: str,
|
||||||
|
destination: str,
|
||||||
|
_directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal collision_path
|
||||||
|
if destination.endswith(".rollback"):
|
||||||
|
collision_path = Path(destination)
|
||||||
|
write_source(collision_path, victim)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction,
|
||||||
|
"_capture_physical",
|
||||||
|
collide_with_rollback_destination,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="exist"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, "old\n", "new\n")],
|
||||||
|
"rollback destination collision",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert collision_path is not None
|
||||||
|
assert target.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert collision_path.read_text(encoding="utf-8") == victim
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_install_does_not_overwrite_concurrent_target(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def create_target_before_install(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
||||||
|
injected = True
|
||||||
|
write_source(target, "concurrent\n")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction,
|
||||||
|
"_replace_physical",
|
||||||
|
create_target_before_install,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="original content preserved"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[make_change(target, "old\n", "new\n")],
|
||||||
|
"no-clobber install",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
assert target.read_text(encoding="utf-8") == "concurrent\n"
|
||||||
|
rollbacks = list(tmp_path.glob(".*.rollback"))
|
||||||
|
assert len(rollbacks) == 1
|
||||||
|
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_does_not_overwrite_concurrent_target(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
second = tmp_path / "second.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
write_source(second, "second-old\n")
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
real_restore = _transaction._restore_physical
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def fail_second_commit(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(second):
|
||||||
|
raise OSError("injected second failure")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
def create_target_before_restore(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
||||||
|
injected = True
|
||||||
|
write_source(target, "concurrent recovery\n")
|
||||||
|
real_restore(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction,
|
||||||
|
"_restore_physical",
|
||||||
|
create_target_before_restore,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="original content preserved"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[
|
||||||
|
make_change(target, "old\n", "new\n"),
|
||||||
|
make_change(second, "second-old\n", "second-new\n"),
|
||||||
|
],
|
||||||
|
"no-clobber restoration",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
assert target.read_text(encoding="utf-8") == "concurrent recovery\n"
|
||||||
|
rollbacks = list(tmp_path.glob(".*.rollback"))
|
||||||
|
assert len(rollbacks) == 1
|
||||||
|
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert Path(_transaction.active_journal_path()).is_file()
|
||||||
|
_transaction.clear_journal()
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_source_replacement_reports_existing_preserved_path(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
second = tmp_path / "second.txt"
|
||||||
|
displaced = tmp_path / "displaced-original.txt"
|
||||||
|
write_source(target, "old\n")
|
||||||
|
write_source(second, "second-old\n")
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
real_restore = _transaction._restore_physical
|
||||||
|
rollback_path: Path | None = None
|
||||||
|
|
||||||
|
def fail_second_commit(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(second):
|
||||||
|
raise OSError("injected second failure")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
def replace_restore_source(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal rollback_path
|
||||||
|
if (
|
||||||
|
rollback_path is None
|
||||||
|
and source.endswith(".rollback")
|
||||||
|
and os.path.abspath(destination) == os.path.abspath(target)
|
||||||
|
):
|
||||||
|
rollback_path = Path(source)
|
||||||
|
rollback_path.rename(displaced)
|
||||||
|
write_source(rollback_path, "concurrent recovery source\n")
|
||||||
|
real_restore(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||||
|
monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="recovery source changed") as raised:
|
||||||
|
_transaction.write_changes(
|
||||||
|
[
|
||||||
|
make_change(target, "old\n", "new\n"),
|
||||||
|
make_change(second, "second-old\n", "second-new\n"),
|
||||||
|
],
|
||||||
|
"restore source replacement",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rollback_path is not None
|
||||||
|
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
|
||||||
|
assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n"
|
||||||
|
assert displaced.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert target.read_text(encoding="utf-8") == "new\n"
|
||||||
|
assert second.read_text(encoding="utf-8") == "second-old\n"
|
||||||
|
assert Path(_transaction.active_journal_path()).is_file()
|
||||||
|
_transaction.clear_journal()
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_rollback_captures_and_preserves_concurrent_replacement(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
created = tmp_path / "created.txt"
|
||||||
|
second = tmp_path / "second.txt"
|
||||||
|
write_source(second, "second-old\n")
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
real_capture = _transaction._capture_physical
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def fail_second_commit(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(second):
|
||||||
|
raise OSError("injected second failure")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
def replace_created_before_recovery_capture(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
nonlocal injected
|
||||||
|
if (
|
||||||
|
not injected
|
||||||
|
and os.path.abspath(source) == os.path.abspath(created)
|
||||||
|
and destination.endswith(".recovery")
|
||||||
|
):
|
||||||
|
injected = True
|
||||||
|
write_source(created, "concurrent replacement\n")
|
||||||
|
real_capture(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_transaction,
|
||||||
|
"_capture_physical",
|
||||||
|
replace_created_before_recovery_capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="changed at recovery boundary"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[
|
||||||
|
make_change(created, None, "generated\n"),
|
||||||
|
make_change(second, "second-old\n", "second-new\n"),
|
||||||
|
],
|
||||||
|
"creation recovery capture",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
assert created.read_text(encoding="utf-8") == "concurrent replacement\n"
|
||||||
|
assert Path(_transaction.active_journal_path()).is_file()
|
||||||
|
_transaction.clear_journal()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
||||||
|
def test_mode_repair_uses_private_rollback_and_restores_original_mode(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
secret = tmp_path / "private.key"
|
||||||
|
second = tmp_path / "config.json"
|
||||||
|
write_source(secret, "secret\n")
|
||||||
|
write_source(second, "old\n")
|
||||||
|
secret.chmod(0o644)
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
rollback_modes: list[int] = []
|
||||||
|
|
||||||
|
def inspect_then_fail_second(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(str(secret)):
|
||||||
|
rollback = next(tmp_path.glob(".private.key.*.rollback"))
|
||||||
|
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
||||||
|
raise OSError("injected second commit failure")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected second commit failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[
|
||||||
|
make_change(secret, "secret\n", "secret\n", enforce_mode=True),
|
||||||
|
make_change(second, "old\n", "new\n"),
|
||||||
|
],
|
||||||
|
"mode repair",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rollback_modes
|
||||||
|
assert set(rollback_modes) == {0o600}
|
||||||
|
assert secret.read_text(encoding="utf-8") == "secret\n"
|
||||||
|
assert secret.stat().st_mode & 0o777 == 0o644
|
||||||
|
assert second.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert list(tmp_path.glob(".*.rollback")) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
||||||
|
def test_deletion_uses_private_rollback_and_restores_original_mode(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
removed = tmp_path / "removed.txt"
|
||||||
|
second = tmp_path / "second.txt"
|
||||||
|
write_source(removed, "remove me\n")
|
||||||
|
write_source(second, "old\n")
|
||||||
|
removed.chmod(0o644)
|
||||||
|
real_replace = _transaction._replace_physical
|
||||||
|
rollback_modes: list[int] = []
|
||||||
|
|
||||||
|
def inspect_then_fail_second(
|
||||||
|
source: str,
|
||||||
|
destination: str,
|
||||||
|
directory: _transaction.PinnedDirectory,
|
||||||
|
) -> None:
|
||||||
|
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
||||||
|
rollback = next(tmp_path.glob(".removed.txt.*.rollback"))
|
||||||
|
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
||||||
|
raise OSError("injected second commit failure")
|
||||||
|
real_replace(source, destination, directory)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
||||||
|
|
||||||
|
with pytest.raises(OSError, match="injected second commit failure"):
|
||||||
|
_transaction.write_changes(
|
||||||
|
[
|
||||||
|
make_change(removed, "remove me\n", None),
|
||||||
|
make_change(second, "old\n", "new\n"),
|
||||||
|
],
|
||||||
|
"deletion rollback mode",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rollback_modes == [0o600]
|
||||||
|
assert removed.read_text(encoding="utf-8") == "remove me\n"
|
||||||
|
assert removed.stat().st_mode & 0o777 == 0o644
|
||||||
|
assert second.read_text(encoding="utf-8") == "old\n"
|
||||||
|
assert list(tmp_path.glob(".*.rollback")) == []
|
||||||
@@ -10,31 +10,13 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from elydora.plugins import _transaction, _transaction_staging
|
from elydora.plugins import _transaction
|
||||||
from elydora.plugins._managed_files import read_physical_directory, read_physical_file
|
from elydora.plugins._managed_files import read_physical_file
|
||||||
|
from transaction_support import (
|
||||||
|
make_change,
|
||||||
def _write(path: Path, source: str) -> None:
|
transaction_subprocess_environment,
|
||||||
path.write_bytes(source.encode("utf-8"))
|
write_source,
|
||||||
|
)
|
||||||
|
|
||||||
def _change(
|
|
||||||
path: Path,
|
|
||||||
original: str | None,
|
|
||||||
next_source: str | None,
|
|
||||||
mode: int = 0o600,
|
|
||||||
enforce_mode: bool = False,
|
|
||||||
) -> _transaction.FileChange:
|
|
||||||
change = _transaction.source_change(
|
|
||||||
str(path),
|
|
||||||
path.name,
|
|
||||||
original,
|
|
||||||
next_source,
|
|
||||||
mode,
|
|
||||||
enforce_mode=enforce_mode,
|
|
||||||
)
|
|
||||||
assert change is not None
|
|
||||||
return change
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_replace_precondition_failure_restores_original_target(
|
def test_post_replace_precondition_failure_restores_original_target(
|
||||||
@@ -43,8 +25,8 @@ def test_post_replace_precondition_failure_restores_original_target(
|
|||||||
) -> None:
|
) -> None:
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
provider = tmp_path / "provider.txt"
|
provider = tmp_path / "provider.txt"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
_write(provider, "stable\n")
|
write_source(provider, "stable\n")
|
||||||
provider_snapshot = read_physical_file(str(provider), "provider")
|
provider_snapshot = read_physical_file(str(provider), "provider")
|
||||||
real_replace = _transaction._replace_physical
|
real_replace = _transaction._replace_physical
|
||||||
|
|
||||||
@@ -55,7 +37,7 @@ def test_post_replace_precondition_failure_restores_original_target(
|
|||||||
) -> None:
|
) -> None:
|
||||||
real_replace(source, destination, directory)
|
real_replace(source, destination, directory)
|
||||||
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
||||||
_write(provider, "concurrent\n")
|
write_source(provider, "concurrent\n")
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
_transaction, "_replace_physical", replace_then_change_provider
|
_transaction, "_replace_physical", replace_then_change_provider
|
||||||
@@ -63,7 +45,7 @@ def test_post_replace_precondition_failure_restores_original_target(
|
|||||||
|
|
||||||
with pytest.raises(OSError, match="provider changed during"):
|
with pytest.raises(OSError, match="provider changed during"):
|
||||||
_transaction.write_changes(
|
_transaction.write_changes(
|
||||||
[_change(target, "old\n", "new\n")],
|
[make_change(target, "old\n", "new\n")],
|
||||||
"provider transaction",
|
"provider transaction",
|
||||||
[_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)],
|
[_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)],
|
||||||
)
|
)
|
||||||
@@ -78,7 +60,7 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
real_capture = _transaction._capture_physical
|
real_capture = _transaction._capture_physical
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -94,14 +76,14 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected(
|
|||||||
and destination.endswith(".rollback")
|
and destination.endswith(".rollback")
|
||||||
):
|
):
|
||||||
changed = True
|
changed = True
|
||||||
_write(target, "concurrent\n")
|
write_source(target, "concurrent\n")
|
||||||
real_capture(source, destination, directory)
|
real_capture(source, destination, directory)
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture)
|
monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture)
|
||||||
|
|
||||||
with pytest.raises(OSError, match="changed at commit boundary"):
|
with pytest.raises(OSError, match="changed at commit boundary"):
|
||||||
_transaction.write_changes(
|
_transaction.write_changes(
|
||||||
[_change(target, "old\n", "new\n")],
|
[make_change(target, "old\n", "new\n")],
|
||||||
"commit boundary",
|
"commit boundary",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,7 +98,7 @@ def test_existing_target_remains_visible_during_atomic_update(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
replace_started = threading.Event()
|
replace_started = threading.Event()
|
||||||
release_replace = threading.Event()
|
release_replace = threading.Event()
|
||||||
failures: list[BaseException] = []
|
failures: list[BaseException] = []
|
||||||
@@ -135,7 +117,7 @@ def test_existing_target_remains_visible_during_atomic_update(
|
|||||||
def update() -> None:
|
def update() -> None:
|
||||||
try:
|
try:
|
||||||
_transaction.write_changes(
|
_transaction.write_changes(
|
||||||
[_change(target, "old\n", "new\n")],
|
[make_change(target, "old\n", "new\n")],
|
||||||
"visible atomic update",
|
"visible atomic update",
|
||||||
)
|
)
|
||||||
except BaseException as error:
|
except BaseException as error:
|
||||||
@@ -156,13 +138,6 @@ def test_existing_target_remains_visible_during_atomic_update(
|
|||||||
assert target.read_text(encoding="utf-8") == "new\n"
|
assert target.read_text(encoding="utf-8") == "new\n"
|
||||||
|
|
||||||
|
|
||||||
def _transaction_subprocess_environment(home: Path) -> dict[str, str]:
|
|
||||||
environment = os.environ.copy()
|
|
||||||
environment["HOME"] = str(home)
|
|
||||||
environment["USERPROFILE"] = str(home)
|
|
||||||
return environment
|
|
||||||
|
|
||||||
|
|
||||||
def test_process_exit_after_atomic_replace_recovers_on_restart(
|
def test_process_exit_after_atomic_replace_recovers_on_restart(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -170,7 +145,7 @@ def test_process_exit_after_atomic_replace_recovers_on_restart(
|
|||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
monkeypatch.setenv("USERPROFILE", str(home))
|
monkeypatch.setenv("USERPROFILE", str(home))
|
||||||
script = """
|
script = """
|
||||||
@@ -193,7 +168,7 @@ _transaction.write_changes([change], "crash recovery")
|
|||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[sys.executable, "-c", script, str(target)],
|
[sys.executable, "-c", script, str(target)],
|
||||||
cwd=Path(__file__).resolve().parents[1],
|
cwd=Path(__file__).resolve().parents[1],
|
||||||
env=_transaction_subprocess_environment(home),
|
env=transaction_subprocess_environment(home),
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,7 +187,7 @@ def test_process_exit_after_committed_marker_keeps_atomic_update(
|
|||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
monkeypatch.setenv("USERPROFILE", str(home))
|
monkeypatch.setenv("USERPROFILE", str(home))
|
||||||
script = """
|
script = """
|
||||||
@@ -230,7 +205,7 @@ _transaction.write_changes([change], "committed crash recovery")
|
|||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[sys.executable, "-c", script, str(target)],
|
[sys.executable, "-c", script, str(target)],
|
||||||
cwd=Path(__file__).resolve().parents[1],
|
cwd=Path(__file__).resolve().parents[1],
|
||||||
env=_transaction_subprocess_environment(home),
|
env=transaction_subprocess_environment(home),
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,8 +225,8 @@ def test_process_exit_mid_transaction_restores_all_targets(
|
|||||||
home.mkdir()
|
home.mkdir()
|
||||||
first = tmp_path / "first.txt"
|
first = tmp_path / "first.txt"
|
||||||
second = tmp_path / "second.txt"
|
second = tmp_path / "second.txt"
|
||||||
_write(first, "first-old\n")
|
write_source(first, "first-old\n")
|
||||||
_write(second, "second-old\n")
|
write_source(second, "second-old\n")
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
monkeypatch.setenv("USERPROFILE", str(home))
|
monkeypatch.setenv("USERPROFILE", str(home))
|
||||||
script = """
|
script = """
|
||||||
@@ -281,7 +256,7 @@ _transaction.write_changes(changes, "multi-file crash recovery")
|
|||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[sys.executable, "-c", script, str(first), str(second)],
|
[sys.executable, "-c", script, str(first), str(second)],
|
||||||
cwd=Path(__file__).resolve().parents[1],
|
cwd=Path(__file__).resolve().parents[1],
|
||||||
env=_transaction_subprocess_environment(home),
|
env=transaction_subprocess_environment(home),
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,7 +288,7 @@ def test_process_exit_recovers_atomic_create_and_remove(
|
|||||||
home.mkdir()
|
home.mkdir()
|
||||||
target = tmp_path / "target.txt"
|
target = tmp_path / "target.txt"
|
||||||
if original is not None:
|
if original is not None:
|
||||||
_write(target, original)
|
write_source(target, original)
|
||||||
monkeypatch.setenv("HOME", str(home))
|
monkeypatch.setenv("HOME", str(home))
|
||||||
monkeypatch.setenv("USERPROFILE", str(home))
|
monkeypatch.setenv("USERPROFILE", str(home))
|
||||||
script = """
|
script = """
|
||||||
@@ -343,7 +318,7 @@ _transaction.write_changes([change], "create-remove crash recovery")
|
|||||||
str(exit_code),
|
str(exit_code),
|
||||||
],
|
],
|
||||||
cwd=Path(__file__).resolve().parents[1],
|
cwd=Path(__file__).resolve().parents[1],
|
||||||
env=_transaction_subprocess_environment(home),
|
env=transaction_subprocess_environment(home),
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -366,8 +341,8 @@ def test_cross_process_writers_are_serialized(
|
|||||||
ready = tmp_path / "ready"
|
ready = tmp_path / "ready"
|
||||||
second_ready = tmp_path / "second-ready"
|
second_ready = tmp_path / "second-ready"
|
||||||
release = tmp_path / "release"
|
release = tmp_path / "release"
|
||||||
_write(target, "old\n")
|
write_source(target, "old\n")
|
||||||
environment = _transaction_subprocess_environment(home)
|
environment = transaction_subprocess_environment(home)
|
||||||
first_script = """
|
first_script = """
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
@@ -443,632 +418,3 @@ except OSError as error:
|
|||||||
assert second.returncode == 19, output
|
assert second.returncode == 19, output
|
||||||
assert "changed during" in output
|
assert "changed during" in output
|
||||||
assert target.read_text(encoding="utf-8") == "first\n"
|
assert target.read_text(encoding="utf-8") == "first\n"
|
||||||
|
|
||||||
|
|
||||||
def test_final_replace_exception_after_success_restores_original(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
|
|
||||||
def replace_then_raise(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
|
||||||
raise OSError("injected post-replace failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected post-replace failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, "old\n", "new\n")],
|
|
||||||
"post-replace failure",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert list(tmp_path.glob(".*.rollback")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_capture_exception_after_success_restores_original(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
real_capture = _transaction._capture_physical
|
|
||||||
|
|
||||||
def capture_then_raise(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
real_capture(source, destination, directory)
|
|
||||||
raise OSError("injected post-capture failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected post-capture failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, "old\n", "new\n")],
|
|
||||||
"post-capture failure",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert list(tmp_path.glob(".*.rollback")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_rollback_reservation_collision_preserves_victim_and_original(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
victim = "concurrent rollback destination\n"
|
|
||||||
collision_path: Path | None = None
|
|
||||||
|
|
||||||
def collide_with_rollback_destination(
|
|
||||||
_source: str,
|
|
||||||
destination: str,
|
|
||||||
_directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal collision_path
|
|
||||||
if destination.endswith(".rollback"):
|
|
||||||
collision_path = Path(destination)
|
|
||||||
_write(collision_path, victim)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction,
|
|
||||||
"_capture_physical",
|
|
||||||
collide_with_rollback_destination,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="exist"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, "old\n", "new\n")],
|
|
||||||
"rollback destination collision",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert collision_path is not None
|
|
||||||
assert target.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert collision_path.read_text(encoding="utf-8") == victim
|
|
||||||
|
|
||||||
|
|
||||||
def test_final_install_does_not_overwrite_concurrent_target(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
injected = False
|
|
||||||
|
|
||||||
def create_target_before_install(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal injected
|
|
||||||
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
|
||||||
injected = True
|
|
||||||
_write(target, "concurrent\n")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction,
|
|
||||||
"_replace_physical",
|
|
||||||
create_target_before_install,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="original content preserved"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, "old\n", "new\n")],
|
|
||||||
"no-clobber install",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert injected
|
|
||||||
assert target.read_text(encoding="utf-8") == "concurrent\n"
|
|
||||||
rollbacks = list(tmp_path.glob(".*.rollback"))
|
|
||||||
assert len(rollbacks) == 1
|
|
||||||
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_does_not_overwrite_concurrent_target(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
second = tmp_path / "second.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
_write(second, "second-old\n")
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
real_restore = _transaction._restore_physical
|
|
||||||
injected = False
|
|
||||||
|
|
||||||
def fail_second_commit(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(second):
|
|
||||||
raise OSError("injected second failure")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
def create_target_before_restore(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal injected
|
|
||||||
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
|
||||||
injected = True
|
|
||||||
_write(target, "concurrent recovery\n")
|
|
||||||
real_restore(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction,
|
|
||||||
"_restore_physical",
|
|
||||||
create_target_before_restore,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="original content preserved"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[
|
|
||||||
_change(target, "old\n", "new\n"),
|
|
||||||
_change(second, "second-old\n", "second-new\n"),
|
|
||||||
],
|
|
||||||
"no-clobber restoration",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert injected
|
|
||||||
assert target.read_text(encoding="utf-8") == "concurrent recovery\n"
|
|
||||||
rollbacks = list(tmp_path.glob(".*.rollback"))
|
|
||||||
assert len(rollbacks) == 1
|
|
||||||
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert Path(_transaction.active_journal_path()).is_file()
|
|
||||||
_transaction.clear_journal()
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_source_replacement_reports_existing_preserved_path(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
target = tmp_path / "target.txt"
|
|
||||||
second = tmp_path / "second.txt"
|
|
||||||
displaced = tmp_path / "displaced-original.txt"
|
|
||||||
_write(target, "old\n")
|
|
||||||
_write(second, "second-old\n")
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
real_restore = _transaction._restore_physical
|
|
||||||
rollback_path: Path | None = None
|
|
||||||
|
|
||||||
def fail_second_commit(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(second):
|
|
||||||
raise OSError("injected second failure")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
def replace_restore_source(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal rollback_path
|
|
||||||
if (
|
|
||||||
rollback_path is None
|
|
||||||
and source.endswith(".rollback")
|
|
||||||
and os.path.abspath(destination) == os.path.abspath(target)
|
|
||||||
):
|
|
||||||
rollback_path = Path(source)
|
|
||||||
rollback_path.rename(displaced)
|
|
||||||
_write(rollback_path, "concurrent recovery source\n")
|
|
||||||
real_restore(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
|
||||||
monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="recovery source changed") as raised:
|
|
||||||
_transaction.write_changes(
|
|
||||||
[
|
|
||||||
_change(target, "old\n", "new\n"),
|
|
||||||
_change(second, "second-old\n", "second-new\n"),
|
|
||||||
],
|
|
||||||
"restore source replacement",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rollback_path is not None
|
|
||||||
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
|
|
||||||
assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n"
|
|
||||||
assert displaced.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert target.read_text(encoding="utf-8") == "new\n"
|
|
||||||
assert second.read_text(encoding="utf-8") == "second-old\n"
|
|
||||||
assert Path(_transaction.active_journal_path()).is_file()
|
|
||||||
_transaction.clear_journal()
|
|
||||||
|
|
||||||
|
|
||||||
def test_creation_rollback_captures_and_preserves_concurrent_replacement(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
created = tmp_path / "created.txt"
|
|
||||||
second = tmp_path / "second.txt"
|
|
||||||
_write(second, "second-old\n")
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
real_capture = _transaction._capture_physical
|
|
||||||
injected = False
|
|
||||||
|
|
||||||
def fail_second_commit(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(second):
|
|
||||||
raise OSError("injected second failure")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
def replace_created_before_recovery_capture(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal injected
|
|
||||||
if (
|
|
||||||
not injected
|
|
||||||
and os.path.abspath(source) == os.path.abspath(created)
|
|
||||||
and destination.endswith(".recovery")
|
|
||||||
):
|
|
||||||
injected = True
|
|
||||||
_write(created, "concurrent replacement\n")
|
|
||||||
real_capture(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction,
|
|
||||||
"_capture_physical",
|
|
||||||
replace_created_before_recovery_capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="changed at recovery boundary"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[
|
|
||||||
_change(created, None, "generated\n"),
|
|
||||||
_change(second, "second-old\n", "second-new\n"),
|
|
||||||
],
|
|
||||||
"creation recovery capture",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert injected
|
|
||||||
assert created.read_text(encoding="utf-8") == "concurrent replacement\n"
|
|
||||||
assert Path(_transaction.active_journal_path()).is_file()
|
|
||||||
_transaction.clear_journal()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
|
||||||
def test_mode_repair_uses_private_rollback_and_restores_original_mode(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
secret = tmp_path / "private.key"
|
|
||||||
second = tmp_path / "config.json"
|
|
||||||
_write(secret, "secret\n")
|
|
||||||
_write(second, "old\n")
|
|
||||||
secret.chmod(0o644)
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
rollback_modes: list[int] = []
|
|
||||||
|
|
||||||
def inspect_then_fail_second(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(str(secret)):
|
|
||||||
rollback = next(tmp_path.glob(".private.key.*.rollback"))
|
|
||||||
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
|
||||||
raise OSError("injected second commit failure")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected second commit failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[
|
|
||||||
_change(secret, "secret\n", "secret\n", enforce_mode=True),
|
|
||||||
_change(second, "old\n", "new\n"),
|
|
||||||
],
|
|
||||||
"mode repair",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rollback_modes
|
|
||||||
assert set(rollback_modes) == {0o600}
|
|
||||||
assert secret.read_text(encoding="utf-8") == "secret\n"
|
|
||||||
assert secret.stat().st_mode & 0o777 == 0o644
|
|
||||||
assert second.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert list(tmp_path.glob(".*.rollback")) == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
|
||||||
def test_deletion_uses_private_rollback_and_restores_original_mode(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
removed = tmp_path / "removed.txt"
|
|
||||||
second = tmp_path / "second.txt"
|
|
||||||
_write(removed, "remove me\n")
|
|
||||||
_write(second, "old\n")
|
|
||||||
removed.chmod(0o644)
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
rollback_modes: list[int] = []
|
|
||||||
|
|
||||||
def inspect_then_fail_second(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
|
||||||
rollback = next(tmp_path.glob(".removed.txt.*.rollback"))
|
|
||||||
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
|
||||||
raise OSError("injected second commit failure")
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected second commit failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[
|
|
||||||
_change(removed, "remove me\n", None),
|
|
||||||
_change(second, "old\n", "new\n"),
|
|
||||||
],
|
|
||||||
"deletion rollback mode",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert rollback_modes == [0o600]
|
|
||||||
assert removed.read_text(encoding="utf-8") == "remove me\n"
|
|
||||||
assert removed.stat().st_mode & 0o777 == 0o644
|
|
||||||
assert second.read_text(encoding="utf-8") == "old\n"
|
|
||||||
assert list(tmp_path.glob(".*.rollback")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def _swap_directory(parent: Path, backup: Path, redirected: Path) -> None:
|
|
||||||
parent.rename(backup)
|
|
||||||
redirected.rename(parent)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
|
||||||
def test_staging_directory_swap_never_writes_secret_to_redirected_path(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
parent = tmp_path / "runtime"
|
|
||||||
backup = tmp_path / "runtime-original"
|
|
||||||
redirected = tmp_path / "runtime-redirected"
|
|
||||||
parent.mkdir()
|
|
||||||
redirected.mkdir()
|
|
||||||
target = parent / "private.key"
|
|
||||||
real_write = _transaction_staging.write_staged
|
|
||||||
swapped = False
|
|
||||||
|
|
||||||
def swap_then_stage(*args: object, **kwargs: object):
|
|
||||||
nonlocal swapped
|
|
||||||
if not swapped:
|
|
||||||
swapped = True
|
|
||||||
_swap_directory(parent, backup, redirected)
|
|
||||||
return real_write(*args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="changed during transaction"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, None, "secret\n")],
|
|
||||||
"staging swap",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not (parent / "private.key").exists()
|
|
||||||
assert not (backup / "private.key").exists()
|
|
||||||
assert list(parent.iterdir()) == []
|
|
||||||
assert list(backup.iterdir()) == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
|
||||||
def test_commit_directory_swap_rolls_back_inside_pinned_directory(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
parent = tmp_path / "runtime"
|
|
||||||
backup = tmp_path / "runtime-original"
|
|
||||||
redirected = tmp_path / "runtime-redirected"
|
|
||||||
parent.mkdir()
|
|
||||||
redirected.mkdir()
|
|
||||||
target = parent / "private.key"
|
|
||||||
real_replace = _transaction._replace_physical
|
|
||||||
swapped = False
|
|
||||||
|
|
||||||
def swap_then_replace(
|
|
||||||
source: str,
|
|
||||||
destination: str,
|
|
||||||
directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
nonlocal swapped
|
|
||||||
if not swapped:
|
|
||||||
swapped = True
|
|
||||||
_swap_directory(parent, backup, redirected)
|
|
||||||
real_replace(source, destination, directory)
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="changed during transaction"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, None, "secret\n")],
|
|
||||||
"commit swap",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not (parent / "private.key").exists()
|
|
||||||
assert not (backup / "private.key").exists()
|
|
||||||
assert list(parent.iterdir()) == []
|
|
||||||
assert list(backup.iterdir()) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_failed_transaction_removes_only_created_empty_directories(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
kiro = tmp_path / ".kiro"
|
|
||||||
hooks = kiro / "hooks"
|
|
||||||
hooks.mkdir(parents=True)
|
|
||||||
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
|
||||||
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
|
||||||
target = hooks / "elydora-audit.json"
|
|
||||||
|
|
||||||
def fail_replace(
|
|
||||||
_source: str,
|
|
||||||
_destination: str,
|
|
||||||
_directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
raise OSError("injected commit failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected commit failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, None, "{}\n")],
|
|
||||||
"created directory recovery",
|
|
||||||
directory_preconditions=[
|
|
||||||
_transaction.DirectoryPrecondition(
|
|
||||||
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
|
||||||
),
|
|
||||||
_transaction.DirectoryPrecondition(
|
|
||||||
str(hooks), "hooks directory", hooks_snapshot, created=True
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not hooks.exists()
|
|
||||||
assert not kiro.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_created_directory_recovery_keeps_parent_and_child_pinned(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
kiro = tmp_path / ".kiro"
|
|
||||||
hooks = kiro / "hooks"
|
|
||||||
hooks.mkdir(parents=True)
|
|
||||||
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
|
||||||
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
|
||||||
target = hooks / "elydora-audit.json"
|
|
||||||
observed: list[tuple[Path, Path]] = []
|
|
||||||
real_remove = _transaction_staging.remove_pinned_empty_directory
|
|
||||||
|
|
||||||
def observe_pins(
|
|
||||||
parent: object,
|
|
||||||
child: object,
|
|
||||||
*,
|
|
||||||
require_empty: bool = True,
|
|
||||||
) -> None:
|
|
||||||
assert isinstance(parent, _transaction.PinnedDirectory)
|
|
||||||
assert isinstance(child, _transaction.PinnedDirectory)
|
|
||||||
assert parent.descriptor is not None
|
|
||||||
assert child.descriptor is not None
|
|
||||||
assert os.fstat(child.descriptor).st_ino == child.snapshot.inode
|
|
||||||
observed.append((Path(parent.path), Path(child.path)))
|
|
||||||
real_remove(parent, child, require_empty=require_empty)
|
|
||||||
|
|
||||||
def fail_replace(
|
|
||||||
_source: str,
|
|
||||||
_destination: str,
|
|
||||||
_directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
raise OSError("injected pinned cleanup failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction_staging,
|
|
||||||
"remove_pinned_empty_directory",
|
|
||||||
observe_pins,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="injected pinned cleanup failure"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, None, "{}\n")],
|
|
||||||
"pinned directory recovery",
|
|
||||||
directory_preconditions=[
|
|
||||||
_transaction.DirectoryPrecondition(
|
|
||||||
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
|
||||||
),
|
|
||||||
_transaction.DirectoryPrecondition(
|
|
||||||
str(hooks), "hooks directory", hooks_snapshot, created=True
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
expected = [(kiro, hooks), (tmp_path, kiro)]
|
|
||||||
assert observed == expected
|
|
||||||
assert not kiro.exists()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
|
||||||
def test_created_directory_recovery_preserves_swapped_directory(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
created = tmp_path / "created"
|
|
||||||
created.mkdir()
|
|
||||||
created_snapshot = read_physical_directory(str(created), "created directory")
|
|
||||||
target = created / "managed.json"
|
|
||||||
replacement = tmp_path / "replacement"
|
|
||||||
replacement.mkdir()
|
|
||||||
marker = replacement / "user-owned"
|
|
||||||
marker.write_text("preserve", encoding="utf-8")
|
|
||||||
original = tmp_path / "created-original"
|
|
||||||
real_remove = _transaction_staging.remove_pinned_empty_directory
|
|
||||||
|
|
||||||
def swap_before_removal(
|
|
||||||
parent: _transaction.PinnedDirectory,
|
|
||||||
child: _transaction.PinnedDirectory,
|
|
||||||
*,
|
|
||||||
require_empty: bool = True,
|
|
||||||
) -> None:
|
|
||||||
created.rename(original)
|
|
||||||
replacement.rename(created)
|
|
||||||
real_remove(parent, child, require_empty=require_empty)
|
|
||||||
|
|
||||||
def fail_replace(
|
|
||||||
_source: str,
|
|
||||||
_destination: str,
|
|
||||||
_directory: _transaction.PinnedDirectory,
|
|
||||||
) -> None:
|
|
||||||
raise OSError("injected swap cleanup failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_transaction_staging,
|
|
||||||
"remove_pinned_empty_directory",
|
|
||||||
swap_before_removal,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
|
||||||
|
|
||||||
with pytest.raises(OSError, match="recovery failed"):
|
|
||||||
_transaction.write_changes(
|
|
||||||
[_change(target, None, "{}\n")],
|
|
||||||
"swapped directory recovery",
|
|
||||||
directory_preconditions=[
|
|
||||||
_transaction.DirectoryPrecondition(
|
|
||||||
str(created),
|
|
||||||
"created directory",
|
|
||||||
created_snapshot,
|
|
||||||
created=True,
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (created / marker.name).read_text(encoding="utf-8") == "preserve"
|
|
||||||
assert original.is_dir()
|
|
||||||
assert Path(_transaction.active_journal_path()).is_file()
|
|
||||||
_transaction.clear_journal()
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
from elydora.plugins import _transaction
|
||||||
|
|
||||||
|
|
||||||
|
def write_source(path: Path, source: str) -> None:
|
||||||
|
path.write_bytes(source.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def make_change(
|
||||||
|
path: Path,
|
||||||
|
original: str | None,
|
||||||
|
next_source: str | None,
|
||||||
|
mode: int = 0o600,
|
||||||
|
enforce_mode: bool = False,
|
||||||
|
) -> _transaction.FileChange:
|
||||||
|
change = _transaction.source_change(
|
||||||
|
str(path),
|
||||||
|
path.name,
|
||||||
|
original,
|
||||||
|
next_source,
|
||||||
|
mode,
|
||||||
|
enforce_mode=enforce_mode,
|
||||||
|
)
|
||||||
|
assert change is not None
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def transaction_subprocess_environment(home: Path) -> dict[str, str]:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["HOME"] = str(home)
|
||||||
|
environment["USERPROFILE"] = str(home)
|
||||||
|
return environment
|
||||||
|
|
||||||
|
|
||||||
|
def swap_directory(parent: Path, backup: Path, redirected: Path) -> None:
|
||||||
|
parent.rename(backup)
|
||||||
|
redirected.rename(parent)
|
||||||
Reference in new issue
Block a user