"""executor 结果文件协议。 该模块只处理受控结果目录、普通文件、大小限制和 JSON/schema 验证;HTTP handler 和进程 supervisor 不应直接读取任意路径。 """ from __future__ import annotations import json import os import re import stat from pathlib import Path from typing import Any, Callable class ExecutorResultStoreError(RuntimeError): """结果文件无法安全读取或通过 schema 校验。""" def __init__(self, code: str, *, reason_code: str | None = None) -> None: """创建稳定结果错误,并可携带不含输入内容的内部原因码。""" self.code = code self.reason_code = reason_code if isinstance(reason_code, str) and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", reason_code) else None super().__init__(code) class ExecutorResultStore: """绑定一个根目录和结果大小上限的 executor 结果读取器。""" def __init__(self, root: Path, *, filename: str, max_bytes: int) -> None: """创建结果读取器;root 不会被自动解析到符号链接外部。""" self.root = Path(root) self.filename = filename self.max_bytes = max_bytes def read(self, path: Path, *, required_fields: set[str], validator: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]: """读取固定层级结果文件并执行有限 schema 验证。""" current = self.root try: root_info = current.lstat() relative = Path(path).relative_to(current) except (OSError, ValueError) as exc: raise ExecutorResultStoreError("agent_result_path_invalid") from exc if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode) or len(relative.parts) != 2 or relative.name != self.filename: raise ExecutorResultStoreError("agent_result_path_invalid") for part in relative.parts: current = current / part try: info = current.lstat() except FileNotFoundError as exc: raise ExecutorResultStoreError("agent_result_file_missing") from exc except OSError as exc: raise ExecutorResultStoreError("agent_result_file_unreadable") from exc if stat.S_ISLNK(info.st_mode): raise ExecutorResultStoreError("agent_result_path_invalid") try: info = Path(path).lstat() if not stat.S_ISREG(info.st_mode): raise ExecutorResultStoreError("agent_result_file_unreadable") if info.st_size > self.max_bytes: raise ExecutorResultStoreError("agent_result_file_too_large") descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) try: raw = os.read(descriptor, self.max_bytes + 1) finally: os.close(descriptor) except ExecutorResultStoreError: raise except FileNotFoundError as exc: raise ExecutorResultStoreError("agent_result_file_missing") from exc except OSError as exc: raise ExecutorResultStoreError("agent_result_file_unreadable") from exc if len(raw) > self.max_bytes: raise ExecutorResultStoreError("agent_result_file_too_large") try: value = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: raise ExecutorResultStoreError("agent_result_file_invalid_json") from exc if not isinstance(value, dict): raise ExecutorResultStoreError("agent_result_file_schema_invalid", reason_code="result_object_required") if not required_fields.issubset(value): raise ExecutorResultStoreError("agent_result_file_schema_invalid", reason_code="result_schema_invalid") if validator is not None: try: validator(value) except Exception as exc: # noqa: BLE001 - validator 只允许映射为稳定错误 reason_code = getattr(exc, "code", None) raise ExecutorResultStoreError("agent_result_file_schema_invalid", reason_code=reason_code) from exc return value __all__ = ["ExecutorResultStore", "ExecutorResultStoreError"]