Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/artifacts/package.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

516 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import hashlib
import json
import os
import stat
import struct
import tempfile
from collections.abc import Mapping
from pathlib import Path
from typing import BinaryIO
from zipfile import ZIP_DEFLATED, ZIP_STORED, BadZipFile, ZipFile, ZipInfo
import yaml
from cryptography.exceptions import InvalidSignature
from plugins.core.artifacts.schema import (
DEFAULT_LIMITS,
ArtifactFile,
ArtifactLimits,
ArtifactMetadata,
ArtifactSignature,
PrivateKeyInput,
PublicKeyInput,
VerifiedArtifact,
public_key,
safe_artifact_path,
strict_json,
validate_path_inventory,
)
from plugins.core.artifacts.schema import (
private_key as load_private_key,
)
from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION, PluginManifest, PluginManifestFactory
CHUNK_BYTES = 1024 * 1024
ZIP_END = struct.Struct('<4s4H2LH')
ZIP_CENTRAL = struct.Struct('<4s6H3L5H2L')
ZIP_MAX_COMMENT_BYTES = 65535
ZIP64_COUNT_SENTINEL = 0xFFFF
ZIP64_SIZE_SENTINEL = 0xFFFFFFFF
ROOT_DOCUMENTS = frozenset({'artifacts.json', 'signature.json'})
CACHE_DIRECTORIES = frozenset({'__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache'})
FORBIDDEN_DIRECTORIES = CACHE_DIRECTORIES | {
'node_modules',
'.git',
'.hg',
'.svn',
'.venv',
'venv',
'target',
'.idea',
'.vscode',
}
FORBIDDEN_FILES = frozenset(
{
'cargo.toml',
'cargo.lock',
'rust-toolchain.toml',
'package.json',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'pyproject.toml',
'setup.py',
'setup.cfg',
}
)
FORBIDDEN_SUFFIXES = frozenset({'.rs', '.vue', '.tsx', '.jsx', '.ts', '.pyc', '.pyo'})
def is_link_or_reparse(info: os.stat_result) -> bool:
"""
判断文件状态是否表示符号链接或 Windows 重解析点。
:param info: 通过 lstat 获取的文件状态信息
:return: 是否为符号链接、目录联接或其他重解析点
"""
return stat.S_ISLNK(info.st_mode) or bool(
getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400)
)
def _regular_file(path: Path) -> os.stat_result:
"""
检查路径是否为未建立额外硬链接的普通文件。
:param path: 待处理的文件路径
:return: 普通文件的状态信息
:raises ValueError: 路径为链接、特殊文件或具有额外硬链接
"""
info = path.lstat()
if is_link_or_reparse(info) or not stat.S_ISREG(info.st_mode) or info.st_nlink > 1:
raise ValueError(f'制品不接受链接或特殊文件:{path}')
return info
def _payload_path(name: str) -> None:
"""
校验部署文件路径,拒绝源码工程、环境文件和构建缓存。
:param name: 制品内文件的规范相对路径
:return: None
:raises ValueError: 路径不安全或包含禁止交付的文件
"""
path = safe_artifact_path(name)
if (
any(part.casefold() in FORBIDDEN_DIRECTORIES for part in path.parts)
or path.name.casefold() in FORBIDDEN_FILES
or path.suffix.casefold() in FORBIDDEN_SUFFIXES
or any(part.casefold() == '.env' or part.casefold().startswith('.env.') for part in path.parts)
):
raise ValueError(f'制品必须使用预构建部署目录,不能包含源码工程或缓存:{name}')
def _scan_directory(
root: Path, limits: ArtifactLimits, *, skip_caches: bool = False
) -> tuple[dict[str, Path], set[str]]:
"""
在不跟随链接的前提下枚举目录,并检查文件类型和资源上限。
:param root: 待扫描的普通目录
:param limits: 文件数量、大小及压缩容器的资源上限
:param skip_caches: 是否在构建阶段跳过 Python 和测试缓存
:return: 相对路径到文件路径的映射,以及扫描到的相对目录集合
:raises ValueError: 目录、文件类型、路径或资源用量不符合制品约束
"""
root_info = root.lstat()
if is_link_or_reparse(root_info) or not stat.S_ISDIR(root_info.st_mode):
raise ValueError('制品目录必须是普通目录,不能是链接或重解析点')
files: dict[str, Path] = {}
directories: set[str] = set()
pending = [root]
total_bytes = 0
while pending:
directory = pending.pop()
with os.scandir(directory) as entries:
for entry in entries:
if skip_caches and (
entry.name.casefold() in CACHE_DIRECTORIES or Path(entry.name).suffix.casefold() in {'.pyc', '.pyo'}
):
continue
path = Path(entry.path)
relative = path.relative_to(root).as_posix()
safe_artifact_path(relative)
# Windows DirEntry.stat 的 st_nlink 可为 0;lstat 才提供硬链接计数。
info = path.lstat()
if is_link_or_reparse(info):
raise ValueError(f'制品目录包含链接或重解析点:{relative}')
if stat.S_ISDIR(info.st_mode):
directories.add(relative)
pending.append(path)
elif stat.S_ISREG(info.st_mode) and info.st_nlink == 1:
files[relative] = path
total_bytes += info.st_size
if info.st_size > limits.max_file_bytes:
raise ValueError('制品单文件大小超过限制')
else:
raise ValueError(f'制品目录包含特殊文件或硬链接:{relative}')
if len(files) > limits.max_files or total_bytes > limits.max_total_bytes:
raise ValueError('制品文件数量或总大小超过限制')
if len(directories) > limits.max_files * 32:
raise ValueError('制品目录数量超过限制')
validate_path_inventory(tuple(files))
return files, directories
def _read_limited(stream: BinaryIO, maximum: int) -> bytes:
"""
在指定字节上限内读取完整文档。
:param stream: 当前位置开始读取的二进制输入流
:param maximum: 允许读取的最大字节数
:return: 已读取的文档字节
:raises ValueError: 文档实际大小超过读取上限
"""
data = stream.read(maximum + 1)
if len(data) > maximum:
raise ValueError('制品文档大小超过限制')
return data
def _hash_stream(stream: BinaryIO, maximum: int, output: BinaryIO | None = None) -> tuple[int, str]:
"""
分块读取内容并计算大小和 SHA256,可同时复制到输出流。
:param stream: 当前位置开始读取的二进制输入流
:param maximum: 允许读取的最大字节数
:param output: 可选的二进制输出流,为 None 时只计算摘要
:return: 实际读取的字节数与小写 SHA256 摘要
:raises ValueError: 文件实际大小超过读取上限
"""
hasher = hashlib.sha256()
size = 0
for chunk in iter(lambda: stream.read(CHUNK_BYTES), b''):
size += len(chunk)
if size > maximum:
raise ValueError('制品文件实际大小超过限制')
hasher.update(chunk)
if output is not None:
output.write(chunk)
return size, hasher.hexdigest()
def _preflight_zip(path: Path, limits: ArtifactLimits) -> None:
"""
在创建 ZipFile 对象前流式检查中央目录,限制一次性元数据分配。
:param path: 待处理的文件路径
:param limits: 文件数量、大小及压缩容器的资源上限
:return: None
:raises ValueError: ZIP 结束记录、中央目录或容器类型不受支持
"""
with path.open('rb') as stream:
size = stream.seek(0, os.SEEK_END)
tail_size = min(size, ZIP_END.size + ZIP_MAX_COMMENT_BYTES)
stream.seek(size - tail_size)
tail = stream.read(tail_size)
marker = tail.rfind(b'PK\x05\x06')
if marker < 0 or len(tail) - marker < ZIP_END.size:
raise ValueError('制品 ZIP 缺少有效结束记录')
_, disk, central_disk, disk_count, count, central_size, offset, comment_size = ZIP_END.unpack_from(tail, marker)
if marker + ZIP_END.size + comment_size != len(tail):
raise ValueError('制品 ZIP 结束记录或注释长度无效')
if disk or central_disk or disk_count != count:
raise ValueError('制品 ZIP 不支持分卷容器')
if count == ZIP64_COUNT_SENTINEL or ZIP64_SIZE_SENTINEL in {central_size, offset}:
raise ValueError('制品 ZIP 不支持 ZIP64 容器')
central_end = size - tail_size + marker
if offset + central_size != central_end or central_size > limits.max_central_directory_bytes:
raise ValueError('制品 ZIP 中央目录位置或大小无效')
if count > limits.max_files:
raise ValueError('制品 ZIP 文件数量超过限制')
stream.seek(offset)
_preflight_entries(stream, central_end, count, limits)
def _preflight_entries(stream: BinaryIO, central_end: int, count: int, limits: ArtifactLimits) -> None:
"""
逐条检查 ZIP 中央目录记录,并核对实际数量与结束位置。
:param stream: 当前位置开始读取的二进制输入流
:param central_end: 中央目录结束位置的绝对字节偏移
:param count: ZIP 结束记录声明的条目数量
:param limits: 文件数量、大小及压缩容器的资源上限
:return: None
:raises ValueError: 中央目录记录不完整、越界或实际数量不匹配
"""
actual_count = 0
while stream.tell() < central_end:
if central_end - stream.tell() < ZIP_CENTRAL.size:
raise ValueError('制品 ZIP 中央目录记录不完整')
header = ZIP_CENTRAL.unpack(stream.read(ZIP_CENTRAL.size))
if header[0] != b'PK\x01\x02' or header[13]:
raise ValueError('制品 ZIP 中央目录记录无效')
if ZIP64_SIZE_SENTINEL in {header[8], header[9], header[16]}:
raise ValueError('制品 ZIP 不支持 ZIP64 文件')
actual_count += 1
if actual_count > limits.max_files:
raise ValueError('制品 ZIP 实际文件数量超过限制')
next_position = stream.tell() + sum(header[10:13])
if next_position > central_end:
raise ValueError('制品 ZIP 中央目录字段越界')
stream.seek(next_position)
if actual_count != count:
raise ValueError('制品 ZIP 中央目录文件数量不一致')
def _zip_inventory(archive: ZipFile, limits: ArtifactLimits) -> dict[str, ZipInfo]:
"""
校验 ZIP 文件类型、路径和资源上限,并建立文件清单。
:param archive: 已打开的 ZIP 压缩容器
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 安全相对文件路径与 ZIP 条目的映射
:raises ValueError: ZIP 条目不安全、超过限制或缺少必要文档
"""
entries = archive.infolist()
if len(entries) > limits.max_files or sum(entry.file_size for entry in entries) > limits.max_total_bytes:
raise ValueError('制品 ZIP 文件数量或展开大小超过限制')
files: dict[str, ZipInfo] = {}
directories: set[str] = set()
names: set[str] = set()
for entry in entries:
if entry.orig_filename != entry.filename:
raise ValueError('制品 ZIP 文件名包含截断字符')
name = entry.filename[:-1] if entry.is_dir() else entry.filename
safe_artifact_path(name)
if name.casefold() in names:
raise ValueError(f'制品 ZIP 包含重复或大小写冲突路径:{name}')
names.add(name.casefold())
mode = entry.external_attr >> 16
kind = stat.S_IFMT(mode)
if (
kind not in {0, stat.S_IFREG, stat.S_IFDIR}
or bool(entry.external_attr & 0x400)
or (kind == stat.S_IFDIR and not entry.is_dir())
or (kind == stat.S_IFREG and entry.is_dir())
):
raise ValueError('制品 ZIP 不能包含链接、重解析点或特殊文件')
if entry.flag_bits & 1 or entry.compress_type not in {ZIP_STORED, ZIP_DEFLATED}:
raise ValueError('制品 ZIP 不支持加密或该压缩算法')
if (
entry.file_size > limits.max_file_bytes
or entry.file_size > max(1, entry.compress_size) * limits.max_compression_ratio
):
raise ValueError('制品 ZIP 单文件大小或压缩比超过限制')
if entry.is_dir():
if entry.file_size:
raise ValueError('制品 ZIP 目录不能携带数据')
directories.add(name)
else:
files[name] = entry
validate_path_inventory(tuple(files), directories)
if not ROOT_DOCUMENTS.issubset(files):
raise ValueError('制品缺少 artifacts.json 或 signature.json')
return files
def _verify_documents(
metadata_bytes: bytes, signature_bytes: bytes, trusted_keys: Mapping[str, PublicKeyInput], limits: ArtifactLimits
) -> tuple[ArtifactMetadata, ArtifactSignature, str]:
"""
解析制品元数据和签名文档,并使用宿主可信公钥验签。
:param metadata_bytes: artifacts.json 的原始内容
:param signature_bytes: signature.json 的原始内容
:param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 制品元数据、签名信息和规范元数据摘要
:raises ValueError: 元数据、签名、信任关系或资源声明校验失败
"""
if len(metadata_bytes) > limits.max_metadata_bytes or len(signature_bytes) > limits.max_signature_bytes:
raise ValueError('制品元数据或签名文档超过大小限制')
metadata = ArtifactMetadata.model_validate(strict_json(metadata_bytes))
signature = ArtifactSignature.model_validate(strict_json(signature_bytes))
if len(metadata.files) > limits.max_files or sum(item.size for item in metadata.files) > limits.max_total_bytes:
raise ValueError('制品清单文件数量或总大小超过限制')
for item in metadata.files:
_payload_path(item.path)
if item.size > limits.max_file_bytes:
raise ValueError('制品清单单文件大小超过限制')
if signature.key_id not in trusted_keys:
raise ValueError(f'未受信任或已撤销的制品签名密钥:{signature.key_id}')
canonical = metadata.canonical_bytes()
try:
public_key(trusted_keys[signature.key_id]).verify(signature.signature_bytes(), canonical)
except InvalidSignature as exc:
raise ValueError('制品签名验证失败') from exc
return metadata, signature, hashlib.sha256(canonical).hexdigest()
def _verify_manifest(data: bytes, metadata: ArtifactMetadata, limits: ArtifactLimits) -> PluginManifest:
"""
静态解析插件清单,检查协议、身份、版本及前端交付类型。
:param data: plugin.yaml 的原始 UTF-8 内容
:param metadata: 签名覆盖的制品元数据
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 通过校验的插件清单对象
:raises ValueError: 插件清单无效、身份不匹配或使用不支持的交付协议
"""
if len(data) > limits.max_manifest_bytes:
raise ValueError('plugin.yaml 大小超过限制')
try:
raw = yaml.safe_load(data.decode('utf-8'))
except (UnicodeError, yaml.YAMLError) as exc:
raise ValueError('制品 plugin.yaml 无效') from exc
if not isinstance(raw, dict) or raw.get('manifestVersion') != EXPLICIT_MANIFEST_VERSION:
raise ValueError('签名制品仅支持 manifestVersion 2')
manifest = PluginManifestFactory.create(raw)
if manifest.id != metadata.plugin_id or manifest.version != metadata.version:
raise ValueError('制品身份或版本与 plugin.yaml 不一致')
if manifest.frontend.delivery.type not in {'none', 'bundle'}:
raise ValueError('签名制品仅支持 none/bundle,不能交付 source 前端')
return manifest
def _verify_archive(
archive: ZipFile, artifact_path: Path, trusted_keys: Mapping[str, PublicKeyInput], limits: ArtifactLimits
) -> VerifiedArtifact:
"""
校验签名、完整文件清单及逐文件摘要,生成只读验证结果。
:param archive: 已打开的 ZIP 压缩容器
:param artifact_path: 当前受验证制品容器的绝对路径
:param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 完成全部静态校验的制品信息
:raises ValueError: 文件清单、签名或实际文件内容不一致
"""
files = _zip_inventory(archive, limits)
with archive.open(files['artifacts.json']) as source:
metadata_bytes = _read_limited(source, limits.max_metadata_bytes)
with archive.open(files['signature.json']) as source:
signature_bytes = _read_limited(source, limits.max_signature_bytes)
metadata, signature, digest = _verify_documents(metadata_bytes, signature_bytes, trusted_keys, limits)
if set(files) != ROOT_DOCUMENTS | {item.path for item in metadata.files}:
raise ValueError('制品文件库存与签名清单不一致,存在额外或缺失文件')
for item in metadata.files:
if files[item.path].file_size != item.size:
raise ValueError(f'制品文件大小不匹配:{item.path}')
with archive.open(files[item.path]) as source:
size, actual = _hash_stream(source, item.size)
if size != item.size or actual != item.sha256:
raise ValueError(f'制品文件 SHA256 或大小不匹配:{item.path}')
with archive.open(files[f'payload/{metadata.plugin_id}/plugin.yaml']) as source:
manifest = _verify_manifest(_read_limited(source, limits.max_manifest_bytes), metadata, limits)
return VerifiedArtifact(
metadata.plugin_id,
metadata.version,
digest,
signature.key_id,
metadata.files,
metadata,
signature,
manifest,
artifact_path,
)
def verify_artifact(
path: Path | str, trusted_keys: Mapping[str, PublicKeyInput], *, limits: ArtifactLimits = DEFAULT_LIMITS
) -> VerifiedArtifact:
"""
只读校验制品签名、清单和全部文件摘要,不安装依赖或导入插件代码。
:param path: 待处理的文件路径
:param trusted_keys: 由宿主提供的签名密钥标识与可信公钥映射
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 完成全部静态校验的制品信息
:raises ValueError: 制品容器、签名、清单或文件内容校验失败
"""
path = Path(path).absolute()
if _regular_file(path).st_size > limits.max_archive_bytes:
raise ValueError('制品容器大小超过限制')
try:
_preflight_zip(path, limits)
with ZipFile(path) as archive:
return _verify_archive(archive, path, trusted_keys, limits)
except (BadZipFile, struct.error) as exc:
raise ValueError('制品 ZIP 无效或 CRC 校验失败') from exc
def build_artifact(
source_plugin_dir: Path | str,
output_file: Path | str,
private_key: PrivateKeyInput,
key_id: str,
*,
limits: ArtifactLimits = DEFAULT_LIMITS,
) -> VerifiedArtifact:
"""
从预构建部署目录签名打包,不覆盖已有输出,仅忽略 Python 和测试缓存。
:param source_plugin_dir: 目录名与插件ID一致的预构建部署目录
:param output_file: 待创建的 rpk 文件路径,必须位于源目录之外
:param private_key: 仅在当前构建进程内使用的 Ed25519 签名私钥
:param key_id: 签名公钥在宿主信任配置中的标识
:param limits: 文件数量、大小及压缩容器的资源上限
:return: 构建完成并重新校验的制品信息
:raises ValueError: 部署内容不合法、超过限制或在构建期间发生变化
:raises FileExistsError: 输出路径已经存在,无法安全创建新制品
"""
source = Path(source_plugin_dir).absolute()
files, _ = _scan_directory(source, limits, skip_caches=True)
if 'plugin.yaml' not in files:
raise ValueError('预构建插件目录缺少 plugin.yaml')
with files['plugin.yaml'].open('rb') as stream:
raw_manifest = _read_limited(stream, limits.max_manifest_bytes)
raw = yaml.safe_load(raw_manifest.decode('utf-8'))
if not isinstance(raw, dict):
raise ValueError('plugin.yaml 必须是对象')
records = []
for relative, path in sorted(files.items()):
_payload_path(relative)
with path.open('rb') as stream:
size, digest = _hash_stream(stream, limits.max_file_bytes)
records.append(ArtifactFile(path=f'payload/{source.name}/{relative}', size=size, sha256=digest))
metadata = ArtifactMetadata(schemaVersion=1, pluginId=source.name, version=raw.get('version'), files=tuple(records))
_verify_manifest(raw_manifest, metadata, limits)
key = load_private_key(private_key)
signature = ArtifactSignature(
algorithm='Ed25519',
keyId=key_id,
signature=base64.b64encode(key.sign(metadata.canonical_bytes())).decode(),
)
output = Path(output_file).absolute()
if output.exists() or output.is_symlink():
raise FileExistsError('制品输出已经存在,拒绝覆盖')
if output.is_relative_to(source):
raise ValueError('制品输出不能位于源插件目录内')
output.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix='.rpk-build-', dir=output.parent)
os.close(descriptor)
temporary = Path(temporary_name)
try:
with ZipFile(temporary, 'w', compression=ZIP_DEFLATED) as archive:
archive.writestr('artifacts.json', metadata.canonical_bytes())
archive.writestr('signature.json', json.dumps(signature.model_dump(by_alias=True), separators=(',', ':')))
for item in records:
relative = item.path.removeprefix(f'payload/{source.name}/')
path = files[relative]
_regular_file(path)
if path.resolve() != path or not path.resolve().is_relative_to(source.resolve()):
raise ValueError('构建期间插件资源路径发生变化')
with path.open('rb') as stream, archive.open(item.path, 'w') as target:
size, digest = _hash_stream(stream, item.size, target)
if (size, digest) != (item.size, item.sha256):
raise ValueError('构建期间插件资源内容发生变化')
verify_artifact(temporary, {key_id: key.public_key()}, limits=limits)
# 硬链接创建具备跨平台的 O_EXCL 语义,避免 POSIX rename 覆盖已有文件。
os.link(temporary, output)
finally:
temporary.unlink(missing_ok=True)
return verify_artifact(output, {key_id: key.public_key()}, limits=limits)