纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。 - pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形); 字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错, 不静默丢) - internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**, 不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)· when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留 status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed) - 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告) 【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿; 我亲手打红变异:把同组同序的回退比较 反成 ⇒ TestCatalog_StableOrder FAIL。 (记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 —— 我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
253 lines
9.8 KiB
Go
253 lines
9.8 KiB
Go
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))
|
||
}
|