Files
Elydora-Python-SDK/tests/test_transaction_security.py
T
ZacharyZhang-NY e37418c489 feat(python): durable transaction subsystem and Kiro IDE 1.0 adapter
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.
2026-07-21 18:53:06 -04:00

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()