diff --git a/internal/capability/paneldecl/catalog.go b/internal/capability/paneldecl/catalog.go new file mode 100644 index 0000000..8289ca7 --- /dev/null +++ b/internal/capability/paneldecl/catalog.go @@ -0,0 +1,246 @@ +package paneldecl + +import ( + "fmt" + "sort" + "sync" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是「清单目录」(§5.2.1 B「插件管理器 = 唯一中枢」里的 catalog 一项): +// 收集各插件**显式声明**的 adminPanels,编译期内置插件与热装条目进同一张目录 —— +// 没声明就是没页面,不做任何启发式猜测(§5.2.1 红线③)。 +// +// 目录**不进** capability registry:那里是「可调用能力」,这里是「UI 声明」,混在一起会让 +// 两侧的语义互相污染(§5.7 B 的落点说明)。 + +// Entry 是目录里的一条面板:声明本身 + 它的属主插件。 +// +// 刻意不带 JSON tag:下发给面板的线形状属于 §5.2.1 的「唯一契约面」 +// (GET /api/v1/admin/panels + panels_changed),那份契约由接线切片按「契约先行」登记进 +// Docs/api-client.md —— 本库不预占字段名,免得线上形状被一个内部类型钉死。 +type Entry struct { + // PluginID 是声明该面板的插件 id。 + PluginID string + // Panel 是该面板声明(目录内部状态的**拷贝**)。 + Panel sdk.Panel +} + +// Group 是侧边栏折叠分组及其条目(§5.2.1 的 nav.group / nav.item 两级)。 +// +// 分组的**标题**不在声明里:§5.2 的 `group` 只是分组 id,而 §5.2.1 的 nav.group 一行又写 +// 「消费 group / title / order」—— 那句里的 title 只能是宿主给的本地化分组名(面板自己的 +// title 是页面标题)。这个歧义点记在 PLG1-report.md,本库按「分组标题由宿主提供」实现。 +type Group struct { + // ID 是分组 id(本期闭集只有 sdk.GroupPlugins)。 + ID sdk.GroupID + // Panels 是该分组下的条目,已按 order 稳定排序。 + Panels []Entry +} + +// storedPanel 是目录内部的一条记录:声明 + 属主 + 登记序号。 +// +// 序号是**稳定序**的最后一根稻草:同 group、同 order 的条目按登记顺序排,从而保证 +// 多插件并存时导航不会因为 map 迭代顺序而抖动(前端导航抖动是真实体验问题)。 +type storedPanel struct { + panel sdk.Panel + pluginID string + seq uint64 +} + +// Catalog 是并发安全的面板声明目录。零值可用(空目录),但要用 when 求值必须经 +// NewCatalog 注入 Resolver。 +type Catalog struct { + resolve Resolver + + mu sync.RWMutex + seq uint64 + panels map[string][]storedPanel // pluginID → 该插件当前的面板集合 +} + +// NewCatalog 构造目录。resolve 必须非 nil(返回 ErrNilResolver)—— +// +// 为什么不给 nil 一个「全都显示」的默认值:那会让「忘了接线启用状态」这种集成错误表现为 +// **看起来正常**(所有面板都在),而不是一个立刻能被发现的返回错误;而给「全都隐藏」的 +// 默认值又会变成静默丢页面。宁可让调用方在这里就把事实来源接上。 +func NewCatalog(resolve Resolver) (*Catalog, error) { + if resolve == nil { + return nil, ErrNilResolver + } + return &Catalog{resolve: resolve, panels: make(map[string][]storedPanel)}, nil +} + +// Register 用 panels **整体替换** pluginID 已有的声明集,并返回被接受的面板 id。 +// +// 整体替换对应 §5.2.1 的生命周期口径:install / enable / disable / uninstall(含 MCP 的 +// tools/list_changed)**都重算面板集合** —— 调用方永远给「这个插件现在的完整声明」, +// 不需要 diff。 +// +// 拒绝口径(对齐 §5.2.1 B「非法声明 ⇒ **该面板不注册** + 显式报错」): +// +// - 插件 id 非法 ⇒ **整插件拒绝**:一个都不进目录。目录是按插件 id 分命名空间的 +// (路由 /admin/plugin//、端点 /api/v1/admin/plugins/{id}/actions/{action}), +// 没有合法命名空间就没有可挂的东西; +// - 某个面板声明非法 ⇒ **只拒该面板**,同插件其余合法面板照常注册,返回的 err 里是 +// 逐条可分类的违规(errors.Is 可判类),accepted 列出真正生效的面板 id。 +// +// 为什么不做「一个面板坏了就整个插件都拒」:§5.2.1 明写的是「该面板不注册」,且整插件拒绝会 +// 让用户端从「少一个页面」变成「这个插件的页面全没了」,更难排查。代价是同一次安装会得到 +// 「部分生效 + 一条错误」这种双重结果,接入方必须**同时**读 err 与 accepted(见报告)。 +// +// 存进来的声明是**规范化并深拷**过的(空 group 回落唯一分组、调用方的后续改动不影响目录)。 +func (c *Catalog) Register(pluginID string, panels []sdk.Panel) (accepted []string, err error) { + if c == nil { + return nil, ErrNilCatalog + } + if vs := checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID); len(vs) > 0 { + return nil, violationsToError(vs) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.panels == nil { + c.panels = make(map[string][]storedPanel) + } + var vs []Violation + seen := make(map[string]bool, len(panels)) + accepted = make([]string, 0, len(panels)) + stored := make([]storedPanel, 0, len(panels)) + for i, p := range panels { + path := panelPath(i) + pvs := validatePanelAt(p, path) + if p.ID != "" && seen[p.ID] { + pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID)) + } + if len(pvs) > 0 { + vs = append(vs, pvs...) + continue + } + seen[p.ID] = true + normalized := normalizePanel(p) + c.seq++ + stored = append(stored, storedPanel{ + panel: clonePanel(normalized), + pluginID: pluginID, + seq: c.seq, + }) + accepted = append(accepted, normalized.ID) + } + c.panels[pluginID] = stored + return accepted, violationsToError(vs) +} + +// Remove 移除一个插件的全部面板声明。未知插件是幂等空操作(重复卸载不报错)。 +func (c *Catalog) Remove(pluginID string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.panels, pluginID) +} + +// List 返回按角色过滤后的面板快照,顺序为 (分组, order, 登记序号) 的**稳定序**。 +// +// 两件过滤都在这里发生:`when` 不满足的入口整个不出现(§5.2.1 nav.item),角色看不到的区块 +// 被裁掉(§5.4.1)。返回值是深拷,调用方随便改(改不动目录内部状态)。 +// +// 角色不在 owner/admin/viewer 之内 ⇒ ErrUnknownRole(**fail-closed**:宁可报错,也不猜一个 +// 默认角色去放行)。 +func (c *Catalog) List(role auth.Role) ([]Entry, error) { + if !role.Valid() { + return nil, fmt.Errorf("%w: %q", ErrUnknownRole, role) + } + c.mu.RLock() + defer c.mu.RUnlock() + stored := c.sortedLocked() + out := make([]Entry, 0, len(stored)) + for _, sp := range stored { + if !Eval(sp.panel.When, c.contextFor(sp.pluginID)) { + continue + } + filtered, visible := filterPanel(role, sp.panel) + if !visible { + continue + } + out = append(out, Entry{PluginID: sp.pluginID, Panel: clonePanel(filtered)}) + } + return out, nil +} + +// Groups 返回按分组组织的同一份快照(§5.2.1 的 nav.group 一级)。 +// +// 分组之间按组内**最小的 order** 排(§5.2.1 说 nav.group 消费 order,那只能来自组内条目), +// 同值时按 List 的稳定序,因此结果是确定的。本期只有 plugins 一个分组,这一段是给后续 +// 分组接入留的正确形状,不额外加接口。 +func (c *Catalog) Groups(role auth.Role) ([]Group, error) { + entries, err := c.List(role) + if err != nil { + return nil, err + } + // List 的排序键第一位就是分组 ⇒ 同组条目在结果里一定连续,顺序扫描即可。 + var groups []Group + for _, e := range entries { + if n := len(groups); n > 0 && groups[n-1].ID == e.Panel.Group { + groups[n-1].Panels = append(groups[n-1].Panels, e) + continue + } + groups = append(groups, Group{ID: e.Panel.Group, Panels: []Entry{e}}) + } + sort.SliceStable(groups, func(i, j int) bool { + return groups[i].Panels[0].Panel.Order < groups[j].Panels[0].Panel.Order + }) + return groups, nil +} + +// PluginIDs 返回当前已登记**声明**的插件 id(已排序)。注意它不等于「已安装的插件」—— +// 目录只认识声明过面板的插件。 +// +// 它的用途是 §5.2.1 红线③:一个「已装、未声明任何页面」的插件要在「插件与能力」页**如实 +// 显示「未声明管理页面」**,而不是被藏掉。插件列表页需要能拿这份 id 与已装列表对照。 +func (c *Catalog) PluginIDs() []string { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]string, 0, len(c.panels)) + for id := range c.panels { + out = append(out, id) + } + sort.Strings(out) + return out +} + +// sortedLocked 返回 (分组, order, 登记序号) 稳定序的全部记录。调用方必须持有读锁。 +func (c *Catalog) sortedLocked() []storedPanel { + out := make([]storedPanel, 0, len(c.panels)) + for _, list := range c.panels { + out = append(out, list...) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].panel.Group != out[j].panel.Group { + return out[i].panel.Group < out[j].panel.Group + } + if out[i].panel.Order != out[j].panel.Order { + return out[i].panel.Order < out[j].panel.Order + } + return out[i].seq < out[j].seq + }) + return out +} + +// contextFor 取某插件的求值上下文。 +// +// resolve 为 nil 只可能出现在零值 Catalog 上(NewCatalog 已拒 nil)或调用方显式构造 —— +// 一律按「未启用」判,fail-closed:宁可不显示入口,也不给一个可能已停用的插件开入口。 +func (c *Catalog) contextFor(pluginID string) Context { + if c.resolve == nil { + return Context{} + } + return c.resolve(pluginID) +} + +// normalizePanel 把面板规范化成存进目录的形态:目前只有「空分组回落到唯一分组」。 +func normalizePanel(p sdk.Panel) sdk.Panel { + p.Group = normalizeGroup(p.Group) + return p +} diff --git a/internal/capability/paneldecl/catalog_test.go b/internal/capability/paneldecl/catalog_test.go new file mode 100644 index 0000000..f4c49a6 --- /dev/null +++ b/internal/capability/paneldecl/catalog_test.go @@ -0,0 +1,371 @@ +package paneldecl + +import ( + "errors" + "fmt" + "reflect" + "slices" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定目录的四条不变量:生命周期重算、稳定序、按面板拒绝、返回值是拷贝。 +// 并发那条交给 `go test -race` 去证。 + +// newTestCatalog 造一个不报错的目录(夹具里构造失败说明夹具坏了,直接 Fatal)。 +func newTestCatalog(t *testing.T, resolve Resolver) *Catalog { + t.Helper() + cat, err := NewCatalog(resolve) + if err != nil { + t.Fatalf("NewCatalog: %v", err) + } + return cat +} + +// mustRegister 注册并断言全部面板都被接受。 +func mustRegister(t *testing.T, cat *Catalog, pluginID string, panels ...sdk.Panel) { + t.Helper() + accepted, err := cat.Register(pluginID, panels) + if err != nil { + t.Fatalf("Register(%s): %v", pluginID, err) + } + if len(accepted) != len(panels) { + t.Fatalf("Register(%s) 接受 %d 个面板,期望 %d", pluginID, len(accepted), len(panels)) + } +} + +// TestNewCatalog_RejectsNilResolver 锁定「事实来源必须显式接入」:给 nil resolver 一个 +// 「全都显示」的默认值会让漏接线表现为看起来正常,所以构造函数直接拒。 +func TestNewCatalog_RejectsNilResolver(t *testing.T) { + t.Parallel() + if _, err := NewCatalog(nil); !errors.Is(err, ErrNilResolver) { + t.Fatalf("NewCatalog(nil) err = %v,期望 ErrNilResolver", err) + } +} + +// TestCatalog_WhenGatesEntrance 锁定 §5.2.1 nav.item 与 §5.6.1:when 不满足 ⇒ 入口不出现。 +func TestCatalog_WhenGatesEntrance(t *testing.T) { + t.Parallel() + enabled := map[string]bool{"web-search": true} + cat := newTestCatalog(t, func(id string) Context { return Context{Enabled: enabled[id]} }) + + always := samplePanel() + always.ID = "always" + always.Order = 1 + always.When = nil + onWhenEnabled := samplePanel() + onWhenEnabled.ID = "on" + onWhenEnabled.Order = 2 + onWhenEnabled.When = &sdk.When{Enabled: boolPtr(true)} + whenDisabled := samplePanel() + whenDisabled.ID = "off" + whenDisabled.Order = 3 + whenDisabled.When = &sdk.When{Enabled: boolPtr(false)} + mustRegister(t, cat, "web-search", always, onWhenEnabled, whenDisabled) + + entries, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "on"}) { + t.Fatalf("插件启用时的入口 = %v,期望 [always on]", got) + } + + enabled["web-search"] = false + entries, err = cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"always", "off"}) { + t.Fatalf("插件停用时的入口 = %v,期望 [always off]", got) + } +} + +// TestCatalog_StableOrder 锁定稳定序:乱序注册也得到固定输出,且反复查询逐字相同。 +// +// 「逐字相同」不是洁癖:List 内部要用 map 收集各插件的面板,如果排序键不含登记序号, +// 同 group 同 order 的条目顺序就会随 map 迭代抖动 —— 前端导航跟着抖。 +func TestCatalog_StableOrder(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + + late := samplePanel() + late.ID = "z-late" + late.Order = 30 + first := samplePanel() + first.ID = "z-first" + first.Order = 5 + // 同一个插件内**先声明 order 大的**,锁定「同 order 时按声明顺序」与「order 优先」两件事。 + mustRegister(t, cat, "zz", late, first) + + same := samplePanel() + same.ID = "a-same-order" + same.Order = 5 + mustRegister(t, cat, "aa", same) + + want := []string{"z-first", "a-same-order", "z-late"} + for i := 0; i < 100; i++ { + entries, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("第 %d 次 List: %v", i, err) + } + if got := mustPanelIDs(t, entries); !slices.Equal(got, want) { + t.Fatalf("第 %d 次 List = %v,期望 %v(排序必须稳定)", i, got, want) + } + } +} + +// TestCatalog_RegisterReplacesAndRemoveRecalculates 锁定 §5.2.1 的生命周期口径: +// 重装是**整体替换**,卸载后集合立即重算。 +func TestCatalog_RegisterReplacesAndRemoveRecalculates(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + mustRegister(t, cat, "web-search", samplePanel()) + + // 重装只带一个新的面板 ⇒ 旧面板必须消失(不是叠加)。 + replacement := statusPanel() + replacement.ID = "chain" + mustRegister(t, cat, "web-search", replacement) + entries, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"chain"}) { + t.Fatalf("重装后的入口 = %v,期望 [chain]", got) + } + + cat.Remove("web-search") + entries, err = cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 0 { + t.Fatalf("卸载后仍有 %d 条入口", len(entries)) + } + // 重复卸载是幂等空操作。 + cat.Remove("web-search") + if ids := cat.PluginIDs(); len(ids) != 0 { + t.Fatalf("卸载后仍登记着 %v", ids) + } +} + +// TestCatalog_RegisterRejectsPanelOnly 锁定拒绝粒度(§5.2.1「该面板不注册 + 显式报错」): +// 坏面板被拒、坏面板所在插件的好面板照常注册,且错误可分类。 +func TestCatalog_RegisterRejectsPanelOnly(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + good := samplePanel() + bad := statusPanel() + bad.ID = "broken" + // 形状错误:只读事实面不该有 source。 + bad.Sections[0].Source = &sdk.Source{Action: "plugin.chain"} + + accepted, err := cat.Register("web-search", []sdk.Panel{good, bad}) + if !errors.Is(err, ErrSectionShape) { + t.Fatalf("err = %v,期望命中 ErrSectionShape", err) + } + if !slices.Equal(accepted, []string{"providers"}) { + t.Fatalf("accepted = %v,期望 [providers](坏面板不注册)", accepted) + } + entries, lerr := cat.List(auth.RoleOwner) + if lerr != nil { + t.Fatalf("List: %v", lerr) + } + if got := mustPanelIDs(t, entries); !slices.Equal(got, []string{"providers"}) { + t.Fatalf("目录内容 = %v,期望 [providers]", got) + } + + // 整份声明都坏 ⇒ 该插件被登记成「零面板」(重算语义),而不是保留旧集合。 + accepted, err = cat.Register("web-search", []sdk.Panel{bad}) + if !errors.Is(err, ErrSectionShape) { + t.Fatalf("err = %v,期望命中 ErrSectionShape", err) + } + if len(accepted) != 0 { + t.Fatalf("accepted = %v,期望空", accepted) + } + entries, lerr = cat.List(auth.RoleOwner) + if lerr != nil { + t.Fatalf("List: %v", lerr) + } + if len(entries) != 0 { + t.Fatalf("全坏声明之后仍有 %d 条入口", len(entries)) + } + // 「已装、未声明任何页面」仍要能被插件列表页查到(§5.2.1 红线③)。 + if ids := cat.PluginIDs(); !slices.Equal(ids, []string{"web-search"}) { + t.Fatalf("PluginIDs = %v,期望 [web-search]", ids) + } +} + +// TestCatalog_RegisterRejectsBadPluginID 锁定另一种拒绝口径:插件 id 非法 ⇒ 整插件拒绝 +// (目录按插件 id 分命名空间,没有合法命名空间就无从挂载)。 +func TestCatalog_RegisterRejectsBadPluginID(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + accepted, err := cat.Register("Web Search", []sdk.Panel{samplePanel()}) + if !errors.Is(err, ErrInvalidPluginID) { + t.Fatalf("err = %v,期望命中 ErrInvalidPluginID", err) + } + if accepted != nil { + t.Fatalf("accepted = %v,期望 nil", accepted) + } + entries, lerr := cat.List(auth.RoleOwner) + if lerr != nil { + t.Fatalf("List: %v", lerr) + } + if len(entries) != 0 || len(cat.PluginIDs()) != 0 { + t.Fatalf("非法插件 id 不该留下任何痕迹:entries=%v ids=%v", entries, cat.PluginIDs()) + } +} + +// TestCatalog_ListReturnsCopy 锁定「返回值必须是拷贝」的两个方向:调用方改结果改不动目录, +// 注册之后改自己那份声明也改不动目录(后者同样重要 —— 插件管理器常常复用一份声明)。 +func TestCatalog_ListReturnsCopy(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + mine := samplePanel() + mustRegister(t, cat, "web-search", mine) + + entries, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if !reflect.DeepEqual(entries[0].Panel, samplePanel()) { + t.Fatalf("目录里的面板与声明不一致:%+v", entries[0].Panel) + } + + // 方向一:改 List 的返回值。 + entries[0].Panel.Title = "被我改了" + entries[0].Panel.Sections[0].Title = "被我改了" + entries[0].Panel.Sections[0].Fields[0].Key = "hacked" + entries[0].Panel.Sections[0].Fields[0].Min = floatPtr(1) + entries[0].Panel.Sections[1].Columns[0].Label = "被我改了" + entries[0].Panel.Sections[1].RowActions[0].Label = "被我改了" + entries[0].PluginID = "hacked" + *entries[0].Panel.When.Enabled = false + entries[0].Panel.Sections = append(entries[0].Panel.Sections, sdk.Section{ID: "extra", Type: sdk.SectionStatus}) + + // 方向二:改注册时用的那份声明。 + mine.Title = "也改了" + mine.Sections[1].Columns[1].Label = "也改了" + + again, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if got := mustPanelIDs(t, again); !slices.Equal(got, []string{"providers"}) { + t.Fatalf("入口 id 被外部改动影响:%v", got) + } + if again[0].PluginID != "web-search" { + t.Fatalf("PluginID 被改坏:%q", again[0].PluginID) + } + if !reflect.DeepEqual(again[0].Panel, samplePanel()) { + t.Fatalf("目录内部状态被调用方改坏了:%+v", again[0].Panel) + } +} + +// TestCatalog_ListRejectsUnknownRole 锁定角色门是 fail-closed:认不出的角色不给任何面板, +// 也不猜一个默认角色放行。 +func TestCatalog_ListRejectsUnknownRole(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + mustRegister(t, cat, "web-search", samplePanel()) + + if _, err := cat.List(auth.Role("superuser")); !errors.Is(err, ErrUnknownRole) { + t.Fatalf("err = %v,期望 ErrUnknownRole", err) + } + if _, err := cat.Groups(auth.Role("")); !errors.Is(err, ErrUnknownRole) { + t.Fatalf("Groups err = %v,期望 ErrUnknownRole", err) + } +} + +// TestCatalog_Groups 锁定分组视图与 List 的一致性(§5.2.1 nav.group 一级)。 +// +// 本期分组是闭集(只有 plugins),所以这里能验的只有「同一分组内条目连续且顺序与 List 一致」; +// 跨分组排序按最小 order 的规则要等分组闭集扩容后才能端到端测(见 PLG1-report.md)。 +func TestCatalog_Groups(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + later := samplePanel() + later.ID = "later" + later.Order = 30 + earlier := statusPanel() + earlier.ID = "earlier" + earlier.Order = 10 + mustRegister(t, cat, "web-search", later, earlier) + + groups, err := cat.Groups(auth.RoleOwner) + if err != nil { + t.Fatalf("Groups: %v", err) + } + if len(groups) != 1 { + t.Fatalf("分组数 = %d,期望 1(本期闭集只有 plugins)", len(groups)) + } + if groups[0].ID != sdk.GroupPlugins { + t.Fatalf("分组 id = %q,期望 %q", groups[0].ID, sdk.GroupPlugins) + } + if got := mustPanelIDs(t, groups[0].Panels); !slices.Equal(got, []string{"earlier", "later"}) { + t.Fatalf("分组内顺序 = %v,期望 [earlier later]", got) + } +} + +// TestCatalog_ConcurrentRegisterList 配合 `go test -race` 证明目录在并发 +// 注册 / 查询 / 卸载 下无数据竞争、无 panic。 +// +// 负载刻意**限核限时**(8 个 goroutine × 40 次,毫秒级):按仓库压测纪律,验证竞态不需要 +// 堆进程风暴,注入受控并发足够。 +func TestCatalog_ConcurrentRegisterList(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + const workers = 8 + const rounds = 40 + + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + pluginID := fmt.Sprintf("plugin-%d", w) + for r := 0; r < rounds; r++ { + p := samplePanel() + p.ID = fmt.Sprintf("panel-%d-%d", w, r%3) + p.Order = r % 5 + if _, err := cat.Register(pluginID, []sdk.Panel{p}); err != nil { + t.Errorf("Register(%s): %v", pluginID, err) + return + } + entries, err := cat.List(auth.RoleViewer) + if err != nil { + t.Errorf("List: %v", err) + return + } + for _, e := range entries { + if e.PluginID == "" || e.Panel.ID == "" { + t.Errorf("快照里出现空标识:%+v", e) + return + } + } + if _, err := cat.Groups(auth.RoleAdmin); err != nil { + t.Errorf("Groups: %v", err) + return + } + _ = cat.PluginIDs() + if r%11 == 0 { + cat.Remove(pluginID) + } + } + cat.Remove(pluginID) + }(w) + } + wg.Wait() + + entries, err := cat.List(auth.RoleOwner) + if err != nil { + t.Fatalf("收尾 List: %v", err) + } + if len(entries) != 0 { + t.Fatalf("全部卸载后仍有 %d 条入口", len(entries)) + } +} diff --git a/internal/capability/paneldecl/clone.go b/internal/capability/paneldecl/clone.go new file mode 100644 index 0000000..8e32c0f --- /dev/null +++ b/internal/capability/paneldecl/clone.go @@ -0,0 +1,112 @@ +package paneldecl + +import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + +// 本文件是目录的**拷贝语义**:存进来时拷一次、发出去时再拷一次。 +// +// 为什么要两次:§5.2.1 的不变量是「`List` 返回的必须是拷贝」——调用方(gateway / 插件管理器) +// 拿着结果做过滤或补字段时,绝不能改到目录内部状态;反向同理,插件管理器注册后继续改自己 +// 那份声明,也不能影响目录里的快照。声明是嵌套结构(sections → fields → 指针字段), +// 浅拷贝会把内部切片暴露出去,所以这里逐层深拷。 + +// clonePanel 深拷一份面板声明。 +func clonePanel(p sdk.Panel) sdk.Panel { + out := p + out.When = cloneWhen(p.When) + if p.Sections != nil { + out.Sections = make([]sdk.Section, len(p.Sections)) + for i, s := range p.Sections { + out.Sections[i] = cloneSection(s) + } + } + return out +} + +// cloneSection 深拷一个区块(切片与指针字段都要换新)。 +func cloneSection(s sdk.Section) sdk.Section { + out := s + if s.Fields != nil { + out.Fields = make([]sdk.Field, len(s.Fields)) + for i, f := range s.Fields { + out.Fields[i] = cloneField(f) + } + } + if s.Columns != nil { + out.Columns = make([]sdk.Column, len(s.Columns)) + copy(out.Columns, s.Columns) + } + if s.RowActions != nil { + out.RowActions = make([]sdk.RowAction, len(s.RowActions)) + copy(out.RowActions, s.RowActions) + } + if s.Submit != nil { + submit := *s.Submit + out.Submit = &submit + } + if s.Source != nil { + source := *s.Source + out.Source = &source + } + return out +} + +// cloneField 深拷一个字段(Default 是 any,可能装着 map/slice)。 +func cloneField(f sdk.Field) sdk.Field { + out := f + out.Default = cloneValue(f.Default) + if f.Min != nil { + min := *f.Min + out.Min = &min + } + if f.Max != nil { + max := *f.Max + out.Max = &max + } + return out +} + +// cloneWhen 深拷 when(Enabled 是指针,共享指针等于共享可变状态)。 +func cloneWhen(w *sdk.When) *sdk.When { + if w == nil { + return nil + } + out := &sdk.When{} + if w.Enabled != nil { + enabled := *w.Enabled + out.Enabled = &enabled + } + return out +} + +// cloneValue 递归拷贝默认值里的容器。标量(字符串/数字/布尔)本身不可变,直接返回。 +// +// 未覆盖的类型(声明由 Go 代码直接构造时可能塞进任意类型)按原值返回并在文档里写明: +// 本包只承诺「结构声明不可被调用方改坏」,不承诺替调用方深拷它自己塞进来的自定义类型。 +func cloneValue(v any) any { + switch val := v.(type) { + case map[string]any: + out := make(map[string]any, len(val)) + for k, item := range val { + out[k] = cloneValue(item) + } + return out + case map[string]string: + out := make(map[string]string, len(val)) + for k, item := range val { + out[k] = item + } + return out + case []any: + out := make([]any, len(val)) + for i, item := range val { + out[i] = cloneValue(item) + } + return out + case []string: + out := make([]string, len(val)) + copy(out, val) + return out + default: + return v + } +} diff --git a/internal/capability/paneldecl/doc.go b/internal/capability/paneldecl/doc.go new file mode 100644 index 0000000..b7a425e --- /dev/null +++ b/internal/capability/paneldecl/doc.go @@ -0,0 +1,19 @@ +// Package paneldecl 是 design/09-插件生态 §5 的「插件管理面板声明」在宿主侧的库: +// 校验(validate)、清单目录(catalog)、when 求值、按角色过滤。 +// +// 职责边界(§5.2.1 B「插件管理器 = 唯一中枢」里的前三项): +// +// - 声明校验:闭集 + 形状 + 命名,逐条给出**可分类**的错误;非法声明绝不静默丢弃 +// (口径见 Catalog.Register 与 PLG1-report.md)。 +// - 清单目录:收集各插件显式声明的 adminPanels,编译期内置插件与热装条目进**同一张** +// 目录;生命周期(install/enable/disable/uninstall)重算面板集合。 +// - 按角色过滤 + when 求值:owner/admin/viewer 与「是否启用」决定下发给面板的快照。 +// +// 刻意**不做**的事(这一片是纯库,接线留给后续切片): +// +// - 不注册任何 HTTP 端点、不认识 gateway、不认识前端(§5.2.1 的「唯一契约面」 +// GET /api/v1/admin/panels 由后续切片按契约先行登记进 Docs/api-client.md); +// - 不读环境变量、不读文件、不读 config:when 的求值上下文由调用方(插件管理器) +// 注入(见 Resolver)—— 单一事实来源,也让本包可以在没有宿主的情况下单测; +// - 不执行动作、不落审计:本包只声明「动作名」,分发与审计在 §5.2.1 的后半段。 +package paneldecl diff --git a/internal/capability/paneldecl/errors.go b/internal/capability/paneldecl/errors.go new file mode 100644 index 0000000..ebda8f0 --- /dev/null +++ b/internal/capability/paneldecl/errors.go @@ -0,0 +1,138 @@ +package paneldecl + +import ( + "errors" + "fmt" + "strings" +) + +// 本文件是校验错误的**可分类**表示。 +// +// 为什么不用一句 `invalid`:§5.2.1 要求「非法声明 ⇒ 该面板不注册 + 显式报错」,而错误 +// 要同时喂给三种消费者 —— 插件管理器(决定拒哪个面板)、启动期日志(排障)、以及未来 +// 的安装响应(把错误码与文案分离,照 Home Assistant 的「错误码与文案分离」,前端可本地化)。 +// 所以错误必须是结构化的:哨兵做分类,Path 做定位,Detail 做人读说明。 + +// 校验失败分类哨兵。调用方用 errors.Is 判类,不靠字符串匹配。 +var ( + // ErrInvalidPluginID 报告插件 id 不合法(无法作为目录命名空间 / 路由段)。 + ErrInvalidPluginID = errors.New("paneldecl: invalid plugin id") + // ErrInvalidPanelID 报告面板 id 不合法。 + ErrInvalidPanelID = errors.New("paneldecl: invalid panel id") + // ErrInvalidSectionID 报告区块 id 不合法。 + ErrInvalidSectionID = errors.New("paneldecl: invalid section id") + // ErrInvalidRowActionID 报告行内动作 id 不合法。 + ErrInvalidRowActionID = errors.New("paneldecl: invalid row action id") + // ErrInvalidFieldKey 报告字段 key 不合法(字段 key 是配置键,字符集比 id 宽,见 shape.go)。 + ErrInvalidFieldKey = errors.New("paneldecl: invalid field key") + // ErrMissingField 报告必填项缺失(id / title / section 的 type / field 的 key、type 等)。 + ErrMissingField = errors.New("paneldecl: required field missing") + // ErrInvalidText 报告文案非法:空白、含控制字符、或超出长度上限。 + ErrInvalidText = errors.New("paneldecl: invalid text") + // ErrUnknownGroup 报告分组不在闭集内(本期只有 plugins)。 + ErrUnknownGroup = errors.New("paneldecl: unknown panel group") + // ErrUnknownSectionType 报告 section 类型不在闭集内。 + ErrUnknownSectionType = errors.New("paneldecl: unknown section type") + // ErrUnknownFieldType 报告字段类型不在闭集内。 + ErrUnknownFieldType = errors.New("paneldecl: unknown field type") + // ErrSectionShape 报告区块形状错误:该类型不该出现的键出现了(如 table 上写 fields)。 + ErrSectionShape = errors.New("paneldecl: invalid section shape") + // ErrReadOnlySection 报告只读区块上出现了写面(status/log 上的 submit / rowActions)。 + ErrReadOnlySection = errors.New("paneldecl: read-only section declares a write surface") + // ErrInvalidOptionsFrom 报告 optionsFrom 非法:只允许 `plugin:*`,且只允许出现在下拉字段上。 + ErrInvalidOptionsFrom = errors.New("paneldecl: invalid optionsFrom") + // ErrInvalidAction 报告动作名非法(允许点分名,禁止 / : 等 URL 成分)。 + ErrInvalidAction = errors.New("paneldecl: invalid action name") + // ErrInvalidOrder 报告 order 为负。 + ErrInvalidOrder = errors.New("paneldecl: invalid order") + // ErrInvalidBounds 报告 min/max 非法(配在非 number 字段上、NaN、或 min > max)。 + ErrInvalidBounds = errors.New("paneldecl: invalid numeric bounds") + // ErrInvalidDefault 报告默认值与字段类型不匹配。 + ErrInvalidDefault = errors.New("paneldecl: default value does not match the field type") + // ErrSecretDefault 报告 secret 字段声明了默认值(§5.4.2 读侧无明文)。 + ErrSecretDefault = errors.New("paneldecl: secret field must not declare a default") + // ErrInvalidLimit 报告 log 区块的 limit 越界。 + ErrInvalidLimit = errors.New("paneldecl: invalid log limit") + // ErrInvalidIcon 报告图标名非法。 + ErrInvalidIcon = errors.New("paneldecl: invalid icon name") + // ErrDuplicateID 报告同一作用域内 id / key 重复。 + ErrDuplicateID = errors.New("paneldecl: duplicate id") + // ErrUnknownRole 报告角色不在 owner/admin/viewer 之内(查询目录时使用)。 + ErrUnknownRole = errors.New("paneldecl: unknown role") + // ErrNilResolver 报告构造目录时没有注入 when 求值上下文来源。 + ErrNilResolver = errors.New("paneldecl: nil panel context resolver") + // ErrNilCatalog 报告在 nil *Catalog 上调用了 Register。 + ErrNilCatalog = errors.New("paneldecl: nil catalog") +) + +// maxReportedViolations 限制聚合错误在 Error() 里铺开的条数:一个坏清单可能有几百条 +// 违规,全铺进一行日志会把有用的信息挤掉。结构化的完整列表仍在 Violations 里,不丢。 +const maxReportedViolations = 5 + +// Violation 是一条**可分类**的校验失败。 +type Violation struct { + // Kind 是分类哨兵(本文件的 Err*),errors.Is 沿着它判定。 + Kind error + // Path 是出问题的位置,索引式 JSON 路径,如 + // `adminPanels[0].sections[1].fields[2].type` —— 索引而不是 id,因为出错的可能正是 id。 + Path string + // Detail 是人读说明,只讲这一条问题本身,不含他人信息。 + Detail string +} + +// Error 实现 error。格式固定为 `<分类>: <路径>: <说明>`,便于日志切片与测试断言。 +func (v Violation) Error() string { + return fmt.Sprintf("%s: %s: %s", v.Kind, v.Path, v.Detail) +} + +// Unwrap 让 errors.Is(err, ErrXxx) 能穿过这条违规。 +func (v Violation) Unwrap() error { return v.Kind } + +// Errors 是逐条违规的聚合。它的存在是为了「一次把所有问题讲清楚」—— 逐个报错会让插件 +// 作者改一轮、跑一轮。 +type Errors struct { + // Violations 按发现顺序排列(声明顺序,因此可复现)。 + Violations []Violation +} + +// Error 实现 error,最多铺开 maxReportedViolations 条,其余只报条数。 +func (e *Errors) Error() string { + if e == nil || len(e.Violations) == 0 { + return "paneldecl: no violations" + } + parts := make([]string, 0, maxReportedViolations) + for i, v := range e.Violations { + if i == maxReportedViolations { + parts = append(parts, fmt.Sprintf("...(+%d more)", len(e.Violations)-maxReportedViolations)) + break + } + parts = append(parts, v.Error()) + } + return fmt.Sprintf("paneldecl: %d violation(s): %s", len(e.Violations), strings.Join(parts, "; ")) +} + +// Unwrap 返回每一条违规,让 errors.Is / errors.As 能穿透聚合看分类。 +func (e *Errors) Unwrap() []error { + if e == nil { + return nil + } + out := make([]error, 0, len(e.Violations)) + for _, v := range e.Violations { + out = append(out, v) + } + return out +} + +// violation 构造一条违规。 +func violation(kind error, path, detail string) Violation { + return Violation{Kind: kind, Path: path, Detail: detail} +} + +// violationsToError 把逐条违规收成一个 error:空切片返回 nil(Go 的 nil-error 约定, +// 免得调用方写 `if len(vs) > 0 { return &Errors{vs} }`)。 +func violationsToError(vs []Violation) error { + if len(vs) == 0 { + return nil + } + return &Errors{Violations: vs} +} diff --git a/internal/capability/paneldecl/fieldcheck.go b/internal/capability/paneldecl/fieldcheck.go new file mode 100644 index 0000000..f2a9581 --- /dev/null +++ b/internal/capability/paneldecl/fieldcheck.go @@ -0,0 +1,210 @@ +package paneldecl + +import ( + "fmt" + "math" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是字段层校验(§5.2 的 fields 元素):key/type 必填、optionsFrom 只允许 plugin:*、 +// secret 不得有默认值、数值边界与默认值类型。 + +// validateFields 校验一组字段声明,并保证 key 在区块内唯一。 +func validateFields(fields []sdk.Field, prefix string) []Violation { + var vs []Violation + seen := make(map[string]bool, len(fields)) + for i, f := range fields { + path := fmt.Sprintf("%s.fields[%d]", prefix, i) + vs = append(vs, validateFieldAt(f, path)...) + if f.Key == "" { + continue + } + if seen[f.Key] { + vs = append(vs, dupViolation(path+".key", "本区块", "字段 key", f.Key)) + continue + } + seen[f.Key] = true + } + return vs +} + +// validateFieldAt 校验单个字段。type 未知时只报类型一条并停止:其余规则都依赖类型 +// (optionsFrom / min-max / 默认值类型),继续推导只会产生噪音。 +func validateFieldAt(f sdk.Field, path string) []Violation { + var vs []Violation + if f.Key == "" { + vs = append(vs, violation(ErrMissingField, path+".key", + "字段 key 必填(它同时是配置键与提交键)")) + } else if !validFieldKey(f.Key) { + vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(f.Key))) + } + vs = append(vs, checkOptionalText(f.Label, path+".label", titleMaxRunes)...) + vs = append(vs, checkOptionalText(f.Placeholder, path+".placeholder", hintMaxRunes)...) + if !f.Type.Valid() { + return append(vs, violation(ErrUnknownFieldType, path+".type", fmt.Sprintf( + "未知字段类型 %q(闭集:text|secret|number|switch|select|multiSelect|date|kv)", f.Type))) + } + vs = append(vs, checkOptionsFrom(f, path)...) + vs = append(vs, checkBounds(f, path)...) + vs = append(vs, checkDefault(f, path)...) + return vs +} + +// checkOptionsFrom 校验下拉选项来源。两条规则: +// 1. 只允许 `plugin:<名字>` —— §5.2 只给了插件内这一种来源,其它一律拒(防「声明里塞 +// 任意 URL」:选项来源一旦可以是 URL,声明就变相获得了发请求的能力); +// 2. 只允许出现在 select / multiSelect 上 —— 其它控件不渲染选项列表,声明了也不生效, +// 静默忽略正是 §5.2.1 要禁的那种假成功。 +func checkOptionsFrom(f sdk.Field, path string) []Violation { + if f.OptionsFrom == "" { + return nil + } + if !f.Type.PullsOptions() { + return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom", + fmt.Sprintf("%s 不渲染下拉选项,optionsFrom 只对 select / multiSelect 有效", f.Type))} + } + if !optionsFromRe.MatchString(f.OptionsFrom) { + return []Violation{violation(ErrInvalidOptionsFrom, path+".optionsFrom", fmt.Sprintf( + "optionsFrom %q 非法:只允许 `plugin:<名字>`(如 plugin:providers),不接受任意 URL", + f.OptionsFrom))} + } + return nil +} + +// checkBounds 校验 min/max:只有 number 可声明,取值为有限数,且 min <= max。 +func checkBounds(f sdk.Field, path string) []Violation { + if f.Min == nil && f.Max == nil { + return nil + } + if !f.Type.Numeric() { + return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf( + "%s 不支持 min/max(闭集里只有 number 是数值控件;date 用数值边界表达日期范围只会歧义)", + f.Type))} + } + // NaN/Inf 在标准 JSON 里写不出来,但声明也可以由 Go 代码直接构造,所以照样拦。 + if f.Min != nil && (math.IsNaN(*f.Min) || math.IsInf(*f.Min, 0)) { + return []Violation{violation(ErrInvalidBounds, path+".min", "min 必须是有限数")} + } + if f.Max != nil && (math.IsNaN(*f.Max) || math.IsInf(*f.Max, 0)) { + return []Violation{violation(ErrInvalidBounds, path+".max", "max 必须是有限数")} + } + if f.Min != nil && f.Max != nil && *f.Min > *f.Max { + return []Violation{violation(ErrInvalidBounds, path+".min", fmt.Sprintf( + "min (%v) 不得大于 max (%v)", *f.Min, *f.Max))} + } + return nil +} + +// checkDefault 校验默认值。secret 字段**一律不许**有默认值(§5.4.2 读侧永不回显明文: +// 声明里写死的凭据会被读侧原样吐回,等于绕过掩码);其余类型要求默认值的 JSON 类型与 +// 控件相容,否则到前端是「控件显示不出该值」的半渲染。 +func checkDefault(f sdk.Field, path string) []Violation { + if f.Default == nil { + return nil + } + if f.Type.Sensitive() { + return []Violation{violation(ErrSecretDefault, path+".default", + "secret 字段不得声明默认值(§5.4.2:读侧永不回显明文)")} + } + want := defaultShapeOf(f.Type) + if !defaultMatches(f.Type, f.Default) { + return []Violation{violation(ErrInvalidDefault, path+".default", + fmt.Sprintf("默认值类型与 %s 不匹配(期望 %s)", f.Type, want))} + } + return nil +} + +// defaultMatches 判定默认值是否与字段类型相容。不做类型转换:能被静默转换的值(如给 +// number 写字符串 "20")正是「看着配好了、其实没生效」的来源。 +func defaultMatches(t sdk.FieldType, v any) bool { + switch t { + case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect: + _, ok := v.(string) + return ok + case sdk.FieldNumber: + return isNumeric(v) + case sdk.FieldSwitch: + _, ok := v.(bool) + return ok + case sdk.FieldMultiSelect: + return isStringList(v) + case sdk.FieldKV: + return isStringMap(v) + default: + // secret 已在 checkDefault 里被拒,走到这里说明字段类型闭集新增了成员而 + // defaultMatches 没跟上 —— 拒绝而不是放行。 + return false + } +} + +// defaultShapeOf 给出期望形态的人类可读说法(错误信息里用)。 +func defaultShapeOf(t sdk.FieldType) string { + switch t { + case sdk.FieldText, sdk.FieldDate, sdk.FieldSelect: + return "字符串" + case sdk.FieldNumber: + return "数字" + case sdk.FieldSwitch: + return "布尔" + case sdk.FieldMultiSelect: + return "字符串数组" + case sdk.FieldKV: + return "键值对对象" + default: + return "与字段类型相容的值" + } +} + +// isNumeric 报告 v 是不是数值。覆盖 Go 侧直接构造声明时可能用的整型(JSON 解码只会给 +// float64,但 SDK 是给插件作者用的 Go 包,不该逼他们写 20.0)。 +func isNumeric(v any) bool { + switch v.(type) { + case float64, float32, int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64: + return true + default: + return false + } +} + +// isStringList 报告 v 是不是字符串列表(JSON → []any,Go 直构 → []string)。 +func isStringList(v any) bool { + switch items := v.(type) { + case []string: + return true + case []any: + for _, it := range items { + if _, ok := it.(string); !ok { + return false + } + } + return true + default: + return false + } +} + +// isStringMap 报告 v 是不是键值对(JSON → map[string]any,Go 直构 → map[string]string)。 +func isStringMap(v any) bool { + switch m := v.(type) { + case map[string]string: + return true + case map[string]any: + for _, val := range m { + if _, ok := val.(string); !ok { + return false + } + } + return true + default: + return false + } +} + +// fieldKeyDetail 是字段 key 形状的统一说明。 +func fieldKeyDetail(key string) string { + return fmt.Sprintf( + "字段 key %q 非法:只允许字母开头、后接字母数字或下划线(如 provider / apiKey),因为它同时是配置键", + key) +} diff --git a/internal/capability/paneldecl/filter.go b/internal/capability/paneldecl/filter.go new file mode 100644 index 0000000..df1656f --- /dev/null +++ b/internal/capability/paneldecl/filter.go @@ -0,0 +1,57 @@ +package paneldecl + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是「按角色过滤面板」的口径落点。依据 design/09 §5.4.1 与 §5.2.1 B: +// +// - 面板可见性与**数据面**都按 owner / admin / viewer 分级; +// - 只读事实面对 viewer 可见;写动作一律 owner/admin(沿用 design/11 的 writeAuth); +// - 逐 section 判 owner / admin / viewer(§5.2.1 B 的「鉴权与敏感策略」一行)。 +// +// 设计没写清的处置**一律只收紧不放宽**(每一条都在下面注明),取舍记在 PLG1-report.md。 + +// filterPanel 按角色裁剪一个面板。第二个返回值为 false 表示该面板对该角色整体不可见。 +// +// owner/admin 原样下发(写面可见);viewer 只留下只读区块,并把表格的行内动作摘掉。 +// +// 收紧点(设计未写、按「只收紧不放宽」处理): +// - 行内动作(rowActions)对 viewer 一律摘除 —— §5.4.1 说「写动作一律 owner/admin」, +// 而行内动作就是写动作(示例:测试连通 / 重配凭据),声明里没有「只读动作」这种标记, +// 所以一律按写处理; +// - 一个区块都不剩时**整条入口不出现** —— 让 viewer 点进一个空页面等于把「没有权限」 +// 表达成「页面坏了」;隐藏入口不泄露任何信息(数据面另有鉴权)。 +func filterPanel(role auth.Role, panel sdk.Panel) (sdk.Panel, bool) { + if auth.HasRole(role, auth.RoleAdmin) { + return panel, true + } + out := panel + out.Sections = make([]sdk.Section, 0, len(panel.Sections)) + for _, s := range panel.Sections { + fs, ok := filterSectionForViewer(s) + if ok { + out.Sections = append(out.Sections, fs) + } + } + if len(out.Sections) == 0 { + return sdk.Panel{}, false + } + return out, true +} + +// filterSectionForViewer 裁剪一个区块给 viewer 看。 +// +// 保留:status / log / table(§5.4.1「只读事实面对 viewer 可见」)。 +// 丢弃:form —— 它是**唯一自带写面**的类型(submit.action),且在 §5.2.1 里对应 +// `section.form` 的「写」侧,对 viewer 没有只读形态可言(本模型没有「只读表单」这种声明)。 +func filterSectionForViewer(s sdk.Section) (sdk.Section, bool) { + if !s.Type.ReadOnly() { + return sdk.Section{}, false + } + out := s + // 摘掉行内动作而不是丢掉整张表:列(事实)对 viewer 有用,按钮(写)不该出现。 + out.RowActions = nil + return out, true +} diff --git a/internal/capability/paneldecl/filter_test.go b/internal/capability/paneldecl/filter_test.go new file mode 100644 index 0000000..3d3bada --- /dev/null +++ b/internal/capability/paneldecl/filter_test.go @@ -0,0 +1,169 @@ +package paneldecl + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定角色过滤(§5.4.1 角色门 + §5.2.1「逐 section 判 owner/admin/viewer」): +// owner/admin 全量、viewer 只见只读事实面且不见任何写面。 + +// mixedPanel 是一份同时含写面与只读面的面板:form(写)+ table(行内动作)+ status + log。 +// 它是对「viewer 到底还能看见什么」最严格的一份夹具。 +func mixedPanel() sdk.Panel { + p := samplePanel() + p.Sections = append(p.Sections, statusPanel().Sections[0], logPanel().Sections[0]) + return p +} + +// sectionIDs 抽取区块 id 序列(顺序敏感)。 +func sectionIDs(p sdk.Panel) []string { + ids := make([]string, 0, len(p.Sections)) + for _, s := range p.Sections { + ids = append(ids, s.ID) + } + return ids +} + +// anySecretField 报告面板里是否还残留 secret 字段声明。 +func anySecretField(p sdk.Panel) bool { + for _, s := range p.Sections { + for _, f := range s.Fields { + if f.Type.Sensitive() { + return true + } + } + } + return false +} + +// panelByID 在快照里找某个面板。 +func panelByID(t *testing.T, entries []Entry, id string) sdk.Panel { + t.Helper() + for _, e := range entries { + if e.Panel.ID == id { + return e.Panel + } + } + t.Fatalf("快照里没有面板 %q(实际 %v)", id, mustPanelIDs(t, entries)) + return sdk.Panel{} +} + +// TestList_OwnerAndAdminSeeEverything 锁定上半段:owner/admin 拿到完整声明(写面、secret +// 字段、行内动作一个不少)—— 过滤只对 viewer 收紧,不能顺手把手握写权的人也削了。 +func TestList_OwnerAndAdminSeeEverything(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + mustRegister(t, cat, "web-search", mixedPanel()) + + for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} { + role := role + t.Run(string(role), func(t *testing.T) { + t.Parallel() + entries, err := cat.List(role) + if err != nil { + t.Fatalf("List(%s): %v", role, err) + } + got := panelByID(t, entries, "providers") + if len(got.Sections) != 4 { + t.Fatalf("%s 看到 %d 个区块,期望 4", role, len(got.Sections)) + } + if !anySecretField(got) { + t.Fatalf("%s 应当看得到 secret 字段声明(读侧掩码是数据面的事,不是声明面)", role) + } + if got.Sections[0].Submit == nil { + t.Fatalf("%s 应当看得到 form 的 submit", role) + } + if len(got.Sections[1].RowActions) != 2 { + t.Fatalf("%s 应当看得到 2 个行内动作,实际 %d", role, len(got.Sections[1].RowActions)) + } + }) + } +} + +// TestList_ViewerLosesWriteSurfaces 锁定下半段:viewer 只见只读区块,form 整体消失 +// (连带 secret 写面),表格保留但行内动作被摘掉。 +func TestList_ViewerLosesWriteSurfaces(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + mustRegister(t, cat, "web-search", mixedPanel()) + + entries, err := cat.List(auth.RoleViewer) + if err != nil { + t.Fatalf("List(viewer): %v", err) + } + got := panelByID(t, entries, "providers") + if ids := sectionIDs(got); len(ids) != 3 || ids[0] != "list" { + t.Fatalf("viewer 的区块 = %v,期望 [list chain recent](form 整体消失)", ids) + } + for _, s := range got.Sections { + if s.Type == sdk.SectionForm { + t.Fatal("viewer 不该看到 form(它是唯一自带写面的区块类型)") + } + if s.Submit != nil || len(s.RowActions) != 0 { + t.Fatalf("viewer 不该看到写面:submit=%v rowActions=%v", s.Submit, s.RowActions) + } + } + if anySecretField(got) { + t.Fatal("viewer 不该看到 secret 字段(它是写面字段)") + } + // 事实本身要留着:列还在(否则「只读事实面对 viewer 可见」就成了空话)。 + table := got.Sections[0] + if len(table.Columns) != 2 || table.Source == nil { + t.Fatalf("viewer 应当看得到表格的列与只读数据源:%+v", table) + } +} + +// TestList_ViewerHidesEmptyEntrances 锁定收紧点:一个只读区块都不剩时,入口整个不出现 —— +// 让 viewer 点进空页面等于把「没有权限」表达成「页面坏了」。 +func TestList_ViewerHidesEmptyEntrances(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + writeOnly := samplePanel() + // 只留 form 一段 ⇒ 对 viewer 一个只读区块都不剩。 + writeOnly.Sections = writeOnly.Sections[:1] + mustRegister(t, cat, "web-search", writeOnly) + + entries, err := cat.List(auth.RoleViewer) + if err != nil { + t.Fatalf("List(viewer): %v", err) + } + if len(entries) != 0 { + t.Fatalf("纯写面板对 viewer 应当整条不出现,实际 %v", mustPanelIDs(t, entries)) + } + + // 直接对 filterPanel 再锁一次返回值语义(避免只靠 List 的一层包装)。 + if _, visible := filterPanel(auth.RoleViewer, writeOnly); visible { + t.Fatal("纯写面板对 viewer 应当不可见") + } + if got, visible := filterPanel(auth.RoleAdmin, writeOnly); !visible || len(got.Sections) != 1 { + t.Fatalf("admin 应当可见且区块不丢:visible=%v sections=%d", visible, len(got.Sections)) + } +} + +// TestList_ViewerKeepsReadOnlyTables 锁定「表格保留、动作摘除」这一半:不能因为表里有写动作 +// 就把整张表藏掉(那样 viewer 连事实都看不到)。 +func TestList_ViewerKeepsReadOnlyTables(t *testing.T) { + t.Parallel() + cat := newTestCatalog(t, alwaysEnabled) + table := samplePanel() + table.Sections = table.Sections[1:] + mustRegister(t, cat, "web-search", table) + + entries, err := cat.List(auth.RoleViewer) + if err != nil { + t.Fatalf("List(viewer): %v", err) + } + if len(entries) != 1 { + t.Fatalf("带行内动作的表格面板对 viewer 仍应可见,实际 %v", mustPanelIDs(t, entries)) + } + got := entries[0].Panel + if len(got.Sections) != 1 || len(got.Sections[0].Columns) != 2 { + t.Fatalf("viewer 应当看到表格与列:%+v", got.Sections) + } + if got.Sections[0].RowActions != nil { + t.Fatalf("viewer 的行内动作应当被摘成 nil,实际 %+v", got.Sections[0].RowActions) + } +} diff --git a/internal/capability/paneldecl/helpers_test.go b/internal/capability/paneldecl/helpers_test.go new file mode 100644 index 0000000..f74c33c --- /dev/null +++ b/internal/capability/paneldecl/helpers_test.go @@ -0,0 +1,108 @@ +package paneldecl + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是测试夹具:一份**逐字对齐 design/09 §5.2 示例**的合法声明。 +// +// 为什么所有非法用例都从它派生:只有这样才能保证「被拒的是那一处改动,而不是碰巧别处也不 +// 合法」—— 否则一个用例变红,无法区分是目标规则生效还是夹具本身有毛病。 + +// boolPtr 是 &true / &false 的简写(sdk.When.Enabled 是三态指针)。 +func boolPtr(v bool) *bool { return &v } + +// floatPtr 是 &x 的简写(sdk.Field.Min / Max)。 +func floatPtr(v float64) *float64 { return &v } + +// samplePanel 返回 §5.2 示例里的面板(web-search 的 providers 页):form + table 两段, +// 覆盖 secret / select(optsFrom) / text 三种字段与两个行内动作。 +func samplePanel() sdk.Panel { + return sdk.Panel{ + ID: "providers", + Title: "搜索提供商", + Group: sdk.GroupPlugins, + Order: 20, + Icon: "search", + When: &sdk.When{Enabled: boolPtr(true)}, + Sections: []sdk.Section{ + { + Type: sdk.SectionForm, + ID: "add", + Title: "添加提供商", + Fields: []sdk.Field{ + {Key: "provider", Label: "提供商", Type: sdk.FieldSelect, + OptionsFrom: "plugin:providers", Required: true}, + {Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true}, + {Key: "endpoint", Label: "接口地址", Type: sdk.FieldText, Placeholder: "https://..."}, + }, + Submit: &sdk.Submit{Action: "plugin.saveProvider"}, + }, + { + Type: sdk.SectionTable, + ID: "list", + Title: "已配置的提供商", + Source: &sdk.Source{Action: "plugin.listProviders"}, + Columns: []sdk.Column{ + {Key: "provider", Label: "提供商"}, + {Key: "state", Label: "状态"}, + }, + RowActions: []sdk.RowAction{ + {ID: "test", Label: "测试连通", Action: "plugin.testProvider"}, + {ID: "reauth", Label: "重配凭据", Action: "plugin.reauthProvider"}, + }, + }, + }, + } +} + +// statusPanel 是只读事实面夹具(§5.2.1 section.status)。 +func statusPanel() sdk.Panel { + return sdk.Panel{ + ID: "chain", + Title: "降级链", + Group: sdk.GroupPlugins, + Order: 10, + Sections: []sdk.Section{{ + Type: sdk.SectionStatus, + ID: "chain", + Title: "当前生效的降级链", + Fields: []sdk.Field{ + {Key: "active", Label: "实际应答者", Type: sdk.FieldText}, + }, + }}, + } +} + +// logPanel 是只读日志卡夹具(§5.2.1 section.log)。 +func logPanel() sdk.Panel { + return sdk.Panel{ + ID: "trace", + Title: "最近调用", + Group: sdk.GroupPlugins, + Order: 30, + Sections: []sdk.Section{{ + Type: sdk.SectionLog, + ID: "recent", + Title: "最近 N 条", + Source: &sdk.Source{Action: "plugin.recentCalls"}, + Limit: 50, + }}, + } +} + +// mustPanelIDs 抽取目录快照里的面板 id 序列(顺序敏感,用于稳定序断言)。 +func mustPanelIDs(t *testing.T, entries []Entry) []string { + t.Helper() + ids := make([]string, 0, len(entries)) + for _, e := range entries { + ids = append(ids, e.Panel.ID) + } + return ids +} + +// alwaysEnabled / neverEnabled 是两种最简 Resolver 桩。 +func alwaysEnabled(string) Context { return Context{Enabled: true} } +func neverEnabled(string) Context { return Context{} } diff --git a/internal/capability/paneldecl/shape.go b/internal/capability/paneldecl/shape.go new file mode 100644 index 0000000..c7b433f --- /dev/null +++ b/internal/capability/paneldecl/shape.go @@ -0,0 +1,182 @@ +package paneldecl + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。 +// +// 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由 +// /admin/plugin//(§5.7 C)与端点 /api/v1/admin/plugins/{id}/actions/{action} +// (§5.2.1),「能直接当路径段用」是硬要求;图标名与动作名则要防「声明里塞 URL/SVG」 +// (§5.4.4 不给插件开 SSRF 面)。 + +// 长度上限。都是「撑坏 Miuix 布局」与「URL/日志行不可读」两类实际后果倒推出来的,不是随手取的数。 +const ( + // idMaxLen 是 id 长度上限。既有插件 id 是 32 位十六进制(runtimeconfig.NewID), + // 面板/区块 id 是短名字,64 给前者留了一倍余量。 + idMaxLen = 64 + // titleMaxRunes 是标题/标签文案上限。侧边栏与页面壳的排版宽度决定它,超出即截断, + // 200 个字符足够任何语言的正常标题。 + titleMaxRunes = 200 + // hintMaxRunes 是 placeholder / description 这类辅助文案上限,比标题宽一档 + // (接口地址、说明段落会更长)。 + hintMaxRunes = 500 + // iconMaxLen 是图标名上限。宿主内置图标名的量级是十几个字符,64 已极宽。 + iconMaxLen = 64 + // actionMaxLen 是动作名上限。示例 plugin.reauthProvider 是 21 字符,64 足够。 + actionMaxLen = 64 + // maxLogLimit 是 log 区块 `limit` 的上限。log 是「只读最近 N 条」的排障卡, + // 一次拉上万条会把页面卡死;1000 与既有日志尾读的量级一致。 + maxLogLimit = 1000 +) + +// 编译期正则。包级 var 存的是**不可变**的编译产物(与 internal/runtime/memory、 +// internal/runtime/tool 等既有文件同一手法),不是可变全局状态。 +var ( + // idRe 是 id(插件 / 面板 / 区块 / 行内动作)的形状:小写字母数字,可含中划线分段。 + // 依据 §5.2 示例 web-search / providers / add / list / test / reauth,并禁止大小写混用 + // (URL 路径段大小写敏感,混用会引入难查的 404)。 + idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + // fieldKeyRe 是字段 key 的形状:§5.2 示例是 provider / apiKey / endpoint —— 小驼峰, + // 因为它同时是**配置键**(写进插件配置段),比 id 宽一档:允许大写与下划线。 + fieldKeyRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) + // optionsFromRe 是下拉选项来源:只允许 `plugin:` 前缀的名字(§5.2 示例 plugin:providers)。 + // 禁止 `/`、第二个 `:` 与空白 ⇒ "http://…" 这类 URL 声明在校验期就被拒。 + optionsFromRe = regexp.MustCompile(`^plugin:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`) + // actionRe 是动作名:§5.2 示例 plugin.saveProvider / plugin.listProviders / plugin.testProvider。 + // 允许点分名,**禁止** `/` 与 `:` —— 动作只是名字,不是 URL(§5.4.4)。 + actionRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$`) + // iconRe 是图标名:宿主内置图标表的名字,禁止任何标点(尤其 `<` / `{` / `;`), + // 顺带堵死「把 SVG/CSS 片段塞进 icon」这条路。 + iconRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) +) + +// requirement 是某个键在某种 section 类型上的必要性。 +type requirement uint8 + +const ( + // notAllowed:该键在这类区块上没有意义,出现即形状错误。 + notAllowed requirement = iota + // optional:可以出现,可以不出现。 + optional + // required:必须出现(缺 ⇒ 必填缺失)。 + required + // writeForbidden:这是**写面**键,出现在只读区块上即越界(§5.4.1 写动作一律 owner/admin)。 + writeForbidden +) + +// sectionShape 是 §5.2.1 接入点表的可执行形式:每种区块消费哪些键、哪些是写面。 +// +// 为什么要把这张表写成硬校验:宿主是「按声明泛化渲染」的,一个 form 没有 submit、 +// 一个 table 没有 columns,到前端就是**半渲染**(区块在、功能不在)—— 正是 §5.2.1 +// 点名要防的。所以形状错误在声明期整体拒掉该面板,并显式报错。 +type sectionShape struct { + fields requirement + submit requirement + source requirement + columns requirement + rowActions requirement + limit requirement +} + +// shapeOf 返回某类区块的键约束(§5.2.1 的 section.form / section.table / section.status / +// section.log 四行)。写成函数而不是包级 map:本仓禁包级可变全局,且 switch 让「新增 +// section 类型忘了补形状」在 review 时一眼可见。 +func shapeOf(t sdk.SectionType) sectionShape { + switch t { + case sdk.SectionForm: + return sectionShape{fields: required, submit: required} + case sdk.SectionTable: + return sectionShape{ + columns: required, source: required, + rowActions: optional, submit: writeForbidden, + } + case sdk.SectionStatus: + return sectionShape{ + fields: required, submit: writeForbidden, rowActions: writeForbidden, + } + case sdk.SectionLog: + return sectionShape{ + source: required, limit: optional, submit: writeForbidden, rowActions: writeForbidden, + } + default: + return sectionShape{} + } +} + +// normalizeGroup 把空分组按 §5.2「本期固定 plugins」回落到唯一分组。 +// +// 为什么把空值当「用默认」而不是「必填缺失」:分组本期只有闭集里的一个成员,缺省不存在 +// 歧义;而四处要求插件作者重复写同一个常量只会制造漏写。非空但不是闭集成员的仍然拒绝。 +func normalizeGroup(g sdk.GroupID) sdk.GroupID { + if strings.TrimSpace(string(g)) == "" { + return sdk.GroupPlugins + } + return g +} + +// validID 报告 id 是否满足路径段形状。 +func validID(id string) bool { + return len(id) > 0 && len(id) <= idMaxLen && idRe.MatchString(id) +} + +// validFieldKey 报告字段 key 是否满足「配置键」形状。 +func validFieldKey(key string) bool { return key != "" && fieldKeyRe.MatchString(key) } + +// validAction 报告动作名是否合法。 +func validAction(action string) bool { + return len(action) > 0 && len(action) <= actionMaxLen && actionRe.MatchString(action) +} + +// validIcon 报告图标名是否合法。 +func validIcon(icon string) bool { + return len(icon) > 0 && len(icon) <= iconMaxLen && iconRe.MatchString(icon) +} + +// checkOptionalText 校验一段可选文案:允许缺省,但不允许「只有空白」、控制字符、 +// 或超过 maxRunes(长度超限会把侧边栏/页面壳排版撑坏,属实际后果而非洁癖)。 +func checkOptionalText(value, path string, maxRunes int) []Violation { + if value == "" { + return nil + } + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return []Violation{violation(ErrInvalidText, path, "文案不能只有空白字符")} + } + if n := utf8.RuneCountInString(trimmed); n > maxRunes { + return []Violation{violation(ErrInvalidText, path, + fmt.Sprintf("文案长度 %d 超过上限 %d", n, maxRunes))} + } + if r := firstControlRune(trimmed); r >= 0 { + return []Violation{violation(ErrInvalidText, path, + fmt.Sprintf("文案含控制字符 %q(前端渲染与日志都会被污染)", r))} + } + return nil +} + +// checkRequiredText 校验一段必填文案:缺省即必填缺失,其余同 checkOptionalText。 +func checkRequiredText(value, path string, maxRunes int) []Violation { + if strings.TrimSpace(value) == "" { + return []Violation{violation(ErrMissingField, path, "必填文案缺失或全为空白")} + } + return checkOptionalText(value, path, maxRunes) +} + +// firstControlRune 返回第一个控制字符,没有则 -1。 +func firstControlRune(s string) rune { + for _, r := range s { + if r < 0x20 || r == 0x7f { + return r + } + } + return -1 +} + +// panelPath 是面板在声明里的索引式路径前缀(§5.2 的 adminPanels 数组)。 +func panelPath(i int) string { return fmt.Sprintf("adminPanels[%d]", i) } diff --git a/internal/capability/paneldecl/validate.go b/internal/capability/paneldecl/validate.go new file mode 100644 index 0000000..5fc6e29 --- /dev/null +++ b/internal/capability/paneldecl/validate.go @@ -0,0 +1,252 @@ +package paneldecl + +import ( + "fmt" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是声明校验的入口与面板/区块层(§5.2.1 B「声明校验」)。字段层在 fieldcheck.go。 + +// Validate 校验一个插件的完整声明:插件 id 形状 + 逐面板校验 + 插件内 id 唯一性。 +// +// 它**只读**:不修补、不改写传入的声明(要存进目录的值由 Register 负责 normalize)。 +// 返回 nil 或 *Errors(聚合了**逐条**可分类的违规)。插件 id 非法时仍继续逐面板校验, +// 让调用方一次拿到全部问题 —— 逐轮报错会让插件作者改一轮跑一轮。 +func Validate(pluginID string, panels []sdk.Panel) error { + var vs []Violation + vs = append(vs, checkID(pluginID, "pluginID", "插件 id", ErrInvalidPluginID)...) + seen := make(map[string]bool, len(panels)) + for i, p := range panels { + path := panelPath(i) + pvs := validatePanelAt(p, path) + if p.ID != "" { + if seen[p.ID] { + pvs = append(pvs, dupViolation(path+".id", "本插件", "面板 id", p.ID)) + } + seen[p.ID] = true + } + vs = append(vs, pvs...) + } + return violationsToError(vs) +} + +// ValidatePanel 校验单个面板,index 是它在插件声明数组里的下标(只用于错误路径)。 +// +// 它存在的理由是 §5.2.1 的拒绝粒度:非法声明「该面板不注册」,所以插件管理器需要**按面板** +// 判定,而不是只能整插件一起拒。 +func ValidatePanel(panel sdk.Panel, index int) error { + return violationsToError(validatePanelAt(panel, panelPath(index))) +} + +// validatePanelAt 校验一个面板及其全部区块。path 是索引式前缀。 +func validatePanelAt(p sdk.Panel, path string) []Violation { + var vs []Violation + vs = append(vs, checkID(p.ID, path+".id", "面板 id", ErrInvalidPanelID)...) + vs = append(vs, checkRequiredText(p.Title, path+".title", titleMaxRunes)...) + vs = append(vs, checkOptionalText(p.Description, path+".description", hintMaxRunes)...) + if g := normalizeGroup(p.Group); !g.Valid() { + vs = append(vs, violation(ErrUnknownGroup, path+".group", + fmt.Sprintf("未知分组 %q(本期分组闭集只有 %q,见 SDK 的 GroupID)", p.Group, sdk.GroupPlugins))) + } + if p.Order < 0 { + vs = append(vs, violation(ErrInvalidOrder, path+".order", + fmt.Sprintf("order 必须 >= 0(小在前),得到 %d", p.Order))) + } + if p.Icon != "" && !validIcon(p.Icon) { + vs = append(vs, violation(ErrInvalidIcon, path+".icon", + fmt.Sprintf("图标名 %q 非法:只允许宿主内置图标名(字母数字与中划线/下划线),不接受 URL 或 SVG 片段", p.Icon))) + } + vs = append(vs, validateSections(p.Sections, path)...) + return vs +} + +// checkID 校验一个 id 类取值:空 ⇒ 必填缺失;形状不符 ⇒ 该 id 种类的分类错误。 +func checkID(id, path, label string, kind error) []Violation { + if id == "" { + return []Violation{violation(ErrMissingField, path, label+" 必填(空值无法构成路由段/端点段)")} + } + if !validID(id) { + return []Violation{violation(kind, path, fmt.Sprintf( + "%s %q 非法:只允许小写字母数字与中划线分段(如 providers / web-search),长度 ≤ %d", + label, id, idMaxLen))} + } + return nil +} + +// validateSections 校验面板的区块列表,并保证区块 id 在面板内唯一。 +func validateSections(sections []sdk.Section, panelPrefix string) []Violation { + var vs []Violation + seen := make(map[string]bool, len(sections)) + for i, s := range sections { + path := fmt.Sprintf("%s.sections[%d]", panelPrefix, i) + vs = append(vs, validateSectionAt(s, path)...) + if s.ID == "" { + continue + } + if seen[s.ID] { + vs = append(vs, dupViolation(path+".id", "本面板", "区块 id", s.ID)) + continue + } + seen[s.ID] = true + } + return vs +} + +// validateSectionAt 校验一个区块:类型闭集 → id/标题 → 形状矩阵 → 载荷(字段/列/动作)。 +func validateSectionAt(s sdk.Section, path string) []Violation { + if !s.Type.Valid() { + // 类型不在闭集 ⇒ 形状矩阵无从谈起。只报这一条:继续按未知类型推导出的 + // 「缺 submit / 缺 columns」全是噪音,会把真正的病因埋掉。 + return []Violation{violation(ErrUnknownSectionType, path+".type", + fmt.Sprintf("未知 section 类型 %q(闭集:form|table|status|log)", s.Type))} + } + var vs []Violation + vs = append(vs, checkID(s.ID, path+".id", "区块 id", ErrInvalidSectionID)...) + vs = append(vs, checkRequiredText(s.Title, path+".title", titleMaxRunes)...) + vs = append(vs, checkSectionShape(s, path)...) + vs = append(vs, validateSectionPayload(s, path)...) + return vs +} + +// checkSectionShape 按 §5.2.1 的接入点表检查「哪些键必须出现 / 不许出现」。 +func checkSectionShape(s sdk.Section, path string) []Violation { + sh := shapeOf(s.Type) + // 六个键 × 四种必要性。写成表是为了让每个键的判定逐行可读、加键时只改一处。 + keys := []struct { + key string + req requirement + has bool + }{ + {"fields", sh.fields, len(s.Fields) > 0}, + {"submit", sh.submit, s.Submit != nil}, + {"source", sh.source, s.Source != nil}, + {"columns", sh.columns, len(s.Columns) > 0}, + {"rowActions", sh.rowActions, len(s.RowActions) > 0}, + {"limit", sh.limit, s.Limit != 0}, + } + var vs []Violation + for _, k := range keys { + kp := path + "." + k.key + switch k.req { + case required: + if !k.has { + vs = append(vs, violation(ErrMissingField, kp, + fmt.Sprintf("%s 区块必须声明 %s", s.Type, k.key))) + } + case notAllowed: + if k.has { + vs = append(vs, violation(ErrSectionShape, kp, + fmt.Sprintf("%s 区块不消费 %s(形状见 §5.2.1 的接入点表)", s.Type, k.key))) + } + case writeForbidden: + if k.has { + vs = append(vs, violation(ErrReadOnlySection, kp, + fmt.Sprintf("%s 是只读区块,不得声明写面 %s(§5.4.1:写动作一律 owner/admin)", s.Type, k.key))) + } + case optional: + } + } + return vs +} + +// validateSectionPayload 校验区块内各元素的载荷(动作名、字段、列、行内动作、limit)。 +func validateSectionPayload(s sdk.Section, path string) []Violation { + var vs []Violation + switch s.Type { + case sdk.SectionForm, sdk.SectionStatus: + vs = append(vs, validateFields(s.Fields, path)...) + } + if s.Submit != nil { + vs = append(vs, checkAction(s.Submit.Action, path+".submit.action")...) + } + if s.Source != nil { + vs = append(vs, checkAction(s.Source.Action, path+".source.action")...) + } + if s.Type == sdk.SectionTable { + vs = append(vs, validateColumns(s.Columns, path)...) + vs = append(vs, validateRowActions(s.RowActions, path)...) + } + if s.Type == sdk.SectionLog { + vs = append(vs, checkLimit(s.Limit, path+".limit")...) + } + return vs +} + +// checkAction 校验动作名。依据 §5.4.4:插件只给**动作名**、不给 URL,所以这里连 +// `:` 与 `/` 都不接受 —— 想塞请求地址的声明在此被拒。空动作名是必填缺失(有 submit / +// source 却没动作,前端点下去什么都不会发生,即半渲染)。 +func checkAction(action, path string) []Violation { + if action == "" { + return []Violation{violation(ErrMissingField, path, "动作名必填(插件只给动作名,不给 URL)")} + } + if !validAction(action) { + return []Violation{violation(ErrInvalidAction, path, fmt.Sprintf( + "动作名 %q 非法:只允许点分标识(如 plugin.saveProvider),不得含 `/` 或 `:` 等 URL 成分", + action))} + } + return nil +} + +// checkLimit 校验 log 区块的 limit。0 表示未声明(由宿主取默认),非 0 必须在 (0, maxLogLimit]。 +func checkLimit(limit int, path string) []Violation { + if limit == 0 { + return nil + } + if limit < 0 || limit > maxLogLimit { + return []Violation{violation(ErrInvalidLimit, path, + fmt.Sprintf("limit 必须在 (0, %d] 内(log 是排障用的只读尾读,超大值会把页面卡死),得到 %d", + maxLogLimit, limit))} + } + return nil +} + +// validateColumns 校验表格列:key 形状与唯一性、label 文案。 +func validateColumns(cols []sdk.Column, prefix string) []Violation { + var vs []Violation + seen := make(map[string]bool, len(cols)) + for i, c := range cols { + path := fmt.Sprintf("%s.columns[%d]", prefix, i) + if c.Key == "" { + vs = append(vs, violation(ErrMissingField, path+".key", "列 key 必填(它是行数据字段名与列的身份)")) + } else if !validFieldKey(c.Key) { + vs = append(vs, violation(ErrInvalidFieldKey, path+".key", fieldKeyDetail(c.Key))) + } else if seen[c.Key] { + vs = append(vs, dupViolation(path+".key", "本表格", "列 key", c.Key)) + } else { + seen[c.Key] = true + } + vs = append(vs, checkOptionalText(c.Label, path+".label", titleMaxRunes)...) + } + return vs +} + +// validateRowActions 校验行内动作:id 形状与唯一性、label 必填、动作名合法。 +func validateRowActions(actions []sdk.RowAction, prefix string) []Violation { + var vs []Violation + seen := make(map[string]bool, len(actions)) + for i, a := range actions { + path := fmt.Sprintf("%s.rowActions[%d]", prefix, i) + vs = append(vs, checkID(a.ID, path+".id", "行内动作 id", ErrInvalidRowActionID)...) + // label 必填且**没有可回落的东西**:不像列 label 能回落到 key,没有文字的行内按钮 + // 就是半渲染。 + vs = append(vs, checkRequiredText(a.Label, path+".label", titleMaxRunes)...) + vs = append(vs, checkAction(a.Action, path+".action")...) + if a.ID == "" { + continue + } + if seen[a.ID] { + vs = append(vs, dupViolation(path+".id", "本表格", "行内动作 id", a.ID)) + continue + } + seen[a.ID] = true + } + return vs +} + +// dupViolation 生成「同作用域内重复」的违规。三处调用共用同一措辞,避免同一类问题在 +// 不同位置报出不同说法。 +func dupViolation(path, scope, kind, value string) Violation { + return violation(ErrDuplicateID, path, + fmt.Sprintf("%s %q 在%s内重复(重复会让入口/锚点/提交键无法区分)", kind, value, scope)) +} diff --git a/internal/capability/paneldecl/validate_field_test.go b/internal/capability/paneldecl/validate_field_test.go new file mode 100644 index 0000000..8a1634d --- /dev/null +++ b/internal/capability/paneldecl/validate_field_test.go @@ -0,0 +1,186 @@ +package paneldecl + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是字段层与表格元素层(列 / 行内动作)的拒绝用例。 + +// rejectCasesFieldCore 是字段的必填、形状与选项来源。 +func rejectCasesFieldCore() []rejectCase { + return []rejectCase{ + { + name: "字段 key 缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields[0].key", + }, + { + name: "字段 key 形状非法(中划线)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Key = "api-key" }) + }, + want: ErrInvalidFieldKey, wantPath: "adminPanels[0].sections[0].fields[0].key", + }, + { + name: "字段类型不在闭集", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[0].Type = "chart" }) + }, + want: ErrUnknownFieldType, wantPath: "adminPanels[0].sections[0].fields[0].type", + }, + { + name: "字段 key 在区块内重复", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields[1].Key = "provider" }) + }, + want: ErrDuplicateID, wantPath: "adminPanels[0].sections[0].fields[1].key", + }, + { + name: "optionsFrom 塞了任意 URL", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[0].OptionsFrom = "https://api.example.com/list" + }) + }, + want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[0].optionsFrom", + }, + { + name: "optionsFrom 配在 text 字段上", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[2].OptionsFrom = "plugin:providers" + }) + }, + want: ErrInvalidOptionsFrom, wantPath: "adminPanels[0].sections[0].fields[2].optionsFrom", + }, + { + name: "字段文案含控制字符", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[2].Label = "接口\n地址" + }) + }, + want: ErrInvalidText, wantPath: "adminPanels[0].sections[0].fields[2].label", + }, + } +} + +// rejectCasesFieldBoundsAndDefaults 是 secret 默认值、数值边界与默认值类型。 +func rejectCasesFieldBoundsAndDefaults() []rejectCase { + return []rejectCase{ + { + name: "secret 字段声明默认值", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[1].Default = "sk-leaked" + }) + }, + want: ErrSecretDefault, wantPath: "adminPanels[0].sections[0].fields[1].default", + }, + { + name: "min/max 配在 text 字段上", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[2].Min = floatPtr(1) + }) + }, + want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min", + }, + { + name: "min 大于 max", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + f := &p.Sections[0].Fields[2] + f.Type = sdk.FieldNumber + f.Min = floatPtr(10) + f.Max = floatPtr(1) + }) + }, + want: ErrInvalidBounds, wantPath: "adminPanels[0].sections[0].fields[2].min", + }, + { + name: "默认值与字段类型不匹配(下拉字段给了数字)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Fields[0].Default = 42 + }) + }, + want: ErrInvalidDefault, wantPath: "adminPanels[0].sections[0].fields[0].default", + }, + } +} + +// rejectCasesTableItems 是列与行内动作的拒绝用例。 +func rejectCasesTableItems() []rejectCase { + return []rejectCase{ + { + name: "列 key 缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Key = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns[0].key", + }, + { + name: "列 key 重复", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[1].Key = "provider" }) + }, + want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].columns[1].key", + }, + { + name: "列 label 含控制字符", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns[0].Label = "状\t态" }) + }, + want: ErrInvalidText, wantPath: "adminPanels[0].sections[1].columns[0].label", + }, + { + name: "行内动作 id 含大写", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].ID = "Test" }) + }, + want: ErrInvalidRowActionID, wantPath: "adminPanels[0].sections[1].rowActions[0].id", + }, + { + name: "行内动作缺 button 文案", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[0].Label = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[1].rowActions[0].label", + }, + { + name: "行内动作塞了任意 URL", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[1].RowActions[0].Action = "https://evil.example.com/x" + }) + }, + want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].rowActions[0].action", + }, + { + name: "行内动作 id 重复", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].RowActions[1].ID = "test" }) + }, + want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].rowActions[1].id", + }, + { + name: "submit 有动作键但没有动作名", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = &sdk.Submit{} }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit.action", + }, + { + name: "source 动作名含冒号(URL 形态)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[1].Source = &sdk.Source{Action: "http://api.example.com/list"} + }) + }, + want: ErrInvalidAction, wantPath: "adminPanels[0].sections[1].source.action", + }, + } +} diff --git a/internal/capability/paneldecl/validate_section_test.go b/internal/capability/paneldecl/validate_section_test.go new file mode 100644 index 0000000..b2f2376 --- /dev/null +++ b/internal/capability/paneldecl/validate_section_test.go @@ -0,0 +1,174 @@ +package paneldecl + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件是区块层的拒绝用例:类型闭集、id/标题必填、以及 §5.2.1 接入点表的形状矩阵 +// (哪些键必须出现、哪些不许出现、哪些是只读区块上的写面)。 + +// rejectCasesSectionCommon 是区块的通用必填与唯一性。 +func rejectCasesSectionCommon() []rejectCase { + return []rejectCase{ + { + name: "区块类型不在闭集", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Type = "chart" }) + }, + want: ErrUnknownSectionType, wantPath: "adminPanels[0].sections[0].type", + }, + { + name: "区块 id 缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].ID = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].id", + }, + { + name: "区块标题缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Title = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].title", + }, + { + name: "区块 id 在面板内重复", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].ID = "add" }) + }, + want: ErrDuplicateID, wantPath: "adminPanels[0].sections[1].id", + }, + } +} + +// rejectCasesFormShape 是 form 区块的形状(§5.2.1 section.form 只消费 fields[] + submit.action)。 +func rejectCasesFormShape() []rejectCase { + return []rejectCase{ + { + name: "form 缺 submit", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Submit = nil }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].submit", + }, + { + name: "form 缺 fields", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[0].Fields = nil }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].fields", + }, + { + name: "form 声明 source(形状错误)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Source = &sdk.Source{Action: "plugin.listProviders"} + }) + }, + want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source", + }, + { + name: "form 声明 columns(形状错误)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[0].Columns = []sdk.Column{{Key: "k"}} + }) + }, + want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].columns", + }, + } +} + +// rejectCasesTableShape 是 table 区块的形状(§5.2.1 section.table 消费 columns[]/rowActions[]/source)。 +func rejectCasesTableShape() []rejectCase { + return []rejectCase{ + { + name: "table 缺 columns", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Columns = nil }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[1].columns", + }, + { + name: "table 缺 source", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Sections[1].Source = nil }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[1].source", + }, + { + name: "table 上出现写面 submit", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[1].Submit = &sdk.Submit{Action: "plugin.saveProvider"} + }) + }, + want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[1].submit", + }, + { + name: "table 声明字段(形状错误)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { + p.Sections[1].Fields = []sdk.Field{{Key: "state", Type: sdk.FieldText}} + }) + }, + want: ErrSectionShape, wantPath: "adminPanels[0].sections[1].fields", + }, + } +} + +// rejectCasesReadOnlySections 是只读区块(status / log)的写面与形状。 +func rejectCasesReadOnlySections() []rejectCase { + return []rejectCase{ + { + name: "status 上出现写面 submit", + panels: func() []sdk.Panel { + return panelsOf(statusPanel(), func(p *sdk.Panel) { + p.Sections[0].Submit = &sdk.Submit{Action: "plugin.saveProvider"} + }) + }, + want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].submit", + }, + { + name: "status 上出现行内动作", + panels: func() []sdk.Panel { + return panelsOf(statusPanel(), func(p *sdk.Panel) { + p.Sections[0].RowActions = []sdk.RowAction{{ID: "test", Label: "测试", Action: "plugin.t"}} + }) + }, + want: ErrReadOnlySection, wantPath: "adminPanels[0].sections[0].rowActions", + }, + { + name: "status 声明 source(形状错误)", + panels: func() []sdk.Panel { + return panelsOf(statusPanel(), func(p *sdk.Panel) { + p.Sections[0].Source = &sdk.Source{Action: "plugin.chain"} + }) + }, + want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].source", + }, + { + name: "log 缺 source", + panels: func() []sdk.Panel { + return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Source = nil }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].sections[0].source", + }, + { + name: "log 的 limit 越界", + panels: func() []sdk.Panel { + return panelsOf(logPanel(), func(p *sdk.Panel) { p.Sections[0].Limit = 5000 }) + }, + want: ErrInvalidLimit, wantPath: "adminPanels[0].sections[0].limit", + }, + { + name: "log 声明 fields(形状错误)", + panels: func() []sdk.Panel { + return panelsOf(logPanel(), func(p *sdk.Panel) { + p.Sections[0].Fields = []sdk.Field{{Key: "line", Type: sdk.FieldText}} + }) + }, + want: ErrSectionShape, wantPath: "adminPanels[0].sections[0].fields", + }, + } +} diff --git a/internal/capability/paneldecl/validate_test.go b/internal/capability/paneldecl/validate_test.go new file mode 100644 index 0000000..15c87a6 --- /dev/null +++ b/internal/capability/paneldecl/validate_test.go @@ -0,0 +1,249 @@ +package paneldecl + +import ( + "errors" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定「非法声明逐条可分类」这条验收线:每一类非法声明一条用例,断言的是 +// **分类哨兵 + 出错路径**,不是错误字符串(字符串匹配会在改文案时假绿)。 +// +// 用例表按层次分在三个文件里:本文件(插件/面板层与聚合错误)、validate_section_test.go +// (区块层)、validate_field_test.go(字段与表格元素层)。 + +// rejectCase 是一条拒绝用例。 +type rejectCase struct { + name string + // pluginID 为空表示用默认的 "web-search";blankPluginID 用于「插件 id 缺失」这条。 + pluginID string + blankPluginID bool + panels func() []sdk.Panel + want error + wantPath string +} + +// panelsOf 用 base 造一份面板并施加 edit(edit 为 nil 表示原样)。 +func panelsOf(base sdk.Panel, edit func(*sdk.Panel)) []sdk.Panel { + p := base + if edit != nil { + edit(&p) + } + return []sdk.Panel{p} +} + +// requireViolation 断言 err 中存在一条 Kind==want 且 Path==wantPath 的违规。 +// +// 两条断言缺一不可:errors.Is 证明**可分类**,Path 证明指的是**那一处**(而不是碰巧 +// 别的字段也不合法)。 +func requireViolation(t *testing.T, err error, want error, wantPath string) { + t.Helper() + if err == nil { + t.Fatalf("期望校验失败(%v @ %s),实际通过", want, wantPath) + } + if !errors.Is(err, want) { + t.Fatalf("errors.Is(err, %v) 为假;err = %v", want, err) + } + var agg *Errors + if !errors.As(err, &agg) { + t.Fatalf("err 不是 *Errors 聚合,实际 %T:%v", err, err) + } + for _, v := range agg.Violations { + if errors.Is(v.Kind, want) && v.Path == wantPath { + return + } + } + t.Fatalf("聚合错误里没有 %v @ %s 这一条;err = %v", want, wantPath, err) +} + +// allRejectCases 汇总三个层次的拒绝用例。 +func allRejectCases() []rejectCase { + cases := rejectCasesPanelLevel() + cases = append(cases, rejectCasesSectionCommon()...) + cases = append(cases, rejectCasesFormShape()...) + cases = append(cases, rejectCasesTableShape()...) + cases = append(cases, rejectCasesReadOnlySections()...) + cases = append(cases, rejectCasesFieldCore()...) + cases = append(cases, rejectCasesFieldBoundsAndDefaults()...) + cases = append(cases, rejectCasesTableItems()...) + return cases +} + +func TestValidate_Rejects(t *testing.T) { + t.Parallel() + for _, tc := range allRejectCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + pluginID := tc.pluginID + if pluginID == "" && !tc.blankPluginID { + pluginID = "web-search" + } + requireViolation(t, Validate(pluginID, tc.panels()), tc.want, tc.wantPath) + }) + } +} + +// rejectCasesPanelLevel 是插件 id 与面板层的拒绝用例。 +func rejectCasesPanelLevel() []rejectCase { + return []rejectCase{ + { + name: "插件 id 含空格与大写(不能当路由段)", pluginID: "Web Search", + panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) }, + want: ErrInvalidPluginID, wantPath: "pluginID", + }, + { + name: "插件 id 缺失", blankPluginID: true, + panels: func() []sdk.Panel { return panelsOf(samplePanel(), nil) }, + want: ErrMissingField, wantPath: "pluginID", + }, + { + name: "面板 id 缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "" }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].id", + }, + { + name: "面板 id 含大写与斜杠", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.ID = "Providers/List" }) + }, + want: ErrInvalidPanelID, wantPath: "adminPanels[0].id", + }, + { + name: "面板标题缺失", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Title = " " }) + }, + want: ErrMissingField, wantPath: "adminPanels[0].title", + }, + { + name: "分组不在闭集(本期只有 plugins)", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Group = "search" }) + }, + want: ErrUnknownGroup, wantPath: "adminPanels[0].group", + }, + { + name: "order 为负", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Order = -1 }) + }, + want: ErrInvalidOrder, wantPath: "adminPanels[0].order", + }, + { + name: "图标名塞了 SVG 片段", + panels: func() []sdk.Panel { + return panelsOf(samplePanel(), func(p *sdk.Panel) { p.Icon = "" }) + }, + want: ErrInvalidIcon, wantPath: "adminPanels[0].icon", + }, + { + name: "面板 id 在本插件内重复", + panels: func() []sdk.Panel { + second := statusPanel() + second.ID = "providers" + return []sdk.Panel{samplePanel(), second} + }, + want: ErrDuplicateID, wantPath: "adminPanels[1].id", + }, + } +} + +// TestValidate_AcceptsSampleDeclaration 锁定「合法声明照单通过」:夹具本身就是 §5.2 的示例, +// 它必须过 —— 否则上面的拒绝用例可能只是因为夹具本来就不合法(假绿)。 +func TestValidate_AcceptsSampleDeclaration(t *testing.T) { + t.Parallel() + cases := map[string]sdk.Panel{ + "§5.2 示例面板": samplePanel(), + "只读事实面": statusPanel(), + "只读日志卡": logPanel(), + } + for name, panel := range cases { + panel := panel + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := Validate("web-search", []sdk.Panel{panel}); err != nil { + t.Fatalf("合法声明被拒:%v", err) + } + }) + } +} + +// TestValidate_AllowsDocumentedLeniency 锁定几处**刻意放宽**的口径,防止后续把「收紧」 +// 当成默认动作无限加码(设计没禁止的就不该拒)。见 PLG1-report.md。 +func TestValidate_AllowsDocumentedLeniency(t *testing.T) { + t.Parallel() + // 空分组 ⇒ 回落到唯一分组(§5.2「本期固定 plugins」),不算必填缺失。 + noGroup := samplePanel() + noGroup.Group = "" + // 零区块面板 ⇒ §5.1「一个插件可声明零到多个管理页面」,面板本身可以是纯壳。 + shell := samplePanel() + shell.Sections = nil + // when 缺省 ⇒ 无条件显示(条件显示是可选能力)。 + noWhen := samplePanel() + noWhen.When = nil + // 列/字段的 label 缺省 ⇒ 宿主回落到 key。 + noLabel := samplePanel() + noLabel.Sections[0].Fields[0].Label = "" + noLabel.Sections[1].Columns[0].Label = "" + + for name, panel := range map[string]sdk.Panel{ + "空分组回落": noGroup, "零区块面板": shell, "when 缺省": noWhen, "label 缺省": noLabel, + } { + panel := panel + t.Run(name, func(t *testing.T) { + t.Parallel() + if err := Validate("web-search", []sdk.Panel{panel}); err != nil { + t.Fatalf("刻意放宽的口径被拒:%v", err) + } + }) + } +} + +// TestValidatePanel_PathUsesIndex 锁定单面板校验的路径锚点与拒绝粒度(§5.2.1「该面板不注册」)。 +func TestValidatePanel_PathUsesIndex(t *testing.T) { + t.Parallel() + if err := ValidatePanel(samplePanel(), 0); err != nil { + t.Fatalf("合法面板被拒:%v", err) + } + broken := statusPanel() + broken.Sections[0].Source = &sdk.Source{Action: "plugin.chain"} + requireViolation(t, ValidatePanel(broken, 7), ErrSectionShape, "adminPanels[7].sections[0].source") +} + +// TestErrors_IsBoundedAndClassifiable 锁定聚合错误的两件事:Error() 不无界铺开(一条坏清单 +// 可能有几百条违规),但结构化的完整列表一条不少。 +func TestErrors_IsBoundedAndClassifiable(t *testing.T) { + t.Parallel() + var vs []Violation + for i := 0; i < maxReportedViolations+3; i++ { + vs = append(vs, violation(ErrInvalidPanelID, panelPath(i)+".id", "坏 id")) + } + err := violationsToError(vs) + if !errors.Is(err, ErrInvalidPanelID) { + t.Fatalf("errors.Is 未穿透聚合:%v", err) + } + var agg *Errors + if !errors.As(err, &agg) { + t.Fatalf("errors.As 未取到 *Errors:%T", err) + } + if len(agg.Violations) != maxReportedViolations+3 { + t.Fatalf("结构化列表条数 = %d,期望 %d", len(agg.Violations), maxReportedViolations+3) + } + if n := strings.Count(err.Error(), "坏 id"); n != maxReportedViolations { + t.Fatalf("Error() 铺设了 %d 条违规,期望最多 %d 条", n, maxReportedViolations) + } + if !strings.Contains(err.Error(), "more") { + t.Fatalf("Error() 应提示还有更多条违规:%s", err.Error()) + } + if errors.Is(err, ErrMissingField) { + t.Fatal("无关哨兵不该被判为命中(分类必须精确)") + } + if got := violationsToError(nil); got != nil { + t.Fatalf("空违规应返回 nil,得到 %v", got) + } +} diff --git a/internal/capability/paneldecl/when.go b/internal/capability/paneldecl/when.go new file mode 100644 index 0000000..0701adc --- /dev/null +++ b/internal/capability/paneldecl/when.go @@ -0,0 +1,40 @@ +package paneldecl + +import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + +// 本文件是 `when` 条件显示的求值(§5.2.1 nav.item:「`when` 不满足 ⇒ 条目不出现」)。 + +// Context 是 when 求值的上下文:**调用方注入的最小事实**。 +// +// 为什么本包不自己去问「这个插件启用了吗」:AGENTS §9 要求单一事实来源,启用状态的真源在 +// 插件管理器(§5.2.1 B 的生命周期一栏),本包去读 config / env / 文件只会造出第二份可能 +// 不一致的状态,而且让单测依赖外部环境。所以这里只声明「求值需要什么」,由调用方填。 +type Context struct { + // Enabled 是「该插件当前已启用」这一事实。install / enable / disable / uninstall + // 之后由管理器给出最新值,对应 §5.2.1「都重算面板集合」。 + Enabled bool +} + +// Resolver 按插件 id 给出求值上下文。用函数类型而不是接口:调用方通常只是读一张 map 或 +// 问一次管理器,闭包足够,不值得为它定义一个类型。 +// +// 约束(写在这里因为编译器管不了):Resolver 不得回调 Catalog 的 Register/List/Groups —— +// 那些方法持有目录锁,回调会自锁死。 +type Resolver func(pluginID string) Context + +// Eval 求值一份 when 声明,报告该面板的入口是否应当出现。 +// +// 语义(本期闭集只有 enabled 一个条件键): +// - when 为 nil、或对象里没写 enabled ⇒ **无条件** ⇒ 显示(条件显示是可选能力, +// 不写就等于不设条件); +// - when.enabled = true ⇒ 仅当 ctx.Enabled 为真时显示(§5.6.1「未启用的插件 ⇒ 入口不出现」); +// - when.enabled = false ⇒ 仅当 ctx.Enabled 为假时显示 —— 条件是「要求值相等」, +// 写 false 不等于没写(把 false 当「无条件」会让声明表达力凭空消失一半)。 +// +// 「不满足」的效果是**入口整个不出现**,不是灰掉(§5.2.1 nav.item 明写)。 +func Eval(w *sdk.When, ctx Context) bool { + if w == nil || w.Enabled == nil { + return true + } + return ctx.Enabled == *w.Enabled +} diff --git a/pkg/plugins/sdk/panel.go b/pkg/plugins/sdk/panel.go new file mode 100644 index 0000000..8b2aba2 --- /dev/null +++ b/pkg/plugins/sdk/panel.go @@ -0,0 +1,153 @@ +package sdk + +// 本文件是 design/09-插件生态 §5.2「声明形状(唯一权威形态)」在 Go 侧的唯一定义: +// 插件作者写下的就是这份形状(plugins//manifest.json 的 adminPanels 段)。 +// +// 为什么声明放在 SDK 而不是后端内部包:§5.2.1 的红线是「声明而非编码」—— 插件不注入 +// 任何 JS/CSS/HTML,只说「我要什么字段、怎么校验、失败怎么说」,渲染与流程由宿主负责 +// (面板可接入点 + 插件管理器)。声明既然是插件作者可见的对外契约,它就属于 SDK; +// JSON tag 与 §5.2 的示例逐字同形,改 tag 等于改对外契约。 +// +// 本文件只放**数据形状 + 闭集判定**:校验、目录(catalog)、when 求值、角色过滤都在 +// internal/capability/paneldecl —— 数据与策略分开,插件包不依赖任何内部包。 + +// GroupID 是侧边栏折叠分组 id(§5.2 的 `group`)。 +type GroupID string + +const ( + // GroupPlugins 是本期的唯一分组。依据 §5.2「group: 侧边栏折叠分组 id(本期固定 + // plugins)」—— 闭集只有一个成员,因此空值可以安全地回落到它(回落动作在 + // paneldecl.Register/Validate 里做,SDK 这里只给判定)。 + GroupPlugins GroupID = "plugins" +) + +// Valid 报告 g 是否属于本期已知的分组闭集。 +func (g GroupID) Valid() bool { return g == GroupPlugins } + +// SectionType 是 section 的渲染形状(§5.2 的 section 类型闭集)。 +// +// 闭集是刻意的:它让「插件声明 UI」在没有任意代码注入的前提下仍然够用,并且每个形状 +// 都能复用既有 Miuix 组件(§5.2 末尾的口径)。 +type SectionType string + +const ( + // SectionForm 是表单区块:消费 `fields[]` + `submit.action`(§5.2.1 section.form), + // 由既有字段渲染器泛化渲染。它是四种区块里**唯一含写面**的一种。 + SectionForm SectionType = "form" + // SectionTable 是表格区块:消费 `columns[]` / `rowActions[]` / `source`(§5.2.1 + // section.table),由既有虚拟滚动表格渲染。 + SectionTable SectionType = "table" + // SectionStatus 是只读事实面(如「当前生效的降级链」):消费只读 `fields[]` + // (§5.2.1 section.status)。 + SectionStatus SectionType = "status" + // SectionLog 是只读日志卡:消费 `source` / `limit`,最新在上(§5.2.1 section.log, + // 与 L1 口径一致)。 + SectionLog SectionType = "log" +) + +// Valid 报告 t 是否属于 section 类型闭集。 +func (t SectionType) Valid() bool { + switch t { + case SectionForm, SectionTable, SectionStatus, SectionLog: + return true + default: + return false + } +} + +// ReadOnly 报告该 section 类型本身是否只读。角色过滤的第一道判据 +// (design/09 §5.4.1「只读事实面对 viewer 可见,写动作一律 owner/admin」): +// 只有 form 自带写面(submit),table 的行内动作需要额外摘除(见 paneldecl.filterPanel)。 +func (t SectionType) ReadOnly() bool { return t != SectionForm } + +// Panel 是一个插件声明的管理页面(§5.2 的 adminPanels 元素)。 +type Panel struct { + // ID 是面板 id。它与插件 id 组合成前端路由 /admin/plugin// + // (§5.7 C),所以必须能直接当 URL 路径段与端点段用 —— 字符集由 paneldecl 校验, + // 不在这里放宽。 + ID string `json:"id"` + // Title 是页面标题,同时是侧边栏条目文案(§5.2.1 nav.item 消费 id/title/icon/order/when)。 + Title string `json:"title"` + // Description 是页面描述。§5.2 的示例里没有这一行,但 §5.2.1 的 `page.root` 一行 + // 明确写「消费 title / description」—— 取并集,缺省为空(不一致处记在 PLG1-report.md)。 + Description string `json:"description,omitempty"` + // Group 是侧边栏折叠分组 id。空值按 §5.2「本期固定 plugins」回落到 GroupPlugins; + // 非空但不是闭集成员 ⇒ 校验拒绝。 + Group GroupID `json:"group"` + // Order 是组内排序,小在前;同 order 时按声明顺序(稳定序,前端导航不许因 map 迭代 + // 抖动)。 + Order int `json:"order"` + // Icon 是宿主图标名(§5.2 的 `icon`,示例为 "search")。宿主按名字取内置图标, + // 因此这里只接受图标名,不接受 URL / SVG 片段 —— 它是「无任意代码注入」的一部分。 + Icon string `json:"icon,omitempty"` + // When 是条件显示声明:不满足 ⇒ 入口**不出现**(不是灰掉,§5.2.1 nav.item)。 + // 缺省(nil)表示无条件显示。求值在 paneldecl.Eval,上下文由调用方注入。 + When *When `json:"when,omitempty"` + // Sections 是页面内的区块列表。允许为空(§5.1「一个插件可声明**零到多个**管理页面」, + // 面板本身也可以是纯壳),不做「空面板即非法」的收紧。 + Sections []Section `json:"sections"` +} + +// Section 是页面内的一个区块(§5.2.1 section.form / section.table / section.status / +// section.log 四个接入点之一)。 +// +// 每种类型**消费哪些键**由 §5.2.1 的接入点表定死,paneldecl 据此做形状校验:形状错了 +// 到前端就是「区块在、功能不在」的半渲染(§5.2.1 点名要防的),所以按面板整体拒掉。 +type Section struct { + // Type 决定这个区块怎么渲染,必须落在闭集内(form|table|status|log)。 + Type SectionType `json:"type"` + // ID 是区块 id,在面板内唯一:它既是前端渲染 key,也是「哪一段坏了」的定位锚点。 + ID string `json:"id"` + // Title 是区块标题。block 无标题会让用户看到一段无来由的表单/表格,因此必填。 + Title string `json:"title"` + // Fields 是字段声明:form(可写)与 status(只读)消费。 + Fields []Field `json:"fields,omitempty"` + // Submit 是表单提交动作:只有 form 消费(写面的唯一入口)。 + Submit *Submit `json:"submit,omitempty"` + // Source 是数据来源:table 与 log 消费。它只给**动作名**,不给 URL(§5.4.4)。 + Source *Source `json:"source,omitempty"` + // Columns 是表格列声明:只有 table 消费。 + Columns []Column `json:"columns,omitempty"` + // RowActions 是行内动作:只有 table 消费(如「测试连通」「重配凭据」)。 + RowActions []RowAction `json:"rowActions,omitempty"` + // Limit 是 log 区块只读的最近条数(§5.2.1 section.log 消费 source/limit)。 + // §5.2 的示例里没有这个键 —— 不一致处记在 PLG1-report.md。 + Limit int `json:"limit,omitempty"` +} + +// Source 是 table / log 的数据来源。 +// +// 为什么只有一个 action 字段:§5.4.4 的硬约束是「插件声明的只是**动作名**,不是任意 +// URL ⇒ 不给插件开 SSRF 面」。所以这里既没有 url 也没有 path —— 想塞 URL 的插件会 +// 在校验期被拒(paneldecl)。 +type Source struct { + // Action 是插件声明的动作名(示例 plugin.listProviders),由宿主端点 + // POST /api/v1/admin/plugins/{id}/actions/{action} 分发。 + Action string `json:"action"` +} + +// Submit 是 form 区块的提交动作(§5.2 的 `submit.action`)。 +type Submit struct { + // Action 的语义与 Source.Action 相同:只有动作名,没有 URL。 + Action string `json:"action"` +} + +// Column 是 table 的一列(§5.2 的 `columns` 元素)。 +type Column struct { + // Key 是行数据里的字段名,也是列的身份:列内唯一,用于渲染与排序。 + Key string `json:"key"` + // Label 是表头文案。缺省时宿主回落到 Key(Key 是机器可读标识且列内唯一, + // 有可靠回落 ⇒ 不作为必填)。 + Label string `json:"label,omitempty"` +} + +// RowAction 是 table 的行内动作(§5.2 的 `rowActions` 元素,示例「测试连通」/「重配凭据」)。 +type RowAction struct { + // ID 是动作在行内的身份,必须唯一 —— 同一行里两个同名动作会让用户点错。 + ID string `json:"id"` + // Label 是按钮文案。**必填且没有可回落的东西**(不像 Column.Label 能回落到 Key): + // 一个没有文字的按钮就是半渲染。 + Label string `json:"label"` + // Action 是插件声明的动作名(同样只有名字、没有 URL)。 + Action string `json:"action"` +} diff --git a/pkg/plugins/sdk/panel_test.go b/pkg/plugins/sdk/panel_test.go new file mode 100644 index 0000000..66bbdce --- /dev/null +++ b/pkg/plugins/sdk/panel_test.go @@ -0,0 +1,229 @@ +package sdk + +import ( + "encoding/json" + "errors" + "reflect" + "testing" +) + +// 本文件锁定两件事: +// 1. 声明的 JSON tag 与 design/09 §5.2 的示例**逐字同形**(示例 JSON 直接解码进来); +// 2. 闭集(分组 / section 类型 / 字段类型)与 when 的严格解码确实按闭集工作。 + +// designExample 是 design/09-插件生态 §5.2 示例里 adminPanels 段的**逐字拷贝** +// (只去掉 JSONC 注释)。它在这里是权威输入:tag 一旦改坏,本用例必红。 +const designExample = `[ + { + "id": "providers", + "title": "搜索提供商", + "group": "plugins", + "order": 20, + "icon": "search", + "when": { "enabled": true }, + "sections": [ + { "type": "form", "id": "add", "title": "添加提供商", + "fields": [ + { "key": "provider", "label": "提供商", "type": "select", "optionsFrom": "plugin:providers", "required": true }, + { "key": "apiKey", "label": "API Key", "type": "secret", "required": true }, + { "key": "endpoint", "label": "接口地址", "type": "text", "placeholder": "https://..." } + ], + "submit": { "action": "plugin.saveProvider" } }, + { "type": "table", "id": "list", "title": "已配置的提供商", + "source": { "action": "plugin.listProviders" }, + "columns": [ + { "key": "provider", "label": "提供商" }, + { "key": "state", "label": "状态" } + ], + "rowActions": [ + { "id": "test", "label": "测试连通", "action": "plugin.testProvider" }, + { "id": "reauth","label": "重配凭据", "action": "plugin.reauthProvider" } + ] } + ] + }]` + +// TestPanel_DecodesDesignExample 证明 §5.2 的示例能原样解码进我们的类型。 +func TestPanel_DecodesDesignExample(t *testing.T) { + t.Parallel() + var panels []Panel + if err := json.Unmarshal([]byte(designExample), &panels); err != nil { + t.Fatalf("§5.2 示例解码失败(说明 JSON tag 与文档不同形):%v", err) + } + if len(panels) != 1 { + t.Fatalf("面板数 = %d,期望 1", len(panels)) + } + p := panels[0] + if p.ID != "providers" || p.Title != "搜索提供商" || p.Group != GroupPlugins { + t.Fatalf("面板头字段解码错误:%+v", p) + } + if p.Order != 20 || p.Icon != "search" { + t.Fatalf("order/icon 解码错误:%+v", p) + } + if p.When == nil || p.When.Enabled == nil || !*p.When.Enabled { + t.Fatalf("when 解码错误:%+v", p.When) + } + if len(p.Sections) != 2 { + t.Fatalf("区块数 = %d,期望 2", len(p.Sections)) + } + form := p.Sections[0] + if form.Type != SectionForm || form.ID != "add" || form.Submit == nil || + form.Submit.Action != "plugin.saveProvider" { + t.Fatalf("form 区块解码错误:%+v", form) + } + if len(form.Fields) != 3 || form.Fields[0].OptionsFrom != "plugin:providers" || + !form.Fields[0].Required || form.Fields[1].Type != FieldSecret || + form.Fields[2].Placeholder != "https://..." { + t.Fatalf("字段解码错误:%+v", form.Fields) + } + table := p.Sections[1] + if table.Type != SectionTable || table.Source == nil || + table.Source.Action != "plugin.listProviders" { + t.Fatalf("table 区块解码错误:%+v", table) + } + if len(table.Columns) != 2 || table.Columns[1].Key != "state" { + t.Fatalf("列解码错误:%+v", table.Columns) + } + if len(table.RowActions) != 2 || table.RowActions[1].ID != "reauth" || + table.RowActions[1].Action != "plugin.reauthProvider" { + t.Fatalf("行内动作解码错误:%+v", table.RowActions) + } +} + +// TestPanel_RoundTrips 锁反向:编码再解码必须一模一样(tag 只对了一半就会在这里露出来)。 +func TestPanel_RoundTrips(t *testing.T) { + t.Parallel() + var first []Panel + if err := json.Unmarshal([]byte(designExample), &first); err != nil { + t.Fatalf("解码: %v", err) + } + raw, err := json.Marshal(first) + if err != nil { + t.Fatalf("编码: %v", err) + } + var second []Panel + if err := json.Unmarshal(raw, &second); err != nil { + t.Fatalf("再解码: %v (%s)", err, raw) + } + if !reflect.DeepEqual(first, second) { + t.Fatalf("编码往返不一致:\n%+v\n%+v", first, second) + } +} + +// TestWhen_StrictDecode 锁定 when 是**闭集**:未知条件键必须显式报错,不能被静默丢掉。 +func TestWhen_StrictDecode(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + want error + }{ + {"只有 enabled", `{"enabled":true}`, nil}, + {"空对象(无条件)", `{}`, nil}, + {"未知条件键", `{"configured":true}`, ErrUnknownWhenKey}, + {"未知条件键(多个,报错必须确定)", `{"zed":1,"alpha":2}`, ErrUnknownWhenKey}, + {"enabled 不是布尔", `{"enabled":"yes"}`, ErrInvalidWhen}, + {"不是对象", `true`, ErrInvalidWhen}, + {"是 null", `null`, ErrInvalidWhen}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var w When + err := json.Unmarshal([]byte(tc.raw), &w) + if tc.want == nil { + if err != nil { + t.Fatalf("解码 %s 失败: %v", tc.raw, err) + } + return + } + if !errors.Is(err, tc.want) { + t.Fatalf("解码 %s err = %v,期望 %v", tc.raw, err, tc.want) + } + }) + } +} + +// TestWhen_UnknownKeyIsDeterministic 锁定多未知键时报的是同一个键(错误信息不能随 map 迭代变)。 +func TestWhen_UnknownKeyIsDeterministic(t *testing.T) { + t.Parallel() + var first error + for i := 0; i < 20; i++ { + var w When + err := json.Unmarshal([]byte(`{"zeta":1,"alpha":2,"mu":3}`), &w) + if first == nil { + first = err + continue + } + if err == nil || err.Error() != first.Error() { + t.Fatalf("第 %d 次报错 %v,与首次 %v 不一致", i, err, first) + } + } +} + +// TestWhen_PropagatesThroughPanel 锁定严格性会穿透面板解码 —— 插件管理器拿到的就是这一层。 +func TestWhen_PropagatesThroughPanel(t *testing.T) { + t.Parallel() + raw := `[{"id":"p","title":"t","group":"plugins","order":1,` + + `"when":{"enabled":true,"configured":true},"sections":[]}]` + var panels []Panel + err := json.Unmarshal([]byte(raw), &panels) + if !errors.Is(err, ErrUnknownWhenKey) { + t.Fatalf("err = %v,期望 ErrUnknownWhenKey 穿透到面板解码", err) + } +} + +// TestClosedSets 锁定三个闭集与它们的判定方法。新增成员必须同时改这里 —— 这正是闭集的意义。 +func TestClosedSets(t *testing.T) { + t.Parallel() + for _, tt := range []FieldType{FieldText, FieldSecret, FieldNumber, FieldSwitch, + FieldSelect, FieldMultiSelect, FieldDate, FieldKV} { + if !tt.Valid() { + t.Errorf("字段类型 %q 应当合法", tt) + } + } + if FieldType("chart").Valid() { + t.Error("字段类型 chart 不该合法(闭集外)") + } + if FieldType("").Valid() { + t.Error("空字段类型不该合法") + } + for _, st := range []SectionType{SectionForm, SectionTable, SectionStatus, SectionLog} { + if !st.Valid() { + t.Errorf("区块类型 %q 应当合法", st) + } + } + if SectionType("chart").Valid() { + t.Error("区块类型 chart 不该合法(闭集外)") + } + if !GroupPlugins.Valid() || GroupID("search").Valid() { + t.Error("分组闭集判定错误(本期只有 plugins)") + } +} + +// TestTypeHelpers 锁定三个语义判定:敏感、数值、要拉选项。 +func TestTypeHelpers(t *testing.T) { + t.Parallel() + if !FieldSecret.Sensitive() || FieldText.Sensitive() { + t.Error("Sensitive 判定错误") + } + if !FieldNumber.Numeric() || FieldDate.Numeric() { + t.Error("Numeric 判定错误(只有 number 接受 min/max)") + } + if !FieldSelect.PullsOptions() || !FieldMultiSelect.PullsOptions() || FieldText.PullsOptions() { + t.Error("PullsOptions 判定错误") + } +} + +// TestSectionType_ReadOnly 锁定角色过滤的第一道判据:只有 form 自带写面。 +func TestSectionType_ReadOnly(t *testing.T) { + t.Parallel() + if SectionForm.ReadOnly() { + t.Error("form 不是只读区块(它有 submit)") + } + for _, st := range []SectionType{SectionTable, SectionStatus, SectionLog} { + if !st.ReadOnly() { + t.Errorf("%q 应当是只读区块", st) + } + } +} diff --git a/pkg/plugins/sdk/panelfield.go b/pkg/plugins/sdk/panelfield.go new file mode 100644 index 0000000..ce7ef3a --- /dev/null +++ b/pkg/plugins/sdk/panelfield.go @@ -0,0 +1,80 @@ +package sdk + +// 本文件是 design/09-插件生态 §5.2「字段类型集合(闭集)」在 Go 侧的唯一定义, +// 以及字段声明本身(form / status 区块的 `fields[]` 元素)。 + +// FieldType 是字段控件类型(闭集,§5.2:不许插件自定义渲染器)。 +// +// 闭集的意义:每个形状都能复用既有 Miuix 组件(渠道配置那套字段渲染 + 虚拟滚动表格), +// 因此「插件声明 UI」在无任意代码注入的前提下依然够用 —— 配置类插件 90% 的需求就是 +// 这几个形状。 +type FieldType string + +const ( + // FieldText 是单行文本(示例 endpoint:接口地址)。 + FieldText FieldType = "text" + // FieldSecret 是凭据输入(示例 apiKey)。读侧永不回显明文(§5.4.2),因此 + // paneldecl 拒绝它声明默认值。 + FieldSecret FieldType = "secret" + // FieldNumber 是数值输入(唯一接受 min/max 边界的类型)。 + FieldNumber FieldType = "number" + // FieldSwitch 是布尔开关。 + FieldSwitch FieldType = "switch" + // FieldSelect 是单选下拉(选项来自 optionsFrom)。 + FieldSelect FieldType = "select" + // FieldMultiSelect 是多选下拉(选项来自 optionsFrom)。 + FieldMultiSelect FieldType = "multiSelect" + // FieldDate 是日期。 + FieldDate FieldType = "date" + // FieldKV 是键值对(§5.2 的例子:自定义 header)。 + FieldKV FieldType = "kv" +) + +// Valid 报告 t 是否属于字段类型闭集。 +func (t FieldType) Valid() bool { + switch t { + case FieldText, FieldSecret, FieldNumber, FieldSwitch, + FieldSelect, FieldMultiSelect, FieldDate, FieldKV: + return true + default: + return false + } +} + +// Sensitive 报告该类型是否承载凭据。§5.4.2 要求读侧永不回显明文(只回「是否已配置」+ +// 尾 4 位或哈希前缀),所以敏感字段不得声明任何默认值 —— 校验在 paneldecl。 +func (t FieldType) Sensitive() bool { return t == FieldSecret } + +// Numeric 报告该类型是否接受 min/max 数值边界。依据:闭集里只有 number 是数值控件, +// date 在声明里是日期字符串,用数值边界表达日期范围只会产生歧义。 +func (t FieldType) Numeric() bool { return t == FieldNumber } + +// PullsOptions 报告该类型是否需要宿主去拉下拉选项。依据:只有 select / multiSelect 会 +// 渲染选项列表,其它类型上的 optionsFrom 是无效声明(会被 paneldecl 拒绝)。 +func (t FieldType) PullsOptions() bool { return t == FieldSelect || t == FieldMultiSelect } + +// Field 是一个字段声明(§5.2 的 fields 元素,form 与 status 共用)。 +type Field struct { + // Key 是配置键 / 行字段名,形如 §5.2 示例的 provider / apiKey / endpoint。 + // 它同时是表单的提交键与 status 行的数据键,因此在区块内必须唯一。 + Key string `json:"key"` + // Label 是字段标签。缺省时宿主回落到 Key(与 Column.Label 同一取舍)。 + Label string `json:"label,omitempty"` + // Type 决定控件,必须落在闭集内。 + Type FieldType `json:"type"` + // Required 是**配置值**的必填标记(§5.2 示例的 `"required": true`),不是声明层的 + // 必填 —— 声明层的必填只认 Key 与 Type。 + Required bool `json:"required,omitempty"` + // Placeholder 是输入提示(示例 `https://...`)。 + Placeholder string `json:"placeholder,omitempty"` + // OptionsFrom 是下拉选项来源。§5.2 只给了插件内的一种来源(示例 plugin:providers); + // **只允许 `plugin:` 前缀**,其它一律拒 —— 防「声明里塞任意 URL」。 + OptionsFrom string `json:"optionsFrom,omitempty"` + // Default 是字段初始值。§5.2 的示例里没有这个键;它按任务书「若你定义了这些可选键」 + // 的口径加入,且对 secret 类型被 paneldecl 硬拒(§5.4.2 读侧无明文)。 + Default any `json:"default,omitempty"` + // Min 是数值下界,仅 number 类型可声明(§5.2 示例里没有这个键,同上)。 + Min *float64 `json:"min,omitempty"` + // Max 是数值上界,仅 number 类型可声明,且必须 >= Min。 + Max *float64 `json:"max,omitempty"` +} diff --git a/pkg/plugins/sdk/panelwhen.go b/pkg/plugins/sdk/panelwhen.go new file mode 100644 index 0000000..ff0ac19 --- /dev/null +++ b/pkg/plugins/sdk/panelwhen.go @@ -0,0 +1,78 @@ +package sdk + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sort" +) + +// 本文件定义面板入口的**条件显示**声明 `when`(design/09 §5.2.1 nav.item: +// 「`when` 不满足 ⇒ 条目不出现」,不是灰掉)。 + +const ( + // whenKeyEnabled 是本期唯一支持的条件键:§5.2 的 `"when": { "enabled": true }` + // 是设计里出现过的唯一条件形态,§5.6 的验收判据也只有「未启用的插件 ⇒ 入口不出现」。 + // 刻意不留第二个键 —— 闭集外的条件属于「先改设计再加」的范畴。 + whenKeyEnabled = "enabled" +) + +var ( + // ErrInvalidWhen 报告 when 不是合法的条件对象(非对象、null、或 enabled 不是布尔)。 + ErrInvalidWhen = errors.New("sdk: invalid when clause") + // ErrUnknownWhenKey 报告 when 里出现了闭集外的条件键。 + // + // 刻意**报错而不是忽略**:静默忽略会让插件作者以为条件生效了,而入口照常出现 —— + // §5.2.1 的口径是「绝不静默丢弃」。这条只能在解码期拦住(结构体解码本身会丢弃未知 + // 键),所以这里自己实现 UnmarshalJSON。 + ErrUnknownWhenKey = errors.New("sdk: unknown when key") +) + +// When 是面板入口的条件显示声明。零值语义是「没有条件」,见 paneldecl.Eval。 +type When struct { + // Enabled 有三个状态,用指针而不是 bool 表达: + // - nil:没声明这个条件(含整份 when 缺省的 nil 指针)⇒ 不参与判定; + // - &true:要求插件当前已启用(§5.6.1「未启用 ⇒ 入口不出现」); + // - &false:要求插件当前**未**启用 —— 条件是「要求值相等」,写 false 不等于没写。 + Enabled *bool `json:"enabled,omitempty"` +} + +// UnmarshalJSON 严格解码 when:只接受对象,只接受闭集内的键,enabled 必须是布尔。 +// +// 为什么不用默认的 struct 解码:默认解码会**静默丢弃**未知键(`{"enabled":true,"x":1}` +// 会解码成一个看似正常的 When),插件作者会以为自己写了条件却没有任何效果。这里把 +// 未知键变成显式拒绝,由插件管理器按 §5.2.1「该面板不注册 + 显式报错」处理。 +func (w *When) UnmarshalJSON(raw []byte) error { + trimmed := bytes.TrimSpace(raw) + if bytes.Equal(trimmed, []byte("null")) { + return fmt.Errorf("%w: null is not a condition set", ErrInvalidWhen) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &fields); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidWhen, err) + } + // 按键名排序后再判定:多个未知键时错误信息必须确定(否则同一个坏声明在不同进程里 + // 报不同的键,排障与测试都会被搞乱)。 + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k != whenKeyEnabled { + return fmt.Errorf("%w: %q", ErrUnknownWhenKey, k) + } + } + rawEnabled, ok := fields[whenKeyEnabled] + if !ok { + w.Enabled = nil + return nil + } + var enabled bool + if err := json.Unmarshal(rawEnabled, &enabled); err != nil { + return fmt.Errorf("%w: enabled must be a boolean: %w", ErrInvalidWhen, err) + } + w.Enabled = &enabled + return nil +}