From 5fd04505c121d7e4e5b34f042ed05736b6fe7c71 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:05:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=88=86=E8=BA=AB=E5=BD=BB?= =?UTF-8?q?=E5=BA=95=E6=B8=85=E7=90=86=EF=BC=8C=E5=B9=B6=E6=8C=89=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E6=80=A7=E6=A0=A1=E9=AA=8C=E6=94=B6=E5=8F=A3=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=20(#6671)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/policy/api.py | 9 + app/agent/policy/mcp.py | 6 + .../policy/resources/api_mcp_schema.json | 69 +++++ app/api/dependencies/plugin.py | 5 + app/api/endpoints/plugininstance.py | 58 +++++ app/api/routers.py | 2 + app/application/plugin/config.py | 105 ++++++++ app/db/plugin/locator.py | 9 +- app/db/plugin/registry.py | 8 +- app/locales/en-US.json | 5 + app/locales/zh-TW.json | 5 + app/runtime/extensions/plugin/datadir.py | 117 +++++++++ app/runtime/extensions/plugin/manager.py | 45 ++++ app/runtime/extensions/plugin/storage.py | 12 + app/schemas/exports.py | 3 + app/schemas/plugin.py | 39 ++- docs/architecture-overview.md | 4 +- docs/mcp-api.md | 17 ++ docs/refactor/agent-api-surface-audit.json | 24 +- docs/refactor/agent-api-surface-audit.md | 11 +- docs/refactor/optimization-checklist.md | 2 +- skills/moviepilot-api/SKILL.md | 2 +- skills/moviepilot-api/api/plugin.md | 7 + .../architecture/dependency-baseline.json | 30 ++- .../startup-performance-baseline.json | 6 +- tests/test_agent_api_gateway.py | 4 +- tests/test_agent_skills_middleware.py | 2 +- tests/test_db_plugin_framework.py | 4 +- tests/test_plugin_config_command.py | 117 ++++++++- .../test_plugin_data_directory_containment.py | 212 ++++++++++++++++ tests/test_plugin_database_hooks.py | 4 +- tests/test_plugin_endpoint.py | 5 + tests/test_plugin_instance_purge_endpoint.py | 169 +++++++++++++ ...st_plugin_instance_purge_path_traversal.py | 237 ++++++++++++++++++ 34 files changed, 1319 insertions(+), 35 deletions(-) create mode 100644 app/api/endpoints/plugininstance.py create mode 100644 app/runtime/extensions/plugin/datadir.py create mode 100644 tests/test_plugin_data_directory_containment.py create mode 100644 tests/test_plugin_instance_purge_endpoint.py create mode 100644 tests/test_plugin_instance_purge_path_traversal.py diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index d567e4b1f..bd6423902 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -573,6 +573,12 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( _write("plugin.default_target.set"), _write("plugin.default_target.clear"), _write("plugin.instance.set_enabled"), + # 彻底清理按勾选范围真删用户数据且不可回滚,与重置同档:要确认、且只能人工补救 + _write( + "plugin.instance.purge", + effect=ActionEffect.DESTRUCTIVE_WRITE, + recovery=RecoveryMode.MANUAL_ONLY, + ), ) @@ -837,6 +843,9 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "plugin.instance.set_enabled": ApiOperationRoute( "POST", "/api/v1/plugin/instance/{instance_id}/enabled" ), + "plugin.instance.purge": ApiOperationRoute( + "POST", "/api/v1/plugin/instance/{instance_id}/purge" + ), } diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 31dc0f8d2..818a9e6b2 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -234,6 +234,7 @@ OPERATION_DESCRIPTIONS = { "plugin.default_target.set": "Set one plugin instance as the plugin's default call target, automatically clearing any previous default.", "plugin.default_target.clear": "Clear one plugin instance's default-call-target flag, only if it is the plugin's current default.", "plugin.instance.set_enabled": "Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable.", + "plugin.instance.purge": "Permanently delete one plugin instance's selected data: saved configuration, plugin data rows, its own database and its data directory; a clone also loses its instance record. Nothing is deleted unless explicitly selected.", } @@ -340,6 +341,10 @@ FIELD_DESCRIPTIONS = { "include_usage": "Include the subscriptions or defaults that reference each rule group.", "include_values": "Return complete setting values instead of discovery summaries.", "instance_id": "Exact plugin instance ID returned by plugin.loglevel.get.", + "config": "Purge scope flag: delete this plugin instance's saved configuration values.", + "plugin_data": "Purge scope flag: delete this plugin instance's rows in the shared plugin data table.", + "own_database": "Purge scope flag: destroy this plugin instance's own database.", + "data_directory": "Purge scope flag: delete this plugin instance's entire data directory; its own database is always destroyed first.", "is_default_target": "Whether this plugin instance is the plugin's default call target, used when a caller does not specify an instance.", "enabled": "Target enabled state; false stops the instance while keeping its configuration and display information.", "is_active": "Whether the configured site is enabled.", @@ -667,6 +672,7 @@ MODEL_DESCRIPTIONS = { "MusicRecognizeRequest": "Exact source-native recording or album identity to resolve into canonical music metadata.", "PluginInstanceEnabledRequest": "One plugin instance's enable-or-disable request; disabling keeps its configuration for a later re-enable.", "PluginInstanceLogLevelUpdateRequest": "One plugin instance's log-level override update request.", + "PluginInstancePurgeRequest": "One plugin instance's purge scope; every listed item is deleted only when explicitly selected.", "PluginSourceChangeRequest": "Explicit online-source change request guarded by the current identity revision.", "PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.", "Site-Input": "Complete site configuration and runtime state.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 3770ac710..83564d956 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -3189,6 +3189,37 @@ "title": "PluginInstanceLogLevelUpdateRequest", "type": "object" }, + "PluginInstancePurgeRequest": { + "description": "One plugin instance's purge scope; every listed item is deleted only when explicitly selected.", + "properties": { + "config": { + "default": false, + "description": "Purge scope flag: delete this plugin instance's saved configuration values.", + "title": "Config", + "type": "boolean" + }, + "data_directory": { + "default": false, + "description": "Purge scope flag: delete this plugin instance's entire data directory; its own database is always destroyed first.", + "title": "Data Directory", + "type": "boolean" + }, + "own_database": { + "default": false, + "description": "Purge scope flag: destroy this plugin instance's own database.", + "title": "Own Database", + "type": "boolean" + }, + "plugin_data": { + "default": false, + "description": "Purge scope flag: delete this plugin instance's rows in the shared plugin data table.", + "title": "Plugin Data", + "type": "boolean" + } + }, + "title": "PluginInstancePurgeRequest", + "type": "object" + }, "PluginMarketSyncRequest": { "description": "Approved Wiki source request for plugin-market synchronization.", "properties": { @@ -10328,6 +10359,43 @@ "total_count_field": "collection.total_count" } }, + { + "additionalProperties": false, + "description": "Permanently delete one plugin instance's selected data: saved configuration, plugin data rows, its own database and its data directory; a clone also loses its instance record. Nothing is deleted unless explicitly selected. Method: POST. Path: /api/v1/plugin/instance/{instance_id}/purge. Effect: destructive_write.", + "properties": { + "body": { + "$ref": "#/$defs/PluginInstancePurgeRequest", + "description": "Request value for plugin.instance.purge. Permanently delete one plugin instance's selected data: saved configuration, plugin data rows, its own database and its data directory; a clone also loses its instance record. Nothing is deleted unless explicitly selected. Use the exact type and fields below." + }, + "operation_id": { + "const": "plugin.instance.purge", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.instance.purge. Permanently delete one plugin instance's selected data: saved configuration, plugin data rows, its own database and its data directory; a clone also loses its instance record. Nothing is deleted unless explicitly selected. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.loglevel.get.", + "title": "Instance Id", + "type": "string" + } + }, + "required": [ + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.instance.purge", + "type": "object" + }, { "additionalProperties": false, "description": "Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable. Method: POST. Path: /api/v1/plugin/instance/{instance_id}/enabled. Effect: reversible_write.", @@ -16228,6 +16296,7 @@ "plugin.history", "plugin.install", "plugin.installed", + "plugin.instance.purge", "plugin.instance.set_enabled", "plugin.loglevel.clear", "plugin.loglevel.get", diff --git a/app/api/dependencies/plugin.py b/app/api/dependencies/plugin.py index d3c12a492..30a445d02 100644 --- a/app/api/dependencies/plugin.py +++ b/app/api/dependencies/plugin.py @@ -41,4 +41,9 @@ def get_plugin_config_command() -> PluginConfigCommand: publish_reset=publish_reset, refresh_registrations=refresh_registrations, mutation=manager.mutation, + delete_plugin_data_rows=manager.delete_plugin_data_rows, + destroy_own_database=manager.destroy_plugin_own_database, + delete_data_directory=manager.delete_plugin_data_directory, + purge_instance=manager.purge_plugin_instance, + is_clone=manager.is_plugin_clone, ) diff --git a/app/api/endpoints/plugininstance.py b/app/api/endpoints/plugininstance.py new file mode 100644 index 000000000..51d2ff7fb --- /dev/null +++ b/app/api/endpoints/plugininstance.py @@ -0,0 +1,58 @@ +"""插件实例彻底清理接口。""" + +from typing import Any + +from fastapi import Depends + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.dependencies.plugin import get_plugin_config_command +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.plugin.config import PluginConfigCommand, PluginPurgeScope +from app.schemas.plugin import PluginInstancePurgeOutcome as _SchemaPluginInstancePurgeOutcome +from app.schemas.plugin import PluginInstancePurgeRequest as _SchemaPluginInstancePurgeRequest +from app.schemas.response import Response as _SchemaResponse + +router = ResponseAPIRouter() + + +@router.post( # type: ignore[misc] + "/instance/{instance_id}/purge", + summary="按选定范围彻底清理插件实例", + response_model=_SchemaResponse[_SchemaPluginInstancePurgeOutcome], +) +def purge_plugin_instance( + instance_id: str, + request: _SchemaPluginInstancePurgeRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), + command: PluginConfigCommand = Depends(get_plugin_config_command), +) -> Any: + """ + 按用户逐项勾选的范围彻底清理一个实例的配置与数据 + + 与停用是两件事:停用只把启用位置假、设置原样留着且可随时恢复,这里才真正删除 + 用户数据,因而范围完全由请求给定,一项没选直接拒绝。分身会连同实例行一并消失, + 本体的行则保留——它还承载着这个插件的启用状态与展示覆盖。 + + 权限与卸载、重置同档(仅超级管理员),因为破坏性相当:它删的是同一批东西。 + ``instance_id`` 会被拼进插件数据目录路径,用例层在动任何存储之前先做格式校验, + 删除目录时再按解析后的真实路径确认落在插件数据根内。 + """ + result = command.purge( + instance_id, + PluginPurgeScope( + config=request.config, + plugin_data=request.plugin_data, + own_database=request.own_database, + data_directory=request.data_directory, + ), + ) + if not result.success: + return _SchemaResponse(success=False, message=result.message) + return _SchemaResponse( + success=True, + data=_SchemaPluginInstancePurgeOutcome( + purged=list(result.purged), + instance_removed=result.instance_removed, + ), + ) diff --git a/app/api/routers.py b/app/api/routers.py index 4d249f4d7..f43299b3b 100644 --- a/app/api/routers.py +++ b/app/api/routers.py @@ -25,6 +25,7 @@ from app.api.endpoints import ( notification, openai, plugin, + plugininstance, pluginloglevel, plugintarget, recommend, @@ -75,6 +76,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = ( RouterSpec(notification.router, "/notification", ("notification",)), RouterSpec(llm.router, "/llm", ("llm",)), RouterSpec(plugin.router, "/plugin", ("plugin",)), + RouterSpec(plugininstance.router, "/plugin", ("plugin",)), RouterSpec(pluginloglevel.router, "/plugin", ("plugin",)), RouterSpec(plugintarget.router, "/plugin", ("plugin",)), RouterSpec(download.router, "/download", ("download",)), diff --git a/app/application/plugin/config.py b/app/application/plugin/config.py index 2e000fe0e..3259b94f4 100644 --- a/app/application/plugin/config.py +++ b/app/application/plugin/config.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, ContextManager from app.schemas.exception import PluginMutationRejectedError +from app.schemas.plugin import validate_plugin_id @dataclass(frozen=True, slots=True) @@ -17,6 +18,37 @@ class PluginConfigResult: message: str = "" +@dataclass(frozen=True, slots=True) +class PluginPurgeScope: + """彻底清理一个实例时用户选定的删除范围。 + + 四项各自独立,由用户逐项勾选:清理动作不可逆,替用户把范围扩大到他没选的 + 东西上,比让他多点一下要糟得多。 + """ + + config: bool = False + plugin_data: bool = False + own_database: bool = False + data_directory: bool = False + + @property + def is_empty(self) -> bool: + """是否一项都没选。""" + return not any( + (self.config, self.plugin_data, self.own_database, self.data_directory) + ) + + +@dataclass(frozen=True, slots=True) +class PluginPurgeResult: + """描述彻底清理的执行结果与实际清掉的范围。""" + + success: bool + message: str = "" + purged: tuple[str, ...] = () + instance_removed: bool = False + + class PluginConfigCommand: """协调插件配置持久化、实例初始化和运行时注册刷新。""" @@ -32,6 +64,11 @@ class PluginConfigCommand: publish_reset: Callable[[str], Any], refresh_registrations: Callable[[str], Any], mutation: Callable[[str], ContextManager[None]], + delete_plugin_data_rows: Callable[[str], Any], + destroy_own_database: Callable[[str], Any], + delete_data_directory: Callable[[str], bool], + purge_instance: Callable[[str], bool], + is_clone: Callable[[str], bool], ) -> None: """保存插件管理 Facade 和运行时注册刷新端口。""" self._save_config = save_config @@ -43,6 +80,11 @@ class PluginConfigCommand: self._publish_reset = publish_reset self._refresh_registrations = refresh_registrations self._mutation = mutation + self._delete_plugin_data_rows = delete_plugin_data_rows + self._destroy_own_database = destroy_own_database + self._delete_data_directory = delete_data_directory + self._purge_instance = purge_instance + self._is_clone = is_clone def update(self, plugin_id: str, config: dict) -> PluginConfigResult: """保存配置并按既有顺序重新初始化实例及运行时注册。""" @@ -56,6 +98,69 @@ class PluginConfigCommand: except PluginMutationRejectedError as error: return PluginConfigResult(False, str(error)) + def purge(self, instance_id: str, scope: PluginPurgeScope) -> PluginPurgeResult: + """按用户选定的范围彻底清理一个实例,逐项执行且互不代劳。 + + 与停用的区别在于不可逆:停用只把启用位置假、配置与展示信息原样留着,这里才 + 真正删除用户数据。因而范围完全由调用方给定,一项没选就直接拒绝,不做「那就 + 全清吧」这种猜测。 + + 实例 ID 先过格式校验再动任何存储:它会被拼进插件数据目录路径,放行一个带 + ``..`` 的 ID 会让后续的整目录删除落到插件数据根之外。校验在最前面,是为了让 + 非法 ID 在停止实例、删配置行之前就被拒绝,而不是删掉一半才发现。 + + 分身在清理后连同实例行一并消失——分身的存在本身就由那一行表达;本体的行则 + 保留,它还承载着这个插件的启用状态与展示覆盖,删掉等于把装着的插件静默停用, + 想卸载插件是另一条路径。 + + 自有数据库与数据目录有先后:库文件就落在该目录下,不先销毁句柄直接删目录, + Windows 下会因文件占用失败,Linux 下则留下一个仍被写入的已删除文件。选了 + 删目录就必然先销毁库,不管用户有没有单独勾选它。 + + 这一串动作跨配置行、业务数据表、库文件与目录四种存储,**不是数据库事务**: + 中途失败时前面几步已经生效且无法回滚,因而失败回报只承诺「这次没做完」,不 + 承诺「什么都没动」。目录删除端口在目标不是该实例自己的目录时拒绝执行并抛出 + ``ValueError``,这里把它收成失败结果而不是让它变成 500。 + + :param instance_id: 实例 ID + :param scope: 用户选定的删除范围 + :return: 执行结果与实际清掉的范围 + """ + if scope.is_empty: + return PluginPurgeResult(False, "请至少选择一项要清理的内容") + try: + validate_plugin_id(instance_id) + except ValueError as error: + return PluginPurgeResult(False, f"实例 ID 非法:{error}") + try: + with self._mutation(f"彻底清理插件实例 {instance_id}"): + self._stop(instance_id) + purged: list[str] = [] + if scope.config: + self._delete_config(instance_id, True) + purged.append("config") + if scope.plugin_data: + self._delete_plugin_data_rows(instance_id) + purged.append("plugin_data") + if scope.own_database or scope.data_directory: + self._destroy_own_database(instance_id) + if scope.own_database: + purged.append("own_database") + if scope.data_directory and self._delete_data_directory(instance_id): + purged.append("data_directory") + instance_removed = ( + self._purge_instance(instance_id) if self._is_clone(instance_id) else False + ) + return PluginPurgeResult( + True, + purged=tuple(purged), + instance_removed=instance_removed, + ) + except PluginMutationRejectedError as error: + return PluginPurgeResult(False, str(error)) + except ValueError as error: + return PluginPurgeResult(False, f"清理插件实例 {instance_id} 失败:{error}") + def reset(self, plugin_id: str) -> PluginConfigResult: """通知插件补偿后停止实例、删除配置数据并重建运行态。""" try: diff --git a/app/db/plugin/locator.py b/app/db/plugin/locator.py index c25cbdd8e..1be317f19 100644 --- a/app/db/plugin/locator.py +++ b/app/db/plugin/locator.py @@ -5,7 +5,7 @@ from __future__ import annotations import hashlib from pathlib import Path -from app.runtime.settings import get_runtime_setting +from app.runtime.extensions.plugin.datadir import resolve_plugin_data_directory __all__ = [ "plugin_schema_name", @@ -30,10 +30,15 @@ def sqlite_database_path(plugin_id: str) -> Path: 落在与 ``_PluginBase.get_data_path()`` 完全相同的插件数据目录下,插件库因此随插件 数据一起被备份、迁移和删除,不会形成第二份需要单独维护的持久化根。本函数不创建 目录:``ensure`` 在插件什么都没声明时不得凭空产生目录。 + + 目录解析走统一入口而不是就地拼接:销毁数据库会 ``unlink`` 这些文件,而插件标识在 + 重置、卸载等路径上直接来自 HTTP 路径段,就地拼接会让一个 ``..`` 把删除抬到插件数据 + 根之外。 :param plugin_id: 插件标识 :return: 库文件绝对路径 + :raise ValueError: 插件标识不是一个合法的单层目录名 """ - return Path(get_runtime_setting("PLUGIN_DATA_PATH")) / plugin_id / DATABASE_FILENAME + return resolve_plugin_data_directory(plugin_id) / DATABASE_FILENAME def sqlite_sidecar_paths(db_path: Path) -> tuple[Path, ...]: diff --git a/app/db/plugin/registry.py b/app/db/plugin/registry.py index f32d0d18e..090794350 100644 --- a/app/db/plugin/registry.py +++ b/app/db/plugin/registry.py @@ -265,7 +265,13 @@ def _remove_storage(plugin_id: str, handle: PluginDatabaseHandle | None) -> None _drop_schema(plugin_id, schema) return - db_path = (handle.db_path if handle else None) or sqlite_database_path(plugin_id) + try: + db_path = (handle.db_path if handle else None) or sqlite_database_path(plugin_id) + except ValueError as error: + # 标识越界时没有「该插件的库」可言,删除请求本身就是伪造的;与其它删除失败 + # 同样只记日志:销毁是一次收尾操作,抛出只会把调用方的收尾流程整条打断 + logger.warning(f"拒绝按非法插件标识 {plugin_id!r} 删除数据库文件:{error}") + return for candidate in (db_path, *sqlite_sidecar_paths(db_path)): try: candidate.unlink(missing_ok=True) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index fd0f6468b..f96ebd071 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -279,6 +279,7 @@ "验证失败": "Verification failed", "没有传入仓库地址,无法正确安装插件,请检查配置": "No repository URL was provided, so the plugin cannot be installed. Please check the configuration", "插件分身创建成功": "Plugin clone created successfully", + "请至少选择一项要清理的内容": "Select at least one item to purge", "默认调用目标正被并发修改,请重试": "The default call target is being modified concurrently, please retry", "未识别到豆瓣媒体信息": "Unable to recognize Douban media information", "未识别到TMDB媒体信息": "Unable to recognize TMDB media information", @@ -1054,6 +1055,10 @@ "source": "创建插件分身失败:{reason}", "target": "Failed to create plugin clone: {reason}" }, + { + "source": "实例 ID 非法:{reason}", + "target": "Invalid instance ID: {reason}" + }, { "source": "请先卸载该插件的分身:{instances}", "target": "Uninstall this plugin's instances first: {instances}" diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 3cd543759..d2be22f6d 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -271,6 +271,7 @@ "验证失败": "驗證失敗", "没有传入仓库地址,无法正确安装插件,请检查配置": "未傳入倉庫位址,無法正確安裝插件,請檢查設定", "插件分身创建成功": "插件分身建立成功", + "请至少选择一项要清理的内容": "請至少選擇一項要清理的內容", "默认调用目标正被并发修改,请重试": "預設呼叫目標正被並行修改,請重試", "未识别到豆瓣媒体信息": "未識別到豆瓣媒體資訊", "未识别到TMDB媒体信息": "未識別到 TMDB 媒體資訊", @@ -1040,6 +1041,10 @@ "source": "创建插件分身失败:{reason}", "target": "建立插件分身失敗:{reason}" }, + { + "source": "实例 ID 非法:{reason}", + "target": "實例 ID 非法:{reason}" + }, { "source": "请先卸载该插件的分身:{instances}", "target": "請先解除安裝該插件的分身:{instances}" diff --git a/app/runtime/extensions/plugin/datadir.py b/app/runtime/extensions/plugin/datadir.py new file mode 100644 index 000000000..269d3582a --- /dev/null +++ b/app/runtime/extensions/plugin/datadir.py @@ -0,0 +1,117 @@ +"""插件数据目录的定位与删除,按插件标识拼出的数据路径一律从这里出去。 + +插件数据根是配置根下的一层子目录(``CONFIG_PATH/plugins``),而插件标识在多条路径上 +都来自 HTTP 请求。把标识直接拼进路径再交给文件系统,一个 ``..`` 就能把删除动作抬到 +配置根上。路径拼接因此收口在本模块,调用方不再各自 ``PLUGIN_DATA_PATH / plugin_id``。 +""" + +from __future__ import annotations + +import shutil +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting + + +def plugin_data_root() -> Path: + """ + 返回插件数据根目录。 + :return: 插件数据根目录,生产环境下为 ``CONFIG_PATH/plugins`` + """ + return Path(get_runtime_setting("PLUGIN_DATA_PATH")) + + +def _require_single_segment(plugin_id: str) -> None: + """ + 要求插件标识恰好构成一个普通目录名,否则拒绝。 + + 这是纯词法判断,不碰文件系统,因而在目录尚未存在时同样成立。判据用的是「必须正好 + 是一个路径段」这个正向条件,而不是「不能含 ``..``」这种反向黑名单:反向名单永远补 + 不完,``..``、URL 解码后的 ``..``、``..%2f`` 解码后的 ``../``、绝对路径、Windows + 盘符各是一种写法,漏掉任意一种就是一个洞。 + + POSIX 与 Windows 两套规则各判一次:宿主可能跑在任一平台上,而 ``a\\..\\..`` 在 + Linux 下只是个古怪的文件名,到了 Windows 下却是两级上跳。 + + :param plugin_id: 待校验的插件标识 + :raise ValueError: 标识为空、是 ``.``/``..``、含路径分隔符、带盘符或为绝对路径 + """ + if not plugin_id or plugin_id in (".", ".."): + raise ValueError(f"插件标识 {plugin_id!r} 不是一个合法的数据目录名") + for flavour in (PurePosixPath, PureWindowsPath): + pure = flavour(plugin_id) + if pure.is_absolute() or pure.drive or pure.root: + raise ValueError(f"插件标识 {plugin_id!r} 不得是绝对路径") + if len(pure.parts) != 1 or pure.parts[0] != plugin_id: + raise ValueError(f"插件标识 {plugin_id!r} 不得包含路径分隔符或上跳") + + +def resolve_plugin_data_directory(plugin_id: str) -> Path: + """ + 解析插件标识对应的数据目录,保证它在词法上就是数据根下的一层子目录。 + + 只做词法判断、不解析软链,是为了保留「把某个插件的数据目录软链到别的盘」这种既有 + 部署方式:读写自有数据库文件本身不具备破坏性,把这类部署一并拒掉是纯粹的功能回退。 + 需要不可逆删除的调用方另有 :func:`remove_plugin_data_directory`,那里才加上解析后的 + 包含性判断。 + + :param plugin_id: 插件标识 + :return: 该插件的数据目录,不保证存在 + :raise ValueError: 标识不是一个合法的单层目录名 + """ + _require_single_segment(plugin_id) + return plugin_data_root() / plugin_id + + +def _require_within_data_root(data_dir: Path) -> Path: + """ + 确认目录解析后确实落在插件数据根之内,并拒绝软链。 + + 包含性判断建立在 ``resolve()`` 之后的真实路径上:这是唯一与编码形式无关的事实判断, + 大小写折叠、Unicode 归一、宿主平台差异造出的花样写法在这里全部塌缩成同一个答案。 + 数据根本身也要解析,否则根路径上有软链时合法目录会被误判成越界。 + + 软链一律拒绝而不是跟随:``resolve()`` 会跟到软链的目标上,一条指向数据根之外的软链 + 足以把递归删除引到任意目录;即便目标仍在根内,删掉的也不是调用方以为的那个插件。 + ``rmtree`` 本身也拒绝删除软链,这里提前拒绝只是为了给出明确原因而不是静默失败。 + + :param data_dir: 待确认的目录 + :return: 解析后的真实目录 + :raise ValueError: 目录是软链,或解析后不在插件数据根之内 + """ + if data_dir.is_symlink(): + raise ValueError(f"插件数据目录 {data_dir} 是软链,拒绝按它递归删除") + resolved_root = plugin_data_root().resolve() + resolved = data_dir.resolve() + if resolved == resolved_root or not resolved.is_relative_to(resolved_root): + raise ValueError(f"插件数据目录 {data_dir} 不在插件数据根 {resolved_root} 之内") + return resolved + + +def remove_plugin_data_directory(plugin_id: str) -> bool: + """ + 删除该插件在数据目录下的整个目录,返回删除前它是否存在。 + + 这个目录此前没有任何代码清理过:插件把落盘文件写在这里,卸载与重置都只动数据库, + 目录会一直留着,重建同名实例时静默继承上一轮的文件。 + + 删除失败只记日志不抛出:彻底清理是一串收尾动作,把某个文件删不掉升级成异常,只会 + 让已经删掉的配置行与仍然存在的目录停在不一致的中间态,而调用方对此无从补救。 + + :param plugin_id: 插件标识 + :return: 删除前该目录是否存在 + :raise ValueError: 标识越界,或目标目录解析后不在插件数据根之内 + """ + data_dir = resolve_plugin_data_directory(plugin_id) + if not data_dir.is_dir(): + return False + target = _require_within_data_root(data_dir) + + def _log_failure(_function: Any, path: Any, error: BaseException) -> None: + """逐个记录删不掉的条目,避免整目录清理静默留下残余。""" + logger.warning(f"删除插件 {plugin_id} 的数据目录条目 {path} 失败:{error}") + + shutil.rmtree(target, onexc=_log_failure) + return True diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index ce964613f..c3470c6f0 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -27,6 +27,7 @@ from app.foundation.singleton import Singleton from app.runtime.events import EventHandlerBinding, eventmanager from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.plugin.access import PluginAccessPolicy +from app.runtime.extensions.plugin.datadir import remove_plugin_data_directory from app.runtime.extensions.plugin.dependency import ( PluginDependencyClassification, PluginDependencyInstallResult, @@ -790,6 +791,50 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): clear_instance_log_level_override(plugin_id) return deleted + def delete_plugin_data_rows(self, instance_id: str) -> None: + """只删该实例在插件数据表里的行,不碰它的自有数据库。 + + 与 :meth:`delete_plugin_data` 的区别正在于此:那个方法把数据表与自有库捆在 + 一起删,而彻底清理要让用户逐项勾选,两者必须拆开。 + """ + self._plugin_config_store.delete_data_rows(instance_id) + + def destroy_plugin_own_database(self, instance_id: str) -> None: + """销毁该实例的自有数据库并释放句柄。""" + self._plugin_config_store.destroy_database(instance_id) + + def delete_plugin_data_directory(self, instance_id: str) -> bool: + """删除该实例在插件数据目录下的整个目录,返回删除前它是否存在。 + + 这个目录此前没有任何代码清理过:插件把落盘文件写在这里,卸载与重置都只动 + 数据库,目录会一直留着,重建同名实例时静默继承上一轮的文件。 + + :param instance_id: 实例 ID + :return: 删除前该目录是否存在 + :raise ValueError: 标识越界,或目标目录解析后不在插件数据根之内 + """ + return remove_plugin_data_directory(instance_id) + + def purge_plugin_instance(self, instance_id: str) -> bool: + """彻底删除一个分身实例的行连同其配置,返回删除前它是否存在。 + + 调用方需自行持有 ``mutation`` 上下文;彻底清理的各个步骤必须落在同一个 + lease 内,否则停机封口可能卡在两步之间,留下删了一半的实例。 + + 本体的行删不掉:底层读取口只认分身,本体传进来一律返回未删除。这是有意的, + 本体那一行还承载着该插件的启用状态与展示覆盖,删掉等于把装着的插件静默停用。 + + 同时清掉该实例的进程内日志等级覆盖:覆盖表按实例 ID 常驻进程,只删库里那一 + 行的话,同一进程内用相同 ID 重建的分身会继承上一个分身的等级。 + """ + deleted = self._plugin_instance_store.delete(instance_id) + clear_instance_log_level_override(instance_id) + return deleted + + def is_plugin_clone(self, instance_id: str) -> bool: + """判断该实例 ID 是否为分身而非源插件本体。""" + return self._plugin_instance_store.get(instance_id) is not None + def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: """ 保存插件配置 diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index 28c217a97..3b8d43532 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -244,6 +244,18 @@ class PluginConfigStore: self._database().destroy(plugin_id) return True + def delete_data_rows(self, plugin_id: str) -> None: + """只删该实例在插件数据表里的行,不碰它的自有数据库。 + + 彻底清理要让用户逐项勾选删除范围,因而这两件事必须能分开做; + :meth:`delete_data` 仍把它们捆在一起,服务「重置」那条既有语义。 + """ + self._storage().delete_data(plugin_id) + + def destroy_database(self, plugin_id: str) -> None: + """销毁该实例的自有数据库并释放句柄。""" + self._database().destroy(plugin_id) + InstanceReader = Callable[[str], "PluginInstance | None"] InstanceLister = Callable[[], "list[PluginInstance]"] diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 4bf18b01b..2e3318cc1 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -357,6 +357,8 @@ SCHEMA_EXPORTS = { 'PluginInstanceLogLevel': ('app.schemas.plugin', 'PluginInstanceLogLevel'), 'PluginInstanceLogLevelOverview': ('app.schemas.plugin', 'PluginInstanceLogLevelOverview'), 'PluginInstanceLogLevelUpdateRequest': ('app.schemas.plugin', 'PluginInstanceLogLevelUpdateRequest'), + 'PluginInstancePurgeOutcome': ('app.schemas.plugin', 'PluginInstancePurgeOutcome'), + 'PluginInstancePurgeRequest': ('app.schemas.plugin', 'PluginInstancePurgeRequest'), 'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'), 'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'), 'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'), @@ -571,6 +573,7 @@ SCHEMA_EXPORTS = { 'json': ('app.schemas.subscribe', 'json'), 'model_validator': ('app.schemas.transfer', 'model_validator'), 're': ('app.schemas.file', 're'), + 'validate_plugin_id': ('app.schemas.plugin', 'validate_plugin_id'), } SCHEMA_CONFLICTS = { diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 5a72ab880..6e5ca4316 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -41,8 +41,15 @@ class PluginUpdateCandidate(BaseModel): # type: ignore[misc] is_bound: bool = Field(description="候选仓库是否为插件当前已绑定仓库") -def _validate_plugin_id(value: str) -> str: - """限制插件实例标识为可安全用作 Python 类名和路由段的格式。""" +def validate_plugin_id(value: str) -> str: + """限制插件实例标识为可安全用作 Python 类名和路由段的格式。 + + 公开可调用:凡是把实例 ID 拼进文件系统路径、模块名或路由段的入口都应当先过这道 + 校验,而不是各自另写一套字符白名单——多套规则之间迟早出现缝隙。 + :param value: 待校验的插件实例 ID + :return: 原样返回的合法 ID + :raise ValueError: ID 为空、不以字母开头、含字母数字以外的字符或超长 + """ if not value or not value[0].isalpha() or not value.isalnum(): raise ValueError("插件 ID 必须以字母开头且只能包含字母和数字") if len(value) > 128: @@ -50,7 +57,7 @@ def _validate_plugin_id(value: str) -> str: return value -_PluginId = _Annotated[str, _AfterValidator(_validate_plugin_id)] +_PluginId = _Annotated[str, _AfterValidator(validate_plugin_id)] class PluginInstance(BaseModel): @@ -94,6 +101,32 @@ class PluginInstanceEnabledRequest(BaseModel): # type: ignore[misc] ) +class PluginInstancePurgeRequest(BaseModel): # type: ignore[misc] + """彻底清理一个插件实例时选定的删除范围。 + + 各项默认为假:清理不可逆,漏选一项只是少删了东西,多选一项则可能毁掉用户特意 + 保留的数据,因而由调用方逐项明确给出,服务端不替它补默认值。 + """ + + config: bool = Field(default=False, description="是否删除该实例的业务参数") + plugin_data: bool = Field(default=False, description="是否删除该实例在插件数据表中的行") + own_database: bool = Field(default=False, description="是否销毁该实例的自有数据库") + data_directory: bool = Field( + default=False, + description="是否删除该实例在插件数据目录下的整个目录;选中时自有数据库必然一并销毁", + ) + + +class PluginInstancePurgeOutcome(BaseModel): # type: ignore[misc] + """彻底清理的执行结果。""" + + purged: List[str] = Field(default_factory=list, description="实际清掉的范围标识") + instance_removed: bool = Field( + default=False, + description="实例行是否随之删除;分身会删,本体保留——它还承载着该插件的展示与启用状态", + ) + + class PluginInstanceLogLevel(BaseModel): # type: ignore[misc] """单个实例的日志等级设置与生效结果。""" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 4fdc9e612..9f34950e2 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -756,8 +756,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1016 | -| 内部导入边 | 8,630 | +| Python 模块 | 1018 | +| 内部导入边 | 8,650 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index b9f5196bc..126f1c6c1 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -81,6 +81,23 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。 是否启用与此刻是否在运行是两回事:前者是落盘的装载判据,后者由运行时持有、不落盘。 +### 插件实例彻底清理 + +`plugin.instance.purge` 用实例 ID 按四项独立范围删除该实例的持久化内容:业务参数 +(`config`)、插件数据表中的行(`plugin_data`)、插件自有数据库(`own_database`)、 +插件数据目录(`data_directory`)。四项全部默认为假,一项都没选直接返回失败,服务端 +不替调用方补默认值——清理不可逆,漏删只是少删,多删可能毁掉用户特意留下的数据。 + +它与停用不是一件事:停用只把启用位置假、设置原样留着随时可恢复,这里才真正删除用户 +数据。响应的 `purged` 列出本次真正清掉的范围,目录本就不存在时不会出现在其中; +`instance_removed` 说明实例行是否随之消失——分身会删,本体保留,本体那一行还承载着 +该插件的启用状态与展示覆盖。 + +选了删数据目录就必然先销毁自有数据库(库文件落在该目录下),但未单独勾选 `own_database` +时不会把它计入 `purged`。实例 ID 必须是合法的插件实例 ID(字母开头、仅字母与数字): +它会被拼进插件数据目录路径,带路径分隔符或上跳的标识一律在动任何存储之前被拒绝。 + + ## 3.1 结构化 Agent 工具与完整参数合同 `tools/list` 会为以下四个正式入口返回可直接校验的 JSON Schema。每个入口都按 operation/action 生成 `oneOf` 分支,分支中包含必填字段、类型、默认值、枚举、嵌套对象和互斥/至少一项等跨字段规则;外部 MCP 客户端可以在一次 `tools/call` 中完成参数构造,不需要猜测 URL、HTTP 方法或第三方 SDK 参数。 diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index 535a786c9..ec63ca728 100644 --- a/docs/refactor/agent-api-surface-audit.json +++ b/docs/refactor/agent-api-surface-audit.json @@ -2,7 +2,7 @@ "disposition_counts": { "alternate-auth-duplicate": 11, "consolidated": 71, - "gateway": 224, + "gateway": 225, "provider-skill": 12, "stream_or_binary": 10, "transport_or_identity": 66, @@ -18,10 +18,10 @@ "reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route." } ], - "gateway_http_route_count": 225, - "gateway_operation_count": 227, - "matched_gateway_http_route_count": 224, - "openapi_operation_count": 407, + "gateway_http_route_count": 226, + "gateway_operation_count": 228, + "matched_gateway_http_route_count": 225, + "openapi_operation_count": 408, "operations": [ { "disposition": "consolidated", @@ -2269,6 +2269,20 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "POST", + "operation_ids": [ + "plugin.instance.purge" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/instance/{instance_id}/purge", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "按选定范围彻底清理插件实例", + "tags": [ + "plugin" + ] + }, { "disposition": "gateway", "method": "DELETE", diff --git a/docs/refactor/agent-api-surface-audit.md b/docs/refactor/agent-api-surface-audit.md index b57d2f224..de015483f 100644 --- a/docs/refactor/agent-api-surface-audit.md +++ b/docs/refactor/agent-api-surface-audit.md @@ -5,10 +5,10 @@ ## Result -- OpenAPI HTTP operations: **407** -- Stable `moviepilot_api` operations: **227** -- Exact HTTP routes used by the gateway: **225** -- OpenAPI routes matched directly by the gateway: **224** +- OpenAPI HTTP operations: **408** +- Stable `moviepilot_api` operations: **228** +- Exact HTTP routes used by the gateway: **226** +- OpenAPI routes matched directly by the gateway: **225** - Bounded dynamic gateway routes: **1** - Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`. - Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input. @@ -19,7 +19,7 @@ | :--- | ---: | :--- | | `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. | | `consolidated` | 71 | Source/UI route represented by a stable aggregate Agent operation. | -| `gateway` | 224 | Approved structured MoviePilot Agent operation. | +| `gateway` | 225 | Approved structured MoviePilot Agent operation. | | `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | @@ -210,6 +210,7 @@ | `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 | | `GET` | `/api/v1/plugin/installed` | plugin | `consolidated` | plugin.installed | 已安装插件 | | `POST` | `/api/v1/plugin/instance/{instance_id}/enabled` | plugin | `gateway` | plugin.instance.set_enabled | 启用或停用插件实例 | +| `POST` | `/api/v1/plugin/instance/{instance_id}/purge` | plugin | `gateway` | plugin.instance.purge | 按选定范围彻底清理插件实例 | | `DELETE` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.clear | 清除插件实例的默认调用目标 | | `PUT` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.set | 设置插件实例的默认调用目标 | | `GET` | `/api/v1/plugin/loglevel/{plugin_id}` | plugin | `gateway` | plugin.loglevel.get | 查询插件全部实例的日志等级设置 | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 064ea18ee..2e0dacf96 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1016 / 8,630 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1018 / 8,650 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 7bc448e77..ca844ce45 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -62,7 +62,7 @@ allowed-api-operations: >- plugin.folder.update plugin.folder.delete plugin.folder.plugins.update plugin.folder.plugin.assign plugin.folder.plugin.remove plugin.loglevel.get plugin.loglevel.set plugin.loglevel.clear plugin.default_target.set plugin.default_target.clear - plugin.instance.set_enabled + plugin.instance.set_enabled plugin.instance.purge --- # MoviePilot API diff --git a/skills/moviepilot-api/api/plugin.md b/skills/moviepilot-api/api/plugin.md index e281d17bf..a04d40776 100644 --- a/skills/moviepilot-api/api/plugin.md +++ b/skills/moviepilot-api/api/plugin.md @@ -131,6 +131,13 @@ Purpose: List installed plugins and their runtime status. - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `body`: none +### `plugin.instance.purge` +`POST /api/v1/plugin/instance/{instance_id}/purge`; policy effect: `destructive_write`. +Purpose: Permanently delete one plugin instance's selected data: saved configuration, plugin data rows, its own database and its data directory; a clone also loses its instance record. Nothing is deleted unless explicitly selected. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get. +- `query`: none +- `body`: `config` (boolean; default `False`): Purge scope flag: delete this plugin instance's saved configuration values.; `data_directory` (boolean; default `False`): Purge scope flag: delete this plugin instance's entire data directory; its own database is always destroyed first.; `own_database` (boolean; default `False`): Purge scope flag: destroy this plugin instance's own database.; `plugin_data` (boolean; default `False`): Purge scope flag: delete this plugin instance's rows in the shared plugin data table. + ### `plugin.instance.set_enabled` `POST /api/v1/plugin/instance/{instance_id}/enabled`; policy effect: `reversible_write`. Purpose: Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable. diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index f01d63f52..b71f01a14 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1077,8 +1077,8 @@ "runtime_only": true } }, - "edge_count": 8630, - "edge_sha256": "498714193dc86bf3580b72f889a8f9ce874cd5185e1bdaaf951d83c523d98b65", + "edge_count": 8650, + "edge_sha256": "b127ad724b438d3a940f056676b637592f97903a38457b1c99842b56ea1deb0f", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2707,6 +2707,18 @@ "app.api.endpoints.pluginfolder -> app.schemas", "app.api.endpoints.pluginfolder -> app.schemas.plugin", "app.api.endpoints.pluginfolder -> app.schemas.response", + "app.api.endpoints.plugininstance -> app.api", + "app.api.endpoints.plugininstance -> app.api.dependencies", + "app.api.endpoints.plugininstance -> app.api.dependencies.auth", + "app.api.endpoints.plugininstance -> app.api.dependencies.plugin", + "app.api.endpoints.plugininstance -> app.api.principal", + "app.api.endpoints.plugininstance -> app.api.response", + "app.api.endpoints.plugininstance -> app.application", + "app.api.endpoints.plugininstance -> app.application.plugin", + "app.api.endpoints.plugininstance -> app.application.plugin.config", + "app.api.endpoints.plugininstance -> app.schemas", + "app.api.endpoints.plugininstance -> app.schemas.plugin", + "app.api.endpoints.plugininstance -> app.schemas.response", "app.api.endpoints.pluginloglevel -> app.api", "app.api.endpoints.pluginloglevel -> app.api.dependencies", "app.api.endpoints.pluginloglevel -> app.api.dependencies.auth", @@ -3135,6 +3147,7 @@ "app.api.routers -> app.api.endpoints.notification", "app.api.routers -> app.api.endpoints.openai", "app.api.routers -> app.api.endpoints.plugin", + "app.api.routers -> app.api.endpoints.plugininstance", "app.api.routers -> app.api.endpoints.pluginloglevel", "app.api.routers -> app.api.endpoints.plugintarget", "app.api.routers -> app.api.endpoints.recommend", @@ -3619,6 +3632,7 @@ "app.application.plugin.catalog -> app.schemas.plugin", "app.application.plugin.config -> app.schemas", "app.application.plugin.config -> app.schemas.exception", + "app.application.plugin.config -> app.schemas.plugin", "app.application.plugin.data -> app.application", "app.application.plugin.data -> app.application.security", "app.application.plugin.data -> app.application.security.secrets", @@ -6090,7 +6104,9 @@ "app.db.oper.workflow -> app.db.oper", "app.db.oper.workflow -> app.db.oper.query", "app.db.plugin.locator -> app.runtime", - "app.db.plugin.locator -> app.runtime.settings", + "app.db.plugin.locator -> app.runtime.extensions", + "app.db.plugin.locator -> app.runtime.extensions.plugin", + "app.db.plugin.locator -> app.runtime.extensions.plugin.datadir", "app.db.plugin.migration -> app.db", "app.db.plugin.migration -> app.db.plugin", "app.db.plugin.migration -> app.db.plugin.container", @@ -8254,6 +8270,9 @@ "app.runtime.extensions.plugin.clone -> app.schemas.plugin", "app.runtime.extensions.plugin.contracts -> app.foundation", "app.runtime.extensions.plugin.contracts -> app.foundation.reflection", + "app.runtime.extensions.plugin.datadir -> app.runtime", + "app.runtime.extensions.plugin.datadir -> app.runtime.log", + "app.runtime.extensions.plugin.datadir -> app.runtime.settings", "app.runtime.extensions.plugin.dependency -> app.runtime", "app.runtime.extensions.plugin.dependency -> app.runtime.extensions", "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin", @@ -8288,6 +8307,7 @@ "app.runtime.extensions.plugin.manager -> app.runtime.extensions", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.access", + "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.datadir", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.manager -> app.runtime.extensions.plugin.monitor", @@ -9711,7 +9731,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1016, + "module_count": 1018, "modules": [ "app", "app.adapters", @@ -9899,6 +9919,7 @@ "app.api.endpoints.openai", "app.api.endpoints.plugin", "app.api.endpoints.pluginfolder", + "app.api.endpoints.plugininstance", "app.api.endpoints.pluginloglevel", "app.api.endpoints.plugintarget", "app.api.endpoints.recommend", @@ -10550,6 +10571,7 @@ "app.runtime.extensions.plugin.clone", "app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin.database", + "app.runtime.extensions.plugin.datadir", "app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.loader", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index deec9a795..f2c21cb51 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 551, + "loaded_app_module_count": 552, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 563, + "loaded_app_module_count": 564, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 565, + "loaded_app_module_count": 566, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 681074b0a..5bec6c69b 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -34,8 +34,8 @@ def test_api_operation_registry_matches_migration_batches() -> None: assert len(API_PARITY_OPERATION_SPECS) == 15 assert len(API_MUSIC_OPERATION_SPECS) == 10 assert len(API_SYSTEM_OPERATION_SPECS) == 7 - assert len(API_EXTENDED_OPERATION_SPECS) == 142 - assert len(API_OPERATION_SPECS) == 227 + assert len(API_EXTENDED_OPERATION_SPECS) == 143 + assert len(API_OPERATION_SPECS) == 228 assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES) assert { "download.list", diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index a06092e63..1a9ca5b6c 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -155,7 +155,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False assert payload["truncation_message"] is None - assert len(payload["skill"]["allowed_api_operations"]) == 227 + assert len(payload["skill"]["allowed_api_operations"]) == 228 assert "## API Category Index" in payload["content"] assert "### `workflow.update`" not in payload["content"] assert "api/workflow.md" in payload["supporting_files"] diff --git a/tests/test_db_plugin_framework.py b/tests/test_db_plugin_framework.py index 488649456..d2eaa8928 100644 --- a/tests/test_db_plugin_framework.py +++ b/tests/test_db_plugin_framework.py @@ -14,10 +14,10 @@ from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import Mapped, mapped_column, scoped_session, sessionmaker import app.db.engine as engine_module -import app.db.plugin.locator as locator_module import app.db.plugin.migration as migration_module import app.db.plugin.registry as registry_module import app.db.session as session_module +import app.runtime.extensions.plugin.datadir as datadir_module from app.db.base import Base as HostBase from app.db.plugin.base import plugin_declarative_base from app.db.plugin.container import PluginDatabaseHandle @@ -124,7 +124,7 @@ def plugin_data_root(tmp_path, monkeypatch) -> Path: """把插件数据库文件隔离到进程私有的临时目录。""" root = tmp_path / "plugins" monkeypatch.setattr( - locator_module, + datadir_module, "get_runtime_setting", lambda key, default=None: root if key == "PLUGIN_DATA_PATH" else default, ) diff --git a/tests/test_plugin_config_command.py b/tests/test_plugin_config_command.py index ef6024e25..5927887e6 100644 --- a/tests/test_plugin_config_command.py +++ b/tests/test_plugin_config_command.py @@ -1,6 +1,6 @@ from contextlib import nullcontext -from app.application.plugin.config import PluginConfigCommand +from app.application.plugin.config import PluginConfigCommand, PluginPurgeScope from app.runtime.extensions.plugin.admission import PluginMutationAdmission @@ -9,6 +9,8 @@ def _command( *, save_result: bool = True, mutation=None, + directory_existed: bool = True, + is_clone: bool = False, ) -> PluginConfigCommand: """构造记录端口调用顺序的插件配置用例。""" return PluginConfigCommand( @@ -31,6 +33,15 @@ def _command( ("registrations", plugin_id) ), mutation=mutation or (lambda _operation: nullcontext()), + delete_plugin_data_rows=lambda plugin_id: calls.append(("data_rows", plugin_id)), + destroy_own_database=lambda plugin_id: calls.append(("own_db", plugin_id)), + delete_data_directory=lambda plugin_id: ( + calls.append(("data_dir", plugin_id)) or directory_existed + ), + purge_instance=lambda plugin_id: ( + calls.append(("purge_row", plugin_id)) or True + ), + is_clone=lambda _plugin_id: is_clone, ) @@ -93,3 +104,107 @@ def test_sealed_config_command_rejects_before_first_side_effect() -> None: assert "停机阶段" in update_result.message assert "停机阶段" in reset_result.message assert calls == [] + + +def test_purge_refuses_an_empty_scope() -> None: + """一项都没勾选时直接拒绝,不做「那就全清吧」这种猜测。""" + calls: list[tuple] = [] + + result = _command(calls).purge("DemoPluginWork", PluginPurgeScope()) + + assert result.success is False + assert "至少选择一项" in result.message + assert calls == [] + + +def test_purge_only_touches_the_selected_scopes() -> None: + """逐项执行且互不代劳:没勾的范围一个字节都不能动。""" + calls: list[tuple] = [] + + result = _command(calls).purge( + "DemoPluginWork", + PluginPurgeScope(config=True), + ) + + assert result.success is True + assert result.purged == ("config",) + assert calls == [ + ("stop", "DemoPluginWork"), + ("delete_config", "DemoPluginWork", True), + ] + + +def test_purge_destroys_the_database_before_removing_its_directory() -> None: + """库文件就落在该目录下,不先销毁句柄直接删目录会留下被占用的文件。""" + calls: list[tuple] = [] + + result = _command(calls).purge( + "DemoPluginWork", + PluginPurgeScope(data_directory=True), + ) + + assert result.success is True + # 用户只勾了删目录,自有库仍必然先销毁,但不计入回报的清理范围 + assert calls == [ + ("stop", "DemoPluginWork"), + ("own_db", "DemoPluginWork"), + ("data_dir", "DemoPluginWork"), + ] + assert result.purged == ("data_directory",) + + +def test_purge_removes_the_row_for_a_clone_but_keeps_it_for_a_host() -> None: + """分身的存在由那一行表达,清理即消失;本体的行要留着,它还说着「应当装载」。""" + clone_calls: list[tuple] = [] + clone = _command(clone_calls, is_clone=True).purge( + "DemoPluginWork", PluginPurgeScope(config=True) + ) + + host_calls: list[tuple] = [] + host = _command(host_calls, is_clone=False).purge( + "DemoPlugin", PluginPurgeScope(config=True) + ) + + assert clone.instance_removed is True + assert ("purge_row", "DemoPluginWork") in clone_calls + assert host.instance_removed is False + assert not any(call[0] == "purge_row" for call in host_calls) + + +def test_purge_reports_a_missing_directory_as_not_purged() -> None: + """目录本就不存在时不谎报清理过它。""" + calls: list[tuple] = [] + + result = _command(calls, directory_existed=False).purge( + "DemoPluginWork", + PluginPurgeScope(config=True, data_directory=True), + ) + + assert result.purged == ("config",) + + +def test_purge_stops_the_instance_before_deleting_anything() -> None: + """删除必须晚于停止:仍在跑的实例会一边被删一边把数据写回来。""" + calls: list[tuple] = [] + + _command(calls).purge( + "DemoPluginWork", + PluginPurgeScope(config=True, plugin_data=True, own_database=True), + ) + + assert calls[0] == ("stop", "DemoPluginWork") + + +def test_sealed_purge_is_rejected_before_any_deletion() -> None: + """停机封口期间拒绝清理,且一步删除都不做。""" + calls: list[tuple] = [] + admission = PluginMutationAdmission() + admission.seal() + + result = _command(calls, mutation=admission.hold).purge( + "DemoPluginWork", PluginPurgeScope(config=True) + ) + + assert result.success is False + assert "停机阶段" in result.message + assert calls == [] diff --git a/tests/test_plugin_data_directory_containment.py b/tests/test_plugin_data_directory_containment.py new file mode 100644 index 000000000..550c69b58 --- /dev/null +++ b/tests/test_plugin_data_directory_containment.py @@ -0,0 +1,212 @@ +"""插件数据目录解析的包含性回归测试。 + +插件标识在重置、卸载、彻底清理等路径上直接来自 HTTP 路径段,而插件数据根是配置根下 +的一层子目录。把标识就地拼进路径再交给文件系统,一个 ``..`` 就足以让删除动作抬到配置 +根上。本文件按「编码形式」逐个钉住这条边界:只挡住字面量 ``..`` 是不够的,URL 编码、 +反斜杠、绝对路径与软链都能绕过字符过滤,可靠的判据只有「解析出来的真实路径确实落在 +允许的根目录之内」。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +import app.db.plugin.registry as registry_module +import app.runtime.extensions.plugin.datadir as datadir_module +from app.db.plugin.locator import DATABASE_FILENAME, sqlite_database_path +from app.runtime.extensions.plugin.datadir import ( + remove_plugin_data_directory, + resolve_plugin_data_directory, +) + +# 用户可控标识到达文件系统前可能呈现的形态。服务端拿到的是 ASGI 解码之后的路径段, +# 因而 ``%2e%2e`` 与 ``..``、``..%2f`` 与 ``../`` 在处理函数里完全等价——只在路由层面 +# 过滤字面量 ``..`` 拦不住任何一种。 +TRAVERSAL_IDS = ( + "..", + "../", + "..\\", + "./..", + ".././..", + "a/../..", + "/etc", + "C:\\Windows", + "", + ".", +) + + +@pytest.fixture(name="config_root") +def fixture_config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """搭出「配置根 / 插件数据根 / 同级受保护内容」这一真实布局。 + + 插件数据根在生产里就是 ``CONFIG_PATH/plugins``,逃出一层即落在配置根上。受保护 + 目录、宿主库文件与哨兵内容用来证明越界删除确实发生过,而不是只看函数返回值。 + """ + config_root = tmp_path / "config" + plugin_root = config_root / "plugins" + (plugin_root / "DemoPlugin").mkdir(parents=True) + (plugin_root / "DemoPlugin" / "cache.bin").write_text("x", encoding="utf-8") + protected = config_root / "protected" + protected.mkdir() + (protected / "keep.txt").write_text("must survive", encoding="utf-8") + (config_root / "user.db").write_text("must survive", encoding="utf-8") + (config_root / DATABASE_FILENAME).write_text("must survive", encoding="utf-8") + monkeypatch.setattr( + datadir_module, + "get_runtime_setting", + lambda key, default=None: plugin_root if key == "PLUGIN_DATA_PATH" else default, + ) + return config_root + + +def _sentinels_intact(config_root: Path) -> bool: + """插件数据根之外的内容是否原封不动。""" + return ( + (config_root / "protected" / "keep.txt").is_file() + and (config_root / "user.db").is_file() + and (config_root / DATABASE_FILENAME).is_file() + ) + + +# --------------------------------------------------------------------------- # +# 目录解析 +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS) +def test_resolution_rejects_every_encoding_of_an_escaping_id(plugin_id: str) -> None: + """越界标识的每一种写法都要在解析阶段被拒。""" + with pytest.raises(ValueError): + resolve_plugin_data_directory(plugin_id) + + +def test_resolution_keeps_legacy_ids_with_separators_in_their_name( + config_root: Path, +) -> None: + """判据是「必须是一个路径段」而不是字符白名单,带下划线、连字符的旧插件照常可用。 + + 插件标识历史上就是 Python 类名,``My_Plugin`` 一类写法一直存在;把它们一并拒掉 + 是纯粹的功能回退,而它们本来就逃不出数据根。 + """ + for plugin_id in ("My_Plugin", "My-Plugin", "plugin.v2", "中文插件"): + assert resolve_plugin_data_directory(plugin_id) == ( + config_root / "plugins" / plugin_id + ) + + +def test_percent_encoded_looking_names_are_ordinary_directory_names( + config_root: Path, +) -> None: + """``%2e%2e`` 这类字面量只是古怪的目录名,不该被当成越界而拒绝。 + + 百分号编码是传输层的事:服务端拿到的路径段已经解码过,真正危险的是解码结果。 + 把未解码的形态也一并拒掉属于在错误的层次上猜测,反而会挡住合法目录名。 + """ + for plugin_id in ("%2e%2e", "..%2f", "%2f"): + assert resolve_plugin_data_directory(plugin_id) == ( + config_root / "plugins" / plugin_id + ) + + +# --------------------------------------------------------------------------- # +# 目录删除 +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS) +def test_directory_removal_rejects_escaping_ids_on_its_own( + config_root: Path, + plugin_id: str, +) -> None: + """删除函数自身即拒绝越界标识,不指望调用方先做过校验。 + + 目录删除是不可逆的终点:它必须自带包含性判断,将来新增调用方忘了校验也不至于把 + 配置目录整个删掉。 + """ + with pytest.raises(ValueError): + remove_plugin_data_directory(plugin_id) + + assert _sentinels_intact(config_root) + + +def test_directory_removal_deletes_only_the_matching_plugin_directory( + config_root: Path, +) -> None: + """合法标识照常删除,且只删它自己那一层。""" + assert remove_plugin_data_directory("DemoPlugin") is True + + assert not (config_root / "plugins" / "DemoPlugin").exists() + assert (config_root / "plugins").is_dir() + assert _sentinels_intact(config_root) + + +def test_directory_removal_reports_a_missing_directory_without_deleting( + config_root: Path, +) -> None: + """目录本就不存在时如实回报未删除,而不是谎报清理过它。""" + assert remove_plugin_data_directory("NeverExisted") is False + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Windows 下建软链需要额外权限,逃逸语义由 POSIX 用例覆盖", +) +def test_directory_removal_refuses_to_follow_a_symlink_out_of_the_root( + config_root: Path, +) -> None: + """数据根下的软链指向根外时拒绝删除,而不是顺着它把目标删掉。 + + ``resolve()`` 会跟随软链,因此包含性判断必须建立在解析之后的真实路径上;只比较 + 拼接出来的字面路径,一条 ``plugins/Evil -> /config/protected`` 的软链就能骗过它。 + """ + (config_root / "plugins" / "Evil").symlink_to( + config_root / "protected", target_is_directory=True + ) + + with pytest.raises(ValueError): + remove_plugin_data_directory("Evil") + + assert _sentinels_intact(config_root) + + +# --------------------------------------------------------------------------- # +# 自有数据库文件定位与销毁 +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("plugin_id", TRAVERSAL_IDS) +def test_database_path_rejects_escaping_ids(plugin_id: str) -> None: + """库文件路径同样按统一入口解析;销毁会 unlink 它,越界即拒。""" + with pytest.raises(ValueError): + sqlite_database_path(plugin_id) + + +def test_database_path_stays_inside_the_plugin_directory(config_root: Path) -> None: + """合法标识下库文件仍落在该插件自己的数据目录里。""" + assert sqlite_database_path("DemoPlugin") == ( + config_root / "plugins" / "DemoPlugin" / DATABASE_FILENAME + ) + + +def test_destroying_a_database_with_an_escaping_id_unlinks_nothing( + config_root: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """销毁入口拿到越界标识时什么都不删,只留一条日志。 + + 这条路径在 ``GET /plugin/reset/{plugin_id}`` 上是可达的:重置按 ``force`` 删数据, + 不检查插件是否存在,``..`` 会把库文件路径解析到配置根下的同名文件上。 + """ + monkeypatch.setattr( + registry_module, + "get_runtime_setting", + lambda key, default=None: "sqlite" if key == "DB_TYPE" else default, + ) + + registry_module.destroy_database("..") + + assert _sentinels_intact(config_root) diff --git a/tests/test_plugin_database_hooks.py b/tests/test_plugin_database_hooks.py index 94486c024..3d33501e5 100644 --- a/tests/test_plugin_database_hooks.py +++ b/tests/test_plugin_database_hooks.py @@ -9,8 +9,8 @@ import pytest from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import Mapped, mapped_column -import app.db.plugin.locator as locator_module import app.db.plugin.registry as registry_module +import app.runtime.extensions.plugin.datadir as datadir_module from app.db.decorators import db_query, db_update from app.db.plugin.base import plugin_declarative_base from app.plugins import _PluginBase @@ -61,7 +61,7 @@ def plugin_data_root(tmp_path, monkeypatch) -> Path: """把插件数据库文件隔离到进程私有的临时目录。""" root = tmp_path / "plugins" monkeypatch.setattr( - locator_module, + datadir_module, "get_runtime_setting", lambda key, default=None: root if key == "PLUGIN_DATA_PATH" else default, ) diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 4ada2cfba..ffe2e6b33 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -1158,6 +1158,11 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data(): publish_reset=publish_reset, refresh_registrations=lambda _plugin_id: None, mutation=lambda _operation: nullcontext(), + delete_plugin_data_rows=plugin_manager.delete_plugin_data_rows, + destroy_own_database=plugin_manager.destroy_plugin_own_database, + delete_data_directory=plugin_manager.delete_plugin_data_directory, + purge_instance=plugin_manager.purge_plugin_instance, + is_clone=plugin_manager.is_plugin_clone, ) result = reset_plugin("SubscribeAssistantEnhanced", None, command) diff --git a/tests/test_plugin_instance_purge_endpoint.py b/tests/test_plugin_instance_purge_endpoint.py new file mode 100644 index 000000000..5ea87479f --- /dev/null +++ b/tests/test_plugin_instance_purge_endpoint.py @@ -0,0 +1,169 @@ +"""插件实例彻底清理端点与其运行时端口测试。""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints import plugininstance as plugininstance_endpoint +from app.api.endpoints.plugininstance import purge_plugin_instance +from app.api.routers import API_V1_ROUTER_SPECS +from app.application.plugin.config import PluginPurgeResult +from app.schemas.plugin import PluginInstance, PluginInstancePurgeRequest + + +class _Command: + """记录调用参数并回放既定结果的清理用例替身。""" + + def __init__(self, result: PluginPurgeResult) -> None: + """保存要回放的结果。""" + self.result = result + self.calls: list[tuple] = [] + + def purge(self, instance_id, scope): + """记录一次清理请求并回放既定结果。""" + self.calls.append((instance_id, scope)) + return self.result + + +def test_endpoint_requires_superuser_dependency() -> None: + """清理端点要求超级管理员,与卸载、重置同档。""" + depends = inspect.signature(purge_plugin_instance).parameters["_"].default + assert depends.dependency is get_current_active_superuser + + +def test_router_registers_the_purge_path_under_the_plugin_prefix() -> None: + """路由独立成文件但仍挂在插件前缀下,路径与单文件时期一致。""" + spec = next( + item + for item in API_V1_ROUTER_SPECS + if item.router is plugininstance_endpoint.router + ) + + assert spec.prefix == "/plugin" + assert {route.path for route in plugininstance_endpoint.router.routes} == { + "/instance/{instance_id}/purge", + } + + +def test_endpoint_passes_the_requested_scope_through_unchanged() -> None: + """勾选范围原样转交,服务端不替调用方补默认值。""" + command = _Command(PluginPurgeResult(True, purged=("config", "plugin_data"))) + + response = purge_plugin_instance( + "DemoPluginWork", + PluginInstancePurgeRequest(config=True, plugin_data=True), + None, + command, + ) + + instance_id, scope = command.calls[0] + assert instance_id == "DemoPluginWork" + assert (scope.config, scope.plugin_data) == (True, True) + assert (scope.own_database, scope.data_directory) == (False, False) + assert response.success is True + assert response.data.purged == ["config", "plugin_data"] + + +def test_endpoint_defaults_every_scope_item_to_false() -> None: + """请求体不带任何字段时四项全假,落到用例层被拒。""" + request = PluginInstancePurgeRequest() + + assert ( + request.config, + request.plugin_data, + request.own_database, + request.data_directory, + ) == (False, False, False, False) + + +def test_endpoint_reports_a_refused_purge_as_a_failed_response() -> None: + """用例层拒绝时如实回报失败,不把 HTTP 200 当成清理成功。""" + command = _Command(PluginPurgeResult(False, "请至少选择一项要清理的内容")) + + response = purge_plugin_instance( + "DemoPluginWork", PluginInstancePurgeRequest(), None, command + ) + + assert response.success is False + assert response.message == "请至少选择一项要清理的内容" + + +def test_endpoint_reports_whether_the_instance_row_was_removed() -> None: + """分身行随清理消失,端点要把这个事实透出去。""" + command = _Command( + PluginPurgeResult(True, purged=("config",), instance_removed=True) + ) + + response = purge_plugin_instance( + "DemoPluginWork", PluginInstancePurgeRequest(config=True), None, command + ) + + assert response.data.instance_removed is True + + +# --------------------------------------------------------------------------- # +# 运行时端口:本体的行不得被清理删掉 +# --------------------------------------------------------------------------- # + + +class _Store: + """按 ID 记录分身行的实例存储替身,本体行不从读取口返回。""" + + def __init__(self) -> None: + """预置一个分身与一个本体。""" + self.rows = { + "DemoPluginWork": PluginInstance( + instance_id="DemoPluginWork", source_plugin_id="DemoPlugin" + ), + } + self.deleted: list[str] = [] + + def get(self, instance_id: str): + """只返回分身行,本体一律为空。""" + return self.rows.get(instance_id) + + def delete(self, instance_id: str) -> bool: + """删除分身行;本体行读不到因而删不掉。""" + if instance_id not in self.rows: + return False + self.deleted.append(instance_id) + del self.rows[instance_id] + return True + + +def _manager_with(store: _Store): + """拼出一个只装了实例存储与配置存储的 Manager 替身。""" + from app.runtime.extensions.plugin.manager import PluginManager + + manager = object.__new__(PluginManager) + manager._plugin_instance_store = store # noqa: SLF001 + manager._plugin_config_store = SimpleNamespace( # noqa: SLF001 + delete_data_rows=lambda plugin_id: store.deleted.append(f"rows:{plugin_id}"), + destroy_database=lambda plugin_id: store.deleted.append(f"db:{plugin_id}"), + ) + return manager + + +def test_manager_purges_a_clone_row_but_leaves_the_host_row_alone() -> None: + """本体那一行承载着插件的启用状态与展示覆盖,清理不得把它删掉。""" + store = _Store() + manager = _manager_with(store) + + assert manager.is_plugin_clone("DemoPluginWork") is True + assert manager.is_plugin_clone("DemoPlugin") is False + assert manager.purge_plugin_instance("DemoPluginWork") is True + assert manager.purge_plugin_instance("DemoPlugin") is False + assert store.deleted == ["DemoPluginWork"] + + +def test_manager_separates_data_rows_from_the_own_database() -> None: + """两件事必须能分开做,否则用户没法只删其中一项。""" + store = _Store() + manager = _manager_with(store) + + manager.delete_plugin_data_rows("DemoPluginWork") + manager.destroy_plugin_own_database("DemoPluginWork") + + assert store.deleted == ["rows:DemoPluginWork", "db:DemoPluginWork"] diff --git a/tests/test_plugin_instance_purge_path_traversal.py b/tests/test_plugin_instance_purge_path_traversal.py new file mode 100644 index 000000000..a208f22dd --- /dev/null +++ b/tests/test_plugin_instance_purge_path_traversal.py @@ -0,0 +1,237 @@ +"""插件实例彻底清理的路径穿越回归测试。 + +彻底清理是唯一会对用户可控标识拼出的目录执行递归删除的入口,而插件数据根是配置根下 +的一层子目录。移植这项功能时原实现把标识直接拼进 ``PLUGIN_DATA_PATH`` 再 ``rmtree``, +``%2e%2e`` 经 ASGI 解码后就是 ``..``,一次请求即递归删掉整个配置目录。这里从 HTTP 入口 +把这条攻击路径按编码形式逐个钉住。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.dependencies.plugin import get_plugin_config_command +from app.api.endpoints import plugininstance as plugininstance_endpoint +from app.application.plugin.config import PluginConfigCommand, PluginPurgeScope +from app.runtime.extensions.plugin import datadir as datadir_module +from app.runtime.extensions.plugin.datadir import remove_plugin_data_directory + +# 路径段在 URL 里可以写成的形态。服务端拿到的是 ASGI 解码之后的结果,因而这些写法在 +# 处理函数里各自等价于 `..`、`../` 等——只在路由层面过滤字面量 `..` 一个都拦不住。 +TRAVERSAL_URL_SEGMENTS = ( + "..", + "%2e%2e", + "%2E%2E", + "..%5c", + "%2e%2e%5c", + ".%2e", + "%2e", +) + +# 到达用例层的解码后标识,逐个都要被格式校验挡掉。 +TRAVERSAL_IDS = ( + "..", + "../", + "..\\", + "./..", + ".././..", + "a/../..", + "/etc", + "C:\\Windows", + "", + ".", + "Demo Plugin", + "1Plugin", +) + + +class _NullMutation: + """无准入约束的 mutation 替身。""" + + def __enter__(self) -> None: + """进入空上下文。""" + + def __exit__(self, *_exc: object) -> bool: + """退出空上下文且不吞异常。""" + return False + + +@pytest.fixture(name="config_root") +def fixture_config_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """搭出「配置根 / 插件数据根 / 同级受保护内容」这一真实布局。""" + config_root = tmp_path / "config" + plugin_root = config_root / "plugins" + (plugin_root / "DemoPlugin").mkdir(parents=True) + (plugin_root / "DemoPlugin" / "cache.bin").write_text("x", encoding="utf-8") + (config_root / "protected").mkdir() + (config_root / "protected" / "keep.txt").write_text("must survive", encoding="utf-8") + (config_root / "user.db").write_text("must survive", encoding="utf-8") + monkeypatch.setattr( + datadir_module, + "get_runtime_setting", + lambda key, default=None: plugin_root if key == "PLUGIN_DATA_PATH" else default, + ) + return config_root + + +def _purge_command(calls: list[tuple]) -> PluginConfigCommand: + """装出一个只有目录删除是真实实现的清理用例。 + + 其余端口全部记账:本文件要断言的是「越界目录没有被删」以及「越界请求根本没有走到 + 任何删除动作」,把真实存储接进来只会让失败原因变糊。 + """ + return PluginConfigCommand( + save_config=lambda *a: True, + initialize=lambda *a: None, + stop=lambda plugin_id: calls.append(("stop", plugin_id)), + delete_config=lambda plugin_id, force: calls.append(("config", plugin_id)), + delete_data=lambda *a: True, + reload_runtime=lambda *a: None, + publish_reset=lambda *a: None, + refresh_registrations=lambda *a: None, + mutation=lambda _operation: _NullMutation(), + delete_plugin_data_rows=lambda plugin_id: calls.append(("rows", plugin_id)), + destroy_own_database=lambda plugin_id: calls.append(("own_db", plugin_id)), + delete_data_directory=remove_plugin_data_directory, + purge_instance=lambda plugin_id: True, + is_clone=lambda _plugin_id: False, + ) + + +def _client(calls: list[tuple]) -> TestClient: + """挂上真实路由与真实目录删除实现的测试客户端。""" + app = FastAPI() + app.include_router(plugininstance_endpoint.router, prefix="/api/v1/plugin") + app.dependency_overrides[get_current_active_superuser] = lambda: object() + app.dependency_overrides[get_plugin_config_command] = lambda: _purge_command(calls) + return TestClient(app) + + +def _sentinels_intact(config_root: Path) -> bool: + """插件数据根之外的内容是否原封不动。""" + return ( + (config_root / "protected" / "keep.txt").is_file() + and (config_root / "user.db").is_file() + and (config_root / "plugins" / "DemoPlugin" / "cache.bin").is_file() + ) + + +def test_percent_encoded_dot_dot_really_reaches_the_handler( + config_root: Path, +) -> None: + """``%2e%2e`` 确实会被路由匹配并交到处理函数手里,服务端才是最后一道防线。 + + 客户端与代理通常会把字面量 ``..`` 规范化掉(这里表现为 404),百分号编码则原样 + 送达、由 ASGI 解码成 ``..``。把「路由层面过滤 `..`」当成防护是无效的,这条用例 + 就是用来防止将来有人据此撤掉服务端校验。 + """ + calls: list[tuple] = [] + + response = _client(calls).post( + "/api/v1/plugin/instance/%2e%2e/purge", + json={"data_directory": True}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is False + assert calls == [] + assert _sentinels_intact(config_root) + + +@pytest.mark.parametrize("segment", TRAVERSAL_URL_SEGMENTS) +def test_no_url_encoding_of_dot_dot_deletes_outside_the_plugin_data_root( + config_root: Path, + segment: str, +) -> None: + """越界标识的任何 URL 写法都不得删到插件数据根之外,也不得触发任何一步删除。 + + 未修版本对 ``%2e%2e`` 会返回 200、``purged: ["data_directory"]``,而配置根下的 + 全部内容已被 ``rmtree`` 清空——``PLUGIN_DATA_PATH/..`` 正是配置根。 + """ + calls: list[tuple] = [] + + response = _client(calls).post( + f"/api/v1/plugin/instance/{segment}/purge", + json={"data_directory": True}, + ) + + # 路由不匹配(404)同样算拦住;匹配上就必须由用例层拒绝 + assert response.status_code in (200, 404) + if response.status_code == 200: + assert response.json()["success"] is False + assert calls == [] + assert _sentinels_intact(config_root) + + +@pytest.mark.parametrize("instance_id", TRAVERSAL_IDS) +def test_purge_refuses_every_malformed_instance_id( + config_root: Path, + instance_id: str, +) -> None: + """解码后的非法标识逐个被拒,且在停实例之前就被拒。 + + 校验放在最前面,非法标识不该先把配置行删掉再发现路径不对。 + """ + calls: list[tuple] = [] + + result = _purge_command(calls).purge( + instance_id, + PluginPurgeScope( + config=True, + plugin_data=True, + own_database=True, + data_directory=True, + ), + ) + + assert result.success is False + assert result.purged == () + assert calls == [] + assert _sentinels_intact(config_root) + + +def test_purge_deletes_the_directory_for_a_well_formed_instance_id( + config_root: Path, +) -> None: + """合法标识照常清理,且只动它自己那一层。""" + calls: list[tuple] = [] + + result = _purge_command(calls).purge( + "DemoPlugin", PluginPurgeScope(data_directory=True) + ) + + assert result.success is True + assert result.purged == ("data_directory",) + assert not (config_root / "plugins" / "DemoPlugin").exists() + assert (config_root / "protected" / "keep.txt").is_file() + assert (config_root / "user.db").is_file() + + +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Windows 下建软链需要额外权限,逃逸语义由 POSIX 用例覆盖", +) +def test_purge_reports_a_refused_directory_removal_instead_of_raising( + config_root: Path, +) -> None: + """兜底判断拒绝删除时回报失败,而不是让 ValueError 变成 500。 + + 实例 ID 合法但目录本身被换成了指向根外的软链,这是运维环境造成的状况而非请求 + 非法;把它收成失败结果,调用方才拿得到可读原因。 + """ + (config_root / "plugins" / "Evil").symlink_to( + config_root / "protected", target_is_directory=True + ) + calls: list[tuple] = [] + + result = _purge_command(calls).purge("Evil", PluginPurgeScope(data_directory=True)) + + assert result.success is False + assert "不在插件数据根" in result.message or "软链" in result.message + assert (config_root / "protected" / "keep.txt").is_file()