atlas-iac/testing/tests/test_hermes_node_io_coverage.py

294 lines
10 KiB
Python
Raw Normal View History

"""Behavioral branch coverage for crash-safe node account file I/O."""
from __future__ import annotations
import os
import stat
import sys
from pathlib import Path
import pytest
from testing.tests.test_hermes_node_account_support import _load
def _io_module():
hardening = _load()
return sys.modules[hardening.atomic_write.__module__]
def _snapshot(module, path: Path, *, value=None, xattrs=()):
metadata = path.stat()
content = path.read_bytes() if value is None else value
return module.FileSnapshot(
value=content,
device=metadata.st_dev,
inode=metadata.st_ino,
mode=stat.S_IMODE(metadata.st_mode),
uid=metadata.st_uid,
gid=metadata.st_gid,
size=len(content),
mtime_ns=metadata.st_mtime_ns,
ctime_ns=metadata.st_ctime_ns,
xattrs=tuple(xattrs),
)
def test_xattr_reader_uses_compatibility_fallback(monkeypatch):
module = _io_module()
calls = []
def listxattr(target, **kwargs):
calls.append((target, kwargs))
if kwargs:
raise TypeError("fd API")
return ["user.safe"]
def getxattr(target, name, **kwargs):
if kwargs:
raise TypeError("fd API")
return b"value"
monkeypatch.setattr(module.os, "listxattr", listxattr)
monkeypatch.setattr(module.os, "getxattr", getxattr)
assert module._xattrs(7) == (("user.safe", b"value"),)
assert len(calls) == 2
@pytest.mark.parametrize("failure", ["count", "name", "value", "total"])
def test_xattr_reader_enforces_all_bounds(monkeypatch, failure):
module = _io_module()
if failure == "count":
names = [f"user.{index}" for index in range(module.MAX_XATTRS + 1)]
elif failure == "name":
names = ["x" * (module.MAX_XATTR_NAME + 1)]
elif failure == "total":
names = ["user.one", "user.two", "user.three", "user.four", "user.five"]
else:
names = ["user.large"]
monkeypatch.setattr(module.os, "listxattr", lambda *_a, **_k: names)
size = module.MAX_XATTR_VALUE + 1 if failure == "value" else module.MAX_XATTR_VALUE
monkeypatch.setattr(module.os, "getxattr", lambda *_a, **_k: b"x" * size)
with pytest.raises(module.HardeningError):
module._xattrs(7)
def test_restore_xattrs_removes_unexpected_and_sets_expected(monkeypatch):
module = _io_module()
monkeypatch.setattr(
module, "_xattrs", lambda _fd: (("user.old", b"old"), ("user.keep", b"before"))
)
removed = []
written = []
monkeypatch.setattr(
module.os, "removexattr", lambda fd, name: removed.append((fd, name))
)
monkeypatch.setattr(
module.os, "setxattr", lambda fd, name, value: written.append((fd, name, value))
)
module._restore_xattrs(4, (("user.keep", b"after"),))
assert removed == [(4, "user.old")]
assert written == [(4, "user.keep", b"after")]
def test_read_regular_rejects_directory_oversize_and_changed_read(
tmp_path, monkeypatch
):
module = _io_module()
with pytest.raises(module.HardeningError, match="unsafe regular"):
module.read_regular(tmp_path, 10)
source = tmp_path / "file"
source.write_bytes(b"12345")
with pytest.raises(module.HardeningError, match="unsafe regular"):
module.read_regular(source, 4)
monkeypatch.setattr(module.os, "read", lambda _fd, _size: b"1")
with pytest.raises(module.HardeningError, match="short file read"):
module.read_regular(source, 10)
def test_read_mounted_secret_follows_csi_projection_inside_mount(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
data = mount / "..2026_08_18.1"
data.mkdir(parents=True)
(data / "node-ssh-public-key").write_bytes(b"ssh-ed25519 synthetic\n")
(mount / "..data").symlink_to(data.name)
(mount / "node-ssh-public-key").symlink_to(Path("..data") / "node-ssh-public-key")
snapshot = module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
assert snapshot.value == b"ssh-ed25519 synthetic\n"
def test_read_mounted_secret_reads_plain_regular_file_unchanged(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
secret = mount / "node-ssh-public-key"
secret.write_bytes(b"ssh-ed25519 synthetic\n")
snapshot = module.read_mounted_secret(secret, mount, 64)
assert snapshot == module.read_regular(secret, 64)
def test_read_mounted_secret_refuses_escape_from_mount(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
outside = tmp_path / "outside"
outside.write_bytes(b"planted\n")
(mount / "node-ssh-public-key").symlink_to(outside)
with pytest.raises(module.HardeningError, match="escapes its mount"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_refuses_symlink_loop(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
(mount / "node-ssh-public-key").symlink_to("node-ssh-public-key")
with pytest.raises(module.HardeningError, match="unresolvable mounted secret"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_reports_missing_secret_as_not_found(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
mount.mkdir()
with pytest.raises(FileNotFoundError):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_read_mounted_secret_refuses_non_regular_resolved_target(tmp_path):
module = _io_module()
mount = tmp_path / "vault-secrets"
data = mount / "..data"
data.mkdir(parents=True)
(mount / "node-ssh-public-key").symlink_to("..data")
with pytest.raises(module.HardeningError, match="unsafe regular"):
module.read_mounted_secret(mount / "node-ssh-public-key", mount, 64)
def test_assert_unchanged_rejects_changed_snapshot(tmp_path):
module = _io_module()
source = tmp_path / "passwd"
source.write_bytes(b"old")
snapshot = module.read_regular(source, 16)
source.write_bytes(b"new")
with pytest.raises(module.HardeningError, match="concurrent"):
module.assert_unchanged(source, snapshot, 16)
def test_atomic_write_short_write_unlinks_temporary(tmp_path, monkeypatch):
module = _io_module()
target = tmp_path / "passwd"
target.write_bytes(b"old")
snapshot = module.read_regular(target, 16)
monkeypatch.setattr(module.os, "write", lambda _fd, _value: 0)
with pytest.raises(module.HardeningError, match="short atomic write"):
module.atomic_write(target, b"new", snapshot)
assert target.read_bytes() == b"old"
assert not list(tmp_path.glob(".passwd.hermes-*"))
def test_atomic_write_ignores_stale_pid_temporary(tmp_path):
module = _io_module()
target = tmp_path / "passwd"
target.write_bytes(b"old")
snapshot = module.read_regular(target, 16)
stale = tmp_path / f".passwd.hermes-{os.getpid()}"
stale.write_bytes(b"interrupted prior run")
module.atomic_write(target, b"new", snapshot)
assert target.read_bytes() == b"new"
assert stale.read_bytes() == b"interrupted prior run"
def test_private_temporary_retries_untrusted_name_collisions(tmp_path, monkeypatch):
module = _io_module()
target = tmp_path / "passwd"
tokens = iter(("collision", "unused"))
stale = tmp_path / f".passwd.hermes-{os.getpid()}-collision"
stale.write_bytes(b"untrusted")
monkeypatch.setattr(module.secrets, "token_hex", lambda _size: next(tokens))
temporary, descriptor = module.open_private_temporary(target, 0o600)
os.close(descriptor)
try:
assert temporary.name.endswith("-unused")
assert stale.read_bytes() == b"untrusted"
finally:
temporary.unlink(missing_ok=True)
def test_private_temporary_fails_closed_after_collision_limit(tmp_path, monkeypatch):
module = _io_module()
target = tmp_path / "passwd"
stale = tmp_path / f".passwd.hermes-{os.getpid()}-collision"
stale.write_bytes(b"untrusted")
monkeypatch.setattr(module.secrets, "token_hex", lambda _size: "collision")
with pytest.raises(module.HardeningError, match="allocate private temporary"):
module.open_private_temporary(target, 0o600)
assert stale.read_bytes() == b"untrusted"
def test_backup_existing_is_validated_and_short_write_is_removed(tmp_path, monkeypatch):
module = _io_module()
source = tmp_path / "passwd"
source.write_bytes(b"old")
snapshot = module.read_regular(source, 16)
backup = tmp_path / "passwd.hermes-boundary-backup"
backup.write_bytes(b"preserved")
assert module.backup_once(source, snapshot, 16) == backup
backup.unlink()
monkeypatch.setattr(module.os, "write", lambda _fd, _value: 0)
with pytest.raises(module.HardeningError, match="short backup write"):
module.backup_once(source, snapshot, 16)
assert not backup.exists()
def test_account_lock_rejects_unsafe_metadata(tmp_path):
module = _io_module()
lock = tmp_path / ".pwd.lock"
lock.write_text("", encoding="utf-8")
lock.chmod(0o644)
with (
pytest.raises(module.HardeningError, match="unsafe standard account lock"),
module.account_lock(tmp_path, expected_uid=os.getuid()),
):
pass
def test_account_lock_retries_then_succeeds(tmp_path, monkeypatch):
module = _io_module()
attempts = []
def lockf(_descriptor, operation):
attempts.append(operation)
if len(attempts) == 1:
raise BlockingIOError()
monkeypatch.setattr(module.fcntl, "lockf", lockf)
monkeypatch.setattr(module.time, "sleep", lambda _seconds: None)
with module.account_lock(tmp_path, expected_uid=os.getuid()):
assert attempts
assert len(attempts) == 3
def test_account_lock_reports_timeout(tmp_path, monkeypatch):
module = _io_module()
def lockf(_descriptor, operation):
if operation != module.fcntl.LOCK_UN:
raise BlockingIOError()
monkeypatch.setattr(module.fcntl, "lockf", lockf)
ticks = iter((0.0, 16.0))
monkeypatch.setattr(module.time, "monotonic", lambda: next(ticks))
with (
pytest.raises(module.HardeningError, match="lock is busy"),
module.account_lock(tmp_path, expected_uid=os.getuid()),
):
pass