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 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
This commit is contained in:
AskaEth committed 2026-10-04 12:29:39 +08:00
1 parent 95f97f5df3
commit 09ee561aa7
6 files changed
+344 -222

No files matched your search

+230
View File
@@ -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)
}
}