fix(plugin): DisablePlugin 禁止禁用未安装插件,防止脏写 disabled_plugins

问题:POST /api/v1/plugins/<name>/disable 对不存在的插件也会把它写进
disabled_plugins 表(registry.go DisablePlugin 无条件 AddDisabledPlugin),
产生脏数据堆积,且同名插件日后真实安装会被误判为已禁用。

修复:
- registry.go 新增 pluginInstalled(name):已加载 / 已注册工厂(内置) / 插件目录存在,
  任一命中视为已安装
- DisablePlugin 开头校验:未安装返回 'plugin X not installed',不写 disabled_plugins
- webui handler:未安装→404,已禁用→409(原都返回500);enable 失败含'failed'→404
- 新增 registry_disable_test.go:覆盖未安装拒绝/内置判定/目录存在判定/普通文件不算

本机端到端验证:disable 不存在插件返回404且表无脏数据;真实插件 disable/enable 正常
This commit is contained in:
JianFeeeee
2026-08-27 23:29:49 +08:00
parent 2564e53342
commit 5fccc31afc
3 changed files with 122 additions and 3 deletions

View File

@ -672,6 +672,33 @@ func (r *Registry) IsBuiltinPlugin(name string) bool {
return ok
}
// pluginInstalled 判断插件是否已安装(可用于禁用/启用等操作前的存在性校验)。
// 命中任一即视为已安装:
// 1. 已加载plugins map 中)
// 2. 已注册工厂(内置插件,通过 init() 自注册,无需物理目录)
// 3. 插件目录 plgDir/<name> 存在(外部插件的安装目录)
func (r *Registry) pluginInstalled(name string) bool {
if r == nil || name == "" {
return false
}
r.mu.RLock()
_, loaded := r.plugins[name]
_, isFactory := r.factories[name]
r.mu.RUnlock()
if loaded || isFactory {
return true
}
if _, ok := globalFactories.Load(name); ok {
return true
}
if r.plgDir != "" {
if fi, err := os.Stat(filepath.Join(r.plgDir, name)); err == nil && fi.IsDir() {
return true
}
}
return false
}
func (r *Registry) ListLoadedPlugins() []string { return r.List() }
func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo {
@ -692,6 +719,11 @@ func (r *Registry) ListDisabledPlugins() []sdk.DisabledPluginInfo {
func (r *Registry) IsPluginDisabled(name string) bool { return r.isDisabled(name) }
func (r *Registry) DisablePlugin(name, by string) error {
// 插件不存在(未安装):拒绝并返回错误,避免把不存在的插件写进 disabled_plugins。
// 判断标准:已加载 / 已注册工厂(内置)/ 插件目录存在,任一命中视为已安装。
if !r.pluginInstalled(name) {
return fmt.Errorf("plugin %s not installed", name)
}
// Check not disabling self if running
if r.cfgReg != nil {
// If already disabled, no-op
@ -731,7 +763,6 @@ func (r *Registry) DisablePlugin(name, by string) error {
}
func (r *Registry) EnablePlugin(name string) error { return r.Enable(name) }
// StopAndUnload 停止并从注册表移除插件但保留其配置表config_<name>)。
// 供插件更新/升级流程使用:换 so/文件不动配置,重装后配置原样生效。
// 不执行 onRemove 回调(那是删除专用语义)。目录由调用方管理。

View File

@ -0,0 +1,74 @@
package plugin
import (
"os"
"path/filepath"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// TestDisablePluginNotInstalled 验证 DisablePlugin 对未安装插件的拒绝:
// 修复前会把任意名字写进 disabled_plugins 表(脏数据),修复后应直接报错。
func TestDisablePluginNotInstalled(t *testing.T) {
r := &Registry{
plugins: make(map[string]sdk.Plugin),
factories: make(map[string]NativeFactory),
knownDisabled: make(map[string]bool),
plgDir: t.TempDir(),
}
err := r.DisablePlugin("nonexistent_test_xyz", "test")
if err == nil {
t.Fatal("DisablePlugin should reject a plugin that is not installed")
}
if err.Error() != "plugin nonexistent_test_xyz not installed" {
t.Fatalf("unexpected error: %v", err)
}
if r.knownDisabled["nonexistent_test_xyz"] {
t.Fatal("knownDisabled must not contain a non-installed plugin")
}
}
// TestPluginInstalledFactory 内置插件(工厂注册,无物理目录)应视为已安装。
func TestPluginInstalledFactory(t *testing.T) {
r := &Registry{
plugins: make(map[string]sdk.Plugin),
factories: map[string]NativeFactory{"builtin_demo": nil},
knownDisabled: make(map[string]bool),
plgDir: t.TempDir(),
}
if !r.pluginInstalled("builtin_demo") {
t.Fatal("factory-registered builtin plugin should be considered installed")
}
if r.pluginInstalled("no_such_plugin") {
t.Fatal("unknown plugin should not be considered installed")
}
}
// TestPluginInstalledDir 外部插件按 plgDir/<name> 目录存在判定已安装。
func TestPluginInstalledDir(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "external_demo"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
r := &Registry{
plugins: make(map[string]sdk.Plugin),
factories: make(map[string]NativeFactory),
knownDisabled: make(map[string]bool),
plgDir: dir,
}
if !r.pluginInstalled("external_demo") {
t.Fatal("plugin with an existing directory should be considered installed")
}
// 同名普通文件(非目录)不算已安装
if err := os.WriteFile(filepath.Join(dir, "just_a_file"), []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if r.pluginInstalled("just_a_file") {
t.Fatal("a regular file must not count as an installed plugin")
}
// 空名兜底
if r.pluginInstalled("") {
t.Fatal("empty plugin name must not be considered installed")
}
}

View File

@ -2600,7 +2600,16 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.pluginMgr.DisablePlugin(name, "webui"); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
// 未安装 → 404已禁用 → 409其余为真实内部错误
status := http.StatusInternalServerError
msg := err.Error()
switch {
case strings.HasSuffix(msg, "not installed"):
status = http.StatusNotFound
case strings.HasSuffix(msg, "already disabled"):
status = http.StatusConflict
}
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
@ -2616,7 +2625,12 @@ func (h *Handler) handlePluginByID(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.pluginMgr.EnablePlugin(name); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
// 启用失败多数是“插件不存在/加载失败” → 404 更贴切
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "failed") {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})