chore(oss): audit the server package and resync the SDK mirrors
Split the 657-line operation service into eor, submit, and verify modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already emits, and condense every multi-line comment. Mirror Node, Python, and Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation grace, and path-encoding fixes. Document which routes and response fields the self-hosted server does not provide. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
2010175a82
commit
bc6983a62e
364 files changed
+10180
-23349
No files matched your search
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import kiroide
|
||||
from elydora.plugins import (
|
||||
kiroide_contract,
|
||||
kiroide_installation,
|
||||
kiroide_io,
|
||||
)
|
||||
from elydora.plugins.base import InstallConfig
|
||||
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([11]) * 32).rstrip(b"=").decode()
|
||||
ROTATED_PRIVATE_KEY = (
|
||||
base64.urlsafe_b64encode(bytes([12]) * 32).rstrip(b"=").decode()
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiroIdeFixture:
|
||||
home: Path
|
||||
workspace: Path
|
||||
config_path: Path
|
||||
legacy_path: Path
|
||||
agent_directory: Path
|
||||
guard_path: Path
|
||||
audit_path: Path
|
||||
plugin: kiroide.KiroIdePlugin
|
||||
config: InstallConfig
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def install_config(home: Path) -> InstallConfig:
|
||||
return {
|
||||
"org_id": "org-1",
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_name": "kiroide",
|
||||
"private_key": PRIVATE_KEY,
|
||||
"kid": "kid-1",
|
||||
"token": "token-1",
|
||||
"base_url": "https://api.elydora.test",
|
||||
"guard_script_path": str(home / ".elydora" / AGENT_ID / "guard.py"),
|
||||
}
|
||||
|
||||
|
||||
def prepare_fixture(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
current: Any = None,
|
||||
legacy: Any = None,
|
||||
base_url: str = "https://api.elydora.test",
|
||||
) -> KiroIdeFixture:
|
||||
home = tmp_path / "home with spaces and 'quote %KIRO%"
|
||||
workspace = tmp_path / "workspace with spaces"
|
||||
home.mkdir(parents=True)
|
||||
workspace.mkdir(parents=True)
|
||||
config_path = workspace / ".kiro" / "hooks" / "elydora-audit.json"
|
||||
legacy_path = home / ".kiro" / "hooks" / "elydora-audit.kiro.hook"
|
||||
if current is not None:
|
||||
if isinstance(current, str):
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(current, encoding="utf-8")
|
||||
else:
|
||||
write_json(config_path, current)
|
||||
if legacy is not None:
|
||||
if isinstance(legacy, str):
|
||||
legacy_path.parent.mkdir(parents=True)
|
||||
legacy_path.write_text(legacy, encoding="utf-8")
|
||||
else:
|
||||
write_json(legacy_path, legacy)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.chdir(workspace)
|
||||
agent_directory = home / ".elydora" / AGENT_ID
|
||||
config = install_config(home)
|
||||
config["base_url"] = base_url
|
||||
return KiroIdeFixture(
|
||||
home,
|
||||
workspace,
|
||||
config_path,
|
||||
legacy_path,
|
||||
agent_directory,
|
||||
agent_directory / "guard.py",
|
||||
agent_directory / "hook.py",
|
||||
kiroide.KiroIdePlugin(),
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def find_hook(document: dict[str, Any], name: str) -> dict[str, Any]:
|
||||
return next(hook for hook in document["hooks"] if hook.get("name") == name)
|
||||
|
||||
|
||||
def managed_document(guard_command: str, audit_command: str) -> dict[str, Any]:
|
||||
return {
|
||||
"version": "v1",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "elydora-guard",
|
||||
"description": "Block tool use when the Elydora agent is frozen",
|
||||
"trigger": "PreToolUse",
|
||||
"matcher": ".*",
|
||||
"action": {"type": "command", "command": guard_command},
|
||||
"timeout": 10,
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"name": "elydora-audit",
|
||||
"description": "Record tool use in the Elydora audit trail",
|
||||
"trigger": "PostToolUse",
|
||||
"matcher": ".*",
|
||||
"action": {"type": "command", "command": audit_command},
|
||||
"timeout": 10,
|
||||
"enabled": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def legacy_document(fixture: KiroIdeFixture, agent_id: str = AGENT_ID) -> dict:
|
||||
agent_directory = fixture.home / ".elydora" / agent_id
|
||||
return {
|
||||
"name": "Elydora Audit",
|
||||
"description": (
|
||||
"Sends tool-use events to the Elydora tamper-evident audit platform"
|
||||
),
|
||||
"version": "1.0.0",
|
||||
"hooks": {
|
||||
"pre_tool_use": {
|
||||
"command": f'"{sys.executable}" {agent_directory / "guard.py"}',
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
"post_tool_use": {
|
||||
"command": str(agent_directory / "hook.py"),
|
||||
"timeout_ms": 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def legacy_runtime_config(
|
||||
fixture: KiroIdeFixture, agent_id: str = AGENT_ID
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"org_id": "org-1",
|
||||
"agent_id": agent_id,
|
||||
"kid": "kid-1",
|
||||
"base_url": "https://api.elydora.test",
|
||||
"token": "",
|
||||
"agent_name": "kiroide",
|
||||
}
|
||||
|
||||
|
||||
def write_legacy_runtime(fixture: KiroIdeFixture, agent_id: str) -> Path:
|
||||
agent_directory = fixture.home / ".elydora" / agent_id
|
||||
write_json(
|
||||
agent_directory / "config.json",
|
||||
legacy_runtime_config(fixture, agent_id),
|
||||
)
|
||||
(agent_directory / "private.key").write_text(PRIVATE_KEY, encoding="utf-8")
|
||||
(agent_directory / "guard.py").write_text("guard\n", encoding="utf-8")
|
||||
(agent_directory / "hook.py").write_text("audit\n", encoding="utf-8")
|
||||
return agent_directory
|
||||
|
||||
|
||||
def write_large_error_log(agent_directory: Path, marker: bytes) -> Path:
|
||||
error_log = agent_directory / "error.log"
|
||||
with error_log.open("wb") as file:
|
||||
for _index in range(48):
|
||||
file.write(marker * 4096)
|
||||
if os.name != "nt":
|
||||
error_log.chmod(0o600)
|
||||
assert error_log.stat().st_size > 2 * 1024 * 1024
|
||||
return error_log
|
||||
|
||||
|
||||
def run_command(
|
||||
command: str, fixture: KiroIdeFixture, payload: dict[str, Any]
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=fixture.workspace,
|
||||
env={
|
||||
**os.environ,
|
||||
"HOME": str(fixture.home),
|
||||
"USERPROFILE": str(fixture.home),
|
||||
},
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class AuditHandler(BaseHTTPRequestHandler):
|
||||
operations: list[dict[str, Any]] = []
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length).decode("utf-8")
|
||||
self.operations.append(json.loads(raw))
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditServer:
|
||||
server: ThreadingHTTPServer
|
||||
thread: threading.Thread
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
host, port = self.server.server_address
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
def close(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join()
|
||||
|
||||
|
||||
def start_audit_server() -> AuditServer:
|
||||
AuditHandler.operations = []
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), AuditHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return AuditServer(server, thread)
|
||||
|
||||
|
||||
def prepare_installation(
|
||||
fixture: KiroIdeFixture,
|
||||
config: InstallConfig | None = None,
|
||||
) -> tuple[
|
||||
kiroide_io.KiroIdeSources,
|
||||
kiroide_installation.KiroIdeRuntimePaths,
|
||||
kiroide_installation.PreparedKiroIdeInstallation,
|
||||
kiroide_contract.RenderedKiroIdeDocument,
|
||||
]:
|
||||
selected = fixture.config if config is None else config
|
||||
sources = kiroide_io.read_kiroide_sources()
|
||||
kiroide_contract.require_available_kiroide_hooks(sources.document.hooks)
|
||||
paths = kiroide_installation.preflight_kiroide_installation(
|
||||
selected, sources
|
||||
)
|
||||
hooks = kiroide_contract.without_managed_kiroide_hooks(
|
||||
sources.document.hooks
|
||||
)
|
||||
rendered = kiroide_contract.render_kiroide_document(
|
||||
sources.document,
|
||||
[
|
||||
*hooks,
|
||||
kiroide_contract.build_kiroide_hook(
|
||||
kiroide_contract.GUARD_NAME, paths.guard_path
|
||||
),
|
||||
kiroide_contract.build_kiroide_hook(
|
||||
kiroide_contract.AUDIT_NAME, paths.audit_path
|
||||
),
|
||||
],
|
||||
)
|
||||
prepared = kiroide_installation.prepare_kiroide_installation(
|
||||
selected, paths, sources, rendered
|
||||
)
|
||||
return sources, paths, prepared, rendered
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
AGENT_NAME = "opencode"
|
||||
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([19]) * 32).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def set_home(monkeypatch: pytest.MonkeyPatch, home: Path) -> None:
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
|
||||
|
||||
def write_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def write_secret(path: Path, value: str) -> None:
|
||||
path.write_text(value, encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def write_error_log(path: Path, chunks: int = 1) -> str:
|
||||
digest = hashlib.sha256()
|
||||
block = b"opaque historical error\n" * 4096
|
||||
with path.open("wb") as file:
|
||||
for _index in range(chunks):
|
||||
file.write(block)
|
||||
digest.update(block)
|
||||
if os.name != "nt":
|
||||
path.chmod(0o600)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def stream_digest(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
while chunk := file.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def prepare_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
agent_name: str = AGENT_NAME,
|
||||
) -> tuple[Path, Path]:
|
||||
home = tmp_path / "home"
|
||||
root = home / ".elydora"
|
||||
agent = root / AGENT_ID
|
||||
set_home(monkeypatch, home)
|
||||
write_json(
|
||||
agent / "config.json",
|
||||
{"agent_id": AGENT_ID, "agent_name": agent_name},
|
||||
)
|
||||
return root, agent
|
||||
|
||||
|
||||
def uninstall_args(agent_name: str = AGENT_NAME) -> argparse.Namespace:
|
||||
return cli.build_parser().parse_args(
|
||||
[
|
||||
"uninstall",
|
||||
"--agent",
|
||||
agent_name,
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
]
|
||||
)
|
||||
@@ -101,7 +101,7 @@ def test_sync_registration_requires_and_sends_integration_type(registration_serv
|
||||
host, port = registration_server.server_address
|
||||
client = ElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=1, token="api-token",
|
||||
base_url=f"http://{host}:{port}", max_retries=0, token="api-token",
|
||||
)
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
@@ -109,6 +109,8 @@ def test_sync_registration_requires_and_sends_integration_type(registration_serv
|
||||
client.register_agent({
|
||||
"agent_id": "agent-1", "integration_type": "future-cli", "keys": [],
|
||||
})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
client.update_agent("agent-1", "future-cli")
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = client.register_agent(registration_request())
|
||||
@@ -122,11 +124,13 @@ async def test_async_registration_requires_and_sends_integration_type(registrati
|
||||
host, port = registration_server.server_address
|
||||
client = AsyncElydoraClient(
|
||||
"org-1", "admin-agent", "unused",
|
||||
base_url=f"http://{host}:{port}", max_retries=1, token="api-token",
|
||||
base_url=f"http://{host}:{port}", max_retries=0, token="api-token",
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="Invalid integration_type None"):
|
||||
await client.register_agent({"agent_id": "agent-1", "keys": []})
|
||||
with pytest.raises(ValueError, match="Invalid integration_type 'future-cli'"):
|
||||
await client.update_agent("agent-1", "future-cli")
|
||||
assert registration_server.requests == []
|
||||
|
||||
response = await client.register_agent(registration_request())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess # nosec B404
|
||||
|
||||
@@ -9,7 +9,7 @@ import sys
|
||||
from threading import Thread
|
||||
import time
|
||||
|
||||
from elydora.plugins.hook_template import generate_guard_script
|
||||
from elydora.plugins.guard_template import generate_guard_script
|
||||
|
||||
|
||||
AGENT_ID = "agent 1"
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins._shell_command import is_python_executable, windows_powershell_path
|
||||
from elydora.plugins import (
|
||||
kiroide_command,
|
||||
)
|
||||
|
||||
from kiroide_support import (
|
||||
AGENT_ID,
|
||||
find_hook,
|
||||
managed_document,
|
||||
prepare_fixture,
|
||||
write_json,
|
||||
)
|
||||
|
||||
|
||||
def test_kiroide_exact_managed_commands_survive_python_relocation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
executable_name = "python.exe" if os.name == "nt" else "python3"
|
||||
fake_executable = tmp_path / "other-runtime" / executable_name
|
||||
with monkeypatch.context() as command_context:
|
||||
command_context.setattr(
|
||||
kiroide_command.sys, "executable", str(fake_executable)
|
||||
)
|
||||
guard_command = kiroide_command.build_kiroide_command(
|
||||
str(fixture.guard_path)
|
||||
)
|
||||
audit_command = kiroide_command.build_kiroide_command(
|
||||
str(fixture.audit_path)
|
||||
)
|
||||
stale_document = managed_document(guard_command, audit_command)
|
||||
write_json(fixture.config_path, stale_document)
|
||||
|
||||
assert fixture.plugin.status() == {
|
||||
"installed": False,
|
||||
"agent": "kiroide",
|
||||
"details": (
|
||||
"Managed hooks are incomplete or invalid: "
|
||||
f"{fixture.config_path}"
|
||||
),
|
||||
}
|
||||
|
||||
fixture.plugin.install(fixture.config)
|
||||
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
assert kiroide_command.kiroide_runtime_reference(
|
||||
find_hook(repaired, "elydora-guard")["action"]["command"],
|
||||
"guard.py",
|
||||
) is not None
|
||||
assert kiroide_command.kiroide_runtime_reference(
|
||||
find_hook(repaired, "elydora-audit")["action"]["command"],
|
||||
"hook.py",
|
||||
) is not None
|
||||
|
||||
write_json(fixture.config_path, stale_document)
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["field", "orphan"])
|
||||
def test_kiroide_stale_command_ownership_survives_contract_drift(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
damage: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
executable_name = "python.exe" if os.name == "nt" else "python3"
|
||||
fake_executable = tmp_path / "other-runtime" / executable_name
|
||||
with monkeypatch.context() as command_context:
|
||||
command_context.setattr(
|
||||
kiroide_command.sys, "executable", str(fake_executable)
|
||||
)
|
||||
document = managed_document(
|
||||
kiroide_command.build_kiroide_command(str(fixture.guard_path)),
|
||||
kiroide_command.build_kiroide_command(str(fixture.audit_path)),
|
||||
)
|
||||
if damage == "field":
|
||||
find_hook(document, "elydora-guard")["timeout"] = 9
|
||||
else:
|
||||
document["hooks"].remove(find_hook(document, "elydora-audit"))
|
||||
write_json(fixture.config_path, document)
|
||||
|
||||
fixture.plugin.install(fixture.config)
|
||||
repaired = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
assert len(repaired["hooks"]) == 2
|
||||
assert all(hook["timeout"] == 10 for hook in repaired["hooks"])
|
||||
|
||||
write_json(fixture.config_path, document)
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
|
||||
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
|
||||
"hooks"
|
||||
] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
|
||||
def test_kiroide_current_commands_require_the_system_powershell_launcher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
command = kiroide_command.build_kiroide_command(str(fixture.guard_path))
|
||||
expected = f'"{windows_powershell_path()}"'
|
||||
altered = command.replace(expected, r'"C:\Other\powershell.exe"', 1)
|
||||
|
||||
assert kiroide_command.kiroide_runtime_reference(altered, "guard.py") is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows launcher contract")
|
||||
def test_kiroide_windows_launcher_fails_when_python_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing_python = tmp_path / "missing-runtime" / "python.exe"
|
||||
script_path = tmp_path / "guard.py"
|
||||
with monkeypatch.context() as command_context:
|
||||
command_context.setattr(
|
||||
kiroide_command.sys, "executable", str(missing_python)
|
||||
)
|
||||
command = kiroide_command.build_kiroide_command(str(script_path))
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"executable_name",
|
||||
["python3.13t", "python3.14t", "python3.13d.exe", "pypy3", "pypy3.10.exe"],
|
||||
)
|
||||
def test_kiroide_ownership_accepts_supported_python_runtime_names(
|
||||
executable_name: str,
|
||||
) -> None:
|
||||
assert is_python_executable(executable_name)
|
||||
|
||||
|
||||
def test_kiroide_builder_rejects_unrecoverable_executable_names(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
with monkeypatch.context() as command_context:
|
||||
command_context.setattr(
|
||||
kiroide_command.sys,
|
||||
"executable",
|
||||
str(tmp_path / "embedded-runtime"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="absolute Python executable"):
|
||||
kiroide_command.build_kiroide_command(str(tmp_path / "guard.py"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["orphan", "duplicate", "altered"])
|
||||
def test_kiroide_status_rejects_every_incomplete_managed_hook_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
damage: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
audit = find_hook(document, "elydora-audit")
|
||||
if damage == "orphan":
|
||||
document["hooks"].remove(audit)
|
||||
elif damage == "duplicate":
|
||||
document["hooks"].append(copy.deepcopy(audit))
|
||||
else:
|
||||
find_hook(document, "elydora-guard")["timeout"] = 9
|
||||
write_json(fixture.config_path, document)
|
||||
|
||||
status = fixture.plugin.status()
|
||||
assert status["installed"] is False
|
||||
assert status["details"] == (
|
||||
f"Managed hooks are incomplete or invalid: {fixture.config_path}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||
@pytest.mark.parametrize(
|
||||
("target", "message"),
|
||||
[
|
||||
("key", "private key must be accessible only by its owner"),
|
||||
("config", "Token-bearing Elydora runtime config must be accessible"),
|
||||
],
|
||||
)
|
||||
def test_kiroide_repairs_and_enforces_owner_only_runtime_secrets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
target: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
paths = {
|
||||
"key": fixture.agent_directory / "private.key",
|
||||
"config": fixture.agent_directory / "config.json",
|
||||
}
|
||||
paths[target].chmod(0o644)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
fixture.plugin.status()
|
||||
|
||||
fixture.plugin.install(fixture.config)
|
||||
assert stat.S_IMODE(paths[target].stat().st_mode) == 0o600
|
||||
assert fixture.plugin.status()["installed"] is True
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import (
|
||||
_transaction,
|
||||
kiroide_installation,
|
||||
)
|
||||
from elydora.plugins.base import InstallConfig
|
||||
|
||||
from kiroide_support import (
|
||||
AGENT_ID,
|
||||
PRIVATE_KEY,
|
||||
ROTATED_PRIVATE_KEY,
|
||||
find_hook,
|
||||
legacy_document,
|
||||
prepare_fixture,
|
||||
prepare_installation,
|
||||
write_json,
|
||||
write_large_error_log,
|
||||
write_legacy_runtime,
|
||||
)
|
||||
|
||||
|
||||
def test_kiroide_uninstall_removes_exact_ownership_and_preserves_user_hooks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
user_hook = {
|
||||
"name": "workspace-context",
|
||||
"trigger": "SessionStart",
|
||||
"action": {"type": "agent", "prompt": "Read AGENTS.md"},
|
||||
}
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
current={"version": "v1", "owner": "workspace", "hooks": [user_hook]},
|
||||
)
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
fixture.plugin.uninstall("agent-2")
|
||||
assert len(json.loads(fixture.config_path.read_text())["hooks"]) == 3
|
||||
assert fixture.agent_directory.exists()
|
||||
assert (fixture.agent_directory / "private.key").read_text(
|
||||
encoding="utf-8"
|
||||
) == PRIVATE_KEY
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == {
|
||||
"version": "v1",
|
||||
"owner": "workspace",
|
||||
"hooks": [user_hook],
|
||||
}
|
||||
|
||||
owned = prepare_fixture(monkeypatch, tmp_path / "owned")
|
||||
owned.plugin.install(owned.config)
|
||||
owned.plugin.uninstall(AGENT_ID)
|
||||
assert not owned.config_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_uninstall_without_agent_id_removes_owned_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert not fixture.config_path.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
assert not (fixture.agent_directory / "private.key").exists()
|
||||
|
||||
|
||||
def test_kiroide_uninstall_without_agent_id_removes_current_and_legacy_runtimes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
legacy_agent_id = "agent-2"
|
||||
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert not fixture.config_path.exists()
|
||||
assert not fixture.legacy_path.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
assert not legacy_directory.exists()
|
||||
assert not (legacy_directory / "private.key").exists()
|
||||
|
||||
|
||||
def test_kiroide_no_id_uninstall_streams_all_oversized_error_logs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
current_error = write_large_error_log(
|
||||
fixture.agent_directory,
|
||||
b"current runtime error\n",
|
||||
)
|
||||
legacy_agent_id = "agent-2"
|
||||
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||
legacy_error = write_large_error_log(
|
||||
legacy_directory,
|
||||
b"legacy runtime error\n",
|
||||
)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert not current_error.exists()
|
||||
assert not legacy_error.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
assert not legacy_directory.exists()
|
||||
|
||||
|
||||
def test_kiroide_uninstall_without_agent_id_deduplicates_current_and_legacy_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert not fixture.config_path.exists()
|
||||
assert not fixture.legacy_path.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
|
||||
|
||||
def test_kiroide_uninstall_without_agent_id_rejects_unknown_runtime_before_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
legacy_agent_id = "agent-2"
|
||||
legacy_directory = write_legacy_runtime(fixture, legacy_agent_id)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture, legacy_agent_id))
|
||||
unknown = legacy_directory / "user-data.txt"
|
||||
unknown.write_text("preserve\n", encoding="utf-8")
|
||||
original_provider = fixture.config_path.read_bytes()
|
||||
original_legacy = fixture.legacy_path.read_bytes()
|
||||
original_key = (fixture.agent_directory / "private.key").read_bytes()
|
||||
original_legacy_key = (legacy_directory / "private.key").read_bytes()
|
||||
|
||||
with pytest.raises(OSError, match="contains unmanaged entries"):
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert fixture.config_path.read_bytes() == original_provider
|
||||
assert fixture.legacy_path.read_bytes() == original_legacy
|
||||
assert (fixture.agent_directory / "private.key").read_bytes() == original_key
|
||||
assert (legacy_directory / "private.key").read_bytes() == original_legacy_key
|
||||
assert unknown.read_text(encoding="utf-8") == "preserve\n"
|
||||
|
||||
|
||||
def test_kiroide_uninstall_without_agent_id_rolls_back_provider_and_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
runtime_paths = [
|
||||
fixture.guard_path,
|
||||
fixture.audit_path,
|
||||
fixture.agent_directory / "private.key",
|
||||
fixture.agent_directory / "config.json",
|
||||
]
|
||||
original_provider = fixture.config_path.read_bytes()
|
||||
original_runtime = {path: path.read_bytes() for path in runtime_paths}
|
||||
original_capture = _transaction._capture_physical
|
||||
|
||||
def fail_private_key_removal(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if Path(source) == fixture.agent_directory / "private.key" and (
|
||||
destination.endswith(".rollback")
|
||||
):
|
||||
raise OSError("injected private key removal failure")
|
||||
original_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", fail_private_key_removal)
|
||||
|
||||
with pytest.raises(OSError, match="injected private key removal failure"):
|
||||
fixture.plugin.uninstall()
|
||||
|
||||
assert fixture.config_path.read_bytes() == original_provider
|
||||
assert {path: path.read_bytes() for path in runtime_paths} == original_runtime
|
||||
|
||||
|
||||
def test_kiroide_transaction_rolls_back_when_legacy_cleanup_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
original = {"version": "v1", "owner": "workspace", "hooks": []}
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path, current=original)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
original_capture = _transaction._capture_physical
|
||||
|
||||
def fail_legacy_removal(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if Path(source) == fixture.legacy_path and destination.endswith(".rollback"):
|
||||
raise OSError("injected legacy cleanup failure")
|
||||
original_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
|
||||
|
||||
with pytest.raises(OSError, match="injected legacy cleanup failure"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert json.loads(fixture.config_path.read_text(encoding="utf-8")) == original
|
||||
assert fixture.legacy_path.exists()
|
||||
for path in (
|
||||
fixture.guard_path,
|
||||
fixture.audit_path,
|
||||
fixture.agent_directory / "config.json",
|
||||
fixture.agent_directory / "private.key",
|
||||
):
|
||||
assert not path.exists()
|
||||
leftovers = list(fixture.config_path.parent.glob(".*.tmp"))
|
||||
leftovers += list(fixture.config_path.parent.glob(".*.rollback"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("swapped", ["workspace", "kiro", "hooks"])
|
||||
def test_kiroide_transaction_rejects_provider_directory_identity_swaps(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
swapped: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
current={"version": "v1", "hooks": []},
|
||||
)
|
||||
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
original_paths = {
|
||||
"workspace": fixture.workspace,
|
||||
"kiro": fixture.workspace / ".kiro",
|
||||
"hooks": fixture.workspace / ".kiro" / "hooks",
|
||||
}
|
||||
original = original_paths[swapped]
|
||||
backup = original.with_name(original.name + "-original")
|
||||
replacement = tmp_path / f"redirected-{swapped}"
|
||||
if swapped == "workspace":
|
||||
(replacement / ".kiro" / "hooks").mkdir(parents=True)
|
||||
redirected_config = replacement / ".kiro" / "hooks" / "elydora-audit.json"
|
||||
elif swapped == "kiro":
|
||||
(replacement / "hooks").mkdir(parents=True)
|
||||
redirected_config = replacement / "hooks" / "elydora-audit.json"
|
||||
else:
|
||||
replacement.mkdir()
|
||||
redirected_config = replacement / "elydora-audit.json"
|
||||
original.rename(backup)
|
||||
try:
|
||||
original.symlink_to(replacement, target_is_directory=True)
|
||||
except OSError as error:
|
||||
backup.rename(original)
|
||||
pytest.skip(f"Directory symbolic links are unavailable: {error}")
|
||||
|
||||
with pytest.raises(OSError, match="not a physical directory|changed during"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert not redirected_config.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
|
||||
|
||||
def test_kiroide_key_rotation_pins_unchanged_provider_sources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
original_key = (fixture.agent_directory / "private.key").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
rotated = InstallConfig(**fixture.config)
|
||||
rotated["private_key"] = ROTATED_PRIVATE_KEY
|
||||
sources, paths, prepared, rendered = prepare_installation(fixture, rotated)
|
||||
assert rendered.changed is False
|
||||
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
document["concurrent_change"] = True
|
||||
write_json(fixture.config_path, document)
|
||||
|
||||
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert (fixture.agent_directory / "private.key").read_text(
|
||||
encoding="utf-8"
|
||||
) == original_key
|
||||
assert json.loads(fixture.config_path.read_text(encoding="utf-8"))[
|
||||
"concurrent_change"
|
||||
] is True
|
||||
|
||||
|
||||
def test_kiroide_noop_install_rejects_concurrent_provider_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
sources, paths, prepared, rendered = prepare_installation(fixture)
|
||||
assert prepared.changes == []
|
||||
assert rendered.changed is False
|
||||
document = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
find_hook(document, "elydora-guard")["enabled"] = False
|
||||
write_json(fixture.config_path, document)
|
||||
|
||||
with pytest.raises(OSError, match="Kiro IDE hooks changed during"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert find_hook(
|
||||
json.loads(fixture.config_path.read_text(encoding="utf-8")),
|
||||
"elydora-guard",
|
||||
)["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative_path",
|
||||
["private.key", "config.json", "guard.py", "hook.py"],
|
||||
)
|
||||
def test_kiroide_noop_install_pins_every_runtime_source(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
relative_path: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||
assert prepared.changes == []
|
||||
assert len(prepared.runtime_preconditions) == 4
|
||||
target = fixture.agent_directory / relative_path
|
||||
concurrent = target.read_text(encoding="utf-8") + "\nconcurrent-change"
|
||||
target.write_text(concurrent, encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == concurrent
|
||||
@@ -0,0 +1,367 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
from elydora.plugins import (
|
||||
_transaction,
|
||||
kiroide_installation,
|
||||
)
|
||||
from elydora.plugins.base import InstallConfig
|
||||
|
||||
from kiroide_support import (
|
||||
AGENT_ID,
|
||||
PRIVATE_KEY,
|
||||
ROTATED_PRIVATE_KEY,
|
||||
legacy_document,
|
||||
legacy_runtime_config,
|
||||
prepare_fixture,
|
||||
prepare_installation,
|
||||
write_json,
|
||||
)
|
||||
|
||||
|
||||
def test_kiroide_runtime_config_binds_agent_to_absolute_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
runtime_config = json.loads(
|
||||
(fixture.agent_directory / "config.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert runtime_config["workspace_root"] == str(fixture.workspace.resolve())
|
||||
assert Path(runtime_config["workspace_root"]).is_absolute()
|
||||
|
||||
|
||||
def test_kiroide_rejects_same_agent_install_from_another_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
original_hook = fixture.config_path.read_bytes()
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
original_runtime = runtime_config_path.read_bytes()
|
||||
second_workspace = tmp_path / "second workspace"
|
||||
second_workspace.mkdir()
|
||||
monkeypatch.chdir(second_workspace)
|
||||
|
||||
with pytest.raises(ValueError, match="bound to another workspace"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert fixture.config_path.read_bytes() == original_hook
|
||||
assert runtime_config_path.read_bytes() == original_runtime
|
||||
assert not (second_workspace / ".kiro").exists()
|
||||
|
||||
|
||||
def test_kiroide_cli_uninstall_requires_bound_workspace(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
original_hook = fixture.config_path.read_bytes()
|
||||
second_workspace = tmp_path / "second workspace"
|
||||
second_workspace.mkdir()
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"uninstall",
|
||||
"--agent",
|
||||
"kiroide",
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
]
|
||||
)
|
||||
monkeypatch.chdir(second_workspace)
|
||||
|
||||
with pytest.raises(ValueError, match="bound to another workspace"):
|
||||
cli.cmd_uninstall(args)
|
||||
|
||||
assert fixture.config_path.read_bytes() == original_hook
|
||||
assert fixture.agent_directory.exists()
|
||||
assert not (second_workspace / ".kiro").exists()
|
||||
|
||||
monkeypatch.chdir(fixture.workspace)
|
||||
cli.cmd_uninstall(args)
|
||||
assert not fixture.config_path.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
|
||||
|
||||
def test_kiroide_install_claims_legacy_runtime_owner_from_matching_hooks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||
runtime_config.pop("workspace_root")
|
||||
write_json(runtime_config_path, runtime_config)
|
||||
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
claimed = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||
assert claimed["workspace_root"] == str(fixture.workspace.resolve())
|
||||
|
||||
|
||||
def test_kiroide_install_migrates_exact_global_legacy_runtime_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
write_json(runtime_config_path, legacy_runtime_config(fixture))
|
||||
config = dict(fixture.config)
|
||||
config.pop("token")
|
||||
|
||||
fixture.plugin.install(config)
|
||||
|
||||
migrated = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||
assert migrated["workspace_root"] == str(fixture.workspace.resolve())
|
||||
assert "token" not in migrated
|
||||
assert fixture.config_path.exists()
|
||||
assert not fixture.legacy_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_legacy_owner_migration_rolls_back_runtime_claim(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
original_runtime = legacy_runtime_config(fixture)
|
||||
write_json(runtime_config_path, original_runtime)
|
||||
original_capture = _transaction._capture_physical
|
||||
|
||||
def fail_legacy_removal(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if Path(source) == fixture.legacy_path and destination.endswith(
|
||||
".rollback"
|
||||
):
|
||||
raise OSError("injected legacy migration failure")
|
||||
original_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", fail_legacy_removal)
|
||||
|
||||
with pytest.raises(OSError, match="injected legacy migration failure"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == (
|
||||
original_runtime
|
||||
)
|
||||
assert fixture.legacy_path.exists()
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_cli_uninstall_accepts_exact_global_legacy_ownership(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
write_json(
|
||||
fixture.agent_directory / "config.json",
|
||||
legacy_runtime_config(fixture),
|
||||
)
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"uninstall",
|
||||
"--agent",
|
||||
"kiroide",
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
]
|
||||
)
|
||||
|
||||
cli.cmd_uninstall(args)
|
||||
|
||||
assert not fixture.legacy_path.exists()
|
||||
assert not fixture.agent_directory.exists()
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_rejects_unproved_legacy_runtime_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture, "agent-2"))
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
original = legacy_runtime_config(fixture)
|
||||
write_json(runtime_config_path, original)
|
||||
|
||||
with pytest.raises(ValueError, match="token is invalid"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == original
|
||||
assert fixture.legacy_path.exists()
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_rejects_invalid_ownerless_legacy_runtime_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(fixture.legacy_path, legacy_document(fixture))
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
invalid = legacy_runtime_config(fixture)
|
||||
invalid["token"] = " "
|
||||
write_json(runtime_config_path, invalid)
|
||||
|
||||
with pytest.raises(ValueError, match="token is invalid"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert json.loads(runtime_config_path.read_text(encoding="utf-8")) == invalid
|
||||
assert fixture.legacy_path.exists()
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("owner", "message"),
|
||||
[("relative", "must be absolute"), ("other", "bound to another workspace")],
|
||||
)
|
||||
def test_kiroide_status_rejects_invalid_runtime_workspace_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
owner: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
runtime_config_path = fixture.agent_directory / "config.json"
|
||||
runtime_config = json.loads(runtime_config_path.read_text(encoding="utf-8"))
|
||||
runtime_config["workspace_root"] = (
|
||||
"relative" if owner == "relative" else str((tmp_path / "other").resolve())
|
||||
)
|
||||
write_json(runtime_config_path, runtime_config)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
fixture.plugin.status()
|
||||
|
||||
|
||||
def test_kiroide_failed_install_removes_created_workspace_and_runtime_directories(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
|
||||
def fail_first_commit(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected Kiro IDE commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_first_commit)
|
||||
|
||||
with pytest.raises(OSError, match="injected Kiro IDE commit failure"):
|
||||
fixture.plugin.install(fixture.config)
|
||||
|
||||
assert not (fixture.workspace / ".kiro").exists()
|
||||
# The durable transaction state directory survives failures by design;
|
||||
# everything else under the runtime root must be rolled back.
|
||||
elydora_root = fixture.home / ".elydora"
|
||||
if elydora_root.exists():
|
||||
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||
@pytest.mark.parametrize("relative_path", ["private.key", "config.json"])
|
||||
def test_kiroide_noop_install_pins_runtime_secret_modes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
relative_path: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||
target = fixture.agent_directory / relative_path
|
||||
target.chmod(0o640)
|
||||
|
||||
with pytest.raises(OSError, match="changed during Install Kiro IDE hooks"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert stat.S_IMODE(target.stat().st_mode) == 0o640
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract")
|
||||
def test_kiroide_key_rotation_rejects_concurrent_mode_changes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.plugin.install(fixture.config)
|
||||
rotated = InstallConfig(**fixture.config)
|
||||
rotated["private_key"] = ROTATED_PRIVATE_KEY
|
||||
sources, paths, prepared, _rendered = prepare_installation(
|
||||
fixture, rotated
|
||||
)
|
||||
key_path = fixture.agent_directory / "private.key"
|
||||
assert any(change.file_path == str(key_path) for change in prepared.changes)
|
||||
key_path.chmod(0o640)
|
||||
|
||||
with pytest.raises(OSError, match="changed during installation"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert key_path.read_text(encoding="utf-8") == PRIVATE_KEY
|
||||
assert stat.S_IMODE(key_path.stat().st_mode) == 0o640
|
||||
|
||||
|
||||
def test_kiroide_install_rejects_runtime_root_link_created_after_prepare(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||
runtime_root = fixture.home / ".elydora"
|
||||
redirected_root = tmp_path / "redirected-runtime-root"
|
||||
redirected_root.mkdir()
|
||||
try:
|
||||
runtime_root.symlink_to(redirected_root, target_is_directory=True)
|
||||
except OSError as error:
|
||||
pytest.skip(f"Directory symbolic links are unavailable: {error}")
|
||||
|
||||
with pytest.raises(OSError, match="not a physical directory|changed during"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert not (redirected_root / AGENT_ID / "private.key").exists()
|
||||
assert not fixture.config_path.exists()
|
||||
|
||||
|
||||
def test_kiroide_install_rejects_agent_directory_identity_swap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.agent_directory.mkdir(parents=True)
|
||||
sources, paths, prepared, _rendered = prepare_installation(fixture)
|
||||
original_directory = fixture.agent_directory.with_name("agent-original")
|
||||
fixture.agent_directory.rename(original_directory)
|
||||
fixture.agent_directory.mkdir()
|
||||
|
||||
with pytest.raises(OSError, match="changed during"):
|
||||
kiroide_installation.commit_kiroide_installation(
|
||||
prepared, sources, paths
|
||||
)
|
||||
|
||||
assert not (fixture.agent_directory / "private.key").exists()
|
||||
assert not fixture.config_path.exists()
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ def install_plugin(
|
||||
elydora_dir = tmp_path / ".elydora"
|
||||
monkeypatch.setattr(opencode, "PLUGIN_DIR", str(plugin_dir))
|
||||
monkeypatch.setattr(opencode, "ELYDORA_DIR", str(elydora_dir))
|
||||
|
||||
agent_dir = elydora_dir / AGENT_ID
|
||||
agent_dir.mkdir(parents=True)
|
||||
guard_path = agent_dir / "guard.py"
|
||||
@@ -31,7 +32,9 @@ def install_plugin(
|
||||
"import sys\nsys.stderr.write('Agent is frozen by Elydora.')\nraise SystemExit(2)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
opencode.OpenCodePlugin().install({
|
||||
|
||||
plugin = opencode.OpenCodePlugin()
|
||||
plugin.install({
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_name": "opencode",
|
||||
"org_id": "org-1",
|
||||
@@ -66,9 +69,11 @@ def test_generated_opencode_plugin_uses_current_api_and_blocks_frozen_agent(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
plugin_path, hook_path, _guard_path = install_plugin(monkeypatch, tmp_path)
|
||||
|
||||
assert hook_path.is_file()
|
||||
module_url = plugin_path.as_uri()
|
||||
script = f"""
|
||||
const pluginModule = await import({json.dumps(plugin_path.as_uri())});
|
||||
const pluginModule = await import({json.dumps(module_url)});
|
||||
const hooks = await pluginModule.ElydoraAuditPlugin({{ project: {{ name: 'project' }} }});
|
||||
if (typeof hooks['tool.execute.before'] !== 'function') process.exit(10);
|
||||
if (typeof hooks['tool.execute.after'] !== 'function') process.exit(11);
|
||||
@@ -82,7 +87,9 @@ def test_generated_opencode_plugin_uses_current_api_and_blocks_frozen_agent(
|
||||
if (!String(error.message).includes('Agent is frozen by Elydora')) process.exit(13);
|
||||
}}
|
||||
"""
|
||||
|
||||
result = run_node(script, tmp_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert opencode.OpenCodePlugin().status()["installed"] is True
|
||||
assert SUPPORTED_AGENTS["opencode"]["config_path"] == "~/.config/opencode/plugins/"
|
||||
@@ -112,8 +119,10 @@ def test_generated_opencode_plugin_forwards_tool_event(
|
||||
{{ title: 'Shell', output: 'test' }},
|
||||
);
|
||||
"""
|
||||
|
||||
result = run_node(script, tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
deadline = time.monotonic() + 3
|
||||
while not capture_path.exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_sync_client_does_not_replay_non_idempotent_requests(
|
||||
)
|
||||
|
||||
with pytest.raises(ElydoraError) as error:
|
||||
client.issue_token()
|
||||
client.issue_api_token()
|
||||
assert error.value.status_code == 503
|
||||
assert scripted_server.methods == ["POST"]
|
||||
assert delays == []
|
||||
@@ -241,7 +241,7 @@ async def test_async_client_does_not_replay_non_idempotent_requests(
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ElydoraError) as error:
|
||||
await client.issue_token()
|
||||
await client.issue_api_token()
|
||||
assert error.value.status_code == 503
|
||||
assert scripted_server.methods == ["POST"]
|
||||
assert delays == []
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora import cli
|
||||
from elydora.plugins import (
|
||||
_directory_removal,
|
||||
_transaction,
|
||||
)
|
||||
from elydora.plugins._runtime_removal import (
|
||||
commit_runtime_removal,
|
||||
prepare_runtime_removal,
|
||||
)
|
||||
from runtime_removal_support import (
|
||||
AGENT_ID,
|
||||
AGENT_NAME,
|
||||
PRIVATE_KEY,
|
||||
prepare_runtime,
|
||||
set_home,
|
||||
write_secret,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-dirfd contract")
|
||||
def test_posix_runtime_directory_removal_cleans_exact_quarantine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
original_rename = _directory_removal._rename_posix_into_quarantine
|
||||
calls: list[tuple[int, str, int, str, str]] = []
|
||||
|
||||
def record_rename(
|
||||
parent_descriptor: int,
|
||||
source_name: str,
|
||||
quarantine_descriptor: int,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
calls.append(
|
||||
(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
)
|
||||
original_rename(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_rename_posix_into_quarantine",
|
||||
record_rename,
|
||||
)
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert isinstance(calls[0][0], int)
|
||||
assert calls[0][1] == AGENT_ID
|
||||
assert isinstance(calls[0][2], int)
|
||||
assert Path(calls[0][3]) == agent
|
||||
assert Path(calls[0][4]).name == "entry"
|
||||
quarantined = list(
|
||||
Path(plan.runtime_root).glob(
|
||||
f".{AGENT_ID}.*.elydora-removed/entry"
|
||||
)
|
||||
)
|
||||
assert quarantined == []
|
||||
assert list(Path(plan.runtime_root).glob(f".{AGENT_ID}.*.elydora-removed")) == []
|
||||
assert not agent.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX quarantine contract")
|
||||
def test_posix_runtime_quarantine_preserves_atomic_boundary_swap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
replacement = root / "replacement"
|
||||
replacement.mkdir()
|
||||
marker = replacement / "user-owned"
|
||||
marker.write_text("preserve", encoding="utf-8")
|
||||
original = root / "agent-original"
|
||||
real_rename = _directory_removal._rename_posix_into_quarantine
|
||||
|
||||
def swap_then_quarantine(
|
||||
parent_descriptor: int,
|
||||
source_name: str,
|
||||
quarantine_descriptor: int,
|
||||
source_path: str,
|
||||
destination_path: str,
|
||||
) -> None:
|
||||
agent.rename(original)
|
||||
replacement.rename(agent)
|
||||
real_rename(
|
||||
parent_descriptor,
|
||||
source_name,
|
||||
quarantine_descriptor,
|
||||
source_path,
|
||||
destination_path,
|
||||
)
|
||||
|
||||
real_rmdir = _directory_removal.os.rmdir
|
||||
|
||||
def reject_path_rmdir(
|
||||
path: str,
|
||||
*,
|
||||
dir_fd: int | None = None,
|
||||
) -> None:
|
||||
if dir_fd is None:
|
||||
raise AssertionError("POSIX runtime cleanup must use a parent dirfd")
|
||||
real_rmdir(path, dir_fd=dir_fd)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_rename_posix_into_quarantine",
|
||||
swap_then_quarantine,
|
||||
)
|
||||
monkeypatch.setattr(_directory_removal.os, "rmdir", reject_path_rmdir)
|
||||
|
||||
with pytest.raises(OSError, match="changed while quarantining"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
quarantined_markers = list(
|
||||
root.glob(f".{AGENT_ID}.*.elydora-removed/entry/user-owned")
|
||||
)
|
||||
assert len(quarantined_markers) == 1
|
||||
assert quarantined_markers[0].read_text(encoding="utf-8") == "preserve"
|
||||
assert original.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows exact-handle contract")
|
||||
def test_windows_runtime_directory_removal_marks_exact_pinned_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
original_mark = _directory_removal._mark_windows_directory_for_deletion
|
||||
observed: list[tuple[int, int]] = []
|
||||
|
||||
def record_mark(directory: Any) -> None:
|
||||
assert directory.descriptor is not None
|
||||
opened = os.fstat(directory.descriptor)
|
||||
observed.append((opened.st_dev, opened.st_ino))
|
||||
assert observed[-1] == (
|
||||
plan.agent_snapshot.device,
|
||||
plan.agent_snapshot.inode,
|
||||
)
|
||||
original_mark(directory)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_directory_removal,
|
||||
"_mark_windows_directory_for_deletion",
|
||||
record_mark,
|
||||
)
|
||||
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert observed == [(plan.agent_snapshot.device, plan.agent_snapshot.inode)]
|
||||
assert not agent.exists()
|
||||
|
||||
|
||||
def test_kiroide_cli_install_failure_leaves_no_runtime_root(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
workspace = tmp_path / "workspace"
|
||||
home.mkdir()
|
||||
workspace.mkdir()
|
||||
set_home(monkeypatch, home)
|
||||
monkeypatch.chdir(workspace)
|
||||
key_path = tmp_path / "install-private.key"
|
||||
write_secret(key_path, PRIVATE_KEY)
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"install",
|
||||
"--agent",
|
||||
"kiroide",
|
||||
"--org_id",
|
||||
"org-1",
|
||||
"--agent_id",
|
||||
AGENT_ID,
|
||||
"--private_key_file",
|
||||
str(key_path),
|
||||
"--kid",
|
||||
"kid-1",
|
||||
]
|
||||
)
|
||||
|
||||
def fail_commit(_staged: object) -> None:
|
||||
raise OSError("injected Kiro IDE CLI commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_commit", fail_commit)
|
||||
|
||||
with pytest.raises(OSError, match="injected Kiro IDE CLI commit failure"):
|
||||
cli.cmd_install(args)
|
||||
|
||||
# The durable transaction state directory survives failures by design;
|
||||
# everything else under the runtime root must be rolled back.
|
||||
elydora_root = home / ".elydora"
|
||||
if elydora_root.exists():
|
||||
assert [entry.name for entry in elydora_root.iterdir()] == ["transactions"]
|
||||
assert not (workspace / ".kiro").exists()
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import (
|
||||
_opaque_cleanup,
|
||||
_opaque_removal,
|
||||
_transaction,
|
||||
)
|
||||
from elydora.plugins._runtime_removal import (
|
||||
commit_runtime_removal,
|
||||
prepare_runtime_removal,
|
||||
)
|
||||
from runtime_removal_support import (
|
||||
AGENT_ID,
|
||||
AGENT_NAME,
|
||||
prepare_runtime,
|
||||
stream_digest,
|
||||
write_error_log,
|
||||
)
|
||||
|
||||
|
||||
def test_process_exit_after_opaque_removal_restores_error_log_on_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
expected_digest = write_error_log(error_log, chunks=4)
|
||||
script = """
|
||||
import os
|
||||
import sys
|
||||
from elydora.plugins import _transaction
|
||||
from elydora.plugins._runtime_removal import prepare_runtime_removal, commit_runtime_removal
|
||||
|
||||
agent_id, agent_name = sys.argv[1:]
|
||||
plan = prepare_runtime_removal(agent_id, agent_name)
|
||||
assert plan is not None
|
||||
_transaction._commit = lambda staged: os._exit(78)
|
||||
commit_runtime_removal(plan)
|
||||
"""
|
||||
environment = os.environ.copy()
|
||||
environment["HOME"] = os.environ["HOME"]
|
||||
environment["USERPROFILE"] = os.environ["USERPROFILE"]
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script, AGENT_ID, AGENT_NAME],
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 78
|
||||
assert not error_log.exists()
|
||||
_transaction.recover_pending_transactions()
|
||||
assert stream_digest(error_log) == expected_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
assert list(agent.glob(".*.opaque-rollback")) == []
|
||||
|
||||
|
||||
def test_error_log_append_at_capture_boundary_fails_and_preserves_append(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
write_error_log(error_log)
|
||||
appended = b"concurrent append\n"
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def append_then_capture(staged: Any) -> None:
|
||||
with error_log.open("ab") as file:
|
||||
file.write(appended)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
append_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at commit boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert error_log.read_bytes().endswith(appended)
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_replace_at_capture_boundary_preserves_both_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = write_error_log(error_log)
|
||||
displaced = agent / "displaced-error.log"
|
||||
replacement = b"concurrent replacement\n"
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def replace_then_capture(staged: Any) -> None:
|
||||
error_log.rename(displaced)
|
||||
error_log.write_bytes(replacement)
|
||||
if os.name != "nt":
|
||||
error_log.chmod(0o600)
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
replace_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at commit boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert error_log.read_bytes() == replacement
|
||||
assert stream_digest(displaced) == original_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_rollback_destination_collision_preserves_victim(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = write_error_log(error_log)
|
||||
victim = b"concurrent opaque rollback destination\n"
|
||||
rollback_path: Path | None = None
|
||||
original_capture = _opaque_removal._capture_physical
|
||||
|
||||
def collide_with_rollback_destination(staged: Any) -> None:
|
||||
nonlocal rollback_path
|
||||
assert staged.rollback_path is not None
|
||||
rollback_path = Path(staged.rollback_path)
|
||||
rollback_path.write_bytes(victim)
|
||||
if os.name != "nt":
|
||||
rollback_path.chmod(0o600)
|
||||
original_capture(staged)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_removal,
|
||||
"_capture_physical",
|
||||
collide_with_rollback_destination,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="exist"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert rollback_path is not None
|
||||
assert rollback_path.read_bytes() == victim
|
||||
assert stream_digest(error_log) == original_digest
|
||||
assert (agent / "config.json").is_file()
|
||||
|
||||
|
||||
def test_error_log_cleanup_boundary_append_is_preserved_and_reported(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
write_error_log(error_log)
|
||||
appended = b"cleanup boundary append\n"
|
||||
original_capture = _opaque_cleanup._capture_cleanup_physical
|
||||
|
||||
def append_then_capture(
|
||||
staged: Any,
|
||||
source_path: str,
|
||||
) -> None:
|
||||
with Path(source_path).open("ab") as file:
|
||||
file.write(appended)
|
||||
file.flush()
|
||||
os.fsync(file.fileno())
|
||||
original_capture(staged, source_path)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_cleanup,
|
||||
"_capture_cleanup_physical",
|
||||
append_then_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at cleanup boundary"):
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
preserved = list(agent.glob(".error.log.*.opaque-rollback"))
|
||||
assert len(preserved) == 1
|
||||
assert preserved[0].read_bytes().endswith(appended)
|
||||
assert list(agent.glob(".*.elydora-quarantine")) == []
|
||||
|
||||
|
||||
def test_error_log_cleanup_replacement_preserves_both_identities(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_digest = write_error_log(error_log)
|
||||
replacement = b"concurrent cleanup replacement\n"
|
||||
displaced = agent / "displaced-opaque-rollback"
|
||||
rollback_path: Path | None = None
|
||||
original_capture = _opaque_cleanup._capture_cleanup_physical
|
||||
|
||||
def replace_before_cleanup_capture(staged: Any, source_path: str) -> None:
|
||||
nonlocal rollback_path
|
||||
rollback_path = Path(source_path)
|
||||
rollback_path.rename(displaced)
|
||||
rollback_path.write_bytes(replacement)
|
||||
if os.name != "nt":
|
||||
rollback_path.chmod(0o600)
|
||||
original_capture(staged, source_path)
|
||||
|
||||
plan = prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
assert plan is not None
|
||||
monkeypatch.setattr(
|
||||
_opaque_cleanup,
|
||||
"_capture_cleanup_physical",
|
||||
replace_before_cleanup_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at cleanup boundary") as raised:
|
||||
commit_runtime_removal(plan)
|
||||
|
||||
assert rollback_path is not None
|
||||
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
|
||||
assert rollback_path.read_bytes() == replacement
|
||||
assert stream_digest(displaced) == original_digest
|
||||
assert list(agent.glob(".*.elydora-quarantine")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry_kind", ["directory", "symlink"])
|
||||
def test_error_log_rejects_nonphysical_entries_before_runtime_mutation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
entry_kind: str,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
original_config = (agent / "config.json").read_bytes()
|
||||
if entry_kind == "directory":
|
||||
error_log.mkdir()
|
||||
(error_log / "marker").write_text("preserve", encoding="utf-8")
|
||||
else:
|
||||
target = tmp_path / "external-error.log"
|
||||
target.write_text("preserve", encoding="utf-8")
|
||||
try:
|
||||
error_log.symlink_to(target)
|
||||
except OSError as error:
|
||||
pytest.skip(f"File symbolic links are unavailable: {error}")
|
||||
|
||||
with pytest.raises(OSError, match="not a physical file"):
|
||||
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
|
||||
assert (agent / "config.json").read_bytes() == original_config
|
||||
assert error_log.exists() or error_log.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX owner-only contract")
|
||||
def test_error_log_requires_owner_only_permissions_during_prepare(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_root, agent = prepare_runtime(monkeypatch, tmp_path)
|
||||
error_log = agent / "error.log"
|
||||
error_log.write_text("historical error\n", encoding="utf-8")
|
||||
error_log.chmod(0o640)
|
||||
|
||||
with pytest.raises(PermissionError, match="accessible only by its owner"):
|
||||
prepare_runtime_removal(AGENT_ID, AGENT_NAME)
|
||||
|
||||
assert error_log.read_text(encoding="utf-8") == "historical error\n"
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction, _transaction_staging
|
||||
from elydora.plugins._managed_files import read_physical_directory
|
||||
from transaction_support import (
|
||||
make_change,
|
||||
swap_directory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||
def test_staging_directory_swap_never_writes_secret_to_redirected_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
parent = tmp_path / "runtime"
|
||||
backup = tmp_path / "runtime-original"
|
||||
redirected = tmp_path / "runtime-redirected"
|
||||
parent.mkdir()
|
||||
redirected.mkdir()
|
||||
target = parent / "private.key"
|
||||
real_write = _transaction_staging.write_staged
|
||||
swapped = False
|
||||
|
||||
def swap_then_stage(*args: object, **kwargs: object):
|
||||
nonlocal swapped
|
||||
if not swapped:
|
||||
swapped = True
|
||||
swap_directory(parent, backup, redirected)
|
||||
return real_write(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_transaction_staging, "write_staged", swap_then_stage)
|
||||
|
||||
with pytest.raises(OSError, match="changed during transaction"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, None, "secret\n")],
|
||||
"staging swap",
|
||||
)
|
||||
|
||||
assert not (parent / "private.key").exists()
|
||||
assert not (backup / "private.key").exists()
|
||||
assert list(parent.iterdir()) == []
|
||||
assert list(backup.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||
def test_commit_directory_swap_rolls_back_inside_pinned_directory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
parent = tmp_path / "runtime"
|
||||
backup = tmp_path / "runtime-original"
|
||||
redirected = tmp_path / "runtime-redirected"
|
||||
parent.mkdir()
|
||||
redirected.mkdir()
|
||||
target = parent / "private.key"
|
||||
real_replace = _transaction._replace_physical
|
||||
swapped = False
|
||||
|
||||
def swap_then_replace(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal swapped
|
||||
if not swapped:
|
||||
swapped = True
|
||||
swap_directory(parent, backup, redirected)
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", swap_then_replace)
|
||||
|
||||
with pytest.raises(OSError, match="changed during transaction"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, None, "secret\n")],
|
||||
"commit swap",
|
||||
)
|
||||
|
||||
assert not (parent / "private.key").exists()
|
||||
assert not (backup / "private.key").exists()
|
||||
assert list(parent.iterdir()) == []
|
||||
assert list(backup.iterdir()) == []
|
||||
|
||||
|
||||
def test_failed_transaction_removes_only_created_empty_directories(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
kiro = tmp_path / ".kiro"
|
||||
hooks = kiro / "hooks"
|
||||
hooks.mkdir(parents=True)
|
||||
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
||||
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
||||
target = hooks / "elydora-audit.json"
|
||||
|
||||
def fail_replace(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected commit failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||
|
||||
with pytest.raises(OSError, match="injected commit failure"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, None, "{}\n")],
|
||||
"created directory recovery",
|
||||
directory_preconditions=[
|
||||
_transaction.DirectoryPrecondition(
|
||||
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
||||
),
|
||||
_transaction.DirectoryPrecondition(
|
||||
str(hooks), "hooks directory", hooks_snapshot, created=True
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert not hooks.exists()
|
||||
assert not kiro.exists()
|
||||
|
||||
|
||||
def test_created_directory_recovery_keeps_parent_and_child_pinned(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
kiro = tmp_path / ".kiro"
|
||||
hooks = kiro / "hooks"
|
||||
hooks.mkdir(parents=True)
|
||||
kiro_snapshot = read_physical_directory(str(kiro), "Kiro directory")
|
||||
hooks_snapshot = read_physical_directory(str(hooks), "hooks directory")
|
||||
target = hooks / "elydora-audit.json"
|
||||
observed: list[tuple[Path, Path]] = []
|
||||
real_remove = _transaction_staging.remove_pinned_empty_directory
|
||||
|
||||
def observe_pins(
|
||||
parent: object,
|
||||
child: object,
|
||||
*,
|
||||
require_empty: bool = True,
|
||||
) -> None:
|
||||
assert isinstance(parent, _transaction.PinnedDirectory)
|
||||
assert isinstance(child, _transaction.PinnedDirectory)
|
||||
assert parent.descriptor is not None
|
||||
assert child.descriptor is not None
|
||||
assert os.fstat(child.descriptor).st_ino == child.snapshot.inode
|
||||
observed.append((Path(parent.path), Path(child.path)))
|
||||
real_remove(parent, child, require_empty=require_empty)
|
||||
|
||||
def fail_replace(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected pinned cleanup failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction_staging,
|
||||
"remove_pinned_empty_directory",
|
||||
observe_pins,
|
||||
)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||
|
||||
with pytest.raises(OSError, match="injected pinned cleanup failure"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, None, "{}\n")],
|
||||
"pinned directory recovery",
|
||||
directory_preconditions=[
|
||||
_transaction.DirectoryPrecondition(
|
||||
str(kiro), "Kiro directory", kiro_snapshot, created=True
|
||||
),
|
||||
_transaction.DirectoryPrecondition(
|
||||
str(hooks), "hooks directory", hooks_snapshot, created=True
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
expected = [(kiro, hooks), (tmp_path, kiro)]
|
||||
assert observed == expected
|
||||
assert not kiro.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Windows handle blocks directory rename")
|
||||
def test_created_directory_recovery_preserves_swapped_directory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
created = tmp_path / "created"
|
||||
created.mkdir()
|
||||
created_snapshot = read_physical_directory(str(created), "created directory")
|
||||
target = created / "managed.json"
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.mkdir()
|
||||
marker = replacement / "user-owned"
|
||||
marker.write_text("preserve", encoding="utf-8")
|
||||
original = tmp_path / "created-original"
|
||||
real_remove = _transaction_staging.remove_pinned_empty_directory
|
||||
|
||||
def swap_before_removal(
|
||||
parent: _transaction.PinnedDirectory,
|
||||
child: _transaction.PinnedDirectory,
|
||||
*,
|
||||
require_empty: bool = True,
|
||||
) -> None:
|
||||
created.rename(original)
|
||||
replacement.rename(created)
|
||||
real_remove(parent, child, require_empty=require_empty)
|
||||
|
||||
def fail_replace(
|
||||
_source: str,
|
||||
_destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
raise OSError("injected swap cleanup failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction_staging,
|
||||
"remove_pinned_empty_directory",
|
||||
swap_before_removal,
|
||||
)
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_replace)
|
||||
|
||||
with pytest.raises(OSError, match="recovery failed"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, None, "{}\n")],
|
||||
"swapped directory recovery",
|
||||
directory_preconditions=[
|
||||
_transaction.DirectoryPrecondition(
|
||||
str(created),
|
||||
"created directory",
|
||||
created_snapshot,
|
||||
created=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert (created / marker.name).read_text(encoding="utf-8") == "preserve"
|
||||
assert original.is_dir()
|
||||
assert Path(_transaction.active_journal_path()).is_file()
|
||||
_transaction.clear_journal()
|
||||
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import _transaction
|
||||
from transaction_support import (
|
||||
make_change,
|
||||
write_source,
|
||||
)
|
||||
|
||||
|
||||
def test_final_replace_exception_after_success_restores_original(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
write_source(target, "old\n")
|
||||
real_replace = _transaction._replace_physical
|
||||
|
||||
def replace_then_raise(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
real_replace(source, destination, directory)
|
||||
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
||||
raise OSError("injected post-replace failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", replace_then_raise)
|
||||
|
||||
with pytest.raises(OSError, match="injected post-replace failure"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, "old\n", "new\n")],
|
||||
"post-replace failure",
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
assert list(tmp_path.glob(".*.rollback")) == []
|
||||
|
||||
|
||||
def test_capture_exception_after_success_restores_original(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
write_source(target, "old\n")
|
||||
real_capture = _transaction._capture_physical
|
||||
|
||||
def capture_then_raise(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
real_capture(source, destination, directory)
|
||||
raise OSError("injected post-capture failure")
|
||||
|
||||
monkeypatch.setattr(_transaction, "_capture_physical", capture_then_raise)
|
||||
|
||||
with pytest.raises(OSError, match="injected post-capture failure"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, "old\n", "new\n")],
|
||||
"post-capture failure",
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
assert list(tmp_path.glob(".*.rollback")) == []
|
||||
|
||||
|
||||
def test_rollback_reservation_collision_preserves_victim_and_original(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
write_source(target, "old\n")
|
||||
victim = "concurrent rollback destination\n"
|
||||
collision_path: Path | None = None
|
||||
|
||||
def collide_with_rollback_destination(
|
||||
_source: str,
|
||||
destination: str,
|
||||
_directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal collision_path
|
||||
if destination.endswith(".rollback"):
|
||||
collision_path = Path(destination)
|
||||
write_source(collision_path, victim)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction,
|
||||
"_capture_physical",
|
||||
collide_with_rollback_destination,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="exist"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, "old\n", "new\n")],
|
||||
"rollback destination collision",
|
||||
)
|
||||
|
||||
assert collision_path is not None
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
assert collision_path.read_text(encoding="utf-8") == victim
|
||||
|
||||
|
||||
def test_final_install_does_not_overwrite_concurrent_target(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
write_source(target, "old\n")
|
||||
real_replace = _transaction._replace_physical
|
||||
injected = False
|
||||
|
||||
def create_target_before_install(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal injected
|
||||
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
||||
injected = True
|
||||
write_source(target, "concurrent\n")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(
|
||||
_transaction,
|
||||
"_replace_physical",
|
||||
create_target_before_install,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="original content preserved"):
|
||||
_transaction.write_changes(
|
||||
[make_change(target, "old\n", "new\n")],
|
||||
"no-clobber install",
|
||||
)
|
||||
|
||||
assert injected
|
||||
assert target.read_text(encoding="utf-8") == "concurrent\n"
|
||||
rollbacks = list(tmp_path.glob(".*.rollback"))
|
||||
assert len(rollbacks) == 1
|
||||
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
||||
|
||||
|
||||
def test_restore_does_not_overwrite_concurrent_target(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
second = tmp_path / "second.txt"
|
||||
write_source(target, "old\n")
|
||||
write_source(second, "second-old\n")
|
||||
real_replace = _transaction._replace_physical
|
||||
real_restore = _transaction._restore_physical
|
||||
injected = False
|
||||
|
||||
def fail_second_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if os.path.abspath(destination) == os.path.abspath(second):
|
||||
raise OSError("injected second failure")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
def create_target_before_restore(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal injected
|
||||
if not injected and os.path.abspath(destination) == os.path.abspath(target):
|
||||
injected = True
|
||||
write_source(target, "concurrent recovery\n")
|
||||
real_restore(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||
monkeypatch.setattr(
|
||||
_transaction,
|
||||
"_restore_physical",
|
||||
create_target_before_restore,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="original content preserved"):
|
||||
_transaction.write_changes(
|
||||
[
|
||||
make_change(target, "old\n", "new\n"),
|
||||
make_change(second, "second-old\n", "second-new\n"),
|
||||
],
|
||||
"no-clobber restoration",
|
||||
)
|
||||
|
||||
assert injected
|
||||
assert target.read_text(encoding="utf-8") == "concurrent recovery\n"
|
||||
rollbacks = list(tmp_path.glob(".*.rollback"))
|
||||
assert len(rollbacks) == 1
|
||||
assert rollbacks[0].read_text(encoding="utf-8") == "old\n"
|
||||
assert Path(_transaction.active_journal_path()).is_file()
|
||||
_transaction.clear_journal()
|
||||
|
||||
|
||||
def test_restore_source_replacement_reports_existing_preserved_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = tmp_path / "target.txt"
|
||||
second = tmp_path / "second.txt"
|
||||
displaced = tmp_path / "displaced-original.txt"
|
||||
write_source(target, "old\n")
|
||||
write_source(second, "second-old\n")
|
||||
real_replace = _transaction._replace_physical
|
||||
real_restore = _transaction._restore_physical
|
||||
rollback_path: Path | None = None
|
||||
|
||||
def fail_second_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if os.path.abspath(destination) == os.path.abspath(second):
|
||||
raise OSError("injected second failure")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
def replace_restore_source(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal rollback_path
|
||||
if (
|
||||
rollback_path is None
|
||||
and source.endswith(".rollback")
|
||||
and os.path.abspath(destination) == os.path.abspath(target)
|
||||
):
|
||||
rollback_path = Path(source)
|
||||
rollback_path.rename(displaced)
|
||||
write_source(rollback_path, "concurrent recovery source\n")
|
||||
real_restore(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||
monkeypatch.setattr(_transaction, "_restore_physical", replace_restore_source)
|
||||
|
||||
with pytest.raises(OSError, match="recovery source changed") as raised:
|
||||
_transaction.write_changes(
|
||||
[
|
||||
make_change(target, "old\n", "new\n"),
|
||||
make_change(second, "second-old\n", "second-new\n"),
|
||||
],
|
||||
"restore source replacement",
|
||||
)
|
||||
|
||||
assert rollback_path is not None
|
||||
assert f"concurrent data preserved at {rollback_path}" in str(raised.value)
|
||||
assert rollback_path.read_text(encoding="utf-8") == "concurrent recovery source\n"
|
||||
assert displaced.read_text(encoding="utf-8") == "old\n"
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert second.read_text(encoding="utf-8") == "second-old\n"
|
||||
assert Path(_transaction.active_journal_path()).is_file()
|
||||
_transaction.clear_journal()
|
||||
|
||||
|
||||
def test_creation_rollback_captures_and_preserves_concurrent_replacement(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
created = tmp_path / "created.txt"
|
||||
second = tmp_path / "second.txt"
|
||||
write_source(second, "second-old\n")
|
||||
real_replace = _transaction._replace_physical
|
||||
real_capture = _transaction._capture_physical
|
||||
injected = False
|
||||
|
||||
def fail_second_commit(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if os.path.abspath(destination) == os.path.abspath(second):
|
||||
raise OSError("injected second failure")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
def replace_created_before_recovery_capture(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
nonlocal injected
|
||||
if (
|
||||
not injected
|
||||
and os.path.abspath(source) == os.path.abspath(created)
|
||||
and destination.endswith(".recovery")
|
||||
):
|
||||
injected = True
|
||||
write_source(created, "concurrent replacement\n")
|
||||
real_capture(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", fail_second_commit)
|
||||
monkeypatch.setattr(
|
||||
_transaction,
|
||||
"_capture_physical",
|
||||
replace_created_before_recovery_capture,
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="changed at recovery boundary"):
|
||||
_transaction.write_changes(
|
||||
[
|
||||
make_change(created, None, "generated\n"),
|
||||
make_change(second, "second-old\n", "second-new\n"),
|
||||
],
|
||||
"creation recovery capture",
|
||||
)
|
||||
|
||||
assert injected
|
||||
assert created.read_text(encoding="utf-8") == "concurrent replacement\n"
|
||||
assert Path(_transaction.active_journal_path()).is_file()
|
||||
_transaction.clear_journal()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
||||
def test_mode_repair_uses_private_rollback_and_restores_original_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
secret = tmp_path / "private.key"
|
||||
second = tmp_path / "config.json"
|
||||
write_source(secret, "secret\n")
|
||||
write_source(second, "old\n")
|
||||
secret.chmod(0o644)
|
||||
real_replace = _transaction._replace_physical
|
||||
rollback_modes: list[int] = []
|
||||
|
||||
def inspect_then_fail_second(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if os.path.abspath(destination) == os.path.abspath(str(secret)):
|
||||
rollback = next(tmp_path.glob(".private.key.*.rollback"))
|
||||
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
||||
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
||||
raise OSError("injected second commit failure")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
||||
|
||||
with pytest.raises(OSError, match="injected second commit failure"):
|
||||
_transaction.write_changes(
|
||||
[
|
||||
make_change(secret, "secret\n", "secret\n", enforce_mode=True),
|
||||
make_change(second, "old\n", "new\n"),
|
||||
],
|
||||
"mode repair",
|
||||
)
|
||||
|
||||
assert rollback_modes
|
||||
assert set(rollback_modes) == {0o600}
|
||||
assert secret.read_text(encoding="utf-8") == "secret\n"
|
||||
assert secret.stat().st_mode & 0o777 == 0o644
|
||||
assert second.read_text(encoding="utf-8") == "old\n"
|
||||
assert list(tmp_path.glob(".*.rollback")) == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode contract")
|
||||
def test_deletion_uses_private_rollback_and_restores_original_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
removed = tmp_path / "removed.txt"
|
||||
second = tmp_path / "second.txt"
|
||||
write_source(removed, "remove me\n")
|
||||
write_source(second, "old\n")
|
||||
removed.chmod(0o644)
|
||||
real_replace = _transaction._replace_physical
|
||||
rollback_modes: list[int] = []
|
||||
|
||||
def inspect_then_fail_second(
|
||||
source: str,
|
||||
destination: str,
|
||||
directory: _transaction.PinnedDirectory,
|
||||
) -> None:
|
||||
if os.path.abspath(destination) == os.path.abspath(str(second)):
|
||||
rollback = next(tmp_path.glob(".removed.txt.*.rollback"))
|
||||
rollback_modes.append(rollback.stat().st_mode & 0o777)
|
||||
raise OSError("injected second commit failure")
|
||||
real_replace(source, destination, directory)
|
||||
|
||||
monkeypatch.setattr(_transaction, "_replace_physical", inspect_then_fail_second)
|
||||
|
||||
with pytest.raises(OSError, match="injected second commit failure"):
|
||||
_transaction.write_changes(
|
||||
[
|
||||
make_change(removed, "remove me\n", None),
|
||||
make_change(second, "old\n", "new\n"),
|
||||
],
|
||||
"deletion rollback mode",
|
||||
)
|
||||
|
||||
assert rollback_modes == [0o600]
|
||||
assert removed.read_text(encoding="utf-8") == "remove me\n"
|
||||
assert removed.stat().st_mode & 0o777 == 0o644
|
||||
assert second.read_text(encoding="utf-8") == "old\n"
|
||||
assert list(tmp_path.glob(".*.rollback")) == []
|
||||
@@ -10,31 +10,13 @@ import time
|
||||
|
||||
import pytest
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from elydora.plugins import _transaction
|
||||
|
||||
|
||||
def write_source(path: Path, source: str) -> None:
|
||||
path.write_bytes(source.encode("utf-8"))
|
||||
|
||||
|
||||
def make_change(
|
||||
path: Path,
|
||||
original: str | None,
|
||||
next_source: str | None,
|
||||
mode: int = 0o600,
|
||||
enforce_mode: bool = False,
|
||||
) -> _transaction.FileChange:
|
||||
change = _transaction.source_change(
|
||||
str(path),
|
||||
path.name,
|
||||
original,
|
||||
next_source,
|
||||
mode,
|
||||
enforce_mode=enforce_mode,
|
||||
)
|
||||
assert change is not None
|
||||
return change
|
||||
|
||||
|
||||
def transaction_subprocess_environment(home: Path) -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
environment["HOME"] = str(home)
|
||||
environment["USERPROFILE"] = str(home)
|
||||
return environment
|
||||
|
||||
|
||||
def swap_directory(parent: Path, backup: Path, redirected: Path) -> None:
|
||||
parent.rename(backup)
|
||||
redirected.rename(parent)
|
||||
Reference in new issue
Block a user