"""Small, argv-only adapter around the installed Nono executable.""" from __future__ import annotations import contextlib import os import platform import re import signal import stat import subprocess import tempfile import threading from dataclasses import dataclass from pathlib import Path from typing import BinaryIO, Iterable, Iterator, Mapping, Sequence _VERSION_RE = re.compile(r"\bnono\s+v?(\d+\.\d+\.\d+)\b", re.IGNORECASE) _REQUIRED_PATH_ENV = ( "HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "TMPDIR", ) _ALLOWED_ENVIRONMENT = frozenset( { *_REQUIRED_PATH_ENV, "PATH", "LANG", "LC_ALL", "PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE", } ) class NonoError(RuntimeError): """Base error for adapter and host-gate failures.""" class HostRequirementError(NonoError): """The mandatory host gate cannot be satisfied.""" @dataclass(frozen=True) class CommandResult: argv: tuple[str, ...] returncode: int | None stdout: str stderr: str timed_out: bool = False @property def ok(self) -> bool: return self.returncode == 0 and not self.timed_out class NonoAdapter: """Execute only known Nono subcommands with an injected absolute executable. The adapter never writes to a profile directory. Profile arguments are always absolute JSON paths, which also makes it impossible for a test to accidentally select an active profile by name. """ def __init__( self, executable: str | os.PathLike[str], cwd: str | os.PathLike[str], timeout: float = 20.0, max_output: int = 64 * 1024, environment: Mapping[str, str] | None = None, ) -> None: executable_path = Path(executable) cwd_path = Path(cwd) if not executable_path.is_absolute(): raise ValueError("nono executable must be an absolute path") if not cwd_path.is_absolute(): raise ValueError("adapter cwd must be an absolute path") if timeout <= 0: raise ValueError("timeout must be positive") if max_output < 1: raise ValueError("max_output must be positive") self.executable = str(executable_path) self.cwd = str(cwd_path) self.timeout = timeout if environment is None: raise ValueError("an explicit isolated environment is required") self.max_output = max_output self.environment = _validated_environment(environment) def version(self) -> CommandResult: return self._execute((self.executable, "--version")) def require_host_0_74(self) -> str: """Require Linux and an installed Nono in the exact 0.74 release line.""" if platform.system() != "Linux": raise HostRequirementError("mandatory Nono host checks require Linux") if not os.path.isfile(self.executable) or not os.access(self.executable, os.X_OK): raise HostRequirementError(f"Nono executable is unavailable: {self.executable}") try: result = self.version() except (OSError, subprocess.SubprocessError) as error: raise HostRequirementError(f"could not execute Nono: {error}") from error if not result.ok: raise HostRequirementError( f"Nono version check failed (exit {result.returncode}): {result.stderr}" ) match = _VERSION_RE.search(result.stdout + "\n" + result.stderr) if match is None or not match.group(1).startswith("0.74."): observed = (result.stdout + result.stderr).strip() or "unknown" raise HostRequirementError( f"mandatory host checks require Nono 0.74.x; observed {observed!r}" ) return match.group(1) def validate(self, profile: str | os.PathLike[str], *, strict: bool = True) -> CommandResult: with self._profile_snapshot(profile) as profile_path: command = [self.executable, "profile", "validate"] if strict: command.append("--strict") command.append(str(profile_path)) return self._execute(command) def show(self, profile: str | os.PathLike[str]) -> CommandResult: with self._profile_snapshot(profile) as profile_path: return self._execute( [self.executable, "profile", "show", str(profile_path), "--json"] ) def run( self, profile: str | os.PathLike[str], command: Sequence[str | os.PathLike[str]], *, allow_cwd: bool = False, ) -> CommandResult: if isinstance(command, (str, bytes)): raise TypeError("sandbox command must be an argv sequence, not a shell string") command_args = [os.fspath(argument) for argument in command] if not command_args or any(not isinstance(argument, str) for argument in command_args): raise ValueError("sandbox command must contain at least one argv string") with self._profile_snapshot(profile) as profile_path: nono_args = [self.executable, "run", "--profile", str(profile_path)] if allow_cwd: nono_args.append("--allow-cwd") return self._execute([*nono_args, "--", *command_args]) def validate_temporary_profile( self, profile: str | os.PathLike[str], *, strict: bool = True ) -> CommandResult: return self.validate(profile, strict=strict) def show_temporary_profile(self, profile: str | os.PathLike[str]) -> CommandResult: return self.show(profile) def run_temporary_profile( self, profile: str | os.PathLike[str], command: Sequence[str | os.PathLike[str]], *, allow_cwd: bool = False, ) -> CommandResult: return self.run( profile, command, allow_cwd=allow_cwd, ) def _open_temporary_profile( self, profile: str | os.PathLike[str], ) -> tuple[Path, int]: path = Path(os.path.normpath(os.fspath(profile))) if not path.is_absolute(): raise ValueError("temporary profile must be an absolute path") if path.suffix != ".json": raise ValueError("temporary profile must be a JSON file") if _has_symlink_component(path): raise ValueError("temporary profile cannot contain a symlinked path component") active_roots = self._active_profile_roots() if any(path.parent == root or root in path.parents for root in active_roots): raise ValueError("active Nono profile namespace is not permitted") try: descriptor = _open_path_nofollow(path) except OSError as error: raise ValueError(f"temporary profile is unavailable: {error}") from error file_stat = os.fstat(descriptor) if not stat.S_ISREG(file_stat.st_mode): os.close(descriptor) raise ValueError("temporary profile must be a regular file") return path, descriptor def _active_profile_roots(self) -> set[Path]: configured_home = self.environment["HOME"] return { (Path.home() / ".config" / "nono" / "profiles").resolve(), ( Path(os.environ.get("HOME", str(Path.home()))) / ".config" / "nono" / "profiles" ).resolve(), (Path(configured_home) / ".config" / "nono" / "profiles").resolve(), } @contextlib.contextmanager def _profile_snapshot( self, profile: str | os.PathLike[str], ) -> Iterator[Path]: _source, descriptor = self._open_temporary_profile(profile) try: source_stat = os.fstat(descriptor) if source_stat.st_size > 1024 * 1024: raise ValueError("temporary profile must be a regular file no larger than 1 MiB") for active_root in self._active_profile_roots(): if not active_root.is_dir(): continue for active_profile in active_root.glob("*.json"): try: active_stat = active_profile.stat() except OSError: continue if (active_stat.st_dev, active_stat.st_ino) == ( source_stat.st_dev, source_stat.st_ino, ): raise ValueError( "temporary profile cannot alias an active Nono profile" ) payload = b"" while len(payload) <= source_stat.st_size: chunk = os.read(descriptor, min(65536, source_stat.st_size + 1 - len(payload))) if not chunk: break payload += chunk final_stat = os.fstat(descriptor) if ( len(payload) != source_stat.st_size or final_stat.st_size != source_stat.st_size or final_stat.st_mtime_ns != source_stat.st_mtime_ns ): raise ValueError("temporary profile changed while it was being read") finally: os.close(descriptor) scratch_root = Path(self.environment["TMPDIR"]) with tempfile.TemporaryDirectory(prefix="nono-profile-", dir=scratch_root) as directory: os.chmod(directory, 0o700) snapshot = Path(directory) / "profile.json" snapshot.write_bytes(payload) os.chmod(snapshot, 0o600) yield snapshot def _execute(self, argv: Iterable[str]) -> CommandResult: return run_bounded_command( argv, cwd=self.cwd, environment=self.environment, timeout=self.timeout, max_output=self.max_output, ) def run_bounded_command( argv: Iterable[str], *, cwd: str | os.PathLike[str], environment: Mapping[str, str], timeout: float, max_output: int, ) -> CommandResult: argument_list = tuple(str(value) for value in argv) if timeout <= 0 or max_output <= 0: raise ValueError("bounded commands require positive timeout and output limits") cwd_path = Path(cwd) if not cwd_path.is_absolute(): raise ValueError("bounded command cwd must be absolute") selected_environment = _validated_environment(environment) buffers = {"stdout": bytearray(), "stderr": bytearray()} def drain(stream: BinaryIO, name: str) -> None: with stream: while chunk := stream.read(65536): remaining = max_output - len(buffers[name]) if remaining > 0: buffers[name].extend(chunk[:remaining]) try: process = subprocess.Popen( argument_list, cwd=os.fspath(cwd), env=selected_environment, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) except OSError as error: return CommandResult( argument_list, None, "", _bounded_text(str(error), max_output), ) assert process.stdout is not None and process.stderr is not None threads = [ threading.Thread(target=drain, args=(process.stdout, "stdout"), daemon=True), threading.Thread(target=drain, args=(process.stderr, "stderr"), daemon=True), ] for thread in threads: thread.start() timed_out = False try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: timed_out = True try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass try: process.wait(timeout=5) except subprocess.TimeoutExpired as error: raise NonoError("timed-out process group did not terminate") from error for thread in threads: thread.join(timeout=5) if thread.is_alive(): raise NonoError("subprocess output pipe did not close") return CommandResult( argument_list, None if timed_out else process.returncode, bytes(buffers["stdout"]).decode("utf-8", errors="replace"), bytes(buffers["stderr"]).decode("utf-8", errors="replace"), timed_out, ) def _bounded_text(value: str, limit: int) -> str: if len(value) <= limit: return value return value[: max(0, limit - 3)] + "..." def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]: selected = dict(environment) for name in _REQUIRED_PATH_ENV: value = selected.get(name) if not value or not Path(value).is_absolute(): raise ValueError(f"the isolated environment requires an absolute {name}") path = Path(value) if _has_symlink_component(path): raise ValueError(f"the isolated environment forbids symlinked {name}") try: path_stat = path.stat() except OSError as error: raise ValueError(f"the isolated environment path is unavailable: {name}") from error if ( not stat.S_ISDIR(path_stat.st_mode) or stat.S_IMODE(path_stat.st_mode) != 0o700 or path_stat.st_uid != os.geteuid() ): raise ValueError( f"the isolated environment requires caller-owned mode-0700 {name}" ) unknown = sorted(set(selected) - _ALLOWED_ENVIRONMENT) if unknown: raise ValueError( "environment variable is not in the isolated allowlist: " + unknown[0] ) return selected def _open_path_nofollow(path: Path) -> int: if not path.is_absolute() or not path.name: raise ValueError("profile path must identify an absolute file") directory_flags = ( os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) ) directory_fd = os.open(path.anchor, directory_flags) try: for component in path.parts[1:-1]: next_fd = os.open(component, directory_flags, dir_fd=directory_fd) os.close(directory_fd) directory_fd = next_fd return os.open( path.name, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0), dir_fd=directory_fd, ) finally: os.close(directory_fd) def _has_symlink_component(path: Path) -> bool: current = path while current.parent != current: if current.is_symlink(): return True current = current.parent return current.is_symlink() __all__ = [ "CommandResult", "HostRequirementError", "NonoAdapter", "NonoError", "run_bounded_command", ]