#!/usr/bin/env python3 """Crash-safe, metadata-preserving host account database I/O.""" from __future__ import annotations import fcntl import os import stat import time from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Iterator MAX_XATTRS = 64 MAX_XATTR_NAME = 255 MAX_XATTR_VALUE = 256 * 1024 MAX_XATTR_TOTAL = 1024 * 1024 LOCK_TIMEOUT_SECONDS = 15.0 class HardeningError(RuntimeError): """Raised before an unsafe or ambiguous host-account change.""" @dataclass(frozen=True) class FileSnapshot: """Bounded file contents and all security-relevant inode metadata.""" value: bytes device: int inode: int mode: int uid: int gid: int size: int mtime_ns: int ctime_ns: int xattrs: tuple[tuple[str, bytes], ...] def _stat_identity(item: os.stat_result) -> tuple[int, ...]: return ( item.st_dev, item.st_ino, item.st_mode, item.st_uid, item.st_gid, item.st_size, item.st_mtime_ns, item.st_ctime_ns, ) def _xattrs(path_or_fd: Path | int) -> tuple[tuple[str, bytes], ...]: try: names = sorted(os.listxattr(path_or_fd, follow_symlinks=False)) except (TypeError, ValueError): names = sorted(os.listxattr(path_or_fd)) if len(names) > MAX_XATTRS: raise HardeningError("host account file has too many extended attributes") values: list[tuple[str, bytes]] = [] total = 0 for name in names: if len(name.encode("utf-8")) > MAX_XATTR_NAME: raise HardeningError("host account file xattr name is too long") try: value = os.getxattr(path_or_fd, name, follow_symlinks=False) except (TypeError, ValueError): value = os.getxattr(path_or_fd, name) total += len(value) if len(value) > MAX_XATTR_VALUE or total > MAX_XATTR_TOTAL: raise HardeningError("host account file xattrs exceed the safe limit") values.append((name, value)) return tuple(values) def _restore_xattrs(descriptor: int, expected: tuple[tuple[str, bytes], ...]) -> None: expected_names = {name for name, _value in expected} for name, _value in _xattrs(descriptor): if name not in expected_names: os.removexattr(descriptor, name) for name, value in expected: os.setxattr(descriptor, name, value) def read_regular(path: Path, maximum: int) -> FileSnapshot: """Read one bounded, non-symlink regular file and capture its metadata.""" flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags) try: metadata = os.fstat(descriptor) if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: raise HardeningError(f"unsafe regular file: {path.name}") value = os.read(descriptor, maximum + 1) attrs = _xattrs(descriptor) after = os.fstat(descriptor) finally: os.close(descriptor) if len(value) != metadata.st_size or _stat_identity(metadata) != _stat_identity(after): raise HardeningError(f"short file read: {path.name}") return FileSnapshot( value=value, device=metadata.st_dev, inode=metadata.st_ino, mode=stat.S_IMODE(metadata.st_mode), uid=metadata.st_uid, gid=metadata.st_gid, size=metadata.st_size, mtime_ns=metadata.st_mtime_ns, ctime_ns=metadata.st_ctime_ns, xattrs=attrs, ) def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None: """Fail if an account file changed since planning began.""" current = read_regular(path, maximum) if current != expected: raise HardeningError(f"concurrent host account change detected: {path.name}") def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None: """Atomically replace a file while retaining ACLs, labels, and xattrs.""" temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}") flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(temporary, flags, metadata.mode) try: if os.write(descriptor, value) != len(value): raise HardeningError(f"short atomic write: {path.name}") os.fchmod(descriptor, metadata.mode) os.fchown(descriptor, metadata.uid, metadata.gid) _restore_xattrs(descriptor, metadata.xattrs) os.fsync(descriptor) except Exception: temporary.unlink(missing_ok=True) raise finally: os.close(descriptor) os.replace(temporary, path) directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(directory) finally: os.close(directory) def backup_once(path: Path, snapshot: FileSnapshot, maximum: int) -> Path: """Create the first durable recovery copy without replacing an older one.""" backup = path.with_name(path.name + ".hermes-boundary-backup") flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(backup, flags, snapshot.mode) except FileExistsError: read_regular(backup, maximum) return backup try: if os.write(descriptor, snapshot.value) != len(snapshot.value): raise HardeningError(f"short backup write: {path.name}") os.fchmod(descriptor, snapshot.mode) os.fchown(descriptor, snapshot.uid, snapshot.gid) _restore_xattrs(descriptor, snapshot.xattrs) os.fsync(descriptor) except Exception: backup.unlink(missing_ok=True) raise finally: os.close(descriptor) directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(directory) finally: os.close(directory) return backup @contextmanager def account_lock(host_etc: Path, *, expected_uid: int = 0) -> Iterator[None]: """Hold the standard shadow-utils account lock for the full transaction.""" path = host_etc / ".pwd.lock" flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) descriptor = os.open(path, flags, 0o600) try: metadata = os.fstat(descriptor) if ( not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != expected_uid or stat.S_IMODE(metadata.st_mode) != 0o600 ): raise HardeningError("unsafe standard account lock") deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS while True: try: fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) break except BlockingIOError as exc: if time.monotonic() >= deadline: raise HardeningError("standard account lock is busy") from exc time.sleep(0.05) yield finally: try: fcntl.lockf(descriptor, fcntl.LOCK_UN) finally: os.close(descriptor)