diff --git a/internal/runtime/exec/file.go b/internal/runtime/exec/file.go deleted file mode 100644 index d282f59..0000000 --- a/internal/runtime/exec/file.go +++ /dev/null @@ -1,99 +0,0 @@ -package exec - -import ( - "fmt" - "log/slog" - "os" - "sync" - - "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" -) - -// FileSandbox confines file read, write, and list access to a set of allowed -// root directories (DevDocs/design/04-运行时.md §8, design/11 §5). Every path is -// validated by [Resolve] before use, which rejects absolute paths outside the -// roots, relative paths that escape them, and symlinks that resolve outside -// them. -type FileSandbox struct { - mu sync.RWMutex - roots []string - log *slog.Logger -} - -// NewFileSandbox builds a FileSandbox confined to roots. Raw roots are made -// absolute and cleaned. The logger is required. -func NewFileSandbox(roots []string, log *slog.Logger) (*FileSandbox, error) { - if log == nil { - return nil, ErrNilLogger - } - return &FileSandbox{ - roots: absCleaners(roots), - log: logger.WithSource(log, "runtime/exec"), - }, nil -} - -// SetRoots atomically replaces the allowed roots. It enables a live config -// reload without racing in-flight Resolve calls. -func (s *FileSandbox) SetRoots(roots []string) { - s.mu.Lock() - defer s.mu.Unlock() - s.roots = absCleaners(roots) -} - -// Resolve validates path against the current allowed roots and returns its -// absolute, symlink-resolved location, or an error if it would escape. A -// relative path is resolved against the first allowed root. -func (s *FileSandbox) Resolve(path string) (string, error) { - roots := s.rootsLocked() - if len(roots) == 0 { - return "", ErrNoAllowedRoot - } - return resolveInsideRoot(path, roots) -} - -// ReadFile reads a file confined to the allowed roots. -func (s *FileSandbox) ReadFile(path string) ([]byte, error) { - abs, err := s.Resolve(path) - if err != nil { - return nil, err - } - data, err := os.ReadFile(abs) - if err != nil { - return nil, fmt.Errorf("exec: read %s: %w", abs, err) - } - return data, nil -} - -// WriteFile writes data to a path confined to the allowed roots. -func (s *FileSandbox) WriteFile(path string, data []byte, perm os.FileMode) error { - abs, err := s.Resolve(path) - if err != nil { - return err - } - if err := os.WriteFile(abs, data, perm); err != nil { - return fmt.Errorf("exec: write %s: %w", abs, err) - } - return nil -} - -// List returns the directory entries of a directory confined to the allowed -// roots. -func (s *FileSandbox) List(dir string) ([]os.DirEntry, error) { - abs, err := s.Resolve(dir) - if err != nil { - return nil, err - } - entries, err := os.ReadDir(abs) - if err != nil { - return nil, fmt.Errorf("exec: list %s: %w", abs, err) - } - return entries, nil -} - -func (s *FileSandbox) rootsLocked() []string { - s.mu.RLock() - defer s.mu.RUnlock() - roots := make([]string, len(s.roots)) - copy(roots, s.roots) - return roots -} diff --git a/internal/runtime/exec/file_test.go b/internal/runtime/exec/file_test.go deleted file mode 100644 index 606ed56..0000000 --- a/internal/runtime/exec/file_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package exec - -import ( - "errors" - "os" - "path/filepath" - "testing" -) - -func newFileSandbox(t *testing.T, roots ...string) *FileSandbox { - t.Helper() - fs, err := NewFileSandbox(roots, discardLogger()) - if err != nil { - t.Fatalf("NewFileSandbox: %v", err) - } - return fs -} - -func TestFileSandbox_Resolve_Allowed(t *testing.T) { - t.Parallel() - root := t.TempDir() - realRoot := root - if r, err := filepath.EvalSymlinks(root); err == nil { - realRoot = r - } - fs := newFileSandbox(t, root) - - got, err := fs.Resolve(filepath.Join(root, "a.txt")) - if err != nil { - t.Fatalf("Resolve: %v", err) - } - if want := filepath.Join(realRoot, "a.txt"); got != want { - t.Errorf("Resolve = %q, want %q", got, want) - } -} - -func TestFileSandbox_Resolve_OutsideRejected(t *testing.T) { - t.Parallel() - fs := newFileSandbox(t, t.TempDir()) - outside := filepath.Join(os.TempDir(), "cyrene-outside-file.txt") - _, err := fs.Resolve(outside) - if !errors.Is(err, ErrPathOutsideRoot) { - t.Fatalf("err = %v, want ErrPathOutsideRoot", err) - } -} - -func TestFileSandbox_Resolve_SymlinkEscape(t *testing.T) { - t.Parallel() - root := t.TempDir() - outside := t.TempDir() - link := filepath.Join(root, "escape") - if err := os.Symlink(outside, link); err != nil { - t.Skipf("symlink not supported: %v", err) - } - fs := newFileSandbox(t, root) - - _, err := fs.Resolve(filepath.Join(link, "secret.txt")) - if !errors.Is(err, ErrSymlinkEscape) { - t.Fatalf("err = %v, want ErrSymlinkEscape", err) - } -} - -func TestFileSandbox_WriteReadList(t *testing.T) { - t.Parallel() - fs := newFileSandbox(t, t.TempDir()) - - if err := fs.WriteFile("a.txt", []byte("content"), 0o600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - data, err := fs.ReadFile("a.txt") - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if string(data) != "content" { - t.Errorf("ReadFile = %q, want content", data) - } - entries, err := fs.List(".") - if err != nil { - t.Fatalf("List: %v", err) - } - if len(entries) != 1 { - t.Errorf("len(entries) = %d, want 1", len(entries)) - } -} - -func TestFileSandbox_NoRoot(t *testing.T) { - t.Parallel() - fs := newFileSandbox(t, "") - _, err := fs.Resolve("a.txt") - if !errors.Is(err, ErrNoAllowedRoot) { - t.Fatalf("err = %v, want ErrNoAllowedRoot", err) - } -} diff --git a/internal/runtime/exec/provider.go b/internal/runtime/exec/provider.go index 6d634b4..85c08e9 100644 --- a/internal/runtime/exec/provider.go +++ b/internal/runtime/exec/provider.go @@ -2,13 +2,15 @@ // Cyrene agent runtime (DevDocs/design/04-运行时.md §8 and design/02-会话与上下文模型.md §4). // // A Provider is the grant-based, sandboxed entry point through which the agent -// may run code on its own host. This milestone ships two concrete pieces: +// may run code on its own host. This milestone ships one concrete piece: // // - [TerminalExecutor], the default command provider, which runs allowlisted // commands inside a locked-down terminal sandbox (command allowlist, working // directory confinement, environment allowlist, timeout, output truncation). -// - [FileSandbox], which confines file read/write/list access to a set of -// allowed root directories. +// +// 文件读写的路径沙箱不在本包:按裁决 13.4.4(design/04 §4),文件访问的 roots +// 收敛、symlink 逃逸与 NUL 拒绝统一由 plugins/tools/io 文件工具承载;本包内 +// sandbox.go 的收敛原语只服务于终端沙箱的工作目录 / roots 守卫。 // // GUI operations and a real virtual machine are reserved for later milestones; // they surface only through the [Provider] interface and are not implemented diff --git a/internal/runtime/exec/sandbox.go b/internal/runtime/exec/sandbox.go index 7a81760..9881f18 100644 --- a/internal/runtime/exec/sandbox.go +++ b/internal/runtime/exec/sandbox.go @@ -6,6 +6,11 @@ import ( "strings" ) +// 本文件是终端沙箱的路径收敛原语(roots 清洗、词法包含判定、symlink 逃逸解析), +// 由 terminal.go 用于工作目录守卫。**它不是文件沙箱**:文件读写的沙箱已按裁决 +// 13.4.4 收敛到 plugins/tools/io 文件工具(design/04 §4)。两侧保持同一收敛口径: +// 词法 roots 包含 + 最深存在祖先的 symlink 解析 + NUL 拒绝。 + // absCleaners returns a slice of absolute, clean paths from raw roots. Dirty or // empty entries are dropped; non-absolute roots are made absolute against the // process working directory. diff --git a/plugins/tools/io/file.go b/plugins/tools/io/file.go index 248d2d7..237b711 100644 --- a/plugins/tools/io/file.go +++ b/plugins/tools/io/file.go @@ -20,6 +20,9 @@ var _ sdk.Tool = (*fileTool)(nil) // fileTool reads, writes, lists and searches files, confined to the configured // data directory plus any explicit whitelist. Paths are cleaned and re-checked // against those roots, so ".." traversal and symlink escapes are rejected. +// +// 本工具是文件访问沙箱的唯一实现处(裁决 13.4.4、design/04 §4):roots 收敛、 +// symlink 逃逸、NUL 字节与 ".." 穿越都在此判定,写入前先按真实落点校验父链。 type fileTool struct { roots []string // absolute, cleaned allowed directories maxRead int64 @@ -121,20 +124,24 @@ func (f *fileTool) read(ctx context.Context, p string) (any, error) { return map[string]any{"path": real, "size": len(data), "content": string(data)}, nil } -// write creates or overwrites a file, capped at maxWrite bytes and confined to -// the allowed roots (parent dirs are created inside the root only). +// write 创建或覆盖文件,内容上限 maxWrite 字节。流程: +// 1. 写入前先算出目标的真实落点,确认仍在 roots 内(防 roots 内的 symlink 逃逸); +// 2. 建目录; +// 3. 建目录之后再核一遍父目录的真实落点——兜住建目录期间新出现的 symlink,以及 +// Windows 目录 junction 这类 filepath.EvalSymlinks 不解析的再解析点(此时解析 +// 报错即拒),保证严格度不低于收敛前的实现(裁决 13.4.4)。 func (f *fileTool) write(ctx context.Context, p, content string) (any, error) { if err := ctx.Err(); err != nil { return nil, err } - clean, err := f.resolve(p) - if err != nil { - return nil, err - } if int64(len(content)) > f.maxWrite { return nil, fmt.Errorf("io: file: content exceeds the %d-byte write limit", f.maxWrite) } - parent := filepath.Dir(clean) + target, err := f.resolveForCreate(p) + if err != nil { + return nil, err + } + parent := filepath.Dir(target) if err := os.MkdirAll(parent, dirPerm); err != nil { return nil, fmt.Errorf("io: file: mkdir: %w", err) } @@ -142,19 +149,14 @@ func (f *fileTool) write(ctx context.Context, p, content string) (any, error) { if err != nil { return nil, fmt.Errorf("io: file: resolve parent: %w", err) } - if !f.within(realParent) { - return nil, fmt.Errorf("io: file: parent directory escapes the allowed directories") + if !f.withinReal(realParent) { + f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", realParent) + return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p) } - if info, err := os.Lstat(clean); err == nil && info.Mode()&os.ModeSymlink != 0 { - real, err := filepath.EvalSymlinks(clean) - if err != nil || !f.within(real) { - return nil, fmt.Errorf("io: file: %q escapes the allowed directories", p) - } - } - if err := os.WriteFile(clean, []byte(content), filePerm); err != nil { + if err := os.WriteFile(target, []byte(content), filePerm); err != nil { return nil, fmt.Errorf("io: file: write: %w", err) } - return map[string]any{"path": clean, "size": len(content)}, nil + return map[string]any{"path": target, "size": len(content)}, nil } // list returns the entries of a directory, capped at maxList. @@ -246,13 +248,22 @@ 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 } -// resolve cleans p and verifies it stays within one of the allowed roots. It -// rejects empty paths and any path that escapes via ".." or an absolute target -// outside the roots. +// ErrNullByte 表示路径中含 NUL 字节:在触达文件系统之前就被拒绝(收敛自原 exec 文件 +// 沙箱,裁决 13.4.4)。调用方与测试一律用 errors.Is(err, ErrNullByte) 判定——不要断言 +// 错误文本;此处只存不带来源前缀的文本,前缀由返回值处的 fmt.Errorf 补上,保证对外错误 +// 文本与收敛前逐字节一致。 +var ErrNullByte = errors.New("path contains a NUL byte") + +// resolve 清洗路径并确认其词法上留在允许的 roots 之内:拒绝空路径与含 NUL 字节的 +// 路径(NUL 收敛自原 exec 文件沙箱,裁决 13.4.4),拒绝 ".." 穿越与越界的绝对路径; +// 返回绝对、clean 的词法路径。 func (f *fileTool) resolve(p string) (string, error) { if strings.TrimSpace(p) == "" { return "", fmt.Errorf("io: file: path is required") } + if strings.IndexByte(p, 0) >= 0 { + return "", fmt.Errorf("io: file: %w", ErrNullByte) + } if len(f.roots) == 0 { return "", fmt.Errorf("io: file: no base directory configured") } @@ -267,9 +278,8 @@ func (f *fileTool) resolve(p string) (string, error) { return clean, nil } -// resolveExisting resolves p and verifies the symlink-resolved real path stays -// within the roots, guarding against escaping through a symlink. It errors when -// the target does not exist. +// resolveExisting 解析一个必须已存在的路径:要求其 symlink 解析后的真实落点仍在 +// roots 的真实路径之内,防住经由 roots 内 symlink 的逃逸。目标不存在时报错。 func (f *fileTool) resolveExisting(p string) (string, error) { clean, err := f.resolve(p) if err != nil { @@ -279,16 +289,56 @@ func (f *fileTool) resolveExisting(p string) (string, error) { if err != nil { return "", fmt.Errorf("io: file: resolve %q: %w", p, err) } - if !f.within(real) { + if !f.withinReal(real) { f.log.Warn("io file: rejected path escaping allowed directories", "path", p, "resolved", real) return "", fmt.Errorf("io: file: %q escapes the allowed directories", p) } return real, nil } -// within reports whether p is inside (or equal to) one of the allowed roots. -func (f *fileTool) within(p string) bool { - for _, root := range f.roots { +// resolveForCreate 解析一个可能尚不存在(即将创建)的写入目标:先推出最深存在祖先 +// 的真实落点,确认仍在 roots 内后返回真实路径(真实祖先 + 尚未创建的分量)。 +func (f *fileTool) resolveForCreate(p string) (string, error) { + clean, err := f.resolve(p) + if err != nil { + return "", err + } + real, err := resolveReal(clean) + if err != nil { + return "", fmt.Errorf("io: file: resolve %q: %w", p, err) + } + if !f.withinReal(real) { + f.log.Warn("io file: rejected write escaping allowed directories", "path", p, "resolved", real) + return "", fmt.Errorf("io: file: %q escapes the allowed directories", p) + } + return real, nil +} + +// within 报告 p 是否词法上位于(或等于)某个 root 之内。 +func (f *fileTool) within(p string) bool { return withinRoots(p, f.roots) } + +// withinReal 报告真实落点 p 是否位于某个 root 的真实路径之内。 +func (f *fileTool) withinReal(p string) bool { return withinRoots(p, f.realRoots()) } + +// realRoots 返回各 root 的真实路径:存在则解析 symlink,尚未创建则按最深存在祖先 +// 推导。把「真实落点」与 roots 放进同一(真实)路径空间比较,既拦得住 roots 内 +// symlink 的逃逸,也不会把「DataDir 自身是 symlink」的正常部署误判为逃逸。 +func (f *fileTool) realRoots() []string { + out := make([]string, 0, len(f.roots)) + for _, r := range f.roots { + real, err := resolveReal(r) + if err != nil { + real = r + } + out = append(out, real) + } + return out +} + +// withinRoots 报告 p 是否位于(或等于)roots 中的某一个之内:按 filepath.Rel 判定, +// 相对路径为 "." 或不以 ".." 开头即为包含。 +func withinRoots(p string, roots []string) bool { + for _, root := range roots { rel, err := filepath.Rel(root, p) if err != nil { continue @@ -300,6 +350,33 @@ func (f *fileTool) within(p string) bool { return false } +// resolveReal 返回 p 的真实(symlink 解析后)路径:p 自身存在时直接解析;不存在时 +// 沿父级上溯到最深的存在祖先,再把尚未创建的分量拼回。这样「即将创建」的路径也能 +// 在落盘前判定真实落点,避免 MkdirAll 沿 roots 内的 symlink 逃逸。 +func resolveReal(p string) (string, error) { + cur := filepath.Clean(p) + remaining := "" + for { + if real, err := filepath.EvalSymlinks(cur); err == nil { + if remaining == "" { + return real, nil + } + return filepath.Join(real, remaining), nil + } + base := filepath.Base(cur) + parent := filepath.Dir(cur) + if parent == cur { + return "", fmt.Errorf("no existing ancestor for %s", p) + } + if remaining == "" { + remaining = base + } else { + remaining = filepath.Join(base, remaining) + } + cur = parent + } +} + // absoluteClean absolutizes and cleans p, returning the best-effort absolute // form used as an allowed root. func absoluteClean(p string) string { diff --git a/plugins/tools/io/file_sandbox_test.go b/plugins/tools/io/file_sandbox_test.go new file mode 100644 index 0000000..a9e2d7b --- /dev/null +++ b/plugins/tools/io/file_sandbox_test.go @@ -0,0 +1,230 @@ +package io + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestFileTool_RejectsNULBytePath 覆盖 NUL 字节拒绝(收敛自原 exec 文件沙箱, +// 裁决 13.4.4):任何 action 的路径含 NUL 都必须在触达文件系统之前被拒,且错误须 +// 经 errors.Is 命中 ErrNullByte——不能按错误文本断言,理由见用例内注释。 +func TestFileTool_RejectsNULBytePath(t *testing.T) { + t.Parallel() + ft := fileToolForTest(t, &config.Config{DataDir: t.TempDir()}) + cases := []struct { + name string + args map[string]any + }{ + {"read", map[string]any{"action": "read", "path": "a\x00b.txt"}}, + {"write", map[string]any{"action": "write", "path": "a\x00b.txt", "content": "x"}}, + {"list", map[string]any{"action": "list", "path": "sub\x00dir"}}, + {"search", map[string]any{"action": "search", "path": "sub\x00dir", "query": "x"}}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + _, err := ft.Execute(context.Background(), c.args) + // 必须用「哨兵 + errors.Is」断言,不能按错误文本里的 "NUL" 做子串匹配: + // t.TempDir() 的目录名带着本测试的函数名(…TestFileTool_RejectsNULBytePath…), + // 而拒绝/失败路径的错误消息会嵌完整路径,于是文本断言必然假阳性——实测把 + // file.go 的 NUL 检查整段删掉,这四个子用例仍全部 PASS,安全门等于没被钉住。 + if err == nil || !errors.Is(err, ErrNullByte) { + t.Fatalf("expected NUL rejection (errors.Is ErrNullByte), got %v", err) + } + }) + } +} + +// TestFileTool_RejectsTraversalOnWrite 覆盖 ".." 穿越在写入面同样被拒, +// 且越界目标不会真的被创建。 +func TestFileTool_RejectsTraversalOnWrite(t *testing.T) { + t.Parallel() + root := t.TempDir() + ft := fileToolForTest(t, &config.Config{DataDir: root}) + const leaf = "cyrene-fsguard-traversal.txt" + outside := filepath.Join(filepath.Dir(root), leaf) + for _, p := range []string{"../" + leaf, "sub/../../" + leaf} { + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": p, "content": "x"}); err == nil { + t.Fatalf("expected traversal rejection for %q", p) + } + } + if _, err := os.Stat(outside); !os.IsNotExist(err) { + t.Fatalf("out-of-root file %q must not exist (stat err = %v)", outside, err) + } +} + +// TestFileTool_Write_CreatesNestedDirsInsideRoot 确认更严的落点校验没有破坏 +// roots 内的多级目录创建与回读。 +func TestFileTool_Write_CreatesNestedDirsInsideRoot(t *testing.T) { + t.Parallel() + root := t.TempDir() + ft := fileToolForTest(t, &config.Config{DataDir: root}) + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "deep/a/b/c.txt", "content": "ok"}); err != nil { + t.Fatalf("write nested: %v", err) + } + data, err := os.ReadFile(filepath.Join(root, "deep", "a", "b", "c.txt")) + if err != nil { + t.Fatalf("read nested: %v", err) + } + if string(data) != "ok" { + t.Fatalf("content = %q", data) + } +} + +// TestFileTool_Write_CreatesMissingDataDir 覆盖 DataDir 尚未创建的场景: +// roots 与真实落点按同一规则推导,首次写入仍应把数据目录建出来。 +func TestFileTool_Write_CreatesMissingDataDir(t *testing.T) { + t.Parallel() + root := filepath.Join(t.TempDir(), "not-created-yet") + ft := fileToolForTest(t, &config.Config{DataDir: root}) + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "a.txt", "content": "ok"}); err != nil { + t.Fatalf("write into missing data dir: %v", err) + } + if data, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(data) != "ok" { + t.Fatalf("read back: %v %q", err, data) + } +} + +// TestFileTool_Write_FollowsSymlinkInsideRoot 覆盖 roots 内的合法 symlink: +// 指向 roots 之内时不应被当作逃逸(收敛前后行为一致)。 +func TestFileTool_Write_FollowsSymlinkInsideRoot(t *testing.T) { + t.Parallel() + root := t.TempDir() + target := filepath.Join(root, "real.txt") + if err := os.WriteFile(target, []byte("old"), filePerm); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(root, "alias.txt")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + ft := fileToolForTest(t, &config.Config{DataDir: root}) + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "alias.txt", "content": "new"}); err != nil { + t.Fatalf("write through in-root symlink: %v", err) + } + if data, err := os.ReadFile(target); err != nil || string(data) != "new" { + t.Fatalf("read back: %v %q", err, data) + } +} + +// TestFileTool_Write_FollowsDirSymlinkInsideRoot 覆盖 roots 内的目录 symlink 指向 +// roots 之内:落点解析后仍应放行,建目录后的父目录复核不得误拒。 +func TestFileTool_Write_FollowsDirSymlinkInsideRoot(t *testing.T) { + t.Parallel() + root := t.TempDir() + sub := filepath.Join(root, "sub") + if err := os.MkdirAll(sub, dirPerm); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sub, filepath.Join(root, "link")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + ft := fileToolForTest(t, &config.Config{DataDir: root}) + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "link/a.txt", "content": "hi"}); err != nil { + t.Fatalf("write through in-root dir symlink: %v", err) + } + if data, err := os.ReadFile(filepath.Join(sub, "a.txt")); err != nil || string(data) != "hi" { + t.Fatalf("read back: %v %q", err, data) + } +} + +// TestFileTool_Write_RejectsSymlinkParentEscape 覆盖「即将创建」路径的 symlink 逃逸: +// 目标父链经由 roots 内的 symlink 指向外部时,写入必须在建目录之前被拒,外部目录 +// 不得留下任何被创建出来的痕迹(旧实现先 MkdirAll 再校验,会先在外部建出目录)。 +func TestFileTool_Write_RejectsSymlinkParentEscape(t *testing.T) { + t.Parallel() + root := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + ft := fileToolForTest(t, &config.Config{DataDir: root}) + _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "escape/sub/pwn.txt", "content": "x"}) + if err == nil { + t.Fatal("expected symlink parent escape to be rejected") + } + if _, err := os.Stat(filepath.Join(outside, "sub")); !os.IsNotExist(err) { + t.Fatalf("rejected write must not create %q outside the root (stat err = %v)", filepath.Join(outside, "sub"), err) + } +} + +// TestFileTool_SymlinkedRootAllowed 覆盖 roots 与真实落点在同一路径空间比较: +// DataDir 自身是 symlink 的正常部署(如 macOS /tmp → /private/tmp)不得被误判为逃逸。 +func TestFileTool_SymlinkedRootAllowed(t *testing.T) { + t.Parallel() + realDir := t.TempDir() + link := filepath.Join(t.TempDir(), "data") + if err := os.Symlink(realDir, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + ft := fileToolForTest(t, &config.Config{DataDir: link}) + if _, err := ft.Execute(context.Background(), map[string]any{"action": "write", "path": "a.txt", "content": "hi"}); err != nil { + t.Fatalf("write through symlinked root: %v", err) + } + data, err := os.ReadFile(filepath.Join(realDir, "a.txt")) + if err != nil { + t.Fatalf("read back: %v", err) + } + if string(data) != "hi" { + t.Fatalf("content = %q", data) + } + got, err := ft.Execute(context.Background(), map[string]any{"action": "read", "path": "a.txt"}) + if err != nil { + t.Fatalf("read through symlinked root: %v", err) + } + if got.(map[string]any)["content"] != "hi" { + t.Fatalf("content = %q", got.(map[string]any)["content"]) + } +} + +// TestWithinRoots 覆盖词法包含判定(含根自身、".." 越界、同前缀兄弟目录)。 +func TestWithinRoots(t *testing.T) { + t.Parallel() + root := filepath.Clean("/allow/root") + cases := []struct { + name string + path string + want bool + }{ + {"inside", filepath.Join(root, "a", "b.txt"), true}, + {"exact-root", root, true}, + {"parent", filepath.Dir(root), false}, + {"sibling", "/allow/other", false}, + {"sibling-prefix", "/allow/root-other", false}, + {"deep", filepath.Join(root, "x", "y", "z"), true}, + {"normalized-dotdot", filepath.Join(root, "sub", "..", "file.txt"), true}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := withinRoots(c.path, []string{root}); got != c.want { + t.Errorf("withinRoots(%q) = %v, want %v", c.path, got, c.want) + } + }) + } +} + +// TestResolveReal_NonExistentNested 覆盖最深存在祖先推导:叶子与中间目录都还不存在 +// 时,真实落点 = 已存在祖先的真实路径 + 未创建分量。 +func TestResolveReal_NonExistentNested(t *testing.T) { + t.Parallel() + dir := t.TempDir() + realDir := dir + if r, err := filepath.EvalSymlinks(dir); err == nil { + realDir = r + } + p := filepath.Join(dir, "a", "b", "c") + got, err := resolveReal(p) + if err != nil { + t.Fatalf("resolveReal(%q) error: %v", p, err) + } + if want := filepath.Join(realDir, "a", "b", "c"); got != want { + t.Errorf("resolveReal = %q, want %q", got, want) + } +}