Files
xas-test-library/console/catalog.py
T

194 lines
10 KiB
Python

"""Source registration and filesystem boundaries shared by API and worker."""
import hashlib
import os
from pathlib import Path
import re
import uuid
from .store import now
SECRET = re.compile(r"(?im)(?:[\w-]*(?:api[_-]?key|password|token|secret)[\w-]*)\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]|-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+[A-Za-z0-9._-]{16,}")
SERVICE = re.compile(r"(?im)\b(?:docker|podman|systemctl|sudo|pkill|killall|shutdown|reboot|nvidia-smi)\b|\brm\s+-[\w]*r|\bkill\s+-")
SMOKE_HASH = "be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d"
def clean_source(content):
try:
text = content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("脚本必须使用 UTF-8 编码") from exc
if "\x00" in text:
raise ValueError("不支持二进制脚本")
return text
def safe_path(path, root, must_exist=True):
candidate, base = Path(path), Path(root)
if not candidate.is_absolute():
raise ValueError("路径必须为绝对路径")
# Never follow a symlink, including symlink ancestors under the allowed root.
resolved_base = base.resolve()
resolved = candidate.resolve()
if not resolved.is_relative_to(resolved_base):
raise ValueError("路径超出允许目录")
for component in [candidate, *candidate.parents]:
if component.is_symlink():
raise ValueError("不允许符号链接")
if component == base:
break
if must_exist and not candidate.exists():
raise ValueError("文件或目录不存在")
return candidate
def source_for(settings, group):
for source in settings.sources:
if source["id"] == group:
return Path(source["path"])
raise ValueError("未知来源目录")
def validate_config(settings, item, config):
from . import native_plans
if native_plans.profile_for(settings, item):
return native_plans.validate_config(settings, item, config)
if config.get("native_options"):
raise ValueError("此项目不支持原环境参数")
defaults = {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}
config = {**defaults, **config}
if config["profile"] not in {"standard", "legacy_smoke"}:
raise ValueError("未知执行环境")
if not isinstance(config["args"], list) or len(config["args"]) > 40 or any(
not isinstance(a, str) or len(a) > 2048 or "\x00" in a for a in config["args"]):
raise ValueError("参数需为最多 40 项的字符串数组")
if SECRET.search("\n".join(config["args"])):
raise ValueError("参数不能包含明文凭据")
if config["profile"] == "legacy_smoke" and config["args"]:
raise ValueError("轻量模型兼容环境使用已核对的固定参数,请新建脚本以配置其他测试")
if type(config["timeout_seconds"]) is not int or not 5 <= config["timeout_seconds"] <= 7200:
raise ValueError("运行时限须为 5 至 7200 秒")
if not isinstance(config["output_paths"], list) or len(config["output_paths"]) > 8:
raise ValueError("最多登记 8 个日志或结果路径")
if not isinstance(config["cwd"], str):
raise ValueError("工作目录格式错误")
if item["kind"] == "managed":
if config["cwd"] or config["output_paths"]:
raise ValueError("上传脚本使用本次独立运行目录;通过 XAS_RUN_DIR 写入结果")
else:
source = source_for(settings, item["source_group"])
base = source.parent if source.name == "scripts" else source
cwd = safe_path(config["cwd"] or str(base), base)
if not cwd.is_dir():
raise ValueError("工作目录不存在")
config["cwd"] = str(cwd)
for raw in config["output_paths"]:
if not isinstance(raw, str):
raise ValueError("输出路径格式错误")
# Only logs/results directories in that script family may become writable.
if not any(Path(raw).resolve().is_relative_to((base / name).resolve()) for name in ("logs", "results")):
raise ValueError("原目录输出只允许该来源的 logs/ 或 results/ 下路径")
safe_path(raw, base)
return config
def script_path(settings, item):
root = settings.library if item["kind"] == "managed" else source_for(settings, item["source_group"])
path = safe_path(item["source_path"], root)
if not path.is_file() or path.suffix not in {".py", ".sh"} or path.stat().st_size > settings.max_upload:
raise ValueError("脚本缺失、类型不支持或大于 1 MiB")
return path
def read_code(settings, item):
data = script_path(settings, item).read_bytes()
text = clean_source(data)
redacted = bool(SECRET.search(text))
if redacted:
text = SECRET.sub("[已隐藏疑似凭据]", text)
return {"code": text, "filename": Path(item["source_path"]).name,
"sha256": hashlib.sha256(data).hexdigest(), "redacted": redacted, "truncated": False}
def upload(store, data, filename, name="", description="", identity=None):
if not filename or Path(filename).name != filename or "/" in filename or "\\" in filename or not re.fullmatch(r"[\w.\- ]{1,120}\.(py|sh)", filename):
raise ValueError("只支持文件名安全的 .py 或 .sh 脚本")
if len(data) > store.settings.max_upload:
raise ValueError("脚本不能超过 1 MiB")
if not data:
raise ValueError("脚本不能为空")
text = clean_source(data)
if SECRET.search(text):
raise ValueError("脚本包含疑似明文凭据,请移除后上传")
if identity:
item = store.script(identity)
if item["kind"] != "managed":
raise ValueError("原位置脚本不支持上传覆盖,请新建脚本")
if item["config"]["profile"] == "legacy_smoke" and hashlib.sha256(data).hexdigest() != SMOKE_HASH:
raise ValueError("此兼容环境绑定已核对的原脚本;修改代码请新建脚本并另行配置认证")
if (Path(filename).suffix == ".py") != (item["language"] == "python"):
raise ValueError("新版本的语言必须与原脚本一致")
else:
item = {"id": uuid.uuid4().hex, "name": (name or filename)[:120], "description": description[:4000],
"kind": "managed", "language": "python" if filename.endswith(".py") else "shell",
"source_group": "uploaded", "source_path": "", "status": "ready", "enabled": True,
"reason": "", "version": 0, "sha256": "", "updated_at": now(), "synthetic": False,
"config": {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}}
return store.upload_version(item, data, filename)
def discover(store):
existing = {s["id"]: s for s in store.scripts() if s["kind"] == "external"}
found, registered = set(), 0
for source in store.settings.sources:
root = Path(source["path"])
if not root.is_dir():
continue
count = 0
for directory, dirs, files in os.walk(root, followlinks=False):
dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".venv", "node_modules", "logs", "results"}
and not (Path(directory) / d).is_symlink()]
for filename in sorted(files):
path = Path(directory) / filename
if path.suffix not in {".py", ".sh"} or path.is_symlink():
continue
count += 1
if count > 2000:
raise ValueError("来源脚本超过扫描上限 2000,请收窄来源目录")
identity = hashlib.sha256(str(path).encode()).hexdigest()[:32]
found.add(identity)
before = existing.get(identity)
try:
if path.stat().st_size > store.settings.max_upload:
raise ValueError("大于 1 MiB,仅登记")
raw = path.read_bytes()
code = clean_source(raw)
digest = hashlib.sha256(raw).hexdigest()
blocked = bool(SERVICE.search(code) or SECRET.search(code))
reason = "涉及服务、设备管理或疑似凭据,需管理员审查后接入" if blocked else "请确认参数、依赖和输出路径后启用"
except (OSError, ValueError):
digest, blocked, reason = "", True, "文件不可读取或格式不支持"
item = before or {"id": identity, "name": filename, "description": "",
"kind": "external", "language": "python" if path.suffix == ".py" else "shell",
"source_group": source["id"], "source_path": str(path), "version": 1, "enabled": False,
"config": {"args": [], "cwd": str(root.parent if root.name == "scripts" else root),
"output_paths": [], "timeout_seconds": 300, "profile": "standard"}}
from .native_plans import profile_for
native = profile_for(store.settings, item)
if native:
blocked = not native.get("available", True)
reason = native.get("unavailable_reason", "原环境方案已登记,请确认参数后启用")
if before and before["sha256"] != digest:
item["version"] += 1
item["enabled"] = False
reason = "源文件已变动,请重新确认配置并启用"
if blocked:
item["enabled"] = False
item.update(sha256=digest, blocked=blocked, reason="" if item["enabled"] else reason,
status="ready" if item["enabled"] else "needs_configuration", updated_at=now())
store.save_script(item)
registered += int(before is None)
for identity, item in existing.items():
if identity not in found:
item.update(status="missing", enabled=False, reason="原脚本当前不存在或来源不可访问", updated_at=now())
store.save_script(item)
return {"registered": registered, "missing": len(set(existing) - found), "total": len(found)}