294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""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
|