mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 05:19:14 +00:00
* feat: 支持 v2 插件、原生扩展与签名发布 * fix: 修复测试 * feat: 增强宿主 SDK、运行观测与制品维护能力 * feat: 增强文件传输、配置观测与本地开发能力 * feat: 支持 SSE 实时事件流并完善开发脚手架 * feat: 完善长连接鉴权复核与关闭回收 * feat: 增强生命周期与任务关闭可靠性 * feat: 收紧健康检查契约并补齐前端与真实网络验收 * feat: 增加SDK溯源与离线更新工具并验证跨版本兼容 * feat: 增加持久化业务示例与受控 Python 交付 * feat: 增加统一运行诊断与多worker状态观测 * feat: 增加固定目标发布等待与自动验收 * fix: 修复多环境插件部署前缀与代理配置不一致 * fix: 修复测试
234 lines
8.1 KiB
Python
234 lines
8.1 KiB
Python
import argparse
|
|
import json
|
|
import shutil
|
|
import stat
|
|
import unicodedata
|
|
from pathlib import Path, PureWindowsPath
|
|
from typing import Any
|
|
|
|
PLUGIN_ID = 'task_demo'
|
|
BUNDLE = True
|
|
SOURCE = Path(__file__).resolve().parent
|
|
RELEASE_MANIFEST = 'release-files.json'
|
|
MANIFEST_VERSION = 1
|
|
MAX_MANIFEST_BYTES = 64 * 1024
|
|
MAX_SOURCE_FILES = 1024
|
|
MAX_PATH_LENGTH = 1024
|
|
MAX_PATH_COMPONENTS = 32
|
|
MAX_COMPONENT_LENGTH = 255
|
|
FIRST_PRINTABLE_CHARACTER = 32
|
|
DELETE_CHARACTER = 127
|
|
SOURCE_EXTENSIONS = {'.py', '.sql'}
|
|
FORBIDDEN_DIRECTORIES = {
|
|
'tests',
|
|
'test',
|
|
'keys',
|
|
'vendor',
|
|
'node_modules',
|
|
'__pycache__',
|
|
'build',
|
|
'dist',
|
|
'target',
|
|
'web',
|
|
}
|
|
FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'}
|
|
WEB_EXTENSIONS = {
|
|
'.html',
|
|
'.js',
|
|
'.css',
|
|
'.svg',
|
|
'.png',
|
|
'.jpg',
|
|
'.jpeg',
|
|
'.gif',
|
|
'.webp',
|
|
'.ico',
|
|
'.woff',
|
|
'.woff2',
|
|
'.ttf',
|
|
}
|
|
REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400)
|
|
|
|
|
|
def checked_file(path: Path) -> Path:
|
|
"""
|
|
校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。
|
|
|
|
:param path: 待交付的源码文件路径
|
|
:return: 通过检查的源码文件路径
|
|
"""
|
|
for part in (path, *path.parents):
|
|
if part == SOURCE:
|
|
break
|
|
metadata = part.lstat()
|
|
if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT:
|
|
raise ValueError(f'交付资源不能是链接或重解析点:{part}')
|
|
resolved = path.resolve()
|
|
if not resolved.is_relative_to(SOURCE) or not path.is_file():
|
|
raise ValueError(f'交付文件必须位于源码目录:{path}')
|
|
if path.stat().st_nlink > 1:
|
|
raise ValueError(f'交付文件不能是硬链接:{path}')
|
|
return path
|
|
|
|
|
|
def checked_relative_path(value: str) -> tuple[str, ...]:
|
|
"""
|
|
校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。
|
|
|
|
:param value: 清单或 bundle 中的相对路径
|
|
:return: 未经折叠或重写的路径组件
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValueError('交付路径必须为字符串')
|
|
parts = value.split('/')
|
|
if (
|
|
not value
|
|
or len(value) > MAX_PATH_LENGTH
|
|
or len(parts) > MAX_PATH_COMPONENTS
|
|
or unicodedata.normalize('NFC', value) != value
|
|
or any(
|
|
not part
|
|
or part.startswith('.')
|
|
or part.endswith((' ', '.'))
|
|
or len(part) > MAX_COMPONENT_LENGTH
|
|
or PureWindowsPath(part).is_reserved()
|
|
or any(
|
|
char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER
|
|
for char in part
|
|
)
|
|
for part in parts
|
|
)
|
|
):
|
|
raise ValueError(f'交付清单包含不安全路径:{value!r}')
|
|
return tuple(parts)
|
|
|
|
|
|
def checked_inventory(names: list[str]) -> None:
|
|
"""
|
|
拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。
|
|
|
|
:param names: 全部交付文件的相对路径
|
|
:return: None
|
|
"""
|
|
spellings: dict[str, str] = {}
|
|
files: set[str] = set()
|
|
directories: set[str] = set()
|
|
for name in names:
|
|
parts = checked_relative_path(name)
|
|
key = name.casefold()
|
|
if key in files:
|
|
raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}')
|
|
files.add(key)
|
|
for count in range(1, len(parts) + 1):
|
|
prefix = '/'.join(parts[:count])
|
|
if spellings.setdefault(prefix.casefold(), prefix) != prefix:
|
|
raise ValueError(f'交付清单包含目录大小写冲突:{name}')
|
|
if count < len(parts):
|
|
directories.add(prefix.casefold())
|
|
if files & directories:
|
|
raise ValueError('交付清单存在文件与父目录冲突')
|
|
|
|
|
|
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
"""
|
|
解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。
|
|
|
|
:param pairs: JSON 对象按原始顺序出现的字段
|
|
:return: 不包含重复字段的对象
|
|
"""
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError(f'交付清单包含重复字段:{key}')
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def source_resources() -> list[Path]:
|
|
"""
|
|
按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。
|
|
|
|
:return: 已通过路径、类型和链接检查的源码文件列表
|
|
"""
|
|
path = checked_file(SOURCE / RELEASE_MANIFEST)
|
|
with path.open('rb') as source:
|
|
content = source.read(MAX_MANIFEST_BYTES + 1)
|
|
if len(content) > MAX_MANIFEST_BYTES:
|
|
raise ValueError('release-files.json 超过 64 KiB 上限')
|
|
manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object)
|
|
if (
|
|
not isinstance(manifest, dict)
|
|
or set(manifest) != {'schemaVersion', 'files'}
|
|
or type(manifest['schemaVersion']) is not int
|
|
or manifest['schemaVersion'] != MANIFEST_VERSION
|
|
):
|
|
raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files')
|
|
names = manifest['files']
|
|
if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES:
|
|
raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表')
|
|
checked_inventory(names)
|
|
for name in names:
|
|
parts = name.split('/')
|
|
filename = parts[-1].casefold()
|
|
if (
|
|
Path(name).suffix not in SOURCE_EXTENSIONS
|
|
or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1])
|
|
or filename in FORBIDDEN_FILES
|
|
or filename.startswith('test_')
|
|
or filename.endswith('_test.py')
|
|
):
|
|
raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}')
|
|
return [checked_file(SOURCE / name) for name in names]
|
|
|
|
|
|
def bundle_resources() -> list[Path]:
|
|
"""
|
|
收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。
|
|
|
|
:return: 已通过检查的 web/dist 文件列表
|
|
"""
|
|
dist = SOURCE / 'web' / 'dist'
|
|
checked_file(dist / 'index.html')
|
|
resources = []
|
|
for path in sorted(dist.rglob('*')):
|
|
if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT:
|
|
raise ValueError(f'bundle 不允许链接:{path}')
|
|
if not path.is_file():
|
|
continue
|
|
if path.suffix.lower() not in WEB_EXTENSIONS:
|
|
raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}')
|
|
resources.append(checked_file(path))
|
|
if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources):
|
|
raise ValueError('bundle 入口必须使用准确的 index.html 文件名')
|
|
return resources
|
|
|
|
|
|
def main() -> None:
|
|
"""
|
|
按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。
|
|
|
|
:return: None
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。'
|
|
)
|
|
parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)')
|
|
output = parser.parse_args().output.resolve()
|
|
if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output):
|
|
parser.error('输出目录必须不存在,且不得与源码目录重叠')
|
|
resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()]
|
|
if BUNDLE:
|
|
resources.extend(bundle_resources())
|
|
checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources])
|
|
plugin = output / PLUGIN_ID
|
|
plugin.mkdir(parents=True)
|
|
for resource in resources:
|
|
target = plugin / resource.relative_to(SOURCE)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(resource, target)
|
|
print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|