Files
2026-08-31 01:43:22 +08:00

93 lines
4.2 KiB
Python

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