from __future__ import annotations import os import subprocess from dataclasses import dataclass from pathlib import Path from typing import Sequence from .redaction import redact_text class CommandExecutionError(RuntimeError): def __init__(self, message: str, *, output: str = ""): super().__init__(message) self.output = redact_text(output) @dataclass(frozen=True, slots=True) class CommandResult: returncode: int stdout: str stderr: str @property def output(self) -> str: return redact_text("\n".join(part for part in (self.stdout, self.stderr) if part)) class SubprocessRunner: """Injectable no-shell subprocess boundary.""" def run( self, argv: Sequence[str], *, timeout: int, cwd: Path | None = None, input_text: str | None = None, ) -> CommandResult: if not argv or any(not isinstance(arg, str) or "\x00" in arg for arg in argv): raise CommandExecutionError("Refusing to execute invalid argv.") env = os.environ.copy() env.update({"PIP_NO_INPUT": "1", "PYTHONUNBUFFERED": "1"}) try: completed = subprocess.run( list(argv), cwd=str(cwd) if cwd else None, env=env, shell=False, check=False, text=True, input=input_text, stdin=subprocess.DEVNULL if input_text is None else None, capture_output=True, timeout=timeout, ) except subprocess.TimeoutExpired as exc: output = "\n".join( str(value) for value in (getattr(exc, "stdout", ""), getattr(exc, "stderr", "")) if value ) raise CommandExecutionError("Command timed out.", output=output) from exc except OSError as exc: raise CommandExecutionError(f"Unable to start command: {type(exc).__name__}") from exc result = CommandResult(completed.returncode, completed.stdout or "", completed.stderr or "") if completed.returncode != 0: raise CommandExecutionError( f"Command failed with exit code {completed.returncode}.", output=result.output ) return result