package io import ( "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // 本文件钉住**文件级互斥的声明面单元**(tool.FileTargeter,实现见 file_targets.go): // 每个 action / 每种 image 来源声明出什么(操作类别与路径)。参数一律写字面量 // "read"/"write"/… —— 那是模型真正发过来的词表,不是实现里的常量。 // // 「真实插件 + 真实分发器」的机制级回归在 file_claims_test.go(夹具在 // file_claims_rig_test.go):本文件只回答「声明得对不对」,那一层回答「声明真的生效吗」。 // TestFileTargets_OpFollowsAction 钉住 file 工具的操作类别判定:read/list/search 是读, // write 是写,**未知 / 缺失的 action 一律按写**(保守方向:宁可多挡一次让模型重试,也不 // 放过两路并发写;执行面对未知 action 会拒绝,但声明面跑在它之前)。 func TestFileTargets_OpFollowsAction(t *testing.T) { t.Parallel() dir := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}) want := filepath.Join(dir, "notes.txt") cases := []struct { name string args map[string]any wantOp tool.FileOp }{ {"read", map[string]any{"action": "read", "path": "notes.txt"}, tool.FileOpRead}, {"list", map[string]any{"action": "list", "path": "notes.txt"}, tool.FileOpRead}, {"search", map[string]any{"action": "search", "path": "notes.txt", "query": "x"}, tool.FileOpRead}, {"write", map[string]any{"action": "write", "path": "notes.txt", "content": "x"}, tool.FileOpWrite}, {"unknown action", map[string]any{"action": "delete", "path": "notes.txt"}, tool.FileOpWrite}, {"missing action", map[string]any{"path": "notes.txt"}, tool.FileOpWrite}, {"absolute path", map[string]any{"action": "write", "path": want}, tool.FileOpWrite}, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() got := ft.FileTargets(c.args) if len(got) != 1 { t.Fatalf("期望声明 1 个目标,得到 %d 个: %#v", len(got), got) } if got[0].Op != c.wantOp { t.Fatalf("Op = %q,期望 %q", got[0].Op, c.wantOp) } // 路径必须是工具**执行时会用的那个**:沙箱解析前的声明值(绝对、clean、 // 相对路径按主 root 展开),而不是模型原样给的相对串 —— 否则占用表的键会 // 按进程当前目录算,与工具的真实落点对不上。 if got[0].Path != want { t.Fatalf("Path = %q,期望 %q", got[0].Path, want) } }) } } // TestFileTargets_DeclaresNothingWhenNoFileCanBeTouched 钉住「声明不出来就不参与互斥」: // 参数不是对象、path 缺失/为空(search 除外)、沙箱会拒绝的路径、根本没有 root 时都不声明。 func TestFileTargets_DeclaresNothingWhenNoFileCanBeTouched(t *testing.T) { t.Parallel() dir := t.TempDir() ft := fileToolForTest(t, &config.Config{DataDir: dir}) cases := []struct { name string args any }{ {"args is not an object", "notes.txt"}, {"path missing", map[string]any{"action": "read"}}, {"path blank", map[string]any{"action": "write", "path": " "}}, {"blank path on a non-search action", map[string]any{"action": "list", "path": ""}}, {"path escaping the sandbox", map[string]any{"action": "write", "path": filepath.Join(dir, "..", "escape.txt")}}, {"NUL byte in path", map[string]any{"action": "read", "path": "a\x00b.txt"}}, } for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { t.Parallel() if got := ft.FileTargets(c.args); len(got) != 0 { t.Fatalf("不该声明任何目标,得到 %#v", got) } }) } // search 的缺省基准是主 root(与 Execute 的缺省同一口径):这种调用真的会走查目录, // 因此必须声明出来,而不是当成「没 path 就不声明」。 got := ft.FileTargets(map[string]any{"action": "search", "path": "", "query": "x"}) if len(got) != 1 || got[0].Op != tool.FileOpRead || got[0].Path != filepath.Clean(dir) { t.Fatalf("search 缺省 base 应声明为主 root 的读,得到 %#v", got) } // 没有任何 root(配置缺失)时连 search 也无从谈起:不声明。 rootless := newFileTool(&tools{}) if got := rootless.FileTargets(map[string]any{"action": "search", "path": ""}); len(got) != 0 { t.Fatalf("没有 root 时不该声明目标,得到 %#v", got) } if got := rootless.FileTargets(map[string]any{"action": "write", "path": "a.txt"}); len(got) != 0 { t.Fatalf("没有 root 时不该声明目标,得到 %#v", got) } } // TestCropFileTargets_OnlyLocalPathsDeclareARead 钉住 image-crop:只有本地路径这一种输入 // 会碰磁盘(http(s) 走网络、data URI 是内存里的 base64),且它**只读**(本工具从不写文件)。 func TestCropFileTargets_OnlyLocalPathsDeclareARead(t *testing.T) { t.Parallel() dir := t.TempDir() crop := findTool(t, buildTools(t, &config.Config{DataDir: dir}), "image-crop").(*cropTool) want := filepath.Join(dir, "shot.png") got := crop.FileTargets(map[string]any{"image": "shot.png", "x": 0, "y": 0, "width": 1, "height": 1}) if len(got) != 1 || got[0].Op != tool.FileOpRead || got[0].Path != want { t.Fatalf("本地路径应声明一次读(路径=%q),得到 %#v", want, got) } for _, args := range []any{ map[string]any{"image": "https://example.com/a.png"}, map[string]any{"image": "data:image/png;base64,AAAA"}, map[string]any{"image": " "}, map[string]any{"image": filepath.Join(dir, "..", "escape.png")}, map[string]any{"image": "a\x00b.png"}, "shot.png", } { if got := crop.FileTargets(args); len(got) != 0 { t.Fatalf("这些输入不碰磁盘或会被沙箱拒绝,不该声明目标,得到 %#v(args=%#v)", got, args) } } // 没有沙箱(file 工具缺席)时解析不出真实落点:不声明。 if got := (&cropTool{}).FileTargets(map[string]any{"image": "shot.png"}); len(got) != 0 { t.Fatalf("没有沙箱时不该声明目标,得到 %#v", got) } }