Files
Cyrene_v2/internal/capability/paneldecl/fieldcheck.go
T
AskaEth c9144c1096
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
2026-10-06 08:06:20 +08:00

211 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}