Land the crash-recoverable managed-file transaction engine (journal, staging, commit, restart recovery, opaque snapshots, private artifacts, Windows DACL hardening) and the Kiro IDE 1.0 workspace hook plugin with legacy migration. Repair five defects that kept the suite red (426 failures to zero): - Anchor the transaction state directory at ~/.elydora/transactions instead of the shared system temp directory. The recovery journal now shares fate with the runtimes it repairs, survives temp cleaners, and isolates naturally under per-test fake homes. - Skip pending-transaction recovery when no state directory exists so rejected installs never create runtime directories. - Create the runtime root's parent before hardening it, matching the Go SDK's expand behavior for fresh homes. - Stop using file timestamps as identity evidence: Windows exposes mtime/ctime with delayed cross-handle visibility. Reads now prove stability through a verification re-read; snapshots compare device, inode, size, and SHA-256. - Stop comparing synthetic Windows permission bits across stat boundaries: path stats derive execute bits from file extensions while handle stats do not, which falsely flagged every managed .cmd wrapper. Snapshots store raw modes for restoration and compare portable bits. Reservation cleanup now verifies content before discarding so a concurrent writer's file at a rollback path is preserved, and the install-failure tests assert the precise contract: no agent runtime residue, with the durable state directory allowed to remain. Gates: pytest 807 passed, mypy clean, pip check clean, wheel builds.
1075 lines
35 KiB
Python
1075 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
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
|
|
|
|
|
|
def test_post_replace_precondition_failure_restores_original_target(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
target = tmp_path / "target.txt"
|
|
provider = tmp_path / "provider.txt"
|
|
_write(target, "old\n")
|
|
_write(provider, "stable\n")
|
|
provider_snapshot = read_physical_file(str(provider), "provider")
|
|
real_replace = _transaction._replace_physical
|
|
|
|
def replace_then_change_provider(
|
|
source: str,
|
|
destination: str,
|
|
directory: _transaction.PinnedDirectory,
|
|
) -> None:
|
|
real_replace(source, destination, directory)
|
|
if os.path.abspath(destination) == os.path.abspath(str(target)):
|
|
_write(provider, "concurrent\n")
|
|
|
|
monkeypatch.setattr(
|
|
_transaction, "_replace_physical", replace_then_change_provider
|
|
)
|
|
|
|
with pytest.raises(OSError, match="provider changed during"):
|
|
_transaction.write_changes(
|
|
[_change(target, "old\n", "new\n")],
|
|
"provider transaction",
|
|
[_transaction.FilePrecondition(str(provider), "provider", provider_snapshot)],
|
|
)
|
|
|
|
assert target.read_text(encoding="utf-8") == "old\n"
|
|
assert provider.read_text(encoding="utf-8") == "concurrent\n"
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
def test_commit_boundary_concurrent_write_is_restored_and_rejected(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
target = tmp_path / "target.txt"
|
|
_write(target, "old\n")
|
|
real_capture = _transaction._capture_physical
|
|
changed = False
|
|
|
|
def write_before_capture(
|
|
source: str,
|
|
destination: str,
|
|
directory: _transaction.PinnedDirectory,
|
|
) -> None:
|
|
nonlocal changed
|
|
if (
|
|
not changed
|
|
and os.path.abspath(source) == os.path.abspath(str(target))
|
|
and destination.endswith(".rollback")
|
|
):
|
|
changed = True
|
|
_write(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")],
|
|
"commit boundary",
|
|
)
|
|
|
|
assert changed
|
|
assert target.read_text(encoding="utf-8") == "concurrent\n"
|
|
assert list(tmp_path.glob(".*.tmp")) == []
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
def test_existing_target_remains_visible_during_atomic_update(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
target = tmp_path / "target.txt"
|
|
_write(target, "old\n")
|
|
replace_started = threading.Event()
|
|
release_replace = threading.Event()
|
|
failures: list[BaseException] = []
|
|
observations: list[str] = []
|
|
|
|
def pause_before_replace(
|
|
_source: str,
|
|
destination: str,
|
|
_directory: _transaction.PinnedDirectory,
|
|
) -> None:
|
|
if os.path.abspath(destination) == os.path.abspath(target):
|
|
replace_started.set()
|
|
if not release_replace.wait(5):
|
|
raise TimeoutError("test replace gate timed out")
|
|
|
|
def update() -> None:
|
|
try:
|
|
_transaction.write_changes(
|
|
[_change(target, "old\n", "new\n")],
|
|
"visible atomic update",
|
|
)
|
|
except BaseException as error:
|
|
failures.append(error)
|
|
|
|
monkeypatch.setattr(_transaction, "_replace_physical", pause_before_replace)
|
|
worker = threading.Thread(target=update)
|
|
worker.start()
|
|
assert replace_started.wait(5)
|
|
for _index in range(200):
|
|
observations.append(target.read_text(encoding="utf-8"))
|
|
release_replace.set()
|
|
worker.join(5)
|
|
|
|
assert not worker.is_alive()
|
|
assert failures == []
|
|
assert set(observations) <= {"old\n", "new\n"}
|
|
assert target.read_text(encoding="utf-8") == "new\n"
|
|
|
|
|
|
def _transaction_subprocess_environment(home: Path) -> dict[str, str]:
|
|
environment = os.environ.copy()
|
|
environment["HOME"] = str(home)
|
|
environment["USERPROFILE"] = str(home)
|
|
return environment
|
|
|
|
|
|
def test_process_exit_after_atomic_replace_recovers_on_restart(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
target = tmp_path / "target.txt"
|
|
_write(target, "old\n")
|
|
monkeypatch.setenv("HOME", str(home))
|
|
monkeypatch.setenv("USERPROFILE", str(home))
|
|
script = """
|
|
import os
|
|
import sys
|
|
from elydora.plugins import _transaction
|
|
|
|
target = sys.argv[1]
|
|
change = _transaction.source_change(target, "target", "old\\n", "new\\n", 0o600)
|
|
assert change is not None
|
|
|
|
def terminate_after_replace(source, destination, directory):
|
|
del source, destination, directory
|
|
os._exit(73)
|
|
|
|
_transaction._after_replace_physical = terminate_after_replace
|
|
_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),
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == 73
|
|
assert target.read_text(encoding="utf-8") == "new\n"
|
|
_transaction.recover_pending_transactions()
|
|
assert target.read_text(encoding="utf-8") == "old\n"
|
|
assert list(tmp_path.glob(".*.tmp")) == []
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
def test_process_exit_after_committed_marker_keeps_atomic_update(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
target = tmp_path / "target.txt"
|
|
_write(target, "old\n")
|
|
monkeypatch.setenv("HOME", str(home))
|
|
monkeypatch.setenv("USERPROFILE", str(home))
|
|
script = """
|
|
import os
|
|
import sys
|
|
from elydora.plugins import _transaction
|
|
|
|
target = sys.argv[1]
|
|
change = _transaction.source_change(target, "target", "old\\n", "new\\n", 0o600)
|
|
assert change is not None
|
|
_transaction._after_journal_commit = lambda: os._exit(74)
|
|
_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),
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == 74
|
|
assert target.read_text(encoding="utf-8") == "new\n"
|
|
_transaction.recover_pending_transactions()
|
|
assert target.read_text(encoding="utf-8") == "new\n"
|
|
assert list(tmp_path.glob(".*.tmp")) == []
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
def test_process_exit_mid_transaction_restores_all_targets(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
first = tmp_path / "first.txt"
|
|
second = tmp_path / "second.txt"
|
|
_write(first, "first-old\n")
|
|
_write(second, "second-old\n")
|
|
monkeypatch.setenv("HOME", str(home))
|
|
monkeypatch.setenv("USERPROFILE", str(home))
|
|
script = """
|
|
import os
|
|
import sys
|
|
from elydora.plugins import _transaction
|
|
|
|
first, second = sys.argv[1:]
|
|
changes = [
|
|
_transaction.source_change(first, "first", "first-old\\n", "first-new\\n", 0o600),
|
|
_transaction.source_change(second, "second", "second-old\\n", "second-new\\n", 0o600),
|
|
]
|
|
assert all(change is not None for change in changes)
|
|
calls = 0
|
|
|
|
def terminate_after_first(source, destination, directory):
|
|
global calls
|
|
del source, destination, directory
|
|
calls += 1
|
|
if calls == 1:
|
|
os._exit(75)
|
|
|
|
_transaction._after_replace_physical = terminate_after_first
|
|
_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),
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == 75
|
|
assert first.read_text(encoding="utf-8") == "first-new\n"
|
|
assert second.read_text(encoding="utf-8") == "second-old\n"
|
|
_transaction.recover_pending_transactions()
|
|
assert first.read_text(encoding="utf-8") == "first-old\n"
|
|
assert second.read_text(encoding="utf-8") == "second-old\n"
|
|
assert list(tmp_path.glob(".*.tmp")) == []
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("original", "next_source", "exit_code"),
|
|
[
|
|
("old\n", None, 76),
|
|
(None, "created\n", 77),
|
|
],
|
|
)
|
|
def test_process_exit_recovers_atomic_create_and_remove(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
original: str | None,
|
|
next_source: str | None,
|
|
exit_code: int,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
target = tmp_path / "target.txt"
|
|
if original is not None:
|
|
_write(target, original)
|
|
monkeypatch.setenv("HOME", str(home))
|
|
monkeypatch.setenv("USERPROFILE", str(home))
|
|
script = """
|
|
import json
|
|
import os
|
|
import sys
|
|
from elydora.plugins import _transaction
|
|
|
|
target = sys.argv[1]
|
|
original = json.loads(sys.argv[2])
|
|
next_source = json.loads(sys.argv[3])
|
|
exit_code = int(sys.argv[4])
|
|
change = _transaction.source_change(target, "target", original, next_source, 0o600)
|
|
assert change is not None
|
|
_transaction._after_replace_physical = lambda source, destination, directory: os._exit(exit_code)
|
|
_transaction.write_changes([change], "create-remove crash recovery")
|
|
"""
|
|
|
|
completed = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
script,
|
|
str(target),
|
|
json.dumps(original),
|
|
json.dumps(next_source),
|
|
str(exit_code),
|
|
],
|
|
cwd=Path(__file__).resolve().parents[1],
|
|
env=_transaction_subprocess_environment(home),
|
|
check=False,
|
|
)
|
|
|
|
assert completed.returncode == exit_code
|
|
assert target.exists() is (next_source is not None)
|
|
_transaction.recover_pending_transactions()
|
|
assert target.exists() is (original is not None)
|
|
if original is not None:
|
|
assert target.read_text(encoding="utf-8") == original
|
|
assert list(tmp_path.glob(".*.tmp")) == []
|
|
assert list(tmp_path.glob(".*.rollback")) == []
|
|
|
|
|
|
def test_cross_process_writers_are_serialized(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
target = tmp_path / "target.txt"
|
|
ready = tmp_path / "ready"
|
|
second_ready = tmp_path / "second-ready"
|
|
release = tmp_path / "release"
|
|
_write(target, "old\n")
|
|
environment = _transaction_subprocess_environment(home)
|
|
first_script = """
|
|
import pathlib
|
|
import sys
|
|
import time
|
|
from elydora.plugins import _transaction
|
|
|
|
target, ready, release = map(pathlib.Path, sys.argv[1:])
|
|
change = _transaction.source_change(str(target), "target", "old\\n", "first\\n", 0o600)
|
|
assert change is not None
|
|
|
|
def pause(source, destination, directory):
|
|
del source, destination, directory
|
|
ready.write_text("ready", encoding="utf-8")
|
|
deadline = time.monotonic() + 10
|
|
while not release.exists():
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError("writer release timed out")
|
|
time.sleep(0.01)
|
|
|
|
_transaction._replace_physical = pause
|
|
_transaction.write_changes([change], "first writer")
|
|
"""
|
|
second_script = """
|
|
import pathlib
|
|
import sys
|
|
from elydora.plugins import _transaction
|
|
|
|
target = sys.argv[1]
|
|
ready = pathlib.Path(sys.argv[2])
|
|
change = _transaction.source_change(target, "target", "old\\n", "second\\n", 0o600)
|
|
assert change is not None
|
|
ready.write_text("ready", encoding="utf-8")
|
|
try:
|
|
_transaction.write_changes([change], "second writer")
|
|
except OSError as error:
|
|
print(error)
|
|
raise SystemExit(19)
|
|
"""
|
|
first = subprocess.Popen(
|
|
[sys.executable, "-c", first_script, str(target), str(ready), str(release)],
|
|
cwd=Path(__file__).resolve().parents[1],
|
|
env=environment,
|
|
)
|
|
deadline = time.monotonic() + 10
|
|
while not ready.exists() and first.poll() is None:
|
|
if time.monotonic() >= deadline:
|
|
first.kill()
|
|
raise TimeoutError("first writer did not reach the replace gate")
|
|
time.sleep(0.01)
|
|
assert first.poll() is None
|
|
second = subprocess.Popen(
|
|
[sys.executable, "-c", second_script, str(target), str(second_ready)],
|
|
cwd=Path(__file__).resolve().parents[1],
|
|
env=environment,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
deadline = time.monotonic() + 10
|
|
while not second_ready.exists() and second.poll() is None:
|
|
if time.monotonic() >= deadline:
|
|
second.kill()
|
|
first.kill()
|
|
raise TimeoutError("second writer did not reach the transaction gate")
|
|
time.sleep(0.01)
|
|
assert second.poll() is None
|
|
time.sleep(0.2)
|
|
assert second.poll() is None
|
|
release.write_text("release", encoding="utf-8")
|
|
assert first.wait(timeout=10) == 0
|
|
output, _stderr = second.communicate(timeout=10)
|
|
|
|
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()
|