Files
Cyrene_v2/plugins/tools/io/file_sandbox_test.go
T
AskaEth 09ee561aa7 refactor(exec,io): 文件沙箱单入口收进 io 工具、删 exec.FileSandbox(裁决 13.4.4)+ 修一条假阳性安全断言
- 删除未接线的 exec.FileSandbox(含 5 个用例):文件沙箱从此只有 plugins/tools/io/file.go 一处实现
- io 侧取更严一侧:NUL 拒绝(原 io 缺)、写前按最深存在祖先判定落点(避免越界建目录)、roots 归化到真实路径(修 roots 自身是 symlink 时的全误拒)
- exec/sandbox.go 保留并注释说明:那是**终端工作目录守卫**的原语(terminal.go 在用),不是文件沙箱
- 修假阳性:原 NUL 用例断言错误文本含 "NUL",而 t.TempDir() 目录名自带测试函数名 "…RejectsNULBytePath…"、错误又内嵌整条临时路径 ⇒ 删掉安全检查用例照样绿。改为哨兵错误 ErrNullByte + errors.Is(错误文本逐字节不变),修复后同一变异必红
- 同类排查:file_test.go 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
2026-10-04 12:29:39 +08:00

231 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}