mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(plugin): accept underscore IDs in instance validation (#6962)
This commit is contained in:
@@ -9,6 +9,7 @@ from pydantic import BeforeValidator as _BeforeValidator
|
||||
from pydantic import PrivateAttr as _PrivateAttr
|
||||
from pydantic import computed_field as _computed_field
|
||||
|
||||
from app.domain.plugin import is_physical_plugin_id as _is_physical_plugin_id
|
||||
from app.schemas.common import JsonData
|
||||
|
||||
|
||||
@@ -49,14 +50,16 @@ def validate_plugin_id(value: str) -> str:
|
||||
|
||||
公开可调用:凡是把实例 ID 拼进文件系统路径、模块名或路由段的入口都应当先过这道
|
||||
校验,而不是各自另写一套字符白名单——多套规则之间迟早出现缝隙。
|
||||
实例与源插件复用安装侧的 ASCII 身份规则,兼容既有下划线 ID;不改写大小写,
|
||||
以免改变配置、数据和路由命名空间。
|
||||
:param value: 待校验的插件实例 ID
|
||||
:return: 原样返回的合法 ID
|
||||
:raise ValueError: ID 为空、不以字母开头、含字母数字以外的字符或超长
|
||||
:raise ValueError: ID 为空、不以字母开头、含 ASCII 字母数字及下划线以外的字符或超长
|
||||
"""
|
||||
if not value or not value[0].isalpha() or not value.isalnum():
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含字母和数字")
|
||||
if len(value) > 128:
|
||||
raise ValueError("插件 ID 长度不能超过 128 个字符")
|
||||
if not _is_physical_plugin_id(value):
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含 ASCII 字母、数字或下划线")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -763,7 +763,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1114 |
|
||||
| 内部导入边 | 9,430 |
|
||||
| 内部导入边 | 9,432 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 49(债务已清零,49 条精确 containment) |
|
||||
@@ -773,7 +773,7 @@ flowchart LR
|
||||
| Model/Oper 自动事务与自建 Session | 0 |
|
||||
| 组合根外 `SystemConfigOper()` | 0 |
|
||||
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,430;种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,432;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
|
||||
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
|
||||
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次
|
||||
|
||||
@@ -98,7 +98,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1114 / 9,430 | `dependency-baseline.json` 当前快照;种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1114 / 9,432 | `dependency-baseline.json` 当前快照;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -941,8 +941,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9430,
|
||||
"edge_sha256": "e2e7ef5284db0d923080a54cb3ff25d5cca80e571324786576a432e4b6b6bcd4",
|
||||
"edge_count": 9432,
|
||||
"edge_sha256": "e4629849e18bc7fcdc153cd39ad4f735b3736d59579f5e0cf8201d08c862ea82",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -9196,6 +9196,8 @@
|
||||
"app.schemas.notification -> app.schemas.types",
|
||||
"app.schemas.openai -> app.schemas",
|
||||
"app.schemas.openai -> app.schemas.common",
|
||||
"app.schemas.plugin -> app.domain",
|
||||
"app.schemas.plugin -> app.domain.plugin",
|
||||
"app.schemas.plugin -> app.schemas",
|
||||
"app.schemas.plugin -> app.schemas.common",
|
||||
"app.schemas.query -> app.schemas",
|
||||
|
||||
37
tests/test_plugin_id_validation.py
Normal file
37
tests/test_plugin_id_validation.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""插件实例与安装来源的 ID 合同一致性回归测试。"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.plugin import is_physical_plugin_id
|
||||
from app.schemas.plugin import PluginInstance, validate_plugin_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin_id", ["DemoPlugin", "nullbr_search", "Nullbr_Search2", "A" * 128])
|
||||
def test_instance_ids_accept_installed_plugin_identity(plugin_id: str) -> None:
|
||||
"""市场允许的物理身份必须能用于本体及共享源码分身,且不改变大小写。"""
|
||||
assert is_physical_plugin_id(plugin_id)
|
||||
assert validate_plugin_id(plugin_id) == plugin_id
|
||||
host = PluginInstance(instance_id=plugin_id, source_plugin_id=plugin_id)
|
||||
assert host.is_host
|
||||
assert host.instance_id == plugin_id
|
||||
if len(plugin_id) < 128:
|
||||
clone = PluginInstance(instance_id=f"{plugin_id}x2", source_plugin_id=plugin_id)
|
||||
assert not clone.is_host
|
||||
assert clone.source_plugin_id == plugin_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"plugin_id",
|
||||
["", "_Plugin", "1Plugin", "../Plugin", "a/b", "a\\b", "a.b", "a-b", "a@b",
|
||||
"a b", "a\n", "插件", "Plugin", "A" * 129],
|
||||
)
|
||||
def test_instance_ids_reject_unsafe_or_unsupported_identity(plugin_id: str) -> None:
|
||||
"""实例校验保持字母开头、ASCII、路径安全和长度限制,与安装侧一致。"""
|
||||
assert not is_physical_plugin_id(plugin_id)
|
||||
with pytest.raises(ValueError):
|
||||
validate_plugin_id(plugin_id)
|
||||
with pytest.raises(ValidationError):
|
||||
PluginInstance(instance_id=plugin_id, source_plugin_id="DemoPlugin")
|
||||
with pytest.raises(ValidationError):
|
||||
PluginInstance(instance_id="DemoPlugin", source_plugin_id=plugin_id)
|
||||
@@ -3,6 +3,8 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.db.oper.plugininstance import PluginInstanceOper
|
||||
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
|
||||
from app.startup.initializers import plugins as plugins_initializer
|
||||
|
||||
|
||||
@@ -43,3 +45,32 @@ def test_plugin_instance_directory_skips_invalid_records_on_every_read(monkeypat
|
||||
assert [item.instance_id for item in directory.list_enabled()] == [valid.instance_id]
|
||||
assert logger.warning.call_count == 4
|
||||
assert invalid.instance_id in str(logger.warning.call_args)
|
||||
|
||||
|
||||
def test_underscore_host_can_be_disabled_without_repeated_projection_warnings(monkeypatch) -> None:
|
||||
"""含下划线的存量本体能在卸载时停用,保留配置的重复读取也不误报无效行。"""
|
||||
plugin_id = "nullbr_search"
|
||||
oper = PluginInstanceOper()
|
||||
logger = MagicMock()
|
||||
monkeypatch.setattr(plugins_initializer, "logger", logger)
|
||||
directory = plugins_initializer._build_plugin_instance_directory()
|
||||
storage = PluginStorage(read=lambda _key: None, write=lambda _key, _value: None)
|
||||
store = PluginInstanceStore(storage=lambda: storage, directory=lambda: directory)
|
||||
try:
|
||||
oper.save(instance_id=plugin_id, source_plugin_id=plugin_id, is_enabled=True)
|
||||
oper.save_config_data(
|
||||
instance_id=plugin_id, source_plugin_id=plugin_id, config_data={"token": "kept"}
|
||||
)
|
||||
assert plugin_id in store.enabled_hosts()
|
||||
assert store.disable_host(plugin_id)
|
||||
assert not store.disable_host(plugin_id)
|
||||
for _ in range(3):
|
||||
host = store.get_host(plugin_id)
|
||||
assert host is not None and host.is_enabled is False
|
||||
assert plugin_id in store.all_hosts()
|
||||
assert store.for_source(plugin_id) == []
|
||||
assert plugin_id not in store.enabled_hosts()
|
||||
assert oper.get_config_data(plugin_id) == {"token": "kept"}
|
||||
logger.warning.assert_not_called()
|
||||
finally:
|
||||
oper.delete(plugin_id)
|
||||
|
||||
@@ -196,19 +196,25 @@ def test_purge_refuses_every_malformed_instance_id(
|
||||
assert _sentinels_intact(config_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("instance_id", ["DemoPlugin", "nullbr_search"])
|
||||
def test_purge_deletes_the_directory_for_a_well_formed_instance_id(
|
||||
config_root: Path,
|
||||
instance_id: str,
|
||||
) -> None:
|
||||
"""合法标识照常清理,且只动它自己那一层。"""
|
||||
"""普通和含下划线的合法标识照常清理,且只动它自己那一层。"""
|
||||
calls: list[tuple] = []
|
||||
target = config_root / "plugins" / instance_id
|
||||
target.mkdir(exist_ok=True)
|
||||
(target / "cache.bin").write_text("x", encoding="utf-8")
|
||||
|
||||
result = _purge_command(calls).purge(
|
||||
"DemoPlugin", PluginPurgeScope(data_directory=True)
|
||||
instance_id, PluginPurgeScope(data_directory=True)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.purged == ("data_directory",)
|
||||
assert not (config_root / "plugins" / "DemoPlugin").exists()
|
||||
assert not target.exists()
|
||||
assert calls == [("stop", instance_id), ("own_db", instance_id)]
|
||||
assert (config_root / "protected" / "keep.txt").is_file()
|
||||
assert (config_root / "user.db").is_file()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user