| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- """Fail-closed descriptor I/O: trusted root dirfd, openat parent chain and atomic output."""
- from __future__ import annotations
- import fcntl, json, os, secrets, stat, threading
- from contextlib import contextmanager
- from pathlib import Path
- from typing import Iterator
- import hashlib
- MAX_JSON_BYTES = 2 * 1024 * 1024
- TRUSTED_ROOT = Path(__file__).resolve().parents[1]
- _DIRECTORY_LOCKS: dict[tuple[int, int], threading.RLock] = {}
- _DIRECTORY_LOCKS_GUARD = threading.Lock()
- _DIRECTORY_LOCK_STATE = threading.local()
- def _safe(st: os.stat_result, name: str) -> None:
- if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1: raise ValueError(f"unsafe path: {name}")
- def _parent(path: Path) -> tuple[int, str]:
- if ".." in path.parts: raise ValueError("parent-traversal paths are forbidden")
- absolute = path if path.is_absolute() else TRUSTED_ROOT / path
- try: absolute.relative_to(TRUSTED_ROOT)
- except ValueError: raise ValueError("path outside trusted root")
- # Refuse a user-controlled leaf/parent before opening; every opened parent is O_NOFOLLOW.
- fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
- parts = absolute.parts[1:-1]
- try:
- for part in parts:
- nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0), dir_fd=fd)
- os.close(fd); fd = nxt
- return fd, absolute.name
- except Exception:
- os.close(fd); raise
- @contextmanager
- def _locked_trusted_parent(parent: int) -> Iterator[None]:
- """Serialize a trusted directory across processes and re-entrantly in-process."""
- directory = os.fstat(parent)
- key = (directory.st_dev, directory.st_ino)
- with _DIRECTORY_LOCKS_GUARD:
- process_lock = _DIRECTORY_LOCKS.setdefault(key, threading.RLock())
- process_lock.acquire()
- held = getattr(_DIRECTORY_LOCK_STATE, "held", {})
- outermost = held.get(key, 0) == 0
- try:
- if outermost:
- fcntl.flock(parent, fcntl.LOCK_EX)
- held[key] = held.get(key, 0) + 1
- _DIRECTORY_LOCK_STATE.held = held
- yield
- finally:
- remaining = held.get(key, 1) - 1
- if remaining:
- held[key] = remaining
- else:
- held.pop(key, None)
- if outermost:
- fcntl.flock(parent, fcntl.LOCK_UN)
- process_lock.release()
- def read_bytes_once(path: Path, *, limit: int = MAX_JSON_BYTES) -> bytes:
- parent, leaf = _parent(path)
- try:
- with _locked_trusted_parent(parent):
- before = os.stat(leaf, dir_fd=parent, follow_symlinks=False); _safe(before, str(path))
- fd = os.open(leaf, os.O_RDONLY | getattr(os,"O_NOFOLLOW",0), dir_fd=parent)
- try:
- opened=os.fstat(fd); _safe(opened,str(path))
- if (opened.st_dev,opened.st_ino)!=(before.st_dev,before.st_ino): raise ValueError("leaf swapped")
- chunks=[]; total=0
- while (chunk:=os.read(fd,65536)):
- total+=len(chunk)
- if total>limit: raise ValueError("input too large")
- chunks.append(chunk)
- after=os.fstat(fd)
- if any(getattr(opened,k)!=getattr(after,k) for k in ("st_dev","st_ino","st_mode","st_nlink","st_size","st_mtime_ns","st_ctime_ns")): raise ValueError("input changed")
- return b"".join(chunks)
- finally: os.close(fd)
- finally: os.close(parent)
- def read_json_once(path: Path, *, limit: int=MAX_JSON_BYTES)->object: return json.loads(read_bytes_once(path,limit=limit))
- def sha256_file_once(path: Path, *, limit: int = MAX_JSON_BYTES) -> str:
- """Stream a stable regular file through SHA-256 with the same FD checks."""
- parent, leaf = _parent(path)
- try:
- with _locked_trusted_parent(parent):
- before = os.stat(leaf, dir_fd=parent, follow_symlinks=False); _safe(before, str(path))
- fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
- try:
- opened = os.fstat(fd); _safe(opened, str(path))
- if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino): raise ValueError("leaf swapped")
- digest = hashlib.sha256(); total = 0
- while (chunk := os.read(fd, 65536)):
- total += len(chunk)
- if total > limit: raise ValueError("input too large")
- digest.update(chunk)
- after = os.fstat(fd)
- if any(getattr(opened,k)!=getattr(after,k) for k in ("st_dev","st_ino","st_mode","st_nlink","st_size","st_mtime_ns","st_ctime_ns")): raise ValueError("input changed")
- return digest.hexdigest()
- finally: os.close(fd)
- finally: os.close(parent)
- def _verify_temp_matches_open_fd(parent: int, temporary: str, fd: int) -> None:
- """Refuse a name swap before publishing a temp file by rename."""
- expected = os.fstat(fd)
- _safe(expected, temporary)
- verify = os.open(temporary, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
- try:
- observed = os.fstat(verify)
- _safe(observed, temporary)
- if (expected.st_dev, expected.st_ino, expected.st_nlink, stat.S_IFMT(expected.st_mode)) != (
- observed.st_dev, observed.st_ino, observed.st_nlink, stat.S_IFMT(observed.st_mode)
- ):
- raise ValueError("temp swapped before atomic replace")
- finally:
- os.close(verify)
- def _verify_published_leaf(parent: int, leaf: str, expected: os.stat_result, expected_digest: str) -> None:
- """Verify that rename published the exact checked temp inode, not a swapped name."""
- fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
- try:
- observed = os.fstat(fd); _safe(observed, leaf)
- if (expected.st_dev, expected.st_ino, stat.S_IFMT(expected.st_mode)) != (observed.st_dev, observed.st_ino, stat.S_IFMT(observed.st_mode)):
- raise ValueError("published leaf swapped after atomic replace")
- digest = hashlib.sha256()
- while (chunk := os.read(fd, 65536)): digest.update(chunk)
- if digest.hexdigest() != expected_digest:
- raise ValueError("published leaf digest mismatch")
- finally:
- os.close(fd)
- def atomic_write_bytes(path: Path,data: bytes)->None:
- """Cooperatively publish only a fully fsynced new or previous record.
- Readers and writers take the same trusted-parent lock. A temporary inode
- is therefore never observable by cooperative readers. An uncooperative
- same-UID pathname mutation is detected after publication; it is not a
- trusted publisher, and the previous verified inode is restored before the
- lock is released.
- """
- parent, leaf = _parent(path)
- temporary = f".{leaf}.{secrets.token_hex(16)}.tmp"
- backup = f".{leaf}.{secrets.token_hex(16)}.bak"
- temp_exists = False
- backup_exists = False
- published = False
- old_expected: os.stat_result | None = None
- old_digest: str | None = None
- try:
- with _locked_trusted_parent(parent):
- try:
- old_expected = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
- _safe(old_expected, leaf)
- old_fd = os.open(leaf, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), dir_fd=parent)
- try:
- if (os.fstat(old_fd).st_dev, os.fstat(old_fd).st_ino) != (old_expected.st_dev, old_expected.st_ino):
- raise ValueError("previous leaf swapped before publication")
- digest = hashlib.sha256()
- while chunk := os.read(old_fd, 65536): digest.update(chunk)
- old_digest = digest.hexdigest()
- finally:
- os.close(old_fd)
- except FileNotFoundError:
- old_expected = None
- fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600, dir_fd=parent)
- temp_exists = True
- try:
- offset = 0
- while offset < len(data):
- written = os.write(fd, data[offset:])
- if written <= 0: raise OSError("short write")
- offset += written
- os.fsync(fd)
- expected = os.fstat(fd); _safe(expected, temporary)
- _verify_temp_matches_open_fd(parent, temporary, fd)
- finally:
- os.close(fd)
- try:
- if old_expected is not None:
- os.replace(leaf, backup, src_dir_fd=parent, dst_dir_fd=parent)
- backup_exists = True
- _verify_published_leaf(parent, backup, old_expected, old_digest or "")
- os.replace(temporary, leaf, src_dir_fd=parent, dst_dir_fd=parent)
- temp_exists = False
- published = True
- _verify_published_leaf(parent, leaf, expected, hashlib.sha256(data).hexdigest())
- os.fsync(parent)
- except Exception:
- if backup_exists and old_expected is not None:
- os.replace(backup, leaf, src_dir_fd=parent, dst_dir_fd=parent)
- backup_exists = False
- _verify_published_leaf(parent, leaf, old_expected, old_digest or "")
- os.fsync(parent)
- elif published:
- os.unlink(leaf, dir_fd=parent)
- os.fsync(parent)
- raise
- if backup_exists:
- os.unlink(backup, dir_fd=parent)
- backup_exists = False
- os.fsync(parent)
- finally:
- # A failure before publication leaves the old final record untouched.
- if temp_exists:
- try:
- os.unlink(temporary, dir_fd=parent)
- os.fsync(parent)
- except FileNotFoundError:
- pass
- if backup_exists:
- try:
- os.replace(backup, leaf, src_dir_fd=parent, dst_dir_fd=parent)
- os.fsync(parent)
- except FileNotFoundError:
- pass
- os.close(parent)
- @contextmanager
- def exclusive_lock(path: Path)->Iterator[str]:
- parent,_leaf=_parent(path)
- try:
- # The directory inode, never a replaceable marker filename, is the lock identity.
- with _locked_trusted_parent(parent):
- yield secrets.token_urlsafe(24)
- finally:
- os.close(parent)
|