418 lines
15 KiB
Python
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",
|
|
]
|