纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
211 lines
7.2 KiB
Go
211 lines
7.2 KiB
Go
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)
|
||
}
|