- 删除未接线的 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 三处文本断言判定无风险(含空格的串不可能出现在临时目录名里),保留
231 lines
9.0 KiB
Go
231 lines
9.0 KiB
Go
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)
|
||
}
|
||
}
|