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) } }