"""Plugin author helpers; no scheduler, device actions or implicit retries. Use snapshot_file before approving a write and consume its immutable bytes, not the original pathname. run_process owns the tool and its descendants on every exit. """ from __future__ import annotations import asyncio from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field import hashlib import json import math import os from pathlib import Path import re import signal import stat import subprocess import sys import tempfile from artemis.plugins.recording_process import ProcessJob MAX_INPUT_BYTES = 64 * 1024 * 1024 DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024 class FileVerificationError(ValueError): """The input cannot be bound to its explicitly approved digest.""" @dataclass(frozen=True) class VerifiedFile: """Bounded, immutable input. No original path is exposed for reopening.""" data: bytes = field(repr=False) sha256: str = field(init=False) def __post_init__(self): if not isinstance(self.data, bytes): raise TypeError("快照内容必须是不可变 bytes") object.__setattr__(self, "sha256", hashlib.sha256(self.data).hexdigest()) @property def size(self) -> int: return len(self.data) def chunks(self, size: int = 256 * 1024) -> Iterator[memoryview]: if type(size) is not int or size <= 0: raise ValueError("分块大小必须是正整数") content = memoryview(self.data) for offset in range(0, len(content), size): yield content[offset : offset + size] def snapshot_file( path: Path | str, expected_sha256: str, *, max_bytes: int = MAX_INPUT_BYTES ) -> VerifiedFile: """Copy once, then verify the exact bytes that will be consumed by the caller. The source must already be authorized by the plugin. This is not a path or permission sandbox. Memory use is bounded by max_bytes plus one read byte. """ if type(max_bytes) is not int or max_bytes <= 0: raise ValueError("文件大小上限必须是正整数") if not isinstance(expected_sha256, str) or not re.fullmatch( r"[a-fA-F0-9]{64}", expected_sha256 ): raise FileVerificationError("必须明确提供已批准的 SHA256") source = Path(path) if source.is_symlink() or not stat.S_ISREG(source.lstat().st_mode): raise FileVerificationError("输入必须是普通文件,不能是链接或设备") flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) descriptor = os.open(source, flags) with os.fdopen(descriptor, "rb") as handle: info = os.fstat(handle.fileno()) if not stat.S_ISREG(info.st_mode) or info.st_size > max_bytes: raise FileVerificationError("输入不是普通文件或超过大小上限") data = handle.read(max_bytes + 1) if len(data) > max_bytes: raise FileVerificationError("输入超过大小上限") snapshot = VerifiedFile(data) if snapshot.sha256 != expected_sha256.lower(): raise FileVerificationError("输入 SHA256 与批准值不一致;不得开始写操作") return snapshot class ManagedProcessError(RuntimeError): """Stable failure metadata. Do not infer a business verdict or retry a write.""" def __init__(self, code: str, *, may_have_started: bool = True): super().__init__(code) self.error_code = code self.may_have_started = may_have_started class ProcessTimedOut(ManagedProcessError, TimeoutError): pass @dataclass(frozen=True) class ProcessResult: returncode: int stdout: bytes = field(repr=False) stderr: bytes = field(repr=False) async def _cleanup(process: asyncio.subprocess.Process, job: ProcessJob | None) -> None: error = None try: if job is not None: job.terminate() async with asyncio.timeout(5): while job.active_processes(): await asyncio.sleep(0.02) elif os.name != "nt": # The bootstrap stays alive until cleanup, pinning the owned PGID. try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass except (OSError, TimeoutError) as exc: error = exc finally: if job is not None: job.close() if process.returncode is None: try: process.kill() except ProcessLookupError: pass # Process.wait also waits for pipe EOF. A paused, full output reader must be # drained separately; it cannot serve as evidence that the OS process lives. try: async with asyncio.timeout(5): while process.returncode is None: await asyncio.sleep(0.02) except TimeoutError as exc: error = exc if error is not None: raise ManagedProcessError("process_cleanup_unconfirmed") from error async def _finish_cleanup(task: asyncio.Task[None]) -> None: cancelled = False while not task.done(): try: await asyncio.shield(task) except asyncio.CancelledError: cancelled = True task.result() if cancelled: raise asyncio.CancelledError async def _read_output(stream: asyncio.StreamReader, consumed: list[int], limit: int) -> bytes: chunks = [] while chunk := await stream.read(65536): consumed[0] += len(chunk) if consumed[0] > limit: raise ManagedProcessError("process_output_limit") chunks.append(chunk) return b"".join(chunks) async def _drain(stream: asyncio.StreamReader | None) -> None: if stream is not None: while await stream.read(65536): pass async def _wait_result(process: asyncio.subprocess.Process, path: Path) -> int: while not path.exists(): if process.returncode is not None: raise ManagedProcessError("process_worker_interrupted") await asyncio.sleep(0.02) if path.stat().st_size > 4096: raise ManagedProcessError("process_invalid_status") try: result = json.loads(path.read_text("utf-8")) if not isinstance(result, dict): raise ValueError except (OSError, ValueError) as exc: raise ManagedProcessError("process_invalid_status") from exc if result.get("launch_failed") is True: raise ManagedProcessError("process_launch_failed", may_have_started=False) if type(result.get("returncode")) is not int: raise ManagedProcessError("process_invalid_status") return result["returncode"] async def run_process( argv: Sequence[str], *, cwd: Path | str, timeout: float, env: Mapping[str, str] | None = None, input_data: bytes | None = None, max_output_bytes: int = DEFAULT_OUTPUT_BYTES, ) -> ProcessResult: """Run one fixed argv without a shell; reclaim descendants even on normal exit. Windows assigns a waiting bootstrap to a Job Object *before* it can start the tool. POSIX uses a dedicated session. Cancellation completes cleanup before propagating. Inputs are at most 64 MiB; stdout+stderr share a bounded budget. Return codes/output are tool results, never a test verdict. No retries occur. """ if ( not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv or any(not isinstance(arg, str) or "\0" in arg for arg in argv) ): raise ValueError("必须提供固定的 argv 字符串列表,不能使用 shell 命令文本") if not argv[0] or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0: raise ValueError("启动程序和有限正数超时是必填项") if type(max_output_bytes) is not int or max_output_bytes <= 0: raise ValueError("输出上限必须是正整数") if input_data is not None and ( not isinstance(input_data, bytes) or len(input_data) > MAX_INPUT_BYTES ): raise ValueError("标准输入必须是至多 64 MiB 的 bytes") directory = Path(cwd).resolve(strict=True) if not directory.is_dir(): raise ValueError("工作目录必须是已存在的目录") control = ( json.dumps( { "argv": list(argv), "has_input": input_data is not None, "input_size": len(input_data or b""), } ).encode() + b"\n" ) if len(control) > 1024 * 1024: raise ValueError("启动参数超过大小上限") job = None process: asyncio.subprocess.Process | None = None cleanup: asyncio.Task[None] | None = None tasks: list[asyncio.Task] = [] async def reclaim(): nonlocal cleanup if process is None: if job is not None: job.close() return if cleanup is None: cleanup = asyncio.create_task(_cleanup(process, job)) await _finish_cleanup(cleanup) with tempfile.TemporaryDirectory(prefix="artemis-plugin-process-") as temporary: status = Path(temporary) / "status.json" try: job = ProcessJob() if os.name == "nt" else None except OSError as exc: raise ManagedProcessError("process_ownership_failed", may_have_started=False) from exc launch = asyncio.create_task( asyncio.create_subprocess_exec( sys.executable, "-I", "-B", str(Path(__file__).with_name("_process_worker.py")), str(status), str(os.getpid()), cwd=str(directory), env=dict(env) if env is not None else None, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, start_new_session=os.name != "nt", ) ) try: cancelled = False while process is None: try: process = await asyncio.shield(launch) except asyncio.CancelledError: cancelled = True except OSError as exc: raise ManagedProcessError( "process_launch_failed", may_have_started=False ) from exc if cancelled: raise asyncio.CancelledError if job is not None: try: job.assign(process.pid) except OSError as exc: raise ManagedProcessError( "process_ownership_failed", may_have_started=False ) from exc stdin, stdout, stderr = process.stdin, process.stdout, process.stderr assert stdin is not None and stdout is not None and stderr is not None consumed = [0] async def feed(): try: stdin.write(control) await stdin.drain() if input_data: stdin.write(input_data) await stdin.drain() except (BrokenPipeError, ConnectionResetError): pass finally: stdin.close() async def wait_tool(): code = await _wait_result(process, status) await reclaim() return code tasks = [ asyncio.create_task(feed()), asyncio.create_task(_read_output(stdout, consumed, max_output_bytes)), asyncio.create_task(_read_output(stderr, consumed, max_output_bytes)), asyncio.create_task(wait_tool()), ] try: _, stdout, stderr, code = await asyncio.wait_for(asyncio.gather(*tasks), timeout) except TimeoutError as exc: raise ProcessTimedOut("process_timeout") from exc return ProcessResult(code, stdout, stderr) finally: for task in tasks: task.cancel() async def finalize(): try: await reclaim() finally: await asyncio.gather(*tasks, return_exceptions=True) if process is not None: try: async with asyncio.timeout(5): await asyncio.gather(_drain(process.stdout), _drain(process.stderr)) await process.wait() except TimeoutError as exc: raise ManagedProcessError("process_cleanup_unconfirmed") from exc await _finish_cleanup(asyncio.create_task(finalize()))