Files
Cyrene_v2/internal/capability/paneldecl/shape.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

183 lines
7.9 KiB
Go

package paneldecl
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。
//
// 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由
// /admin/plugin/<pluginId>/<panelId>(§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) }