feat(tools): 真实工具声明文件目标(让文件级互斥真正生效)
新增 plugins/tools/io/file_targets.go:fileTool 与 cropTool 实现 tool.FileTargeter (编译期钉住 var _ tool.FileTargeter,声明集中一处可审计)。 file:read/list/search ⇒ 读;其余(含 delete / 未知 / 缺失 action)⇒ 写(保守侧,宁可多挡一次)。 路径取沙箱解析前的声明值(绝对、clean、已确认在 roots 内,纯词法不触盘);沙箱会拒的路径不声明。 search 的缺省 base 与执行路径共用 f.searchBase(避免声明目录 ≠ 实际走查目录)。 image-crop:仅本地路径声明为读(URL/data URI 不落盘);分类与 load 共用一份。 体检清单:file ✅ / image-crop ✅ / document-read ❌(sidecar 走「临时文件 + rename」原子替换, 无撕读风险;且布局属未导出的第二份真相)/ basic、http、knowledge 等不碰盘; vision·ocr(internal 越界)、terminal(cmd 越界 + 目标不可知)、MCP 外部工具 未做 —— 如实登记为遗留。 测试:单元(action→Op 全表 / 路径归一 / 不声明场景)+ 机制层 6 条(真插件 + 真 Dispatcher + 真占用表, 用闸门工具把并发变确定性事件,不靠 sleep)。 变异 5 条(一切按写 / 未知按读 / 用原始相对串 / crop 不声明 / file 声明被抹掉)。 注:本切片声明正确但生效依赖 ef3dcb6(网关给每条在途路挂互斥身份)。
This commit is contained in:
1 parent
ef3dcb699b
commit
3589f4b14f
7 files changed
+842
-15
No files matched your search
@@ -192,13 +192,39 @@ func encodePNG(img image.Image) ([]byte, error) {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// cropSourceKind 是 image 参数的来源分类:只有本地路径会碰文件系统,URL 与 data URI
|
||||
// 都不落盘。分类只此一份 —— 执行分派(load)与目标声明(file_targets.go 的
|
||||
// cropTool.FileTargets)共用它,两处就不会各自推演前缀规则而漂移。
|
||||
type cropSourceKind int
|
||||
|
||||
const (
|
||||
// cropSourceFile 是本地路径:读取要过 file 工具的沙箱。
|
||||
cropSourceFile cropSourceKind = iota
|
||||
// cropSourceURL 是 http(s) 源:走共享的 SSRF 防护客户端。
|
||||
cropSourceURL
|
||||
// cropSourceData 是 base64/data: URI:内容就在参数里,不碰磁盘。
|
||||
cropSourceData
|
||||
)
|
||||
|
||||
// cropSourceKindOf 分类一份 image 参数。
|
||||
func cropSourceKindOf(src string) cropSourceKind {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "data:"):
|
||||
return cropSourceData
|
||||
case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"):
|
||||
return cropSourceURL
|
||||
default:
|
||||
return cropSourceFile
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the source image: a local path through the file sandbox, an http(s)
|
||||
// URL through the shared SSRF-guarded client, or a base64/data: URI payload.
|
||||
func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "data:"):
|
||||
switch cropSourceKindOf(src) {
|
||||
case cropSourceData:
|
||||
return decodeCropDataURI(src)
|
||||
case strings.HasPrefix(src, "http://"), strings.HasPrefix(src, "https://"):
|
||||
case cropSourceURL:
|
||||
return c.fetch(ctx, src)
|
||||
default:
|
||||
return c.readFile(src)
|
||||
|
||||
+13
-2
@@ -1,6 +1,6 @@
|
||||
// Package io implements the built-in I/O and network tools for Cyrene v2
|
||||
// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, http, web-search,
|
||||
// web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the
|
||||
// (DevDocs/design/09-插件生态.md). It exposes file, image-crop, document-read, http,
|
||||
// web-search, web-fetch and knowledge as pkg/plugins/sdk.Tool values, wired to the
|
||||
// process-internal config, logger and runtime/memory retriever.
|
||||
//
|
||||
// Build the full configured tool set with New(cfg, opts...). Tools() and
|
||||
@@ -25,5 +25,16 @@
|
||||
// - knowledge: RAG lookup over the runtime memory retriever; a clear message
|
||||
// when no knowledge base is configured.
|
||||
//
|
||||
// File-level mutual exclusion
|
||||
//
|
||||
// The two tools that touch files declare the file each call will touch
|
||||
// (tool.FileTargeter, implemented in file_targets.go): file declares read for
|
||||
// read/list/search and write for write; image-crop declares a read when its image
|
||||
// is a local path. When another in-flight parallel leg is already operating on that
|
||||
// same file, the call returns tool.ErrFileBusy with the file's state instead of
|
||||
// running or queueing; two reads of one file still run in parallel, and calls that
|
||||
// cannot touch a file (a sandbox-rejected path, an HTTP or data: URI source) declare
|
||||
// nothing and keep their previous behavior.
|
||||
//
|
||||
// All tools are safe for concurrent use, as required by sdk.Tool.
|
||||
package io
|
||||
+21
-10
@@ -61,7 +61,7 @@ func (f *fileTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []any{"read", "write", "list", "search"}, "description": "Operation to perform."},
|
||||
"action": map[string]any{"type": "string", "enum": []any{actionRead, actionWrite, actionList, actionSearch}, "description": "Operation to perform."},
|
||||
"path": map[string]any{"type": "string", "description": "Absolute or data-directory-relative file path."},
|
||||
"content": map[string]any{"type": "string", "description": "Body to write."},
|
||||
"query": map[string]any{"type": "string", "description": "Substring filter for search."},
|
||||
@@ -77,13 +77,13 @@ func (f *fileTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
return nil, fmt.Errorf("io: file: args must be an object")
|
||||
}
|
||||
switch action := argString(m, "action"); action {
|
||||
case "read":
|
||||
case actionRead:
|
||||
return f.read(ctx, argString(m, "path"))
|
||||
case "write":
|
||||
case actionWrite:
|
||||
return f.write(ctx, argString(m, "path"), argString(m, "content"))
|
||||
case "list":
|
||||
case actionList:
|
||||
return f.list(ctx, argString(m, "path"))
|
||||
case "search":
|
||||
case actionSearch:
|
||||
return f.search(ctx, argString(m, "path"), argString(m, "query"))
|
||||
default:
|
||||
return nil, fmt.Errorf("io: file: unknown action %q", action)
|
||||
@@ -202,11 +202,9 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error)
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("io: file: search query is required")
|
||||
}
|
||||
if strings.TrimSpace(base) == "" {
|
||||
if len(f.roots) == 0 {
|
||||
return nil, fmt.Errorf("io: file: no base directory configured")
|
||||
}
|
||||
base = f.roots[0]
|
||||
base, err := f.searchBase(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
real, err := f.resolveExisting(base)
|
||||
if err != nil {
|
||||
@@ -248,6 +246,19 @@ func (f *fileTool) search(ctx context.Context, base, query string) (any, error)
|
||||
return map[string]any{"base": real, "query": query, "hits": hits, "count": len(hits)}, nil
|
||||
}
|
||||
|
||||
// searchBase 解析 search 的基准目录:显式给定就原样返回(由 resolveExisting 归一),
|
||||
// 缺省时用主 root。执行路径与目标声明(file_targets.go 的 declaredPath)必须用同一
|
||||
// 份缺省口径,否则声明出来的路径会与真正走查的目录不是同一个。
|
||||
func (f *fileTool) searchBase(base string) (string, error) {
|
||||
if strings.TrimSpace(base) != "" {
|
||||
return base, nil
|
||||
}
|
||||
if len(f.roots) == 0 {
|
||||
return "", fmt.Errorf("io: file: no base directory configured")
|
||||
}
|
||||
return f.roots[0], nil
|
||||
}
|
||||
|
||||
// ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件
|
||||
// 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言
|
||||
// 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件是 file_claims_test.go(真实插件 + 真实分发器的互斥回归)的夹具:记录版占用表、
|
||||
// 带闸门的工具包装、以及等待与断言助手。夹具只搭台子,不写断言语义 —— 读共享 / 写独占的
|
||||
// 判定一律由**真实的**进程内占用表给出(tool.NewFileClaimer),不拿自造的假表去测。
|
||||
|
||||
// claimProbeTimeout 是「第一路必须进入闸门 / 一路必须结束」这类断言的等待上限:足够宽
|
||||
// 以免依赖机器速度,又足够短以免偶发死锁拖成整包超时。
|
||||
const claimProbeTimeout = 2 * time.Second
|
||||
|
||||
// legResult 是一路调用的结果。
|
||||
type legResult struct {
|
||||
val any
|
||||
err error
|
||||
}
|
||||
|
||||
// recordingClaimer 包住真实的进程内占用表(tool.NewFileClaimer),把每一次登记尝试的目标
|
||||
// 键记下来:测试因此能断言「哪些调用参与了互斥、此刻占用几个键」。
|
||||
type recordingClaimer struct {
|
||||
inner tool.FileClaimer
|
||||
mu sync.Mutex
|
||||
// live 镜像真实占用表的键集合:目标键 -> 路标识 -> 登记次数(同路重复登记按幂等算,
|
||||
// 与真实表一致;释放到最后一个占用者时删键)。
|
||||
live map[string]map[string]int
|
||||
// attempted 按发生顺序记录**每一次**登记尝试的目标键(含被判忙而失败的那些):
|
||||
// 「尝试过」与「成功占到键」是两件事 —— 前者证明这一路确实拿着声明的目标来问过表。
|
||||
attempted []string
|
||||
}
|
||||
|
||||
func newRecordingClaimer() *recordingClaimer {
|
||||
return &recordingClaimer{inner: tool.NewFileClaimer(), live: map[string]map[string]int{}}
|
||||
}
|
||||
|
||||
// Claim 实现 tool.FileClaimer:先记账(判忙的尝试也要留痕),再让真实占用表判;只有
|
||||
// 成功了才占键(失败即未登记,与真实表一致)。
|
||||
func (r *recordingClaimer) Claim(ctx context.Context, legID, target string) error {
|
||||
r.mu.Lock()
|
||||
r.attempted = append(r.attempted, target)
|
||||
r.mu.Unlock()
|
||||
if err := r.inner.Claim(ctx, legID, target); err != nil {
|
||||
return err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
legs := r.live[target]
|
||||
if legs == nil {
|
||||
legs = map[string]int{}
|
||||
r.live[target] = legs
|
||||
}
|
||||
legs[legID]++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release 实现 tool.FileClaimer:无条件转发给真实占用表,并同步镜像(非占用者的释放是
|
||||
// 空操作,与真实表一致)。
|
||||
func (r *recordingClaimer) Release(ctx context.Context, legID, target string) {
|
||||
r.inner.Release(ctx, legID, target)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
legs := r.live[target]
|
||||
if legs == nil {
|
||||
return
|
||||
}
|
||||
if n := legs[legID]; n > 1 {
|
||||
legs[legID] = n - 1
|
||||
} else {
|
||||
delete(legs, legID)
|
||||
}
|
||||
if len(legs) == 0 {
|
||||
delete(r.live, target)
|
||||
}
|
||||
}
|
||||
|
||||
// liveCount 返回此刻占用的目标键数(= 真实占用表的键数)。
|
||||
func (r *recordingClaimer) liveCount() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.live)
|
||||
}
|
||||
|
||||
// claimAttempts 返回登记**尝试**的次数(未声明目标的调用必须为 0:它连表都不问)。
|
||||
func (r *recordingClaimer) claimAttempts() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.attempted)
|
||||
}
|
||||
|
||||
// targets 返回登记尝试过的目标键(副本)。
|
||||
func (r *recordingClaimer) targets() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, len(r.attempted))
|
||||
copy(out, r.attempted)
|
||||
return out
|
||||
}
|
||||
|
||||
// claimRig 是一套「真实插件 + 真实分发器」的互斥夹具:DataDir 即 file 工具唯一的 root,
|
||||
// 占用表换成记录版。
|
||||
type claimRig struct {
|
||||
d *tool.Dispatcher
|
||||
rec *recordingClaimer
|
||||
dir string
|
||||
tools map[string]sdk.Tool
|
||||
}
|
||||
|
||||
func newClaimRig(t *testing.T) *claimRig {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
rec := newRecordingClaimer()
|
||||
d, err := tool.New(registry.New(), tool.WithFileClaimer(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
set := buildTools(t, &config.Config{DataDir: dir})
|
||||
byName := make(map[string]sdk.Tool, len(set))
|
||||
for _, tk := range set {
|
||||
byName[tk.Name()] = tk
|
||||
if err := d.Register(tk); err != nil {
|
||||
t.Fatalf("Register %q: %v", tk.Name(), err)
|
||||
}
|
||||
}
|
||||
return &claimRig{d: d, rec: rec, dir: dir, tools: byName}
|
||||
}
|
||||
|
||||
// seed 在沙箱 root 里放一个文件(测试夹具,不经过工具)。
|
||||
func (r *claimRig) seed(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(r.dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("seed %q: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// gated 注册一个带闸门的**真实插件**实例(名字换成 name,声明面原样转发)。
|
||||
func (r *claimRig) gated(t *testing.T, toolName, name string) *gatedTool {
|
||||
t.Helper()
|
||||
inner, ok := r.tools[toolName]
|
||||
if !ok {
|
||||
t.Fatalf("工具集里没有 %q", toolName)
|
||||
}
|
||||
g := newGatedTool(inner, name)
|
||||
if err := r.d.Register(g); err != nil {
|
||||
t.Fatalf("Register %q: %v", name, err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// gatedTool 包住一个真实工具,在 Execute 前后加两个可控闸门(进入信号 + 让行等待):占用
|
||||
// 登记在 Execute **之前**、释放在它**之后**,所以停在闸门里就等于「这一路持有声明出来的
|
||||
// 占用」,撞车因此是确定性事件,而不是靠 sleep 抢时序。它还必须显式转发 FileTargets ——
|
||||
// 内嵌接口只会提升接口里的方法,声明面不转发就变成了「未实现 FileTargeter 的工具」,
|
||||
// 用例会退化成一个假绿。
|
||||
type gatedTool struct {
|
||||
inner sdk.Tool
|
||||
name string
|
||||
entered chan struct{}
|
||||
hold chan struct{}
|
||||
enteredOne sync.Once
|
||||
holdOne sync.Once
|
||||
}
|
||||
|
||||
func newGatedTool(inner sdk.Tool, name string) *gatedTool {
|
||||
return &gatedTool{inner: inner, name: name, entered: make(chan struct{}), hold: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (g *gatedTool) Name() string { return g.name }
|
||||
|
||||
func (g *gatedTool) Description() string { return g.inner.Description() }
|
||||
|
||||
func (g *gatedTool) Parameters() (map[string]any, error) { return g.inner.Parameters() }
|
||||
|
||||
// FileTargets 原样转发内层工具的声明:测试验证的始终是**插件自己**的目标判定。
|
||||
func (g *gatedTool) FileTargets(args any) []tool.FileTarget {
|
||||
targeter, ok := g.inner.(tool.FileTargeter)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return targeter.FileTargets(args)
|
||||
}
|
||||
|
||||
func (g *gatedTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
g.enteredOne.Do(func() { close(g.entered) })
|
||||
select {
|
||||
case <-g.hold:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return g.inner.Execute(ctx, args)
|
||||
}
|
||||
|
||||
// release 放行闸门(幂等:不会二次 close)。
|
||||
func (g *gatedTool) release() { g.holdOne.Do(func() { close(g.hold) }) }
|
||||
|
||||
// invokeLeg 在指定并发路上同步调用工具。
|
||||
func invokeLeg(d *tool.Dispatcher, leg, name string, args map[string]any) (any, error) {
|
||||
return d.Invoke(tool.WithLegID(context.Background(), leg), name, args)
|
||||
}
|
||||
|
||||
// runLeg 在指定并发路上**异步**调用工具:它一进入闸门,持有该目标的占用就已经成立。
|
||||
func runLeg(d *tool.Dispatcher, leg, name string, args map[string]any) <-chan legResult {
|
||||
out := make(chan legResult, 1)
|
||||
go func() {
|
||||
val, err := invokeLeg(d, leg, name, args)
|
||||
out <- legResult{val: val, err: err}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
// awaitEnter 等一路真的进入 Execute(此刻它的占用已登记)。
|
||||
func awaitEnter(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(claimProbeTimeout):
|
||||
t.Fatal("第一路没有进入执行")
|
||||
}
|
||||
}
|
||||
|
||||
// awaitDone 等一路结束。
|
||||
func awaitDone(t *testing.T, ch <-chan legResult) legResult {
|
||||
t.Helper()
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res
|
||||
case <-time.After(claimProbeTimeout):
|
||||
t.Fatal("这一路没有结束")
|
||||
return legResult{}
|
||||
}
|
||||
}
|
||||
|
||||
// assertBusy 断言一次调用**被判忙**:errors.Is 命中 tool.ErrFileBusy,且面向模型的文案里
|
||||
// 带文件名与文件状态(说出是谁在动它、本次做不了什么)。
|
||||
func assertBusy(t *testing.T, err error, wantSubs ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("期望被判忙,实际调用成功了")
|
||||
}
|
||||
if !errors.Is(err, tool.ErrFileBusy) {
|
||||
t.Fatalf("错误不是 ErrFileBusy: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range append([]string{"请稍后再试"}, wantSubs...) {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("回灌给模型的文案缺少 %q: %s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNotBusy 断言一次调用没被判忙(其它失败允许:这里只关心互斥不该拦它)。
|
||||
func assertNotBusy(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if errors.Is(err, tool.ErrFileBusy) {
|
||||
t.Fatalf("不该被判忙,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoClaims 断言此刻占用表为空、且登记**尝试**次数恰为 wantAttempts:未声明目标的
|
||||
// 调用必须一次都不问表,声明了但被判忙的那些则只留尝试、不占键。
|
||||
func assertNoClaims(t *testing.T, rec *recordingClaimer, wantAttempts int) {
|
||||
t.Helper()
|
||||
if got := rec.liveCount(); got != 0 {
|
||||
t.Fatalf("占用表必须为空,残留 %d 个键", got)
|
||||
}
|
||||
if got := rec.claimAttempts(); got != wantAttempts {
|
||||
t.Fatalf("登记尝试次数 = %d,期望 %d", got, wantAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// plainTool 是最小 sdk.Tool:**只**实现 sdk.Tool(不实现 FileTargeter),用来钉住「未声明
|
||||
// 目标的工具完全不参与互斥」。
|
||||
type plainTool struct{ name string }
|
||||
|
||||
func (p *plainTool) Name() string { return p.name }
|
||||
|
||||
func (p *plainTool) Description() string { return "test tool without file targets" }
|
||||
|
||||
func (p *plainTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"echo": map[string]any{"type": "string"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *plainTool) Execute(_ context.Context, args any) (any, error) {
|
||||
m, _ := args.(map[string]any)
|
||||
return argString(m, "echo"), nil
|
||||
}
|
||||
|
||||
// toolContent 取 file 工具读到的正文。
|
||||
func toolContent(t *testing.T, val any) string {
|
||||
t.Helper()
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("读取结果不是对象: %#v", val)
|
||||
}
|
||||
content, _ := m["content"].(string)
|
||||
return content
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件是**文件级互斥的机制级回归**(声明面单元见 file_targets_test.go,夹具见
|
||||
// file_claims_rig_test.go):把**真实插件**注册进真实的 tool.Dispatcher,用可控闸门把
|
||||
// 「一路持有占用」变成确定性事件(占用登记在 Execute 之前、释放在其后,因此停在闸门里就
|
||||
// 等于持有),逐条钉住主人口径:
|
||||
//
|
||||
// - 同一文件的两个读**并行放行**(检索类并行不该被互斥拖住);
|
||||
// - 读与写、写与写**判忙**,且回灌给模型的文案带文件名与文件状态;
|
||||
// - 不同文件**放行**;
|
||||
// - 未实现 FileTargeter 的工具**完全不受影响**(占用表 0 键)。
|
||||
//
|
||||
// 这一层走的是派发器真实的登记 / 释放路径与真实的进程内占用表 —— 声明面一错就红。
|
||||
|
||||
// TestFileClaims_TwoReadsOfTheSameFileRunParallel 钉住①:同一文件的两个读并行放行
|
||||
// (主人「优先考虑性能而不是预算」的口径)。
|
||||
func TestFileClaims_TwoReadsOfTheSameFileRunParallel(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
file := rig.seed(t, "notes.txt", "hello from disk")
|
||||
holder := rig.gated(t, "file", "file-read-holder")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "read", "path": file})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
|
||||
if err != nil {
|
||||
t.Fatalf("同一文件的两个读必须允许并行,得到 %v", err)
|
||||
}
|
||||
if content := toolContent(t, val); content != "hello from disk" {
|
||||
t.Fatalf("第二路读到的正文 = %q", content)
|
||||
}
|
||||
// 两路登记的是**同一个键**(同类别、同路径):这才叫「同一个文件的读读并行」,
|
||||
// 而不是两路各自登记了不同的键。
|
||||
targets := rig.rec.targets()
|
||||
if len(targets) != 2 || targets[0] != targets[1] {
|
||||
t.Fatalf("两路读应登记同一个目标键,得到 %#v", targets)
|
||||
}
|
||||
if got := rig.rec.liveCount(); got != 1 {
|
||||
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
|
||||
}
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
assertNoClaims(t, rig.rec, 2)
|
||||
}
|
||||
|
||||
// TestFileClaims_ReadIsBusyWhileAnotherLegWrites 钉住②:读与写同一文件 ⇒ 判忙,且面向
|
||||
// 模型的文案带文件名与文件状态(谁在写、本次读不了);持锁那一路退场后立刻可再进。
|
||||
func TestFileClaims_ReadIsBusyWhileAnotherLegWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
file := rig.seed(t, "notes.txt", "v1")
|
||||
holder := rig.gated(t, "file", "file-write-holder")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
_, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "read", "path": file})
|
||||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
if data, err := os.ReadFile(file); err != nil || string(data) != "v2" {
|
||||
t.Fatalf("持锁那一路的写入没有落盘: %q, %v", data, err)
|
||||
}
|
||||
if _, err := invokeLeg(rig.d, "turn-c", "file", map[string]any{"action": "read", "path": file}); err != nil {
|
||||
t.Fatalf("前一路退场后必须已释放占用,得到 %v", err)
|
||||
}
|
||||
// 三次尝试:持锁那一路的写、被判忙的那次读、退场后的第二次读。
|
||||
assertNoClaims(t, rig.rec, 3)
|
||||
}
|
||||
|
||||
// TestFileClaims_WriteVsWriteIsBusy 钉住②(写写):第二路写同一文件被判忙、**根本没有执行**
|
||||
// (闸门未被进入),文件里留下的是持锁那一路的内容。
|
||||
func TestFileClaims_WriteVsWriteIsBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
file := rig.seed(t, "notes.txt", "seed")
|
||||
holder := rig.gated(t, "file", "file-write-holder")
|
||||
peer := rig.gated(t, "file", "file-write-peer")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "held"})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
_, err := invokeLeg(rig.d, "turn-b", peer.Name(), map[string]any{"action": "write", "path": file, "content": "peer"})
|
||||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次写入无法进行")
|
||||
select {
|
||||
case <-peer.entered:
|
||||
t.Fatal("被判忙的调用绝不该执行")
|
||||
default:
|
||||
}
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
if data, err := os.ReadFile(file); err != nil || string(data) != "held" {
|
||||
t.Fatalf("文件内容应只剩持锁那一路写的 held: %q, %v", data, err)
|
||||
}
|
||||
// 两次尝试:持锁那一路的写,以及第二路被判忙的写(判忙那一路也问过表,只是没占到键)。
|
||||
assertNoClaims(t, rig.rec, 2)
|
||||
}
|
||||
|
||||
// TestFileClaims_CrossToolReadOfAWrittenFileIsBusy 钉住②(跨工具):file 正在写一个文件时,
|
||||
// image-crop 读**同一个文件**同样判忙 —— 这正是文件级互斥要盖住的场景(读到的会是撕开的
|
||||
// 内容),也是「同一份路径口径被两个工具共用」的证据。
|
||||
func TestFileClaims_CrossToolReadOfAWrittenFileIsBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
file := rig.seed(t, "notes.txt", "v1")
|
||||
holder := rig.gated(t, "file", "file-write-holder")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
_, err := invokeLeg(rig.d, "turn-b", "image-crop", map[string]any{
|
||||
"image": file, "x": 0, "y": 0, "width": 1, "height": 1,
|
||||
})
|
||||
assertBusy(t, err, "notes.txt", "正在被另一路写入", "本次读取无法进行")
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
// 两次尝试:持锁那一路的写,以及 image-crop 读同一文件被判忙的那次。
|
||||
assertNoClaims(t, rig.rec, 2)
|
||||
}
|
||||
|
||||
// TestFileClaims_DifferentFilesRunParallel 钉住③:不是同一个文件 ⇒ 放行(且真的写成功),
|
||||
// 占用表里两路各占一个键、退场后清空。
|
||||
func TestFileClaims_DifferentFilesRunParallel(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
held := rig.seed(t, "a.txt", "a")
|
||||
other := filepath.Join(rig.dir, "b.txt")
|
||||
holder := rig.gated(t, "file", "file-write-holder")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": held, "content": "held"})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
val, err := invokeLeg(rig.d, "turn-b", "file", map[string]any{"action": "write", "path": other, "content": "bee"})
|
||||
if err != nil {
|
||||
t.Fatalf("不同文件必须允许并行操作,得到 %v", err)
|
||||
}
|
||||
if m, ok := val.(map[string]any); !ok || m["size"] != len("bee") {
|
||||
t.Fatalf("第二路的结果异常: %#v", val)
|
||||
}
|
||||
if data, err := os.ReadFile(other); err != nil || string(data) != "bee" {
|
||||
t.Fatalf("第二路的写入没有落盘: %q, %v", data, err)
|
||||
}
|
||||
if got := rig.rec.liveCount(); got != 1 {
|
||||
t.Fatalf("第二路退场后应只剩持锁那一路的键,实际 %d 个", got)
|
||||
}
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
assertNoClaims(t, rig.rec, 2)
|
||||
}
|
||||
|
||||
// TestFileClaims_ToolWithoutDeclarationIsUnaffected 钉住④:未实现 FileTargeter 的工具完全
|
||||
// 不受影响 —— (a) 它一次都不碰占用表;(b) 真实插件里不碰文件的 web-search 在别的路持有
|
||||
// 写占用时照常调用,不判忙、不登记。
|
||||
func TestFileClaims_ToolWithoutDeclarationIsUnaffected(t *testing.T) {
|
||||
t.Parallel()
|
||||
rig := newClaimRig(t)
|
||||
|
||||
// (a) 两路并发调用一个只实现 sdk.Tool 的工具:都成功,占用表 0 键、登记 0 次。
|
||||
plain := &plainTool{name: "plain-tool"}
|
||||
if err := rig.d.Register(plain); err != nil {
|
||||
t.Fatalf("Register %q: %v", plain.Name(), err)
|
||||
}
|
||||
for _, leg := range []string{"turn-a", "turn-b"} {
|
||||
val, err := invokeLeg(rig.d, leg, plain.Name(), map[string]any{"echo": "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("未声明目标的工具不该受影响(leg=%s): %v", leg, err)
|
||||
}
|
||||
if echo, _ := val.(string); echo != "hi" {
|
||||
t.Fatalf("结果 = %#v", val)
|
||||
}
|
||||
}
|
||||
assertNoClaims(t, rig.rec, 0)
|
||||
|
||||
// (b) 另一路持有某个文件的写占用时,未声明目标的真实工具照常可调用。
|
||||
file := rig.seed(t, "notes.txt", "v1")
|
||||
holder := rig.gated(t, "file", "file-write-holder")
|
||||
first := runLeg(rig.d, "turn-a", holder.Name(), map[string]any{"action": "write", "path": file, "content": "v2"})
|
||||
awaitEnter(t, holder.entered)
|
||||
|
||||
_, err := invokeLeg(rig.d, "turn-b", "web-search", map[string]any{"query": "x"})
|
||||
assertNotBusy(t, err)
|
||||
if got := rig.rec.claimAttempts(); got != 1 {
|
||||
t.Fatalf("只有持锁那一路该问占用表,实际问了 %d 次", got)
|
||||
}
|
||||
|
||||
holder.release()
|
||||
if res := awaitDone(t, first); res.err != nil {
|
||||
t.Fatalf("持锁那一路应正常完成: %v", res.err)
|
||||
}
|
||||
assertNoClaims(t, rig.rec, 1)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件给**真正碰文件的工具**实现文件级互斥的目标声明(tool.FileTargeter,机制见
|
||||
// internal/runtime/tool/fileclaims.go 与 design/15 §2.5.7):占用表要知道「本次调用
|
||||
// 会碰哪个文件、是读还是写」,而这件事只有工具自己知道 —— file 工具的 path 语义随
|
||||
// action 变(read/list/search 是读,write 是写),image-crop 的 image 只有「本地路径」
|
||||
// 这一种输入才碰磁盘。声明集中在这一个文件里(而不是散进各工具文件),是为了让
|
||||
// 「哪些工具参与互斥」在一处可审计:先前占用表恒空的缺口,正是「机制已就绪、没有
|
||||
// 任何工具声明」。
|
||||
//
|
||||
// 体检结论(plugins/tools/**,逐文件核对 import 与 os/filepath 用法):
|
||||
// - file:按路径读写/列目录/检索,**唯一会改文件的工具** ⇒ 实现(见下)。
|
||||
// - image-crop:会按路径读本地源图(URL 与 data URI 不落盘)⇒ 实现(读)。
|
||||
// - document-read:只读 <DataDir>/extracted 下的 sidecar,**不实现** —— sidecar 的
|
||||
// 路径布局(子目录名与后缀)是 internal/runtime/documents 的未导出实现,在插件里
|
||||
// 再拼一遍等于把布局抄成第二份真相,日后布局一改就静默失配;而且该包的写侧是
|
||||
// 「临时文件 + rename」原子替换,读侧读不到半截文件,这条互斥要挡的撕读风险本就
|
||||
// 不存在(经**工具**写 sidecar 的唯一路径是 file 工具直指该路径,那是越权目标,
|
||||
// 本该被沙箱与模型侧拦住,不靠占用表兜)。
|
||||
// - http / web-fetch / web-search / knowledge:只走网络与内存库,不接任何路径。
|
||||
// - basic/**(calculator、datetime、json、markdown、random、text 等):纯计算与
|
||||
// 格式化,整个包没有任何 os/filepath 调用。
|
||||
//
|
||||
// 两个实现都必须是**纯函数**(FileTargeter 的约定):只做词法解析(Clean/Abs/roots
|
||||
// 归属判定),不触盘、不阻塞 —— 声明在每次工具调用**之前**被调用一次。
|
||||
|
||||
// file 工具的 action 词表。Execute 的 switch、Parameters 的 enum 与目标声明共用这
|
||||
// 一份常量,避免声明面与执行面对 action 各写一遍而漂移(声明错了方向,互斥要么漏挡
|
||||
// 要么误挡)。
|
||||
const (
|
||||
actionRead = "read"
|
||||
actionWrite = "write"
|
||||
actionList = "list"
|
||||
actionSearch = "search"
|
||||
)
|
||||
|
||||
// 编译期钉住:会碰文件的工具都实现了声明面。少一个,占用表就少一份覆盖 —— 这正是
|
||||
// 本文件要防的那类静默缺口,因此用编译期断言而不是运行时检查。
|
||||
var (
|
||||
_ tool.FileTargeter = (*fileTool)(nil)
|
||||
_ tool.FileTargeter = (*cropTool)(nil)
|
||||
)
|
||||
|
||||
// FileTargets 实现 tool.FileTargeter:file 工具本次要碰的就是参数里的 path(search
|
||||
// 缺 path 时是主 root,与 Execute 的缺省同一口径),操作类别由 action 决定。
|
||||
//
|
||||
// 路径取沙箱**解析前的声明值**(f.resolve 的产物:绝对、clean、已确认留在 roots
|
||||
// 内),而不是模型原样给的相对路径:占用表的词法规范化是按进程当前目录求绝对路径,
|
||||
// 而工具的真实落点是按 roots 求的,两者不一致就会出现「同一个文件两个键」而放行
|
||||
// 并行。沙箱会拒绝的路径一律不声明 —— 那种调用根本碰不到任何文件,声明了只会误挡。
|
||||
func (f *fileTool) FileTargets(args any) []tool.FileTarget {
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
// 参数不是对象 ⇒ Execute 直接拒绝,碰不到文件。
|
||||
return nil
|
||||
}
|
||||
action := argString(m, "action")
|
||||
path, ok := f.declaredPath(action, argString(m, "path"))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []tool.FileTarget{{Op: fileTargetOp(action), Path: path}}
|
||||
}
|
||||
|
||||
// declaredPath 返回本次调用真正会碰的那个路径(词法层),报告是否有一个值得声明的
|
||||
// 目标。空 path 只有 search 有意义(缺省基准是主 root),其余 action 会被 Execute
|
||||
// 拒绝;沙箱拒绝的路径也不声明。
|
||||
func (f *fileTool) declaredPath(action, p string) (string, bool) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
if action != actionSearch {
|
||||
return "", false
|
||||
}
|
||||
base, err := f.searchBase(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
p = base
|
||||
}
|
||||
clean, err := f.resolve(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// fileTargetOp 把 action 映射成互斥用的操作类别。**未知/缺失的 action 一律按写处理**
|
||||
// —— 方向必须选保守的那一侧:宁可多挡一次让模型稍后重试,也不放过两路并发写。执行面
|
||||
// 对未知 action 会拒绝(schema 的 enum 由分发器校验),但声明面跑在它**之前**,不能
|
||||
// 假设参数一定合法。
|
||||
func fileTargetOp(action string) tool.FileOp {
|
||||
switch action {
|
||||
case actionRead, actionList, actionSearch:
|
||||
return tool.FileOpRead
|
||||
default:
|
||||
return tool.FileOpWrite
|
||||
}
|
||||
}
|
||||
|
||||
// FileTargets 实现 tool.FileTargeter:image-crop 只读源图,且只有「本地路径」这一种
|
||||
// 输入会碰文件系统(http(s) URL 走网络、data URI 是内存里的一段 base64)。路径同样
|
||||
// 取沙箱解析前的声明值;沙箱会拒的路径不声明。
|
||||
func (c *cropTool) FileTargets(args any) []tool.FileTarget {
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok || c.file == nil {
|
||||
return nil
|
||||
}
|
||||
src := strings.TrimSpace(argString(m, "image"))
|
||||
if src == "" || cropSourceKindOf(src) != cropSourceFile {
|
||||
return nil
|
||||
}
|
||||
clean, err := c.file.resolve(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []tool.FileTarget{{Op: tool.FileOpRead, Path: clean}}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user