feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)

store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
This commit is contained in:
JianFeeeee
2026-09-27 12:47:18 +08:00
parent 7532af7e9b
commit 3d753126a3
3 changed files with 580 additions and 1 deletions

View File

@ -2,6 +2,7 @@ package seq
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
@ -18,12 +19,24 @@ type toolRunner interface {
call(name string, args map[string]interface{}) (string, error)
}
// errToolNotFound 表示「工具不存在」(未注册 / 插件未加载、已卸载或崩溃)。
//
// 它是**本包定义**的标记,不复用内核的 agentIO.ErrToolNotFound:seq 是插件,
// 拿得到的是 sdk.ToolAPI(ExecuteTool/GetAllTools),拿不到 io 包的类型
// (见设计文档 §7 的边界声明)。内核侧的类型化错误本就要经 D4 才下放到插件。
var errToolNotFound = errors.New("工具不存在或未注册")
// IsToolNotFound 报告 err 是否为「工具不存在」。
func IsToolNotFound(err error) bool { return errors.Is(err, errToolNotFound) }
// GroupResult 是一组的执行结果。
type GroupResult struct {
Group string
Skipped bool // 条件为假而整组跳过
Slots map[string]interface{}
Tools []ToolRun
// Missing 列出因「工具不存在」而被 skip/degrade 的工具名。
Missing []string
Err error
}
@ -91,6 +104,18 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe
var firstErr error
for _, r := range results {
res.Tools = append(res.Tools, r)
tc := g.Tools[r.Order]
// 「工具不存在」单独处理:动态注册下它是**常态**(插件未加载/崩溃),
// 与「执行失败」语义不同 —— 前者该按 missing 策略走,后者才该 retry。
//
// ⚠️ 必须先于通用的 on_error 检查:若「不存在」先被记成 firstErr/
// failed,missing skip/degrade 就会被 on_error=abort 连坐中断。
if r.Err != nil && IsToolNotFound(r.Err) {
dealMissing(g, tc, r, &res, &failed, &firstErr)
continue
}
if r.Err != nil {
if firstErr == nil {
firstErr = fmt.Errorf("工具 %s 失败: %w", r.Name, r.Err)
@ -99,7 +124,7 @@ func execGroup(g Group, args map[string]interface{}, runner toolRunner) (GroupRe
failed = true
}
}
tc := g.Tools[r.Order]
if tc.As == "" {
continue
}
@ -422,3 +447,44 @@ func compactJSON(v interface{}) string {
}
return string(b)
}
// parseFallback 解析 degrade 的兜底值(紧凑 JSON 文本)。
// 解析失败时原样作为字符串返回——兜底值本身不该让整组失败。
func parseFallback(s string) interface{} {
if strings.TrimSpace(s) == "" {
return ""
}
var v interface{}
if err := json.Unmarshal([]byte(s), &v); err == nil {
return v
}
return s
}
// dealMissing 处理「工具不存在」这一**常态**情形(动态注册下插件可能
// 未加载、已卸载或崩溃),按 group 的 missing 策略处置。
//
// 与「执行失败」严格分开:后者才该走 on_error / retry。若把两者混同,
// 一条"插件挂了"会被当成业务失败反复重试,或反过来该重试的被整组跳过。
func dealMissing(g Group, tc ToolCall, r ToolRun, res *GroupResult, failed *bool, firstErr *error) {
switch g.missingPolicy() {
case "skip":
res.Missing = append(res.Missing, tc.Tool)
return // 不给槽赋值(与「条件为假」同一情形:下游要能应对槽缺失)
case "degrade":
res.Missing = append(res.Missing, tc.Tool)
if tc.As != "" {
res.Slots[tc.As] = parseFallback(tc.Fallback)
}
return
default: // fail
*failed = true
if *firstErr == nil {
*firstErr = fmt.Errorf("工具 %s 不存在或未注册"+
"(可能属于未加载/已崩溃的插件;用 seq_list 看可用序列,或改用其他工具)", tc.Tool)
}
if tc.As != "" {
res.Slots[tc.As] = "错误:工具不存在"
}
}
}

View File

@ -0,0 +1,279 @@
package seq
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// maxCallDepth 是**嵌套调用的结构上界**,不是配置项。
//
// 沿用内核 MaxInterruptFrames 的做法(见 core/scheduler.go:271「是中断栈帧数的
// 结构上界,不是配置项」):上界一旦可配,总有人会把它调大到栈溢出。
//
// 取 4 与内核的 4 级中断一致。
const maxCallDepth = 4
// Store 负责序列的持久化:**存 AST,不存文本**。
//
// 为什么不存原始文本:执行期若重新解析文本,一次格式改动就会改变已保存
// 序列的行为;存 AST 则解析只发生在创建时,注释/空白/引号形式在 AST
// 层面已消失,不引入执行期差异。
type Store struct {
dir string
mu sync.RWMutex
}
// NewStore 在 dir 下管理序列文件(不创建目录,由 Save 惰性创建)。
func NewStore(dir string) *Store { return &Store{dir: dir} }
// fileOf 返回某序列的落盘路径。
func (s *Store) fileOf(name string) string {
return filepath.Join(s.dir, name+".json")
}
// Save 落盘一条序列的 AST。
func (s *Store) Save(seq *Sequence) error {
if seq == nil || strings.TrimSpace(seq.Name) == "" {
return fmt.Errorf("序列缺少 name")
}
if err := s.checkName(seq.Name); err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0755); err != nil {
return fmt.Errorf("创建序列目录失败: %w", err)
}
b, err := json.MarshalIndent(seq, "", " ")
if err != nil {
return fmt.Errorf("序列化序列 %q 失败: %w", seq.Name, err)
}
// 静态校验:同序列内的 group 引用必须存在、不得自调用。
// 跨序列目标的存在性由 CheckGraph 统一查(此时新序列还没落盘)。
if err := s.CheckNew(seq); err != nil {
return err
}
// 先写临时文件再 rename:避免写一半被读(与内核原子替换同一思路)
tmp := s.fileOf(seq.Name) + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err != nil {
return fmt.Errorf("写序列 %q 失败: %w", seq.Name, err)
}
if err := os.Rename(tmp, s.fileOf(seq.Name)); err != nil {
// 清理失败**有意忽略**:rename 已失败,再报一个清理错误只会
// 盖掉真正的失败原因(这正是 rename 失败要暴露的那条)。
// 残留的 .tmp 由下次 Save 覆盖。
_ = os.Remove(tmp)
return fmt.Errorf("替换序列 %q 失败: %w", seq.Name, err)
}
return nil
}
// Load 读回一条序列的 AST。
func (s *Store) Load(name string) (*Sequence, error) {
if err := s.checkName(name); err != nil {
return nil, err
}
b, err := os.ReadFile(s.fileOf(name))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
}
return nil, fmt.Errorf("读序列 %q 失败: %w", name, err)
}
var seq Sequence
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(&seq); err != nil {
return nil, fmt.Errorf("序列 %q 的存档损坏: %w", name, err)
}
return &seq, nil
}
// Delete 删除一条序列。不存在时报错(不静默成功 —— 模型会以为删掉了)。
func (s *Store) Delete(name string) error {
if err := s.checkName(name); err != nil {
return err
}
if err := os.Remove(s.fileOf(name)); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
}
return fmt.Errorf("删除序列 %q 失败: %w", name, err)
}
return nil
}
// List 列出全部序列名(升序)。
func (s *Store) List() []string {
s.mu.RLock()
defer s.mu.RUnlock()
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
out = append(out, strings.TrimSuffix(e.Name(), ".json"))
}
sort.Strings(out)
return out
}
// checkName 校验序列名:必须能安全用作文件名。
//
// ⚠️ 名字来自模型,且会被拼进路径(Load/Save/Delete 都用)⇒ 必须挡住
// 路径穿越(`../`)与分隔符,否则 `seq_load` 能读到任意文件。
func (s *Store) checkName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("序列名不能为空")
}
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return fmt.Errorf("序列名 %q 非法:不能包含路径分隔符或 ..", name)
}
if strings.HasPrefix(name, ".") {
return fmt.Errorf("序列名 %q 非法:不能以 . 开头", name)
}
return nil
}
// callTargets 返回某序列内所有 seq_call 的跨序列目标。
func callTargets(seq *Sequence) []string {
var out []string
for _, g := range seq.Groups {
for _, t := range g.Tools {
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
continue
}
if tgt, ok := t.Args["target"].(string); ok && strings.HasPrefix(tgt, "#") {
out = append(out, tgt)
}
}
}
return out
}
// CheckGraph 校验跨序列调用图。
//
// 两条检查(都必须在**建序列/保存**时做,而不是等运行):
// 1. 每个 `seq_call` 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
// 2. 不得有环(否则无限嵌套,每层都真的在调工具)
func (s *Store) CheckGraph() error {
names := s.List()
seqs := make(map[string]*Sequence, len(names))
for _, n := range names {
seq, err := s.Load(n)
if err != nil {
return err
}
seqs[n] = seq
}
// 目标存在性
for _, name := range names {
for _, tgt := range callTargets(seqs[name]) {
bare := strings.TrimPrefix(tgt, "#")
if _, ok := seqs[bare]; !ok {
return fmt.Errorf("序列 %q 调用了不存在的序列 %q(用 seq_list 看可用序列)", name, tgt)
}
}
}
// 环检测(三色 DFS),错误里带**环路径**便于定位
const (
white = 0 // 未访问
gray = 1 // 在栈上
black = 2 // 已完成
)
color := make(map[string]int, len(seqs))
var path []string
var dfs func(n string) error
dfs = func(n string) error {
color[n] = gray
path = append(path, "#"+n)
for _, tgt := range callTargets(seqs[n]) {
bare := strings.TrimPrefix(tgt, "#")
switch color[bare] {
case gray:
// 找到环:从 path 里第一次出现 bare 处截断,给出完整环
ring := path
for i, p := range path {
if p == tgt {
ring = path[i:]
break
}
}
return fmt.Errorf("跨序列调用成环: %s → %s",
strings.Join(ring, " → "), tgt)
case white:
if err := dfs(bare); err != nil {
return err
}
}
}
path = path[:len(path)-1]
color[n] = black
return nil
}
for _, n := range names {
if color[n] == white {
if err := dfs(n); err != nil {
return err
}
}
}
return nil
}
// CheckNew 在**保存前**校验一条新序列:组内/跨序列引用是否存在。
//
// 分两步:先查**同序列内**的 seq_call 目标(组名)是否存在,再查
// **跨序列**目标是否已存在(存盘之后才能查全图,故由 Save 后的
// CheckGraph 负责)。
func (s *Store) CheckNew(seq *Sequence) error {
groupNames := map[string]bool{}
for _, g := range seq.Groups {
groupNames[g.Name] = true
}
for _, g := range seq.Groups {
for i, t := range g.Tools {
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
continue
}
tgt, _ := t.Args["target"].(string)
if strings.TrimSpace(tgt) == "" {
return fmt.Errorf("group %q 第 %d 个工具的 seq_call 缺少 target", g.Name, i+1)
}
if strings.HasPrefix(tgt, "#") {
bare := strings.TrimPrefix(tgt, "#")
if bare == seq.Name {
return fmt.Errorf("序列 %q 调用了自身(会造成无限递归)", seq.Name)
}
// ⚠️ 跨序列目标**不在这里**要求存在:互调的两条序列
// 谁也存不下来(A 要 B 先在、B 要 A 先在),是设计上死锁。
// 存在性与环统一由 CheckGraph 在保存后兜底。
continue
}
if !groupNames[tgt] {
return fmt.Errorf("group %q 第 %d 个工具调用了不存在的 group %q"+
"(本序列现有:%s)", g.Name, i+1, tgt, joinNames(groupNames))
}
}
}
return nil
}
func joinNames(m map[string]bool) string {
if len(m) == 0 {
return "(无)"
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, ", ")
}

View File

@ -0,0 +1,234 @@
package seq
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 阶段 P3:序列的存储、调用图与跨序列调用。
//
// 重点是三条**安全**不变量(错一条就是不可执行的流程或安全缺口):
// 1. 存的是 **AST**,不是文本;执行期不再碰原始文件
// 2. 跨序列调用图**有环**必须在建序列时报错(含环路径)
// 3. 调用深度有**结构上界**(沿用内核 MaxInterruptFrames 的惯例:
// 上界不是配置项)
// ① 存取往返:写入后读回必须是**等价的 AST**,且不再依赖原文本。
func TestStoreRoundTripKeepsAST(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
src, err := Parse([]byte(validSeq))
if err != nil {
t.Fatalf("解析失败: %v", err)
}
if err := st.Save(src); err != nil {
t.Fatalf("Save: %v", err)
}
// 删掉原文本来源:只靠磁盘上的 AST 也能读回
got, err := st.Load("巡检三节点")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Name != src.Name || len(got.Groups) != 1 {
t.Fatalf("往返后结构不符: %+v", got)
}
if len(got.Groups[0].Tools) != 1 || got.Groups[0].Tools[0].Tool != "cmd_run" {
t.Errorf("工具未保住: %+v", got.Groups[0].Tools)
}
// 槽声明也必须保住(它是签名的一部分)
if _, ok := got.Groups[0].Out["summary"]; !ok {
t.Error("out 声明丢失 —— 签名不完整则无法按名调用")
}
}
// ② 列表与删除。
func TestStoreListAndDelete(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
for _, name := range []string{"a", "b"} {
s, err := Parse([]byte(`{"name":"` + name + `","groups":[{"name":"g",` +
`"in":{},"out":{"x":"string"},"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"x\"},\"as\":\"x\"} ;"}]}`))
if err != nil {
t.Fatalf("解析 %s 失败: %v", name, err)
}
if err := st.Save(s); err != nil {
t.Fatalf("Save %s: %v", name, err)
}
}
if names := st.List(); len(names) != 2 {
t.Fatalf("应列出 2 条,实际 %v", names)
}
if err := st.Delete("a"); err != nil {
t.Fatalf("Delete: %v", err)
}
if names := st.List(); len(names) != 1 || names[0] != "b" {
t.Fatalf("删除后应只剩 b,实际 %v", names)
}
// 删除不存在的必须**报错**,不静默成功
if err := st.Delete("nope"); err == nil {
t.Error("删除不存在的序列却返回成功(模型会以为删掉了)")
}
// 加载不存在的同理
if _, err := st.Load("nope"); err == nil {
t.Error("加载不存在的序列却成功了")
}
}
// ③ ★ 跨序列调用图有环 ⇒ 建序列时报错,且给出**环路径**。
//
// 无环检测会让 #A → #B → #A 无限执行,而且每次都真的在调工具
// (不是空转)—— 这与 spawn_child 之所以要硬编码黑名单是同类风险。
func TestCrossSeqCallCycleIsRejected(t *testing.T) {
// ⚠️ 序列**自身名字**不带 "#"—— "#" 只是 `seq_call` 的 target 里的
// 前缀标记("target":"#B" 指跨序列)。我第一版把名字写成 "#A",
// 于是 CheckGraph 按 "#B" 去找落盘名 "B",误报「不存在」。
// A 调 B,B 调 A
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#B\"},\"as\":\"x\"} ;"}]}`)
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
// ⚠️ 跨序列目标**允许在 Save 时暂缺**:若要求"目标必须先存在",
// 那么互调的两条序列谁也存不下来(A 要 B 先在,B 要 A 先在)——
// 这是一个无法满足的依赖,死锁在设计上而非运行时。
// ⇒ Save 只校验**同序列内**的 group 引用(那部分信息是自足的),
// 跨序列目标的存在性与环由 CheckGraph 在**保存后**统一兜底。
st := NewStore(t.TempDir())
if err := st.Save(a); err != nil {
t.Fatalf("Save A: %v", err)
}
if err := st.Save(b); err != nil {
t.Fatalf("Save B: %v", err)
}
err := st.CheckGraph()
if err == nil {
t.Fatal("A→B→A 成环却通过检查")
}
if !strings.Contains(err.Error(), "#A") || !strings.Contains(err.Error(), "#B") {
t.Errorf("错误应给出环路径(含 #A 与 #B),实际: %v", err)
}
}
// ④ 无环必须通过。
func TestCrossSeqCallAcyclicPasses(t *testing.T) {
st := NewStore(t.TempDir())
a := mustParse(t, `{"name":"A","groups":[{"name":"ga","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"cmd_run\",\"args\":{\"command\":\"a\"},\"as\":\"x\"} ;"}]}`)
b := mustParse(t, `{"name":"B","groups":[{"name":"gb","in":{},"out":{"x":"string"},`+
`"tools":"{\"tool\":\"seq_call\",\"args\":{\"target\":\"#A\"},\"as\":\"x\"} ;"}]}`)
if err := st.Save(a); err != nil {
t.Fatalf("Save A: %v", err)
}
if err := st.Save(b); err != nil {
t.Fatalf("Save B: %v", err)
}
if err := st.CheckGraph(); err != nil {
t.Fatalf("无环却被判为有环: %v", err)
}
}
// ⑤ 深度上界是**结构常量**,不是配置项。
func TestMaxCallDepthIsConstant(t *testing.T) {
// 上界必须存在且为正;且不是从配置读的
if maxCallDepth <= 0 {
t.Fatalf("maxCallDepth 应为正,实际 %d", maxCallDepth)
}
// 同一数值在多次调用间稳定(不可被外部改写)
if maxCallDepth != maxCallDepth {
t.Fatal("maxCallDepth 不稳定")
}
}
// ⑥ 工具不存在:missing 策略的三个取值各有明确行为。
//
// 动态注册下"工具不存在"是**常态**(插件未加载/已卸载/崩溃),
// 不是异常边界 —— 因此策略必须显式,不能靠"报错"兜底。
func TestMissingPolicyBehaviors(t *testing.T) {
ft := newFakeTool()
ft.errs["gone"] = errToolNotFound
ft.results["kept"] = "OK"
build := func(policy string) Group {
return Group{
Name: "g", In: map[string]string{},
Out: map[string]string{"a": "string", "b": "string"},
When: "true", Parallel: false, Missing: policy,
Tools: []ToolCall{
{Tool: "gone", As: "a", Fallback: `{"fallback":true}`},
{Tool: "kept", As: "b"},
},
}
}
t.Run("fail", func(t *testing.T) {
if _, err := execGroup(build("fail"), map[string]interface{}{}, ft); err == nil {
t.Error("missing=fail 时缺工具应使整组失败")
}
})
t.Run("skip", func(t *testing.T) {
res, err := execGroup(build("skip"), map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("missing=skip 不应整组失败: %v", err)
}
if _, ok := res.Slots["a"]; ok {
t.Error("skip 时不应给缺失工具的槽赋值(下游读到缺失)")
}
if got, _ := res.Slots["b"].(string); got != "OK" {
t.Errorf("skip 时其余工具应照常执行,b = %v", res.Slots["b"])
}
})
t.Run("degrade", func(t *testing.T) {
res, err := execGroup(build("degrade"), map[string]interface{}{}, ft)
if err != nil {
t.Fatalf("missing=degrade 不应整组失败: %v", err)
}
if _, ok := res.Slots["a"]; !ok {
t.Error("degrade 时应写入兜底值")
}
})
}
func mustParse(t *testing.T, s string) *Sequence {
t.Helper()
seq, err := Parse([]byte(s))
if err != nil {
t.Fatalf("解析失败: %v\n文本: %s", err, s)
}
return seq
}
// ⑦ ★ 路径穿越防护(安全相关,判据必须有)。
//
// 序列名来自模型,并被直接拼进文件路径(Load/Save/Delete)⇒ 若不校验,
// `seq_load("../secret")` 就能读到任意文件、`seq_delete("../x")` 能删任意文件。
//
// 这条不是"读代码看着对",而是在 store 目录**外**放一个真实文件,
// 断言它既读不到、也删不掉。
func TestStoreBlocksPathTraversal(t *testing.T) {
dir := t.TempDir()
st := NewStore(filepath.Join(dir, "sequences"))
outside := filepath.Join(dir, "secret.json")
if err := os.WriteFile(outside, []byte(`{"name":"leaked","groups":[]}`), 0644); err != nil {
t.Fatalf("准备外部文件失败: %v", err)
}
for _, name := range []string{
"../secret", "../../etc/passwd", "a/b", `a\b`, "..", ".hidden", "", "sub/../..",
} {
if seq, err := st.Load(name); err == nil {
t.Errorf("Load(%q) 竟然成功了,读到 %+v —— 路径穿越未被拦住", name, seq)
}
if err := st.Delete(name); err == nil {
t.Errorf("Delete(%q) 竟然成功了", name)
}
}
// 外部文件必须仍在(越权删除会破坏目录边界)
if _, err := os.Stat(outside); err != nil {
t.Error("store 目录外的文件被删掉了 —— 路径穿越已突破边界")
}
}