Files
harness-tools/tests/nono/harness/nono.py
T

418 lines
15 KiB
Python

"""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",
]