mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 21:35:31 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
266 lines
12 KiB
Python
266 lines
12 KiB
Python
import base64
|
|
import csv
|
|
import hashlib
|
|
import importlib.metadata
|
|
import io
|
|
import platform
|
|
import shutil
|
|
import stat
|
|
from email.parser import Parser
|
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
from zipfile import ZipFile, ZipInfo
|
|
|
|
from packaging.requirements import Requirement
|
|
from packaging.specifiers import SpecifierSet
|
|
from packaging.tags import parse_tag, sys_tags
|
|
from packaging.utils import canonicalize_name, parse_wheel_filename
|
|
from packaging.version import Version
|
|
|
|
from plugins.core.manifest.schema import PluginManifest
|
|
|
|
MAX_WHEEL_FILES = 10000
|
|
MAX_WHEEL_BYTES = 512 * 1024 * 1024
|
|
MAX_MEMBER_BYTES = 256 * 1024 * 1024
|
|
RECORD_COLUMN_COUNT = 3
|
|
|
|
|
|
def _matching_rank(path: Path, manifest: PluginManifest) -> int:
|
|
"""
|
|
校验 wheel 身份并计算当前解释器支持的最佳平台标签优先级。
|
|
|
|
:param path: 待处理的文件路径
|
|
:param manifest: 插件清单对象
|
|
:return: 匹配标签在解释器支持列表中的最小序号
|
|
:raises ValueError: wheel 身份不匹配或当前解释器、ABI、平台不受支持
|
|
"""
|
|
name, version, _, tags = parse_wheel_filename(path.name)
|
|
if name != canonicalize_name(manifest.backend.native.distribution) or version != Version(manifest.version):
|
|
raise ValueError('wheel 名称或版本与插件清单不一致')
|
|
ranks = [
|
|
index for index, tag in enumerate(sys_tags()) if tag in tags and tag.abi != 'none' and tag.platform != 'any'
|
|
]
|
|
if not ranks:
|
|
raise ValueError('wheel 不支持当前 Python、ABI 或平台')
|
|
return min(ranks)
|
|
|
|
|
|
def select_native_wheel(directory: Path, manifest: PluginManifest) -> Path:
|
|
"""
|
|
从目录中选择名称、版本及解释器平台标签均匹配的原生 wheel。
|
|
|
|
:param directory: 包含待选择 wheel 文件的目录
|
|
:param manifest: 插件清单对象
|
|
:return: 兼容性优先级最高的 wheel 文件路径
|
|
:raises ValueError: 插件不是原生类型或目录中没有匹配的 wheel
|
|
"""
|
|
if manifest.runtime_kind != 'native':
|
|
raise ValueError('仅 native 插件可选择 wheel')
|
|
|
|
def candidate(path: Path) -> tuple[int, str, Path] | None:
|
|
"""
|
|
尝试生成单个 wheel 的排序候选,忽略不兼容的文件。
|
|
|
|
:param path: 待处理的文件路径
|
|
:return: 标签优先级、文件名和路径,不兼容时为 None
|
|
"""
|
|
try:
|
|
return _matching_rank(path, manifest), path.name, path
|
|
except ValueError:
|
|
return None
|
|
|
|
candidates = [item for path in directory.glob('*.whl') if (item := candidate(path)) is not None]
|
|
if not candidates:
|
|
raise ValueError('未找到与当前解释器及插件版本匹配的原生 wheel')
|
|
return min(candidates)[2]
|
|
|
|
|
|
def _safe_member(name: str) -> PurePosixPath:
|
|
"""
|
|
校验 wheel 内相对路径,拒绝目录穿越及 Windows 路径歧义。
|
|
|
|
:param name: wheel 中的原始条目名称
|
|
:return: 通过校验的 POSIX 相对路径
|
|
:raises ValueError: 条目路径不符合跨平台安全要求
|
|
"""
|
|
parts = name.rstrip('/').split('/')
|
|
path = PurePosixPath(name)
|
|
if (
|
|
not parts
|
|
or path.is_absolute()
|
|
or '\\' in name
|
|
or ':' in name
|
|
or any(
|
|
part in {'', '.', '..'} or part.endswith((' ', '.')) or PureWindowsPath(part).is_reserved()
|
|
for part in parts
|
|
)
|
|
):
|
|
raise ValueError(f'wheel 中包含不安全路径:{name}')
|
|
return path
|
|
|
|
|
|
def _inventory(archive: ZipFile, manifest: PluginManifest) -> tuple[dict[str, ZipInfo], str]:
|
|
"""
|
|
检查压缩条目、资源上限及允许的模块边界,并定位唯一元数据目录。
|
|
|
|
:param archive: 已打开的 ZIP 压缩容器
|
|
:param manifest: 插件清单对象
|
|
:return: 文件条目映射与 dist-info 目录名
|
|
:raises ValueError: 压缩条目、文件类型、资源用量或顶层模块范围不合法
|
|
"""
|
|
entries = archive.infolist()
|
|
if len(entries) > MAX_WHEEL_FILES or sum(item.file_size for item in entries) > MAX_WHEEL_BYTES:
|
|
raise ValueError('wheel 文件数量或解压大小超过限制')
|
|
names: dict[str, str] = {}
|
|
files = {}
|
|
for entry in entries:
|
|
path = _safe_member(entry.filename)
|
|
normalized = str(path).casefold()
|
|
if normalized in names:
|
|
raise ValueError(f'wheel 包含重复或大小写冲突路径:{entry.filename}')
|
|
names[normalized] = entry.filename
|
|
mode = entry.external_attr >> 16
|
|
if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))):
|
|
raise ValueError('wheel 不能包含链接或特殊文件')
|
|
if entry.file_size > MAX_MEMBER_BYTES or entry.flag_bits & 1:
|
|
raise ValueError('wheel 包含超限或加密文件')
|
|
if not entry.is_dir():
|
|
files[entry.filename] = entry
|
|
|
|
wheel_files = [name for name in files if name.endswith('.dist-info/WHEEL') and name.count('/') == 1]
|
|
if len(wheel_files) != 1:
|
|
raise ValueError('wheel 必须包含唯一的发行包元数据')
|
|
metadata_root = wheel_files[0].split('/')[0]
|
|
allowed_roots = {manifest.backend.module, metadata_root}
|
|
if any(PurePosixPath(name).parts[0] not in allowed_roots for name in names.values()):
|
|
raise ValueError('wheel 只能包含插件模块及其 dist-info;不支持 .data 或其他顶层模块')
|
|
return files, metadata_root
|
|
|
|
|
|
def _verify_dependency(value: str) -> None:
|
|
"""
|
|
要求宿主已安装满足约束的依赖,不隐式联网安装。
|
|
|
|
:param value: METADATA 中的单条 Requires-Dist 依赖声明
|
|
:return: None
|
|
:raises ValueError: 依赖使用未锁定 URL、尚未安装或版本不满足要求
|
|
"""
|
|
requirement = Requirement(value)
|
|
if requirement.marker and not requirement.marker.evaluate({'extra': ''}):
|
|
return
|
|
if requirement.url:
|
|
raise ValueError('原生 wheel 依赖不能使用未锁定的 URL')
|
|
try:
|
|
installed = importlib.metadata.version(requirement.name)
|
|
except importlib.metadata.PackageNotFoundError as exc:
|
|
raise ValueError(f'宿主缺少 wheel 依赖:{requirement.name}') from exc
|
|
if not requirement.specifier.contains(installed):
|
|
raise ValueError(f'宿主 wheel 依赖版本不匹配:{requirement}')
|
|
|
|
|
|
def _verify_metadata(archive: ZipFile, metadata_root: str, wheel: Path, manifest: PluginManifest) -> None:
|
|
"""
|
|
核对 wheel 发行包身份、版本、Python 要求及已安装依赖。
|
|
|
|
:param archive: 已打开的 ZIP 压缩容器
|
|
:param metadata_root: wheel 内唯一的 dist-info 元数据目录名
|
|
:param wheel: 待验证或展开的原生 wheel 文件路径
|
|
:param manifest: 插件清单对象
|
|
:return: None
|
|
:raises ValueError: 发行包元数据、平台标签或宿主依赖不匹配
|
|
"""
|
|
metadata_path = f'{metadata_root}/METADATA'
|
|
record_path = f'{metadata_root}/RECORD'
|
|
if metadata_path not in archive.namelist() or record_path not in archive.namelist():
|
|
raise ValueError('wheel 缺少 METADATA 或 RECORD')
|
|
metadata = Parser().parsestr(archive.read(metadata_path).decode('utf-8'))
|
|
if canonicalize_name(metadata.get('Name', '')) != canonicalize_name(manifest.backend.native.distribution):
|
|
raise ValueError('wheel METADATA 的发行包名称不匹配')
|
|
if Version(metadata.get('Version', '')) != Version(manifest.version):
|
|
raise ValueError('wheel METADATA 的版本不匹配')
|
|
if metadata.get('Requires-Python') and not SpecifierSet(metadata['Requires-Python']).contains(
|
|
platform.python_version()
|
|
):
|
|
raise ValueError('wheel Requires-Python 与当前解释器不兼容')
|
|
for value in metadata.get_all('Requires-Dist', []):
|
|
_verify_dependency(value)
|
|
wheel_metadata = Parser().parsestr(archive.read(f'{metadata_root}/WHEEL').decode('utf-8'))
|
|
declared_tags = set().union(*(parse_tag(value) for value in wheel_metadata.get_all('Tag', [])))
|
|
if (
|
|
declared_tags != parse_wheel_filename(wheel.name)[3]
|
|
or wheel_metadata.get('Root-Is-Purelib', '').lower() != 'false'
|
|
):
|
|
raise ValueError('WHEEL 的 tag 或原生安装布局不匹配')
|
|
|
|
|
|
def _verify_record(archive: ZipFile, files: dict[str, ZipInfo], record_path: str) -> None:
|
|
"""
|
|
按 RECORD 校验完整文件清单、大小和 SHA256,拒绝未登记文件。
|
|
|
|
:param archive: 已打开的 ZIP 压缩容器
|
|
:param files: 通过安全清单校验的 wheel 文件条目映射
|
|
:param record_path: wheel 内 RECORD 文件的相对路径
|
|
:return: None
|
|
:raises ValueError: RECORD 文件清单、大小或 SHA256 与实际内容不一致
|
|
"""
|
|
records = {}
|
|
for row in csv.reader(io.StringIO(archive.read(record_path).decode('utf-8'))):
|
|
if len(row) != RECORD_COLUMN_COUNT or row[0] in records:
|
|
raise ValueError('RECORD 存在非法或重复条目')
|
|
records[row[0]] = (row[1], row[2])
|
|
if set(records) != set(files):
|
|
raise ValueError('RECORD 必须完整覆盖 wheel 文件,且不能引用包外文件')
|
|
for name, entry in files.items():
|
|
digest, size = records[name]
|
|
if name == record_path:
|
|
if digest or size:
|
|
raise ValueError('RECORD 自身不应声明摘要或大小')
|
|
continue
|
|
if not digest.startswith('sha256=') or size != str(entry.file_size):
|
|
raise ValueError(f'RECORD 缺少 SHA256 或大小不一致:{name}')
|
|
hasher = hashlib.sha256()
|
|
with archive.open(entry) as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
|
hasher.update(chunk)
|
|
actual = base64.urlsafe_b64encode(hasher.digest()).rstrip(b'=').decode('ascii')
|
|
if digest != f'sha256={actual}':
|
|
raise ValueError(f'wheel 文件摘要不匹配:{name}')
|
|
|
|
|
|
def install_native_wheel(wheel: Path, destination: Path, manifest: PluginManifest) -> None:
|
|
"""
|
|
验证原生 wheel 后展开到全新目录,不覆盖已有版本或执行安装脚本。
|
|
|
|
RECORD 只证明包内一致性,发布者身份由后续制品签名层验证。
|
|
展开失败时仅清理本次创建的目标目录。
|
|
|
|
:param wheel: 待验证或展开的原生 wheel 文件路径
|
|
:param destination: 尚不存在的原生插件安装目录
|
|
:param manifest: 插件清单对象
|
|
:return: None
|
|
:raises ValueError: wheel 类型、兼容性、文件内容或展开路径校验失败
|
|
:raises FileExistsError: 目标目录已经存在,不能覆盖安装
|
|
"""
|
|
if manifest.runtime_kind != 'native':
|
|
raise ValueError('仅 native 插件可安装 wheel')
|
|
target = destination.resolve()
|
|
if target.exists():
|
|
raise FileExistsError('原生安装目录已存在,请使用新目录并通过重启切换版本')
|
|
with ZipFile(wheel) as archive:
|
|
_matching_rank(wheel, manifest)
|
|
files, metadata_root = _inventory(archive, manifest)
|
|
_verify_metadata(archive, metadata_root, wheel, manifest)
|
|
_verify_record(archive, files, f'{metadata_root}/RECORD')
|
|
target.mkdir(parents=True, exist_ok=False)
|
|
try:
|
|
for name in files:
|
|
path = target.joinpath(*PurePosixPath(name).parts)
|
|
if not path.resolve().is_relative_to(target):
|
|
raise ValueError('wheel 解压目标越过安装目录')
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with archive.open(name) as source, path.open('xb') as output:
|
|
shutil.copyfileobj(source, output, length=1024 * 1024)
|
|
except BaseException:
|
|
# target 是本次创建且已解析的目录,所有写入都验证过边界。
|
|
shutil.rmtree(target)
|
|
raise
|