2026-08-17 08:16:35 -03:00
|
|
|
"""Failure-boundary coverage for immutable lane evidence and retention."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import errno
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from testing.tests.test_hermes_cli_support import _pending_terminal_record, lanes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rename_noreplace_rejects_missing_kernel_support(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(lanes.ctypes, "CDLL", lambda *_args, **_kwargs: object())
|
|
|
|
|
with pytest.raises(OSError) as raised:
|
|
|
|
|
lanes._rename_noreplace("a", "b", source_dir=1, destination_dir=1)
|
|
|
|
|
assert raised.value.errno == errno.ENOSYS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_rename_noreplace_surfaces_kernel_errno(monkeypatch):
|
|
|
|
|
class Rename:
|
|
|
|
|
argtypes = None
|
|
|
|
|
restype = None
|
|
|
|
|
|
|
|
|
|
def __call__(self, *_args):
|
|
|
|
|
return -1
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes.ctypes,
|
|
|
|
|
"CDLL",
|
|
|
|
|
lambda *_args, **_kwargs: SimpleNamespace(renameat2=Rename()),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(lanes.ctypes, "get_errno", lambda: errno.EEXIST)
|
|
|
|
|
with pytest.raises(FileExistsError):
|
|
|
|
|
lanes._rename_noreplace("a", "b", source_dir=1, destination_dir=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retirement_reports_missing_replacement_collision_and_restoration(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
path = tmp_path / "pending"
|
|
|
|
|
path.write_text("old", encoding="utf-8")
|
|
|
|
|
source = path.stat()
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor = os.open(path, os.O_RDONLY)
|
2026-08-17 08:16:35 -03:00
|
|
|
directory = os.open(tmp_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
|
|
|
try:
|
|
|
|
|
path.unlink()
|
|
|
|
|
assert lanes._retire_terminal_entry(
|
|
|
|
|
path,
|
|
|
|
|
source,
|
|
|
|
|
board_descriptor=directory,
|
|
|
|
|
quarantine_descriptor=directory,
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor=source_descriptor,
|
2026-08-17 08:16:35 -03:00
|
|
|
) == "missing"
|
|
|
|
|
|
|
|
|
|
path.write_text("new", encoding="utf-8")
|
|
|
|
|
assert lanes._retire_terminal_entry(
|
|
|
|
|
path,
|
|
|
|
|
source,
|
|
|
|
|
board_descriptor=directory,
|
|
|
|
|
quarantine_descriptor=directory,
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor=source_descriptor,
|
2026-08-17 08:16:35 -03:00
|
|
|
) == "replacement"
|
|
|
|
|
|
2026-08-17 16:31:15 +00:00
|
|
|
os.close(source_descriptor)
|
|
|
|
|
source_descriptor = os.open(path, os.O_RDONLY)
|
2026-08-17 08:16:35 -03:00
|
|
|
replacement = path.stat()
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"_rename_noreplace",
|
|
|
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(FileExistsError()),
|
|
|
|
|
)
|
|
|
|
|
assert lanes._retire_terminal_entry(
|
|
|
|
|
path,
|
|
|
|
|
replacement,
|
|
|
|
|
board_descriptor=directory,
|
|
|
|
|
quarantine_descriptor=directory,
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor=source_descriptor,
|
2026-08-17 08:16:35 -03:00
|
|
|
) == "collision"
|
|
|
|
|
finally:
|
2026-08-17 16:31:15 +00:00
|
|
|
os.close(source_descriptor)
|
2026-08-17 08:16:35 -03:00
|
|
|
os.close(directory)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retirement_preserves_a_postcheck_replacement(tmp_path: Path, monkeypatch):
|
|
|
|
|
path = tmp_path / "pending"
|
|
|
|
|
path.write_text("old", encoding="utf-8")
|
|
|
|
|
source = path.stat()
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor = os.open(path, os.O_RDONLY)
|
2026-08-17 08:16:35 -03:00
|
|
|
directory = os.open(tmp_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def race(source_name, destination, **_kwargs):
|
|
|
|
|
calls.append((source_name, destination))
|
|
|
|
|
if len(calls) == 1:
|
|
|
|
|
os.rename(source_name, destination, src_dir_fd=directory, dst_dir_fd=directory)
|
|
|
|
|
staged = tmp_path / destination
|
|
|
|
|
staged.unlink()
|
|
|
|
|
staged.write_text("replacement", encoding="utf-8")
|
|
|
|
|
else:
|
|
|
|
|
os.rename(source_name, destination, src_dir_fd=directory, dst_dir_fd=directory)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes, "_rename_noreplace", race)
|
|
|
|
|
try:
|
|
|
|
|
assert lanes._retire_terminal_entry(
|
|
|
|
|
path,
|
|
|
|
|
source,
|
|
|
|
|
board_descriptor=directory,
|
|
|
|
|
quarantine_descriptor=directory,
|
2026-08-17 16:31:15 +00:00
|
|
|
source_descriptor=source_descriptor,
|
2026-08-17 08:16:35 -03:00
|
|
|
) == "replacement"
|
|
|
|
|
finally:
|
2026-08-17 16:31:15 +00:00
|
|
|
os.close(source_descriptor)
|
2026-08-17 08:16:35 -03:00
|
|
|
os.close(directory)
|
|
|
|
|
assert path.read_text(encoding="utf-8") == "replacement"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_evidence_writer_bounds_payload_and_cleans_failed_temporary(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 8)
|
|
|
|
|
with pytest.raises(ValueError, match="bounded"):
|
|
|
|
|
lanes._write_json_noreplace(tmp_path / "large.json", {"value": "x" * 5000})
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 1024 * 1024)
|
|
|
|
|
real_retire = lanes._retire_terminal_entry
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"_rename_noreplace",
|
|
|
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("rename failed")),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"_retire_terminal_entry",
|
|
|
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("cleanup failed")),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(OSError, match="rename failed"):
|
|
|
|
|
lanes._write_json_noreplace(tmp_path / "failed.json", {"value": "small"})
|
|
|
|
|
monkeypatch.setattr(lanes, "_retire_terminal_entry", real_retire)
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 16:31:15 +00:00
|
|
|
def test_evidence_writer_closes_unwrapped_and_rejects_unsafe_temporary_files(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
destination = tmp_path / "evidence.json"
|
|
|
|
|
real_fdopen = lanes.os.fdopen
|
|
|
|
|
opened = []
|
|
|
|
|
|
|
|
|
|
def fail_fdopen(descriptor, *_args, **_kwargs):
|
|
|
|
|
opened.append(descriptor)
|
|
|
|
|
raise OSError("fdopen failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes.os, "fdopen", fail_fdopen)
|
|
|
|
|
with pytest.raises(OSError, match="fdopen failed"):
|
|
|
|
|
lanes._write_json_noreplace(destination, {"value": "bounded"})
|
|
|
|
|
with pytest.raises(OSError):
|
|
|
|
|
os.fstat(opened[0])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes.os, "fdopen", real_fdopen)
|
|
|
|
|
with monkeypatch.context() as unsafe:
|
|
|
|
|
unsafe.setattr(lanes.stat, "S_IMODE", lambda _mode: 0o644)
|
|
|
|
|
with pytest.raises(OSError, match="not private"):
|
|
|
|
|
lanes._write_json_noreplace(destination, {"value": "bounded"})
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 08:16:35 -03:00
|
|
|
def test_evidence_paths_and_collision_validation_fail_closed(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
|
|
|
|
identity = lanes.TerminalIdentity("cassandra", "t_evidence", 9, "pending")
|
|
|
|
|
document = _pending_terminal_record("cassandra", "t_evidence", 9, "accepted")
|
|
|
|
|
with pytest.raises(ValueError, match="evidence state"):
|
|
|
|
|
lanes._terminal_evidence_path(identity, "pending", document)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
|
|
|
|
monkeypatch.setattr(lanes, "_terminal_evidence_identity", lambda _path: None)
|
|
|
|
|
with pytest.raises(OSError, match="identity is invalid"):
|
|
|
|
|
lanes._persist_prepared_evidence(identity, document)
|
|
|
|
|
with pytest.raises(OSError, match="conflict evidence identity"):
|
|
|
|
|
lanes._persist_conflict_evidence(identity, document, "loser")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_existing_evidence_with_different_result_is_rejected(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
|
|
|
|
identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "pending")
|
|
|
|
|
document = _pending_terminal_record("cassandra", "t_evidence", 10, "accepted")
|
|
|
|
|
prepared_identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "prepared")
|
|
|
|
|
conflict_identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "conflict")
|
|
|
|
|
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"_terminal_evidence_identity",
|
|
|
|
|
lambda path: conflict_identity if "conflict" in path.name else prepared_identity,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(lanes, "_load_small_json", lambda _path: {})
|
|
|
|
|
with pytest.raises(OSError, match="conflicting result"):
|
|
|
|
|
lanes._persist_prepared_evidence(identity, document)
|
|
|
|
|
with pytest.raises(OSError, match="different result"):
|
|
|
|
|
lanes._persist_conflict_evidence(identity, document, "loser")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retention_failure_paths_are_bounded(tmp_path: Path, monkeypatch, capsys):
|
|
|
|
|
path = tmp_path / "artifact"
|
|
|
|
|
path.write_text("data", encoding="utf-8")
|
|
|
|
|
observed = path.stat()
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"_retire_terminal_entry",
|
|
|
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
|
|
|
|
)
|
|
|
|
|
assert lanes._unlink_artifact_if_same(path, observed) is False
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes, "LAST_ARTIFACT_GC", 0.0)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
lanes,
|
|
|
|
|
"gc_lane_artifacts",
|
|
|
|
|
lambda **_kwargs: (_ for _ in ()).throw(OSError("volume")),
|
|
|
|
|
)
|
|
|
|
|
assert lanes.maybe_gc_lane_artifacts(now=1000) == 0
|
|
|
|
|
assert "retention deferred" in capsys.readouterr().err
|
2026-08-17 15:03:59 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_evidence_writer_rejects_a_tampered_temporary(tmp_path: Path, monkeypatch):
|
|
|
|
|
"""A shared or replaced temporary inode never becomes durable evidence."""
|
|
|
|
|
real_fstat = os.fstat
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def hardlinked_fstat(descriptor):
|
|
|
|
|
observed = real_fstat(descriptor)
|
|
|
|
|
calls.append(observed)
|
|
|
|
|
if len(calls) == 2:
|
|
|
|
|
return os.stat_result((*observed[:3], 2, *observed[4:]))
|
|
|
|
|
return observed
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes.os, "fstat", hardlinked_fstat)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(OSError, match="not private and singly linked"):
|
|
|
|
|
lanes._write_json_noreplace(tmp_path / "tampered.json", {"value": "x"})
|
|
|
|
|
|
|
|
|
|
assert list(tmp_path.iterdir()) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_evidence_writer_releases_descriptor_when_inspection_fails(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
"""An fstat fault closes the created descriptor without staging cleanup."""
|
|
|
|
|
|
|
|
|
|
def failing_fstat(_descriptor):
|
|
|
|
|
raise OSError("inspection failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(lanes.os, "fstat", failing_fstat)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(OSError, match="inspection failed"):
|
|
|
|
|
lanes._write_json_noreplace(tmp_path / "orphaned.json", {"value": "x"})
|