From aa4b8a8bdf7c2ae25cd2a63c1b73eb656a85a464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 3 Sep 2026 00:30:17 -0400 Subject: [PATCH] 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 --- elydora/plugins/_private_artifact.py | 5 +- elydora/plugins/qwen_sources.py | 2 +- tests/kiroide_support.py | 286 +++++ tests/runtime_removal_support.py | 83 ++ tests/test_cursor_plugin.py | 1 - tests/test_gemini_plugin.py | 1 - tests/test_kirocli_plugin.py | 1 - tests/test_kiroide_commands.py | 222 ++++ tests/test_kiroide_plugin.py | 1215 +-------------------- tests/test_kiroide_uninstall.py | 359 ++++++ tests/test_kiroide_workspace.py | 367 +++++++ tests/test_letta_plugin.py | 1 - tests/test_pinned_directory.py | 2 +- tests/test_runtime_removal.py | 577 +--------- tests/test_runtime_removal_directories.py | 222 ++++ tests/test_runtime_removal_error_log.py | 286 +++++ tests/test_transaction_directories.py | 245 +++++ tests/test_transaction_rollback.py | 404 +++++++ tests/test_transaction_security.py | 708 +----------- tests/transaction_support.py | 42 + 20 files changed, 2611 insertions(+), 2418 deletions(-) create mode 100644 tests/kiroide_support.py create mode 100644 tests/runtime_removal_support.py create mode 100644 tests/test_kiroide_commands.py create mode 100644 tests/test_kiroide_uninstall.py create mode 100644 tests/test_kiroide_workspace.py create mode 100644 tests/test_runtime_removal_directories.py create mode 100644 tests/test_runtime_removal_error_log.py create mode 100644 tests/test_transaction_directories.py create mode 100644 tests/test_transaction_rollback.py create mode 100644 tests/transaction_support.py diff --git a/elydora/plugins/_private_artifact.py b/elydora/plugins/_private_artifact.py index 2fe2a1f..fb4f713 100644 --- a/elydora/plugins/_private_artifact.py +++ b/elydora/plugins/_private_artifact.py @@ -12,9 +12,12 @@ from ._managed_files import DirectorySnapshot, FileSnapshot 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: """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") os.rmdir(name, dir_fd=parent_descriptor) diff --git a/elydora/plugins/qwen_sources.py b/elydora/plugins/qwen_sources.py index b38849e..c4cc12b 100644 --- a/elydora/plugins/qwen_sources.py +++ b/elydora/plugins/qwen_sources.py @@ -10,7 +10,7 @@ import tempfile from typing import Dict, List, Optional, Set, Tuple 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 ._transaction import FilePrecondition from .qwen_config import ( diff --git a/tests/kiroide_support.py b/tests/kiroide_support.py new file mode 100644 index 0000000..6ba2f36 --- /dev/null +++ b/tests/kiroide_support.py @@ -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 diff --git a/tests/runtime_removal_support.py b/tests/runtime_removal_support.py new file mode 100644 index 0000000..fffb340 --- /dev/null +++ b/tests/runtime_removal_support.py @@ -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, + ] + ) diff --git a/tests/test_cursor_plugin.py b/tests/test_cursor_plugin.py index 2103b6c..5bbf700 100644 --- a/tests/test_cursor_plugin.py +++ b/tests/test_cursor_plugin.py @@ -1,7 +1,6 @@ from __future__ import annotations from copy import deepcopy -import json import os from pathlib import Path import sys diff --git a/tests/test_gemini_plugin.py b/tests/test_gemini_plugin.py index f70f299..09d869a 100644 --- a/tests/test_gemini_plugin.py +++ b/tests/test_gemini_plugin.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import os from pathlib import Path import subprocess # nosec B404 diff --git a/tests/test_kirocli_plugin.py b/tests/test_kirocli_plugin.py index 9e11761..9d72489 100644 --- a/tests/test_kirocli_plugin.py +++ b/tests/test_kirocli_plugin.py @@ -5,7 +5,6 @@ import json import os from pathlib import Path import subprocess -import sys from typing import Any import pytest diff --git a/tests/test_kiroide_commands.py b/tests/test_kiroide_commands.py new file mode 100644 index 0000000..0c56904 --- /dev/null +++ b/tests/test_kiroide_commands.py @@ -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 diff --git a/tests/test_kiroide_plugin.py b/tests/test_kiroide_plugin.py index ee32f9e..7f437a9 100644 --- a/tests/test_kiroide_plugin.py +++ b/tests/test_kiroide_plugin.py @@ -1,16 +1,8 @@ from __future__ import annotations -import base64 -import copy -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import os from pathlib import Path -import subprocess -import stat -import sys -import threading import time from typing import Any @@ -18,281 +10,22 @@ import pytest from elydora import cli from elydora.plugins import kiroide -from elydora.plugins._shell_command import is_python_executable, windows_powershell_path -from elydora.plugins import ( - _transaction, - kiroide_command, - kiroide_contract, - kiroide_installation, - kiroide_io, -) -from elydora.plugins.base import InstallConfig from elydora.plugins.registry import SUPPORTED_AGENTS - -AGENT_ID = "agent-1" -PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(b"=").decode() -ROTATED_PRIVATE_KEY = ( - base64.urlsafe_b64encode(bytes([12]) * 32).rstrip(b"=").decode() +from kiroide_support import ( + AGENT_ID, + AuditHandler, + PRIVATE_KEY, + find_hook, + install_config, + legacy_document, + prepare_fixture, + run_command, + start_audit_server, + write_json, ) -@dataclass(frozen=True) -class KiroIdeFixture: - home: Path - workspace: Path - config_path: Path - legacy_path: Path - agent_directory: Path - guard_path: Path - audit_path: Path - plugin: kiroide.KiroIdePlugin - config: InstallConfig - - -def _write_json(path: Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - - -def _install_config(home: Path) -> InstallConfig: - return { - "org_id": "org-1", - "agent_id": AGENT_ID, - "agent_name": "kiroide", - "private_key": PRIVATE_KEY, - "kid": "kid-1", - "token": "token-1", - "base_url": "https://api.elydora.test", - "guard_script_path": str(home / ".elydora" / AGENT_ID / "guard.py"), - } - - -def _prepare_fixture( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - *, - current: Any = None, - legacy: Any = None, - base_url: str = "https://api.elydora.test", -) -> KiroIdeFixture: - home = tmp_path / "home with spaces and 'quote %KIRO%" - workspace = tmp_path / "workspace with spaces" - home.mkdir(parents=True) - workspace.mkdir(parents=True) - config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json" - legacy_path = home / ".kiro" / "hooks" / "elydora-audit.kiro.hook" - if current is not None: - if isinstance(current, str): - config_path.parent.mkdir(parents=True) - config_path.write_text(current, encoding="utf-8") - else: - _write_json(config_path, current) - if legacy is not None: - if isinstance(legacy, str): - legacy_path.parent.mkdir(parents=True) - legacy_path.write_text(legacy, encoding="utf-8") - else: - _write_json(legacy_path, legacy) - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) - monkeypatch.chdir(workspace) - agent_directory = home / ".elydora" / AGENT_ID - config = _install_config(home) - config["base_url"] = base_url - return KiroIdeFixture( - home, - workspace, - config_path, - legacy_path, - agent_directory, - agent_directory / "guard.py", - agent_directory / "hook.py", - kiroide.KiroIdePlugin(), - config, - ) - - -def _find_hook(document: dict[str, Any], name: str) -> dict[str, Any]: - return next(hook for hook in document["hooks"] if hook.get("name") == name) - - -def _managed_document(guard_command: str, audit_command: str) -> dict[str, Any]: - return { - "version": "v1", - "hooks": [ - { - "name": "elydora-guard", - "description": "Block tool use when the Elydora agent is frozen", - "trigger": "PreToolUse", - "matcher": ".*", - "action": {"type": "command", "command": guard_command}, - "timeout": 10, - "enabled": True, - }, - { - "name": "elydora-audit", - "description": "Record tool use in the Elydora audit trail", - "trigger": "PostToolUse", - "matcher": ".*", - "action": {"type": "command", "command": audit_command}, - "timeout": 10, - "enabled": True, - }, - ], - } - - -def _legacy_document(fixture: KiroIdeFixture, agent_id: str = AGENT_ID) -> dict: - agent_directory = fixture.home / ".elydora" / agent_id - return { - "name": "Elydora Audit", - "description": ( - "Sends tool-use events to the Elydora tamper-evident audit platform" - ), - "version": "1.0.0", - "hooks": { - "pre_tool_use": { - "command": f'"{sys.executable}" {agent_directory / "guard.py"}', - "timeout_ms": 5000, - }, - "post_tool_use": { - "command": str(agent_directory / "hook.py"), - "timeout_ms": 5000, - }, - }, - } - - -def _legacy_runtime_config( - fixture: KiroIdeFixture, agent_id: str = AGENT_ID -) -> dict[str, str]: - return { - "org_id": "org-1", - "agent_id": agent_id, - "kid": "kid-1", - "base_url": "https://api.elydora.test", - "token": "", - "agent_name": "kiroide", - } - - -def _write_legacy_runtime(fixture: KiroIdeFixture, agent_id: str) -> Path: - agent_directory = fixture.home / ".elydora" / agent_id - _write_json( - agent_directory / "config.json", - _legacy_runtime_config(fixture, agent_id), - ) - (agent_directory / "private.key").write_text(PRIVATE_KEY, encoding="utf-8") - (agent_directory / "guard.py").write_text("guard\n", encoding="utf-8") - (agent_directory / "hook.py").write_text("audit\n", encoding="utf-8") - return agent_directory - - -def _write_large_error_log(agent_directory: Path, marker: bytes) -> Path: - error_log = agent_directory / "error.log" - with error_log.open("wb") as file: - for _index in range(48): - file.write(marker * 4096) - if os.name != "nt": - error_log.chmod(0o600) - assert error_log.stat().st_size > 2 * 1024 * 1024 - return error_log - - -def _run_command( - command: str, fixture: KiroIdeFixture, payload: dict[str, Any] -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - command, - shell=True, - cwd=fixture.workspace, - env={ - **os.environ, - "HOME": str(fixture.home), - "USERPROFILE": str(fixture.home), - }, - input=json.dumps(payload), - text=True, - capture_output=True, - check=False, - ) - - -class _AuditHandler(BaseHTTPRequestHandler): - operations: list[dict[str, Any]] = [] - - def do_POST(self) -> None: - length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(length).decode("utf-8") - self.operations.append(json.loads(raw)) - self.send_response(201) - self.end_headers() - - def log_message(self, _format: str, *_args: object) -> None: - return - - -@dataclass(frozen=True) -class _AuditServer: - server: ThreadingHTTPServer - thread: threading.Thread - - @property - def base_url(self) -> str: - host, port = self.server.server_address - return f"http://{host}:{port}" - - def close(self) -> None: - self.server.shutdown() - self.server.server_close() - self.thread.join() - - -def _start_audit_server() -> _AuditServer: - _AuditHandler.operations = [] - server = ThreadingHTTPServer(("127.0.0.1", 0), _AuditHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return _AuditServer(server, thread) - - -def _prepare_installation( - fixture: KiroIdeFixture, - config: InstallConfig | None = None, -) -> tuple[ - kiroide_io.KiroIdeSources, - kiroide_installation.KiroIdeRuntimePaths, - kiroide_installation.PreparedKiroIdeInstallation, - kiroide_contract.RenderedKiroIdeDocument, -]: - selected = fixture.config if config is None else config - sources = kiroide_io.read_kiroide_sources() - kiroide_contract.require_available_kiroide_hooks(sources.document.hooks) - paths = kiroide_installation.preflight_kiroide_installation( - selected, sources - ) - hooks = kiroide_contract.without_managed_kiroide_hooks( - sources.document.hooks - ) - rendered = kiroide_contract.render_kiroide_document( - sources.document, - [ - *hooks, - kiroide_contract.build_kiroide_hook( - kiroide_contract.GUARD_NAME, paths.guard_path - ), - kiroide_contract.build_kiroide_hook( - kiroide_contract.AUDIT_NAME, paths.audit_path - ), - ], - ) - prepared = kiroide_installation.prepare_kiroide_installation( - selected, paths, sources, rendered - ) - return sources, paths, prepared, rendered - - def test_kiroide_uses_current_workspace_v1_hook_contract( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -308,7 +41,7 @@ def test_kiroide_uses_current_workspace_v1_hook_contract( "action": {"type": "agent", "prompt": "Read AGENTS.md"}, "enabled": True, } - _write_json( + write_json( config_path, {"version": "v1", "owner": "workspace", "hooks": [existing_hook]}, ) @@ -317,9 +50,9 @@ def test_kiroide_uses_current_workspace_v1_hook_contract( monkeypatch.chdir(workspace) plugin = kiroide.KiroIdePlugin() - plugin.install(_install_config(home)) + plugin.install(install_config(home)) first_source = config_path.read_text(encoding="utf-8") - plugin.install(_install_config(home)) + plugin.install(install_config(home)) assert SUPPORTED_AGENTS["kiroide"] == { "name": "Kiro IDE", @@ -350,9 +83,9 @@ def test_kiroide_commands_block_frozen_agents_and_forward_native_events( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - api = _start_audit_server() + api = start_audit_server() try: - fixture = _prepare_fixture( + fixture = prepare_fixture( monkeypatch, tmp_path, base_url=api.base_url ) fixture.plugin.install(fixture.config) @@ -366,15 +99,15 @@ def test_kiroide_commands_block_frozen_agents_and_forward_native_events( "future_field": {"retained": True}, } status_cache_path = fixture.agent_directory / "status-cache.json" - _write_json( + write_json( status_cache_path, {"status": "frozen", "cached_at": time.time()}, ) if os.name != "nt": status_cache_path.chmod(0o600) - blocked = _run_command( - _find_hook(document, "elydora-guard")["action"]["command"], + blocked = run_command( + find_hook(document, "elydora-guard")["action"]["command"], fixture, payload, ) @@ -383,17 +116,17 @@ def test_kiroide_commands_block_frozen_agents_and_forward_native_events( payload["hook_event_name"] = "postToolUse" payload["tool_response"] = {"success": True, "result": "12 passed"} - audited = _run_command( - _find_hook(document, "elydora-audit")["action"]["command"], + audited = run_command( + find_hook(document, "elydora-audit")["action"]["command"], fixture, payload, ) assert audited.returncode == 0, audited.stderr - assert _AuditHandler.operations[-1]["payload"] == payload - assert _AuditHandler.operations[-1]["action"] == { + assert AuditHandler.operations[-1]["payload"] == payload + assert AuditHandler.operations[-1]["action"] == { "tool": "execute_bash" } - assert _AuditHandler.operations[-1]["subject"] == { + assert AuditHandler.operations[-1]["subject"] == { "session_id": "session-1" } finally: @@ -404,7 +137,7 @@ def test_kiroide_status_requires_exact_enabled_hooks_and_runtime_sources( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture = prepare_fixture(monkeypatch, tmp_path) fixture.plugin.install(fixture.config) assert fixture.plugin.status() == { "installed": True, @@ -413,8 +146,8 @@ def test_kiroide_status_requires_exact_enabled_hooks_and_runtime_sources( } document = json.loads(fixture.config_path.read_text(encoding="utf-8")) - _find_hook(document, "elydora-guard")["enabled"] = False - _write_json(fixture.config_path, document) + find_hook(document, "elydora-guard")["enabled"] = False + write_json(fixture.config_path, document) assert fixture.plugin.status() == { "installed": False, "agent": "kiroide", @@ -435,7 +168,7 @@ def test_kiroide_status_surfaces_malformed_runtime_metadata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture = prepare_fixture(monkeypatch, tmp_path) fixture.plugin.install(fixture.config) (fixture.agent_directory / "config.json").write_text( "{ malformed", encoding="utf-8" @@ -476,7 +209,7 @@ def test_kiroide_rejects_invalid_workspace_hooks_before_runtime_writes( source: Any, message: str, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path, current=source) + fixture = prepare_fixture(monkeypatch, tmp_path, current=source) original = fixture.config_path.read_text(encoding="utf-8") with pytest.raises(ValueError, match=message): @@ -490,7 +223,7 @@ def test_kiroide_cli_preflight_rejects_workspace_before_runtime_creation( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path, current="{ malformed") + fixture = prepare_fixture(monkeypatch, tmp_path, current="{ malformed") private_key_file = tmp_path / "private.key" token_file = tmp_path / "token" private_key_file.write_text(PRIVATE_KEY, encoding="utf-8") @@ -527,7 +260,7 @@ def test_kiroide_rejects_noncanonical_private_keys_before_writes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture = prepare_fixture(monkeypatch, tmp_path) fixture.config["private_key"] = PRIVATE_KEY + "=" with pytest.raises(ValueError, match="canonical 32-byte base64url"): @@ -551,7 +284,7 @@ def test_kiroide_rejects_managed_name_collisions( } ], } - fixture = _prepare_fixture(monkeypatch, tmp_path, current=current) + fixture = prepare_fixture(monkeypatch, tmp_path, current=current) with pytest.raises(ValueError, match="conflicts with the Elydora contract"): fixture.plugin.install(fixture.config) @@ -564,7 +297,7 @@ def test_kiroide_rejects_linked_workspace_configuration_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) + fixture = prepare_fixture(monkeypatch, tmp_path) external = tmp_path / "external-kiro" external.mkdir() try: @@ -583,13 +316,13 @@ def test_kiroide_migrates_only_the_matching_legacy_python_hook( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture)) + fixture = prepare_fixture(monkeypatch, tmp_path) + write_json(fixture.legacy_path, legacy_document(fixture)) fixture.plugin.install(fixture.config) assert not fixture.legacy_path.exists() - _write_json(fixture.legacy_path, _legacy_document(fixture, "agent-2")) + write_json(fixture.legacy_path, legacy_document(fixture, "agent-2")) fixture.plugin.install(fixture.config) assert fixture.legacy_path.exists() @@ -603,7 +336,7 @@ def test_kiroide_preserves_unrelated_legacy_hook_and_rejects_malformed_legacy( "version": "1.0.0", "hooks": {"post_tool_use": {"command": "user-command"}}, } - fixture = _prepare_fixture(monkeypatch, tmp_path, legacy=unrelated) + fixture = prepare_fixture(monkeypatch, tmp_path, legacy=unrelated) fixture.plugin.install(fixture.config) assert json.loads(fixture.legacy_path.read_text(encoding="utf-8")) == unrelated @@ -612,877 +345,3 @@ def test_kiroide_preserves_unrelated_legacy_hook_and_rejects_malformed_legacy( with pytest.raises(ValueError, match="parse legacy Kiro IDE hook"): fixture.plugin.install(fixture.config) assert fixture.legacy_path.read_text(encoding="utf-8") == "{ malformed" - - -def test_kiroide_uninstall_removes_exact_ownership_and_preserves_user_hooks( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - user_hook = { - "name": "workspace-context", - "trigger": "SessionStart", - "action": {"type": "agent", "prompt": "Read AGENTS.md"}, - } - fixture = _prepare_fixture( - monkeypatch, - tmp_path, - current={"version": "v1", "owner": "workspace", "hooks": [user_hook]}, - ) - fixture.plugin.install(fixture.config) - - fixture.plugin.uninstall("agent-2") - assert len(json.loads(fixture.config_path.read_text())["hooks"]) == 3 - assert fixture.agent_directory.exists() - assert (fixture.agent_directory / "private.key").read_text( - encoding="utf-8" - ) == PRIVATE_KEY - fixture.plugin.uninstall(AGENT_ID) - assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == { - "version": "v1", - "owner": "workspace", - "hooks": [user_hook], - } - - owned = _prepare_fixture(monkeypatch, tmp_path / "owned") - owned.plugin.install(owned.config) - owned.plugin.uninstall(AGENT_ID) - assert not owned.config_path.exists() - - -def test_kiroide_uninstall_without_agent_id_removes_owned_runtime( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - - fixture.plugin.uninstall() - - assert not fixture.config_path.exists() - assert not fixture.agent_directory.exists() - assert not (fixture.agent_directory / "private.key").exists() - - -def test_kiroide_uninstall_without_agent_id_removes_current_and_legacy_runtimes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - legacy_agent_id = "agent-2" - legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) - _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) - - fixture.plugin.uninstall() - - assert not fixture.config_path.exists() - assert not fixture.legacy_path.exists() - assert not fixture.agent_directory.exists() - assert not legacy_directory.exists() - assert not (legacy_directory / "private.key").exists() - - -def test_kiroide_no_id_uninstall_streams_all_oversized_error_logs( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - current_error = _write_large_error_log( - fixture.agent_directory, - b"current runtime error\n", - ) - legacy_agent_id = "agent-2" - legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) - legacy_error = _write_large_error_log( - legacy_directory, - b"legacy runtime error\n", - ) - _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) - - fixture.plugin.uninstall() - - assert not current_error.exists() - assert not legacy_error.exists() - assert not fixture.agent_directory.exists() - assert not legacy_directory.exists() - - -def test_kiroide_uninstall_without_agent_id_deduplicates_current_and_legacy_owner( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - - fixture.plugin.uninstall() - - assert not fixture.config_path.exists() - assert not fixture.legacy_path.exists() - assert not fixture.agent_directory.exists() - - -def test_kiroide_uninstall_without_agent_id_rejects_unknown_runtime_before_writes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - legacy_agent_id = "agent-2" - legacy_directory = _write_legacy_runtime(fixture, legacy_agent_id) - _write_json(fixture.legacy_path, _legacy_document(fixture, legacy_agent_id)) - unknown = legacy_directory / "user-data.txt" - unknown.write_text("preserve\n", encoding="utf-8") - original_provider = fixture.config_path.read_bytes() - original_legacy = fixture.legacy_path.read_bytes() - original_key = (fixture.agent_directory / "private.key").read_bytes() - original_legacy_key = (legacy_directory / "private.key").read_bytes() - - with pytest.raises(OSError, match="contains unmanaged entries"): - fixture.plugin.uninstall() - - assert fixture.config_path.read_bytes() == original_provider - assert fixture.legacy_path.read_bytes() == original_legacy - assert (fixture.agent_directory / "private.key").read_bytes() == original_key - assert (legacy_directory / "private.key").read_bytes() == original_legacy_key - assert unknown.read_text(encoding="utf-8") == "preserve\n" - - -def test_kiroide_uninstall_without_agent_id_rolls_back_provider_and_runtime( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - runtime_paths = [ - fixture.guard_path, - fixture.audit_path, - fixture.agent_directory / "private.key", - fixture.agent_directory / "config.json", - ] - original_provider = fixture.config_path.read_bytes() - original_runtime = {path: path.read_bytes() for path in runtime_paths} - original_capture = _transaction._capture_physical - - def fail_private_key_removal( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if Path(source) == fixture.agent_directory / "private.key" and ( - destination.endswith(".rollback") - ): - raise OSError("injected private key removal failure") - original_capture(source, destination, directory) - - monkeypatch.setattr(_transaction, "_capture_physical", fail_private_key_removal) - - with pytest.raises(OSError, match="injected private key removal failure"): - fixture.plugin.uninstall() - - assert fixture.config_path.read_bytes() == original_provider - assert {path: path.read_bytes() for path in runtime_paths} == original_runtime - - -def test_kiroide_transaction_rolls_back_when_legacy_cleanup_fails( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - original = {"version": "v1", "owner": "workspace", "hooks": []} - fixture = _prepare_fixture(monkeypatch, tmp_path, current=original) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - original_capture = _transaction._capture_physical - - def fail_legacy_removal( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if Path(source) == fixture.legacy_path and destination.endswith(".rollback"): - raise OSError("injected legacy cleanup failure") - original_capture(source, destination, directory) - - monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal) - - with pytest.raises(OSError, match="injected legacy cleanup failure"): - fixture.plugin.install(fixture.config) - - assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == original - assert fixture.legacy_path.exists() - for path in ( - fixture.guard_path, - fixture.audit_path, - fixture.agent_directory / "config.json", - fixture.agent_directory / "private.key", - ): - assert not path.exists() - leftovers = list(fixture.config_path.parent.glob(".*.tmp")) - leftovers += list(fixture.config_path.parent.glob(".*.rollback")) - assert leftovers == [] - - -@pytest.mark.parametrize("swapped", ["workspace", "kiro", "hooks"]) -def test_kiroide_transaction_rejects_provider_directory_identity_swaps( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - swapped: str, -) -> None: - fixture = _prepare_fixture( - monkeypatch, - tmp_path, - current={"version": "v1", "hooks": []}, - ) - sources, paths, prepared, _rendered = _prepare_installation(fixture) - monkeypatch.chdir(tmp_path) - original_paths = { - "workspace": fixture.workspace, - "kiro": fixture.workspace / ".kiro", - "hooks": fixture.workspace / ".kiro" / "hooks", - } - original = original_paths[swapped] - backup = original.with_name(original.name + "-original") - replacement = tmp_path / f"redirected-{swapped}" - if swapped == "workspace": - (replacement / ".kiro" / "hooks").mkdir(parents=True) - redirected_config = replacement / ".kiro" / "hooks" / "elydora-audit.json" - elif swapped == "kiro": - (replacement / "hooks").mkdir(parents=True) - redirected_config = replacement / "hooks" / "elydora-audit.json" - else: - replacement.mkdir() - redirected_config = replacement / "elydora-audit.json" - original.rename(backup) - try: - original.symlink_to(replacement, target_is_directory=True) - except OSError as error: - backup.rename(original) - pytest.skip(f"Directory symbolic links are unavailable: {error}") - - with pytest.raises(OSError, match="not a physical directory|changed during"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert not redirected_config.exists() - assert not fixture.agent_directory.exists() - - -def test_kiroide_key_rotation_pins_unchanged_provider_sources( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - original_key = (fixture.agent_directory / "private.key").read_text( - encoding="utf-8" - ) - rotated = InstallConfig(**fixture.config) - rotated["private_key"] = ROTATED_PRIVATE_KEY - sources, paths, prepared, rendered = _prepare_installation(fixture, rotated) - assert rendered.changed is False - document = json.loads(fixture.config_path.read_text(encoding="utf-8")) - document["concurrent_change"] = True - _write_json(fixture.config_path, document) - - with pytest.raises(OSError, match="Kiro IDE hooks changed during"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert (fixture.agent_directory / "private.key").read_text( - encoding="utf-8" - ) == original_key - assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[ - "concurrent_change" - ] is True - - -def test_kiroide_noop_install_rejects_concurrent_provider_changes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - sources, paths, prepared, rendered = _prepare_installation(fixture) - assert prepared.changes == [] - assert rendered.changed is False - document = json.loads(fixture.config_path.read_text(encoding="utf-8")) - _find_hook(document, "elydora-guard")["enabled"] = False - _write_json(fixture.config_path, document) - - with pytest.raises(OSError, match="Kiro IDE hooks changed during"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert _find_hook( - json.loads(fixture.config_path.read_text(encoding="utf-8")), - "elydora-guard", - )["enabled"] is False - - -@pytest.mark.parametrize( - "relative_path", - ["private.key", "config.json", "guard.py", "hook.py"], -) -def test_kiroide_noop_install_pins_every_runtime_source( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - relative_path: str, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - sources, paths, prepared, _rendered = _prepare_installation(fixture) - assert prepared.changes == [] - assert len(prepared.runtime_preconditions) == 4 - target = fixture.agent_directory / relative_path - concurrent = target.read_text(encoding="utf-8") + "\nconcurrent-change" - target.write_text(concurrent, encoding="utf-8") - - with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert target.read_text(encoding="utf-8") == concurrent - - -def test_kiroide_runtime_config_binds_agent_to_absolute_workspace( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - - runtime_config = json.loads( - (fixture.agent_directory / "config.json").read_text(encoding="utf-8") - ) - assert runtime_config["workspace_root"] == str(fixture.workspace.resolve()) - assert Path(runtime_config["workspace_root"]).is_absolute() - - -def test_kiroide_rejects_same_agent_install_from_another_workspace( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - original_hook = fixture.config_path.read_bytes() - runtime_config_path = fixture.agent_directory / "config.json" - original_runtime = runtime_config_path.read_bytes() - second_workspace = tmp_path / "second workspace" - second_workspace.mkdir() - monkeypatch.chdir(second_workspace) - - with pytest.raises(ValueError, match="bound to another workspace"): - fixture.plugin.install(fixture.config) - - assert fixture.config_path.read_bytes() == original_hook - assert runtime_config_path.read_bytes() == original_runtime - assert not (second_workspace / ".kiro").exists() - - -def test_kiroide_cli_uninstall_requires_bound_workspace( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - original_hook = fixture.config_path.read_bytes() - second_workspace = tmp_path / "second workspace" - second_workspace.mkdir() - args = cli.build_parser().parse_args( - [ - "uninstall", - "--agent", - "kiroide", - "--agent_id", - AGENT_ID, - ] - ) - monkeypatch.chdir(second_workspace) - - with pytest.raises(ValueError, match="bound to another workspace"): - cli.cmd_uninstall(args) - - assert fixture.config_path.read_bytes() == original_hook - assert fixture.agent_directory.exists() - assert not (second_workspace / ".kiro").exists() - - monkeypatch.chdir(fixture.workspace) - cli.cmd_uninstall(args) - assert not fixture.config_path.exists() - assert not fixture.agent_directory.exists() - - -def test_kiroide_install_claims_legacy_runtime_owner_from_matching_hooks( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - runtime_config_path = fixture.agent_directory / "config.json" - runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8")) - runtime_config.pop("workspace_root") - _write_json(runtime_config_path, runtime_config) - - fixture.plugin.install(fixture.config) - - claimed = json.loads(runtime_config_path.read_text(encoding="utf-8")) - assert claimed["workspace_root"] == str(fixture.workspace.resolve()) - - -def test_kiroide_install_migrates_exact_global_legacy_runtime_owner( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - runtime_config_path = fixture.agent_directory / "config.json" - _write_json(runtime_config_path, _legacy_runtime_config(fixture)) - config = dict(fixture.config) - config.pop("token") - - fixture.plugin.install(config) - - migrated = json.loads(runtime_config_path.read_text(encoding="utf-8")) - assert migrated["workspace_root"] == str(fixture.workspace.resolve()) - assert "token" not in migrated - assert fixture.config_path.exists() - assert not fixture.legacy_path.exists() - - -def test_kiroide_legacy_owner_migration_rolls_back_runtime_claim( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - runtime_config_path = fixture.agent_directory / "config.json" - original_runtime = _legacy_runtime_config(fixture) - _write_json(runtime_config_path, original_runtime) - original_capture = _transaction._capture_physical - - def fail_legacy_removal( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if Path(source) == fixture.legacy_path and destination.endswith( - ".rollback" - ): - raise OSError("injected legacy migration failure") - original_capture(source, destination, directory) - - monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal) - - with pytest.raises(OSError, match="injected legacy migration failure"): - fixture.plugin.install(fixture.config) - - assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == ( - original_runtime - ) - assert fixture.legacy_path.exists() - assert not fixture.config_path.exists() - - -def test_kiroide_cli_uninstall_accepts_exact_global_legacy_ownership( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - _write_json( - fixture.agent_directory / "config.json", - _legacy_runtime_config(fixture), - ) - args = cli.build_parser().parse_args( - [ - "uninstall", - "--agent", - "kiroide", - "--agent_id", - AGENT_ID, - ] - ) - - cli.cmd_uninstall(args) - - assert not fixture.legacy_path.exists() - assert not fixture.agent_directory.exists() - assert not fixture.config_path.exists() - - -def test_kiroide_rejects_unproved_legacy_runtime_owner( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture, "agent-2")) - runtime_config_path = fixture.agent_directory / "config.json" - original = _legacy_runtime_config(fixture) - _write_json(runtime_config_path, original) - - with pytest.raises(ValueError, match="token is invalid"): - fixture.plugin.install(fixture.config) - - assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == original - assert fixture.legacy_path.exists() - assert not fixture.config_path.exists() - - -def test_kiroide_rejects_invalid_ownerless_legacy_runtime_config( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - _write_json(fixture.legacy_path, _legacy_document(fixture)) - runtime_config_path = fixture.agent_directory / "config.json" - invalid = _legacy_runtime_config(fixture) - invalid["token"] = " " - _write_json(runtime_config_path, invalid) - - with pytest.raises(ValueError, match="token is invalid"): - fixture.plugin.install(fixture.config) - - assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == invalid - assert fixture.legacy_path.exists() - assert not fixture.config_path.exists() - - -@pytest.mark.parametrize( - ("owner", "message"), - [("relative", "must be absolute"), ("other", "bound to another workspace")], -) -def test_kiroide_status_rejects_invalid_runtime_workspace_owner( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - owner: str, - message: str, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - runtime_config_path = fixture.agent_directory / "config.json" - runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8")) - runtime_config["workspace_root"] = ( - "relative" if owner == "relative" else str((tmp_path / "other").resolve()) - ) - _write_json(runtime_config_path, runtime_config) - - with pytest.raises(ValueError, match=message): - fixture.plugin.status() - - -def test_kiroide_failed_install_removes_created_workspace_and_runtime_directories( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - - def fail_first_commit( - _source: str, - _destination: str, - _directory: _transaction.PinnedDirectory, - ) -> None: - raise OSError("injected Kiro IDE commit failure") - - monkeypatch.setattr(_transaction, "_replace_physical", fail_first_commit) - - with pytest.raises(OSError, match="injected Kiro IDE commit failure"): - fixture.plugin.install(fixture.config) - - assert not (fixture.workspace / ".kiro").exists() - # The durable transaction state directory survives failures by design; - # everything else under the runtime root must be rolled back. - elydora_root = fixture.home / ".elydora" - if elydora_root.exists(): - assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"] - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") -@pytest.mark.parametrize("relative_path", ["private.key", "config.json"]) -def test_kiroide_noop_install_pins_runtime_secret_modes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - relative_path: str, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - sources, paths, prepared, _rendered = _prepare_installation(fixture) - target = fixture.agent_directory / relative_path - target.chmod(0o640) - - with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert stat.S_IMODE(target.stat().st_mode) == 0o640 - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") -def test_kiroide_key_rotation_rejects_concurrent_mode_changes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.plugin.install(fixture.config) - rotated = InstallConfig(**fixture.config) - rotated["private_key"] = ROTATED_PRIVATE_KEY - sources, paths, prepared, _rendered = _prepare_installation( - fixture, rotated - ) - key_path = fixture.agent_directory / "private.key" - assert any(change.file_path == str(key_path) for change in prepared.changes) - key_path.chmod(0o640) - - with pytest.raises(OSError, match="changed during installation"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert key_path.read_text(encoding="utf-8") == PRIVATE_KEY - assert stat.S_IMODE(key_path.stat().st_mode) == 0o640 - - -def test_kiroide_install_rejects_runtime_root_link_created_after_prepare( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - sources, paths, prepared, _rendered = _prepare_installation(fixture) - runtime_root = fixture.home / ".elydora" - redirected_root = tmp_path / "redirected-runtime-root" - redirected_root.mkdir() - try: - runtime_root.symlink_to(redirected_root, target_is_directory=True) - except OSError as error: - pytest.skip(f"Directory symbolic links are unavailable: {error}") - - with pytest.raises(OSError, match="not a physical directory|changed during"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert not (redirected_root / AGENT_ID / "private.key").exists() - assert not fixture.config_path.exists() - - -def test_kiroide_install_rejects_agent_directory_identity_swap( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - fixture.agent_directory.mkdir(parents=True) - sources, paths, prepared, _rendered = _prepare_installation(fixture) - original_directory = fixture.agent_directory.with_name("agent-original") - fixture.agent_directory.rename(original_directory) - fixture.agent_directory.mkdir() - - with pytest.raises(OSError, match="changed during"): - kiroide_installation.commit_kiroide_installation( - prepared, sources, paths - ) - - assert not (fixture.agent_directory / "private.key").exists() - assert not fixture.config_path.exists() - - -def test_kiroide_exact_managed_commands_survive_python_relocation( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - executable_name = "python.exe" if os.name == "nt" else "python3" - fake_executable = tmp_path / "other-runtime" / executable_name - with monkeypatch.context() as command_context: - command_context.setattr( - kiroide_command.sys, "executable", str(fake_executable) - ) - guard_command = kiroide_command.build_kiroide_command( - str(fixture.guard_path) - ) - audit_command = kiroide_command.build_kiroide_command( - str(fixture.audit_path) - ) - stale_document = _managed_document(guard_command, audit_command) - _write_json(fixture.config_path, stale_document) - - assert fixture.plugin.status() == { - "installed": False, - "agent": "kiroide", - "details": ( - "Managed hooks are incomplete or invalid: " - f"{fixture.config_path}" - ), - } - - fixture.plugin.install(fixture.config) - repaired = json.loads(fixture.config_path.read_text(encoding="utf-8")) - assert kiroide_command.kiroide_runtime_reference( - _find_hook(repaired, "elydora-guard")["action"]["command"], - "guard.py", - ) is not None - assert kiroide_command.kiroide_runtime_reference( - _find_hook(repaired, "elydora-audit")["action"]["command"], - "hook.py", - ) is not None - - _write_json(fixture.config_path, stale_document) - fixture.plugin.uninstall(AGENT_ID) - - assert not fixture.config_path.exists() - - -@pytest.mark.parametrize("damage", ["field", "orphan"]) -def test_kiroide_stale_command_ownership_survives_contract_drift( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - damage: str, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - executable_name = "python.exe" if os.name == "nt" else "python3" - fake_executable = tmp_path / "other-runtime" / executable_name - with monkeypatch.context() as command_context: - command_context.setattr( - kiroide_command.sys, "executable", str(fake_executable) - ) - document = _managed_document( - kiroide_command.build_kiroide_command(str(fixture.guard_path)), - kiroide_command.build_kiroide_command(str(fixture.audit_path)), - ) - if damage == "field": - _find_hook(document, "elydora-guard")["timeout"] = 9 - else: - document["hooks"].remove(_find_hook(document, "elydora-audit")) - _write_json(fixture.config_path, document) - - fixture.plugin.install(fixture.config) - repaired = json.loads(fixture.config_path.read_text(encoding="utf-8")) - assert len(repaired["hooks"]) == 2 - assert all(hook["timeout"] == 10 for hook in repaired["hooks"]) - - _write_json(fixture.config_path, document) - fixture.plugin.uninstall(AGENT_ID) - - assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[ - "hooks" - ] == [] - - -@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract") -def test_kiroide_current_commands_require_the_system_powershell_launcher( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - fixture = _prepare_fixture(monkeypatch, tmp_path) - command = kiroide_command.build_kiroide_command(str(fixture.guard_path)) - expected = f'"{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 diff --git a/tests/test_kiroide_uninstall.py b/tests/test_kiroide_uninstall.py new file mode 100644 index 0000000..59366ea --- /dev/null +++ b/tests/test_kiroide_uninstall.py @@ -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 diff --git a/tests/test_kiroide_workspace.py b/tests/test_kiroide_workspace.py new file mode 100644 index 0000000..e43dfcf --- /dev/null +++ b/tests/test_kiroide_workspace.py @@ -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() diff --git a/tests/test_letta_plugin.py b/tests/test_letta_plugin.py index 46b6152..abafc55 100644 --- a/tests/test_letta_plugin.py +++ b/tests/test_letta_plugin.py @@ -3,7 +3,6 @@ from __future__ import annotations import json import os from pathlib import Path -from typing import Any import pytest diff --git a/tests/test_pinned_directory.py b/tests/test_pinned_directory.py index 2158bba..10532e7 100644 --- a/tests/test_pinned_directory.py +++ b/tests/test_pinned_directory.py @@ -21,7 +21,7 @@ def test_darwin_private_directory_removal_uses_stdlib_dir_fd( calls.append((path, dir_fd)) 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") diff --git a/tests/test_runtime_removal.py b/tests/test_runtime_removal.py index af37e03..76493d8 100644 --- a/tests/test_runtime_removal.py +++ b/tests/test_runtime_removal.py @@ -1,23 +1,14 @@ from __future__ import annotations -import argparse -import base64 -import hashlib import json import os from pathlib import Path import stat -import subprocess -import sys -from typing import Any import pytest from elydora import cli from elydora.plugins import ( - _directory_removal, - _opaque_cleanup, - _opaque_removal, _transaction, ) from elydora.plugins._runtime_removal import ( @@ -25,76 +16,15 @@ from elydora.plugins._runtime_removal import ( finalize_runtime_removal, prepare_runtime_removal, ) - - -AGENT_ID = "agent-1" -AGENT_NAME = "opencode" -PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode() - - -def _set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None: - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setenv("USERPROFILE", str(home)) - - -def _write_json(path: Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") - - -def _write_secret(path: Path, value: str) -> None: - path.write_text(value, encoding="utf-8") - if os.name != "nt": - path.chmod(0o600) - - -def _write_error_log(path: Path, chunks: int = 1) -> str: - digest = hashlib.sha256() - block = b"opaque historical error\n" * 4096 - with path.open("wb") as file: - for _index in range(chunks): - file.write(block) - digest.update(block) - if os.name != "nt": - path.chmod(0o600) - return digest.hexdigest() - - -def _stream_digest(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as file: - while chunk := file.read(1024 * 1024): - digest.update(chunk) - return digest.hexdigest() - - -def _runtime( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - *, - agent_name: str = AGENT_NAME, -) -> tuple[Path, Path]: - home = tmp_path / "home" - root = home / ".elydora" - agent = root / AGENT_ID - _set_home(monkeypatch, home) - _write_json( - agent / "config.json", - {"agent_id": AGENT_ID, "agent_name": agent_name}, - ) - return root, agent - - -def _uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace: - return cli.build_parser().parse_args( - [ - "uninstall", - "--agent", - agent_name, - "--agent_id", - AGENT_ID, - ] - ) +from runtime_removal_support import ( + AGENT_ID, + AGENT_NAME, + prepare_runtime, + stream_digest, + uninstall_args, + write_error_log, + write_json, +) @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, entry_kind: str, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) + _root, agent = prepare_runtime(monkeypatch, tmp_path) unknown = agent / "user-owned" if entry_kind == "file": 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()) with pytest.raises(OSError, match="unmanaged entries"): - cli.cmd_uninstall(_uninstall_args()) + cli.cmd_uninstall(uninstall_args()) assert provider_calls == [] 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, tmp_path: Path, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path, agent_name="kiroide") + _root, agent = prepare_runtime(monkeypatch, tmp_path, agent_name="kiroide") provider_calls: list[str] = [] 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, "prepare_runtime_removal", reject_generic_prepare) - cli.cmd_uninstall(_uninstall_args("kiroide")) + cli.cmd_uninstall(uninstall_args("kiroide")) assert provider_calls == [AGENT_ID] assert (agent / "config.json").is_file() @@ -163,11 +93,11 @@ def test_runtime_removal_rejects_config_identity_drift( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) + _root, agent = prepare_runtime(monkeypatch, tmp_path) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) assert plan is not None concurrent = {"agent_id": AGENT_ID, "agent_name": "cursor"} - _write_json(agent / "config.json", concurrent) + write_json(agent / "config.json", concurrent) with pytest.raises(OSError, match="changed"): commit_runtime_removal(plan) @@ -181,7 +111,7 @@ def test_runtime_removal_treats_other_plugin_wrappers_as_unmanaged( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> 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" user_file = agent / wrapper_name 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, swap: str, ) -> None: - root, agent = _runtime(monkeypatch, tmp_path) + root, agent = prepare_runtime(monkeypatch, tmp_path) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) assert plan is not None @@ -228,7 +158,7 @@ def test_runtime_removal_rejects_concurrent_creation_of_missing_managed_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) + _root, agent = prepare_runtime(monkeypatch, tmp_path) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) assert plan is not None concurrent = agent / "status-cache.json" @@ -247,7 +177,7 @@ def test_runtime_removal_preserves_unknown_entry_created_before_finalize( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) + _root, agent = prepare_runtime(monkeypatch, tmp_path) plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) assert plan is not None _transaction.write_changes( @@ -270,7 +200,7 @@ def test_runtime_removal_deletes_all_known_dynamic_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - root, agent = _runtime(monkeypatch, tmp_path) + root, agent = prepare_runtime(monkeypatch, tmp_path) dynamic = { "chain-state.json": '{"sequence":1}\n', "status-cache.json": '{"status":"active"}\n', @@ -293,8 +223,8 @@ def test_runtime_removal_streams_oversized_error_log( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - root, agent = _runtime(monkeypatch, tmp_path) - expected_digest = _write_error_log(agent / "error.log", chunks=32) + root, agent = prepare_runtime(monkeypatch, tmp_path) + expected_digest = write_error_log(agent / "error.log", chunks=32) assert (agent / "error.log").stat().st_size > 2 * 1024 * 1024 plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) @@ -312,9 +242,9 @@ def test_oversized_error_log_rolls_back_after_later_text_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) + _root, agent = prepare_runtime(monkeypatch, tmp_path) 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) original_commit = _transaction._commit @@ -329,464 +259,7 @@ def test_oversized_error_log_rolls_back_after_later_text_failure( commit_runtime_removal(plan) 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 (agent / "config.json").is_file() assert list(agent.glob("*.opaque-rollback")) == [] - - -def test_process_exit_after_opaque_removal_restores_error_log_on_restart( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - expected_digest = _write_error_log(error_log, chunks=4) - script = """ -import os -import sys -from elydora.plugins import _transaction -from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal - -agent_id, agent_name = sys.argv[1:] -plan = prepare_runtime_removal(agent_id, agent_name) -assert plan is not None -_transaction._commit = lambda staged: os._exit(78) -commit_runtime_removal(plan) -""" - environment = os.environ.copy() - environment["HOME"] = os.environ["HOME"] - environment["USERPROFILE"] = os.environ["USERPROFILE"] - - completed = subprocess.run( - [sys.executable, "-c", script, AGENT_ID, AGENT_NAME], - cwd=Path(__file__).resolve().parents[1], - env=environment, - check=False, - ) - - assert completed.returncode == 78 - assert not error_log.exists() - _transaction.recover_pending_transactions() - assert _stream_digest(error_log) == expected_digest - assert (agent / "config.json").is_file() - assert list(agent.glob(".*.opaque-rollback")) == [] - - -def test_error_log_append_at_capture_boundary_fails_and_preserves_append( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - _write_error_log(error_log) - appended = b"concurrent append\n" - original_capture = _opaque_removal._capture_physical - - def append_then_capture(staged: Any) -> None: - with error_log.open("ab") as file: - file.write(appended) - file.flush() - os.fsync(file.fileno()) - original_capture(staged) - - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - monkeypatch.setattr( - _opaque_removal, - "_capture_physical", - append_then_capture, - ) - - with pytest.raises(OSError, match="changed at commit boundary"): - commit_runtime_removal(plan) - - assert error_log.read_bytes().endswith(appended) - assert (agent / "config.json").is_file() - - -def test_error_log_replace_at_capture_boundary_preserves_both_files( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - original_digest = _write_error_log(error_log) - displaced = agent / "displaced-error.log" - replacement = b"concurrent replacement\n" - original_capture = _opaque_removal._capture_physical - - def replace_then_capture(staged: Any) -> None: - error_log.rename(displaced) - error_log.write_bytes(replacement) - if os.name != "nt": - error_log.chmod(0o600) - original_capture(staged) - - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - monkeypatch.setattr( - _opaque_removal, - "_capture_physical", - replace_then_capture, - ) - - with pytest.raises(OSError, match="changed at commit boundary"): - commit_runtime_removal(plan) - - assert error_log.read_bytes() == replacement - assert _stream_digest(displaced) == original_digest - assert (agent / "config.json").is_file() - - -def test_error_log_rollback_destination_collision_preserves_victim( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - original_digest = _write_error_log(error_log) - victim = b"concurrent opaque rollback destination\n" - rollback_path: Path | None = None - original_capture = _opaque_removal._capture_physical - - def collide_with_rollback_destination(staged: Any) -> None: - nonlocal rollback_path - assert staged.rollback_path is not None - rollback_path = Path(staged.rollback_path) - rollback_path.write_bytes(victim) - if os.name != "nt": - rollback_path.chmod(0o600) - original_capture(staged) - - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - monkeypatch.setattr( - _opaque_removal, - "_capture_physical", - collide_with_rollback_destination, - ) - - with pytest.raises(OSError, match="exist"): - commit_runtime_removal(plan) - - assert rollback_path is not None - assert rollback_path.read_bytes() == victim - assert _stream_digest(error_log) == original_digest - assert (agent / "config.json").is_file() - - -def test_error_log_cleanup_boundary_append_is_preserved_and_reported( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - _write_error_log(error_log) - appended = b"cleanup boundary append\n" - original_capture = _opaque_cleanup._capture_cleanup_physical - - def append_then_capture( - staged: Any, - source_path: str, - ) -> None: - with Path(source_path).open("ab") as file: - file.write(appended) - file.flush() - os.fsync(file.fileno()) - original_capture(staged, source_path) - - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - monkeypatch.setattr( - _opaque_cleanup, - "_capture_cleanup_physical", - append_then_capture, - ) - - with pytest.raises(OSError, match="changed at cleanup boundary"): - commit_runtime_removal(plan) - - preserved = list(agent.glob(".error.log.*.opaque-rollback")) - assert len(preserved) == 1 - assert preserved[0].read_bytes().endswith(appended) - assert list(agent.glob(".*.elydora-quarantine")) == [] - - -def test_error_log_cleanup_replacement_preserves_both_identities( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - original_digest = _write_error_log(error_log) - replacement = b"concurrent cleanup replacement\n" - displaced = agent / "displaced-opaque-rollback" - rollback_path: Path | None = None - original_capture = _opaque_cleanup._capture_cleanup_physical - - def replace_before_cleanup_capture(staged: Any, source_path: str) -> None: - nonlocal rollback_path - rollback_path = Path(source_path) - rollback_path.rename(displaced) - rollback_path.write_bytes(replacement) - if os.name != "nt": - rollback_path.chmod(0o600) - original_capture(staged, source_path) - - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - monkeypatch.setattr( - _opaque_cleanup, - "_capture_cleanup_physical", - replace_before_cleanup_capture, - ) - - with pytest.raises(OSError, match="changed at cleanup boundary") as raised: - commit_runtime_removal(plan) - - assert rollback_path is not None - assert f"concurrent data preserved at {rollback_path}" in str(raised.value) - assert rollback_path.read_bytes() == replacement - assert _stream_digest(displaced) == original_digest - assert list(agent.glob(".*.elydora-quarantine")) == [] - - -@pytest.mark.parametrize("entry_kind", ["directory", "symlink"]) -def test_error_log_rejects_nonphysical_entries_before_runtime_mutation( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - entry_kind: str, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - original_config = (agent / "config.json").read_bytes() - if entry_kind == "directory": - error_log.mkdir() - (error_log / "marker").write_text("preserve", encoding="utf-8") - else: - target = tmp_path / "external-error.log" - target.write_text("preserve", encoding="utf-8") - try: - error_log.symlink_to(target) - except OSError as error: - pytest.skip(f"File symbolic links are unavailable: {error}") - - with pytest.raises(OSError, match="not a physical file"): - prepare_runtime_removal(AGENT_ID, AGENT_NAME) - - assert (agent / "config.json").read_bytes() == original_config - assert error_log.exists() or error_log.is_symlink() - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract") -def test_error_log_requires_owner_only_permissions_during_prepare( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - error_log = agent / "error.log" - error_log.write_text("historical error\n", encoding="utf-8") - error_log.chmod(0o640) - - with pytest.raises(PermissionError, match="accessible only by its owner"): - prepare_runtime_removal(AGENT_ID, AGENT_NAME) - - assert error_log.read_text(encoding="utf-8") == "historical error\n" - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract") -def test_posix_runtime_directory_removal_cleans_exact_quarantine( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - original_rename = _directory_removal._rename_posix_into_quarantine - calls: list[tuple[int, str, int, str, str]] = [] - - def record_rename( - parent_descriptor: int, - source_name: str, - quarantine_descriptor: int, - source_path: str, - destination_path: str, - ) -> None: - calls.append( - ( - parent_descriptor, - source_name, - quarantine_descriptor, - source_path, - destination_path, - ) - ) - original_rename( - parent_descriptor, - source_name, - quarantine_descriptor, - source_path, - destination_path, - ) - - monkeypatch.setattr( - _directory_removal, - "_rename_posix_into_quarantine", - record_rename, - ) - - commit_runtime_removal(plan) - - assert len(calls) == 1 - assert isinstance(calls[0][0], int) - assert calls[0][1] == AGENT_ID - assert isinstance(calls[0][2], int) - assert Path(calls[0][3]) == agent - assert Path(calls[0][4]).name == "entry" - quarantined = list( - Path(plan.runtime_root).glob( - f".{AGENT_ID}.*.elydora-removed/entry" - ) - ) - assert quarantined == [] - assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == [] - assert not agent.exists() - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract") -def test_posix_runtime_quarantine_preserves_atomic_boundary_swap( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - root, agent = _runtime(monkeypatch, tmp_path) - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - replacement = root / "replacement" - replacement.mkdir() - marker = replacement / "user-owned" - marker.write_text("preserve", encoding="utf-8") - original = root / "agent-original" - real_rename = _directory_removal._rename_posix_into_quarantine - - def swap_then_quarantine( - parent_descriptor: int, - source_name: str, - quarantine_descriptor: int, - source_path: str, - destination_path: str, - ) -> None: - agent.rename(original) - replacement.rename(agent) - real_rename( - parent_descriptor, - source_name, - quarantine_descriptor, - source_path, - destination_path, - ) - - real_rmdir = _directory_removal.os.rmdir - - def reject_path_rmdir( - path: str, - *, - dir_fd: int | None = None, - ) -> None: - if dir_fd is None: - raise AssertionError("POSIX runtime cleanup must use a parent dirfd") - real_rmdir(path, dir_fd=dir_fd) - - monkeypatch.setattr( - _directory_removal, - "_rename_posix_into_quarantine", - swap_then_quarantine, - ) - monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir) - - with pytest.raises(OSError, match="changed while quarantining"): - commit_runtime_removal(plan) - - quarantined_markers = list( - root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned") - ) - assert len(quarantined_markers) == 1 - assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve" - assert original.is_dir() - - -@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract") -def test_windows_runtime_directory_removal_marks_exact_pinned_handle( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _root, agent = _runtime(monkeypatch, tmp_path) - plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME) - assert plan is not None - original_mark = _directory_removal._mark_windows_directory_for_deletion - observed: list[tuple[int, int]] = [] - - def record_mark(directory: Any) -> None: - assert directory.descriptor is not None - opened = os.fstat(directory.descriptor) - observed.append((opened.st_dev, opened.st_ino)) - assert observed[-1] == ( - plan.agent_snapshot.device, - plan.agent_snapshot.inode, - ) - original_mark(directory) - - monkeypatch.setattr( - _directory_removal, - "_mark_windows_directory_for_deletion", - record_mark, - ) - - commit_runtime_removal(plan) - - assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)] - assert not agent.exists() - - -def test_kiroide_cli_install_failure_leaves_no_runtime_root( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - home.mkdir() - workspace.mkdir() - _set_home(monkeypatch, home) - monkeypatch.chdir(workspace) - key_path = tmp_path / "install-private.key" - _write_secret(key_path, PRIVATE_KEY) - args = cli.build_parser().parse_args( - [ - "install", - "--agent", - "kiroide", - "--org_id", - "org-1", - "--agent_id", - AGENT_ID, - "--private_key_file", - str(key_path), - "--kid", - "kid-1", - ] - ) - - def fail_commit(_staged: object) -> None: - raise OSError("injected Kiro IDE CLI commit failure") - - monkeypatch.setattr(_transaction, "_commit", fail_commit) - - with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"): - cli.cmd_install(args) - - # The durable transaction state directory survives failures by design; - # everything else under the runtime root must be rolled back. - elydora_root = home / ".elydora" - if elydora_root.exists(): - assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"] - assert not (workspace / ".kiro").exists() diff --git a/tests/test_runtime_removal_directories.py b/tests/test_runtime_removal_directories.py new file mode 100644 index 0000000..b7fb00a --- /dev/null +++ b/tests/test_runtime_removal_directories.py @@ -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() diff --git a/tests/test_runtime_removal_error_log.py b/tests/test_runtime_removal_error_log.py new file mode 100644 index 0000000..bd681b0 --- /dev/null +++ b/tests/test_runtime_removal_error_log.py @@ -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" diff --git a/tests/test_transaction_directories.py b/tests/test_transaction_directories.py new file mode 100644 index 0000000..cfad378 --- /dev/null +++ b/tests/test_transaction_directories.py @@ -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() diff --git a/tests/test_transaction_rollback.py b/tests/test_transaction_rollback.py new file mode 100644 index 0000000..3daf942 --- /dev/null +++ b/tests/test_transaction_rollback.py @@ -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")) == [] diff --git a/tests/test_transaction_security.py b/tests/test_transaction_security.py index 2134da6..caf9909 100644 --- a/tests/test_transaction_security.py +++ b/tests/test_transaction_security.py @@ -10,31 +10,13 @@ import time import pytest -from elydora.plugins import _transaction, _transaction_staging -from elydora.plugins._managed_files import read_physical_directory, read_physical_file - - -def _write(path: Path, source: str) -> None: - path.write_bytes(source.encode("utf-8")) - - -def _change( - path: Path, - original: str | None, - next_source: str | None, - mode: int = 0o600, - enforce_mode: bool = False, -) -> _transaction.FileChange: - change = _transaction.source_change( - str(path), - path.name, - original, - next_source, - mode, - enforce_mode=enforce_mode, - ) - assert change is not None - return change +from elydora.plugins import _transaction +from elydora.plugins._managed_files import read_physical_file +from transaction_support import ( + make_change, + transaction_subprocess_environment, + write_source, +) def test_post_replace_precondition_failure_restores_original_target( @@ -43,8 +25,8 @@ def test_post_replace_precondition_failure_restores_original_target( ) -> None: target = tmp_path / "target.txt" provider = tmp_path / "provider.txt" - _write(target, "old\n") - _write(provider, "stable\n") + write_source(target, "old\n") + write_source(provider, "stable\n") provider_snapshot = read_physical_file(str(provider), "provider") real_replace = _transaction._replace_physical @@ -55,7 +37,7 @@ def test_post_replace_precondition_failure_restores_original_target( ) -> None: real_replace(source, destination, directory) if os.path.abspath(destination) == os.path.abspath(str(target)): - _write(provider, "concurrent\n") + write_source(provider, "concurrent\n") monkeypatch.setattr( _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"): _transaction.write_changes( - [_change(target, "old\n", "new\n")], + [make_change(target, "old\n", "new\n")], "provider transaction", [_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)], ) @@ -78,7 +60,7 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected( tmp_path: Path, ) -> None: target = tmp_path / "target.txt" - _write(target, "old\n") + write_source(target, "old\n") real_capture = _transaction._capture_physical changed = False @@ -94,14 +76,14 @@ def test_commit_boundary_concurrent_write_is_restored_and_rejected( and destination.endswith(".rollback") ): changed = True - _write(target, "concurrent\n") + write_source(target, "concurrent\n") real_capture(source, destination, directory) monkeypatch.setattr(_transaction, "_capture_physical", write_before_capture) with pytest.raises(OSError, match="changed at commit boundary"): _transaction.write_changes( - [_change(target, "old\n", "new\n")], + [make_change(target, "old\n", "new\n")], "commit boundary", ) @@ -116,7 +98,7 @@ def test_existing_target_remains_visible_during_atomic_update( tmp_path: Path, ) -> None: target = tmp_path / "target.txt" - _write(target, "old\n") + write_source(target, "old\n") replace_started = threading.Event() release_replace = threading.Event() failures: list[BaseException] = [] @@ -135,7 +117,7 @@ def test_existing_target_remains_visible_during_atomic_update( def update() -> None: try: _transaction.write_changes( - [_change(target, "old\n", "new\n")], + [make_change(target, "old\n", "new\n")], "visible atomic update", ) 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" -def _transaction_subprocess_environment(home: Path) -> dict[str, str]: - environment = os.environ.copy() - environment["HOME"] = str(home) - environment["USERPROFILE"] = str(home) - return environment - - def test_process_exit_after_atomic_replace_recovers_on_restart( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -170,7 +145,7 @@ def test_process_exit_after_atomic_replace_recovers_on_restart( home = tmp_path / "home" home.mkdir() target = tmp_path / "target.txt" - _write(target, "old\n") + write_source(target, "old\n") monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) script = """ @@ -193,7 +168,7 @@ _transaction.write_changes([change], "crash recovery") completed = subprocess.run( [sys.executable, "-c", script, str(target)], cwd=Path(__file__).resolve().parents[1], - env=_transaction_subprocess_environment(home), + env=transaction_subprocess_environment(home), check=False, ) @@ -212,7 +187,7 @@ def test_process_exit_after_committed_marker_keeps_atomic_update( home = tmp_path / "home" home.mkdir() target = tmp_path / "target.txt" - _write(target, "old\n") + write_source(target, "old\n") monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) script = """ @@ -230,7 +205,7 @@ _transaction.write_changes([change], "committed crash recovery") completed = subprocess.run( [sys.executable, "-c", script, str(target)], cwd=Path(__file__).resolve().parents[1], - env=_transaction_subprocess_environment(home), + env=transaction_subprocess_environment(home), check=False, ) @@ -250,8 +225,8 @@ def test_process_exit_mid_transaction_restores_all_targets( home.mkdir() first = tmp_path / "first.txt" second = tmp_path / "second.txt" - _write(first, "first-old\n") - _write(second, "second-old\n") + write_source(first, "first-old\n") + write_source(second, "second-old\n") monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) script = """ @@ -281,7 +256,7 @@ _transaction.write_changes(changes, "multi-file crash recovery") completed = subprocess.run( [sys.executable, "-c", script, str(first), str(second)], cwd=Path(__file__).resolve().parents[1], - env=_transaction_subprocess_environment(home), + env=transaction_subprocess_environment(home), check=False, ) @@ -313,7 +288,7 @@ def test_process_exit_recovers_atomic_create_and_remove( home.mkdir() target = tmp_path / "target.txt" if original is not None: - _write(target, original) + write_source(target, original) monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) script = """ @@ -343,7 +318,7 @@ _transaction.write_changes([change], "create-remove crash recovery") str(exit_code), ], cwd=Path(__file__).resolve().parents[1], - env=_transaction_subprocess_environment(home), + env=transaction_subprocess_environment(home), check=False, ) @@ -366,8 +341,8 @@ def test_cross_process_writers_are_serialized( ready = tmp_path / "ready" second_ready = tmp_path / "second-ready" release = tmp_path / "release" - _write(target, "old\n") - environment = _transaction_subprocess_environment(home) + write_source(target, "old\n") + environment = transaction_subprocess_environment(home) first_script = """ import pathlib import sys @@ -443,632 +418,3 @@ except OSError as error: assert second.returncode == 19, output assert "changed during" in output assert target.read_text(encoding="utf-8") == "first\n" - - -def test_final_replace_exception_after_success_restores_original( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - _write(target, "old\n") - real_replace = _transaction._replace_physical - - def replace_then_raise( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - real_replace(source, destination, directory) - if os.path.abspath(destination) == os.path.abspath(str(target)): - raise OSError("injected post-replace failure") - - monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise) - - with pytest.raises(OSError, match="injected post-replace failure"): - _transaction.write_changes( - [_change(target, "old\n", "new\n")], - "post-replace failure", - ) - - assert target.read_text(encoding="utf-8") == "old\n" - assert list(tmp_path.glob(".*.rollback")) == [] - - -def test_capture_exception_after_success_restores_original( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - _write(target, "old\n") - real_capture = _transaction._capture_physical - - def capture_then_raise( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - real_capture(source, destination, directory) - raise OSError("injected post-capture failure") - - monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise) - - with pytest.raises(OSError, match="injected post-capture failure"): - _transaction.write_changes( - [_change(target, "old\n", "new\n")], - "post-capture failure", - ) - - assert target.read_text(encoding="utf-8") == "old\n" - assert list(tmp_path.glob(".*.rollback")) == [] - - -def test_rollback_reservation_collision_preserves_victim_and_original( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - _write(target, "old\n") - victim = "concurrent rollback destination\n" - collision_path: Path | None = None - - def collide_with_rollback_destination( - _source: str, - destination: str, - _directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal collision_path - if destination.endswith(".rollback"): - collision_path = Path(destination) - _write(collision_path, victim) - - monkeypatch.setattr( - _transaction, - "_capture_physical", - collide_with_rollback_destination, - ) - - with pytest.raises(OSError, match="exist"): - _transaction.write_changes( - [_change(target, "old\n", "new\n")], - "rollback destination collision", - ) - - assert collision_path is not None - assert target.read_text(encoding="utf-8") == "old\n" - assert collision_path.read_text(encoding="utf-8") == victim - - -def test_final_install_does_not_overwrite_concurrent_target( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - _write(target, "old\n") - real_replace = _transaction._replace_physical - injected = False - - def create_target_before_install( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal injected - if not injected and os.path.abspath(destination) == os.path.abspath(target): - injected = True - _write(target, "concurrent\n") - real_replace(source, destination, directory) - - monkeypatch.setattr( - _transaction, - "_replace_physical", - create_target_before_install, - ) - - with pytest.raises(OSError, match="original content preserved"): - _transaction.write_changes( - [_change(target, "old\n", "new\n")], - "no-clobber install", - ) - - assert injected - assert target.read_text(encoding="utf-8") == "concurrent\n" - rollbacks = list(tmp_path.glob(".*.rollback")) - assert len(rollbacks) == 1 - assert rollbacks[0].read_text(encoding="utf-8") == "old\n" - - -def test_restore_does_not_overwrite_concurrent_target( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - second = tmp_path / "second.txt" - _write(target, "old\n") - _write(second, "second-old\n") - real_replace = _transaction._replace_physical - real_restore = _transaction._restore_physical - injected = False - - def fail_second_commit( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if os.path.abspath(destination) == os.path.abspath(second): - raise OSError("injected second failure") - real_replace(source, destination, directory) - - def create_target_before_restore( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal injected - if not injected and os.path.abspath(destination) == os.path.abspath(target): - injected = True - _write(target, "concurrent recovery\n") - real_restore(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) - monkeypatch.setattr( - _transaction, - "_restore_physical", - create_target_before_restore, - ) - - with pytest.raises(OSError, match="original content preserved"): - _transaction.write_changes( - [ - _change(target, "old\n", "new\n"), - _change(second, "second-old\n", "second-new\n"), - ], - "no-clobber restoration", - ) - - assert injected - assert target.read_text(encoding="utf-8") == "concurrent recovery\n" - rollbacks = list(tmp_path.glob(".*.rollback")) - assert len(rollbacks) == 1 - assert rollbacks[0].read_text(encoding="utf-8") == "old\n" - assert Path(_transaction.active_journal_path()).is_file() - _transaction.clear_journal() - - -def test_restore_source_replacement_reports_existing_preserved_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - target = tmp_path / "target.txt" - second = tmp_path / "second.txt" - displaced = tmp_path / "displaced-original.txt" - _write(target, "old\n") - _write(second, "second-old\n") - real_replace = _transaction._replace_physical - real_restore = _transaction._restore_physical - rollback_path: Path | None = None - - def fail_second_commit( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if os.path.abspath(destination) == os.path.abspath(second): - raise OSError("injected second failure") - real_replace(source, destination, directory) - - def replace_restore_source( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal rollback_path - if ( - rollback_path is None - and source.endswith(".rollback") - and os.path.abspath(destination) == os.path.abspath(target) - ): - rollback_path = Path(source) - rollback_path.rename(displaced) - _write(rollback_path, "concurrent recovery source\n") - real_restore(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) - monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source) - - with pytest.raises(OSError, match="recovery source changed") as raised: - _transaction.write_changes( - [ - _change(target, "old\n", "new\n"), - _change(second, "second-old\n", "second-new\n"), - ], - "restore source replacement", - ) - - assert rollback_path is not None - assert f"concurrent data preserved at {rollback_path}" in str(raised.value) - assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n" - assert displaced.read_text(encoding="utf-8") == "old\n" - assert target.read_text(encoding="utf-8") == "new\n" - assert second.read_text(encoding="utf-8") == "second-old\n" - assert Path(_transaction.active_journal_path()).is_file() - _transaction.clear_journal() - - -def test_creation_rollback_captures_and_preserves_concurrent_replacement( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - created = tmp_path / "created.txt" - second = tmp_path / "second.txt" - _write(second, "second-old\n") - real_replace = _transaction._replace_physical - real_capture = _transaction._capture_physical - injected = False - - def fail_second_commit( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if os.path.abspath(destination) == os.path.abspath(second): - raise OSError("injected second failure") - real_replace(source, destination, directory) - - def replace_created_before_recovery_capture( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal injected - if ( - not injected - and os.path.abspath(source) == os.path.abspath(created) - and destination.endswith(".recovery") - ): - injected = True - _write(created, "concurrent replacement\n") - real_capture(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit) - monkeypatch.setattr( - _transaction, - "_capture_physical", - replace_created_before_recovery_capture, - ) - - with pytest.raises(OSError, match="changed at recovery boundary"): - _transaction.write_changes( - [ - _change(created, None, "generated\n"), - _change(second, "second-old\n", "second-new\n"), - ], - "creation recovery capture", - ) - - assert injected - assert created.read_text(encoding="utf-8") == "concurrent replacement\n" - assert Path(_transaction.active_journal_path()).is_file() - _transaction.clear_journal() - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract") -def test_mode_repair_uses_private_rollback_and_restores_original_mode( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - secret = tmp_path / "private.key" - second = tmp_path / "config.json" - _write(secret, "secret\n") - _write(second, "old\n") - secret.chmod(0o644) - real_replace = _transaction._replace_physical - rollback_modes: list[int] = [] - - def inspect_then_fail_second( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if os.path.abspath(destination) == os.path.abspath(str(secret)): - rollback = next(tmp_path.glob(".private.key.*.rollback")) - rollback_modes.append(rollback.stat().st_mode & 0o777) - if os.path.abspath(destination) == os.path.abspath(str(second)): - raise OSError("injected second commit failure") - real_replace(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second) - - with pytest.raises(OSError, match="injected second commit failure"): - _transaction.write_changes( - [ - _change(secret, "secret\n", "secret\n", enforce_mode=True), - _change(second, "old\n", "new\n"), - ], - "mode repair", - ) - - assert rollback_modes - assert set(rollback_modes) == {0o600} - assert secret.read_text(encoding="utf-8") == "secret\n" - assert secret.stat().st_mode & 0o777 == 0o644 - assert second.read_text(encoding="utf-8") == "old\n" - assert list(tmp_path.glob(".*.rollback")) == [] - - -@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract") -def test_deletion_uses_private_rollback_and_restores_original_mode( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - removed = tmp_path / "removed.txt" - second = tmp_path / "second.txt" - _write(removed, "remove me\n") - _write(second, "old\n") - removed.chmod(0o644) - real_replace = _transaction._replace_physical - rollback_modes: list[int] = [] - - def inspect_then_fail_second( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - if os.path.abspath(destination) == os.path.abspath(str(second)): - rollback = next(tmp_path.glob(".removed.txt.*.rollback")) - rollback_modes.append(rollback.stat().st_mode & 0o777) - raise OSError("injected second commit failure") - real_replace(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second) - - with pytest.raises(OSError, match="injected second commit failure"): - _transaction.write_changes( - [ - _change(removed, "remove me\n", None), - _change(second, "old\n", "new\n"), - ], - "deletion rollback mode", - ) - - assert rollback_modes == [0o600] - assert removed.read_text(encoding="utf-8") == "remove me\n" - assert removed.stat().st_mode & 0o777 == 0o644 - assert second.read_text(encoding="utf-8") == "old\n" - assert list(tmp_path.glob(".*.rollback")) == [] - - -def _swap_directory(parent: Path, backup: Path, redirected: Path) -> None: - parent.rename(backup) - redirected.rename(parent) - - -@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") -def test_staging_directory_swap_never_writes_secret_to_redirected_path( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - parent = tmp_path / "runtime" - backup = tmp_path / "runtime-original" - redirected = tmp_path / "runtime-redirected" - parent.mkdir() - redirected.mkdir() - target = parent / "private.key" - real_write = _transaction_staging.write_staged - swapped = False - - def swap_then_stage(*args: object, **kwargs: object): - nonlocal swapped - if not swapped: - swapped = True - _swap_directory(parent, backup, redirected) - return real_write(*args, **kwargs) - - monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage) - - with pytest.raises(OSError, match="changed during transaction"): - _transaction.write_changes( - [_change(target, None, "secret\n")], - "staging swap", - ) - - assert not (parent / "private.key").exists() - assert not (backup / "private.key").exists() - assert list(parent.iterdir()) == [] - assert list(backup.iterdir()) == [] - - -@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") -def test_commit_directory_swap_rolls_back_inside_pinned_directory( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - parent = tmp_path / "runtime" - backup = tmp_path / "runtime-original" - redirected = tmp_path / "runtime-redirected" - parent.mkdir() - redirected.mkdir() - target = parent / "private.key" - real_replace = _transaction._replace_physical - swapped = False - - def swap_then_replace( - source: str, - destination: str, - directory: _transaction.PinnedDirectory, - ) -> None: - nonlocal swapped - if not swapped: - swapped = True - _swap_directory(parent, backup, redirected) - real_replace(source, destination, directory) - - monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace) - - with pytest.raises(OSError, match="changed during transaction"): - _transaction.write_changes( - [_change(target, None, "secret\n")], - "commit swap", - ) - - assert not (parent / "private.key").exists() - assert not (backup / "private.key").exists() - assert list(parent.iterdir()) == [] - assert list(backup.iterdir()) == [] - - -def test_failed_transaction_removes_only_created_empty_directories( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - kiro = tmp_path / ".kiro" - hooks = kiro / "hooks" - hooks.mkdir(parents=True) - kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory") - hooks_snapshot = read_physical_directory(str(hooks), "hooks directory") - target = hooks / "elydora-audit.json" - - def fail_replace( - _source: str, - _destination: str, - _directory: _transaction.PinnedDirectory, - ) -> None: - raise OSError("injected commit failure") - - monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) - - with pytest.raises(OSError, match="injected commit failure"): - _transaction.write_changes( - [_change(target, None, "{}\n")], - "created directory recovery", - directory_preconditions=[ - _transaction.DirectoryPrecondition( - str(kiro), "Kiro directory", kiro_snapshot, created=True - ), - _transaction.DirectoryPrecondition( - str(hooks), "hooks directory", hooks_snapshot, created=True - ), - ], - ) - - assert not hooks.exists() - assert not kiro.exists() - - -def test_created_directory_recovery_keeps_parent_and_child_pinned( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - kiro = tmp_path / ".kiro" - hooks = kiro / "hooks" - hooks.mkdir(parents=True) - kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory") - hooks_snapshot = read_physical_directory(str(hooks), "hooks directory") - target = hooks / "elydora-audit.json" - observed: list[tuple[Path, Path]] = [] - real_remove = _transaction_staging.remove_pinned_empty_directory - - def observe_pins( - parent: object, - child: object, - *, - require_empty: bool = True, - ) -> None: - assert isinstance(parent, _transaction.PinnedDirectory) - assert isinstance(child, _transaction.PinnedDirectory) - assert parent.descriptor is not None - assert child.descriptor is not None - assert os.fstat(child.descriptor).st_ino == child.snapshot.inode - observed.append((Path(parent.path), Path(child.path))) - real_remove(parent, child, require_empty=require_empty) - - def fail_replace( - _source: str, - _destination: str, - _directory: _transaction.PinnedDirectory, - ) -> None: - raise OSError("injected pinned cleanup failure") - - monkeypatch.setattr( - _transaction_staging, - "remove_pinned_empty_directory", - observe_pins, - ) - monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) - - with pytest.raises(OSError, match="injected pinned cleanup failure"): - _transaction.write_changes( - [_change(target, None, "{}\n")], - "pinned directory recovery", - directory_preconditions=[ - _transaction.DirectoryPrecondition( - str(kiro), "Kiro directory", kiro_snapshot, created=True - ), - _transaction.DirectoryPrecondition( - str(hooks), "hooks directory", hooks_snapshot, created=True - ), - ], - ) - - expected = [(kiro, hooks), (tmp_path, kiro)] - assert observed == expected - assert not kiro.exists() - - -@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename") -def test_created_directory_recovery_preserves_swapped_directory( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - created = tmp_path / "created" - created.mkdir() - created_snapshot = read_physical_directory(str(created), "created directory") - target = created / "managed.json" - replacement = tmp_path / "replacement" - replacement.mkdir() - marker = replacement / "user-owned" - marker.write_text("preserve", encoding="utf-8") - original = tmp_path / "created-original" - real_remove = _transaction_staging.remove_pinned_empty_directory - - def swap_before_removal( - parent: _transaction.PinnedDirectory, - child: _transaction.PinnedDirectory, - *, - require_empty: bool = True, - ) -> None: - created.rename(original) - replacement.rename(created) - real_remove(parent, child, require_empty=require_empty) - - def fail_replace( - _source: str, - _destination: str, - _directory: _transaction.PinnedDirectory, - ) -> None: - raise OSError("injected swap cleanup failure") - - monkeypatch.setattr( - _transaction_staging, - "remove_pinned_empty_directory", - swap_before_removal, - ) - monkeypatch.setattr(_transaction, "_replace_physical", fail_replace) - - with pytest.raises(OSError, match="recovery failed"): - _transaction.write_changes( - [_change(target, None, "{}\n")], - "swapped directory recovery", - directory_preconditions=[ - _transaction.DirectoryPrecondition( - str(created), - "created directory", - created_snapshot, - created=True, - ) - ], - ) - - assert (created / marker.name).read_text(encoding="utf-8") == "preserve" - assert original.is_dir() - assert Path(_transaction.active_journal_path()).is_file() - _transaction.clear_journal() diff --git a/tests/transaction_support.py b/tests/transaction_support.py new file mode 100644 index 0000000..4d2f41c --- /dev/null +++ b/tests/transaction_support.py @@ -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)