refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃

## 问题

三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:

    ui = { visibilty = "admin" }   -- 一个字母之差
    ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分

今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。

## 改动

新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。

readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。

## 顺带修掉一个真 bug:handler 必须是名字而非函数值

`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。

## 更正一个我写了但从未核实的说法

之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:

    setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
    json global present when readPlugin* runs: true
    luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages

真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。

一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。

## 判据

- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了

变异自证:strictKeys 短路 → 变红。

已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-04 20:10:07 +08:00
parent 700f4179fa
commit 39cda89def
3 changed files with 620 additions and 137 deletions

View File

@ -0,0 +1,244 @@
package lua
import (
"fmt"
golua "github.com/aarzilli/golua/lua"
"sort"
"strings"
)
// The declarative decoder.
//
// Why this exists: plugin declarations were read by three hand-written functions
// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the
// same walk over a decoded Lua table — get this field, coerce it, append it. The
// field names lived in the .lua and again in the Go, so adding one meant editing
// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently
// read as unset.
//
// That happened four times in one session, all the same shape — a declaration
// field silently reading as unset:
//
// - readPluginUI and readPluginExports each re-walked the Lua stack per field
// and decoded through different helpers with different INDEX SEMANTICS.
// - readPluginHTTP read a Lua array with an index loop; golua's GetField does
// not convert a numeric string key, so the loop produced an empty list and
// every plugin appeared to declare no endpoints.
// - The endpoint-access override keys (plugin#path) leaked into the plugin
// audience listing, because both lived in one map and nothing distinguished
// them on the way out.
//
// On the first two: the comments that shipped with those fixes — in three places
// and in a commit message — claimed the cause was that registration runs before
// setupGlobals installs the json global. That was never verified and is FALSE.
// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present,
// and luaToJSON decodes the same stack shape correctly. The real constraint is
// that luaTableToGoValue resolves a negative index to absolute immediately
// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds —
// which is why the decode now happens once, at the edge, and never per field.
//
// // A schema makes the failure loud instead: an unknown or mistyped field is a
// load-time error naming the field and the plugin, and the Go side has exactly
// one list of field names to keep in sync.
//
// The schema is deliberately data, not reflection or struct tags. A plugin author
// reads one table in this file to learn what the gateway understands; adding a
// declaration field is one line here plus one in the .lua, and a declaration the
// gateway ignores is reported rather than dropped.
// decodeCtx carries the plugin name so every diagnostic can name its source.
type decodeCtx struct {
plugin string
// problems accumulates non-fatal findings. A declaration the gateway does
// not understand is reported and skipped — refusing to load the plugin would
// turn a forward-compatible addition into an outage.
problems []string
}
func (c *decodeCtx) warnf(format string, args ...interface{}) {
c.problems = append(c.problems, fmt.Sprintf(format, args...))
}
// strictKeys reports keys present in m that the schema does not describe.
//
// This is the whole point: the old decoders silently ignored everything they did
// not know about, which is indistinguishable from a plugin that simply did not
// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to
// vanish with no trace at all.
func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) {
set := make(map[string]struct{}, len(allowed))
for _, a := range allowed {
set[a] = struct{}{}
}
var unknown []string
for k := range m {
if _, ok := set[k]; !ok {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return
}
sort.Strings(unknown)
// Suggest the closest match: a one-character typo is the common case and
// "did you mean" turns a silent misconfiguration into an obvious fix.
for _, u := range unknown {
if near := closestField(u, allowed); near != "" {
c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near)
} else {
c.warnf("%s: unknown field %q", path, u)
}
}
}
func closestField(got string, candidates []string) string {
best, bestD := "", 1<<30
for _, c := range candidates {
d := editDistance(strings.ToLower(got), strings.ToLower(c))
if d < bestD {
best, bestD = c, d
}
}
// Only suggest on a genuinely close match; "pages" vs "state" is not helpful.
if bestD <= 2 {
return best
}
return ""
}
func editDistance(a, b string) int {
la, lb := len(a), len(b)
prev := make([]int, lb+1)
cur := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
cur[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, cur = cur, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if b < a {
a = b
}
if c < a {
a = c
}
return a
}
// ---- field readers -------------------------------------------------------
//
// Each reader takes the already-decoded Go value (map[string]interface{} from
// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the
// decode is the part that must never depend on a global installed later, and
// keeping it at the edge means the field readers are pure functions that can be
// tested without a VM.
func decString(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
return asString(m[key])
}
func decStringList(m map[string]interface{}, key string) []string {
if m == nil {
return nil
}
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []string
for _, it := range raw {
if s := asString(it); s != "" {
out = append(out, s)
}
}
return out
}
func decTable(m map[string]interface{}, key string) map[string]interface{} {
if m == nil {
return nil
}
t, _ := m[key].(map[string]interface{})
return t
}
func decTableList(m map[string]interface{}, key string) []map[string]interface{} {
if m == nil {
return nil
}
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
var out []map[string]interface{}
for _, it := range raw {
if t, ok := it.(map[string]interface{}); ok {
out = append(out, t)
}
}
return out
}
// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }.
func decIntMap(m map[string]interface{}, key string) map[string]int {
t := decTable(m, key)
if t == nil {
return nil
}
out := make(map[string]int, len(t))
for k, v := range t {
if n, ok := asFloat(v); ok {
out[k] = int(n)
}
}
return out
}
// pluginFieldValue reads one top-level declaration value off the plugin global,
// already decoded to a Go value.
//
// Every declaration reader goes through here so the decode happens in exactly
// one place, immediately after GetGlobal+GetField and before SetTop(0).
//
// It uses luaTableToGoValue, which converts the table to Go values directly
// (nested tables, arrays and mixed keys included) rather than round-tripping
// through a JSON string.
//
// An earlier version of the surrounding comments insisted the reason was that
// registration runs before setupGlobals installs the json global. Measured, that
// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the
// stack. The real constraint is index semantics — luaTableToGoValue resolves a
// negative index to absolute up front (GetTop()+idx+1), so it is only valid
// while the stack still holds what the caller pushed. Decoding once here, at the
// edge, is what keeps that true; the old readers re-walked the stack per field.
func pluginFieldValue(L *golua.State, field string) (interface{}, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, field)
if L.Type(-1) == golua.LUA_TNIL {
L.SetTop(0)
return nil, false
}
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
return raw, raw != nil
}