// path_guard_test.go -- 路径安全防护函数的单元测试. // // 覆盖场景: // - validateEntryName 合法/非法名称验证 // - validateBaseDir 合法/非法目录路径验证 // - confinedPath 路径监禁(正常/逃逸/边界) // - WithStrictSymlink 严格符号链接模式 // - checkSymlinkWithMode 宽松/严格双模式 // - isCJK U+F900-U+FAFF 不再被视为 CJK package memory import ( "context" "os" "path/filepath" "testing" ) // ─── validateEntryName ──────────────────────────────────────────────────────── // TestValidateEntryName_Valid 验证合法名称通过 func TestValidateEntryName_Valid(t *testing.T) { cases := []string{ "hello", "my-memory", "test_123", "你好世界", "A very long name that is still under the limit", } for _, name := range cases { if err := validateEntryName(name); err != nil { t.Errorf("validateEntryName(%q) 应通过, 实际错误: %v", name, err) } } } // TestValidateEntryName_Empty 验证空名称报错 func TestValidateEntryName_Empty(t *testing.T) { if err := validateEntryName(""); err == nil { t.Error("空名称应报错") } } // TestValidateEntryName_PathSeparator 验证含路径分隔符的名称报错 func TestValidateEntryName_PathSeparator(t *testing.T) { cases := []string{ "../escape", "foo/bar", "path\\traversal", "../../etc/passwd", } for _, name := range cases { if err := validateEntryName(name); err == nil { t.Errorf("含路径分隔符的名称 %q 应报错", name) } } } // TestValidateEntryName_TooLong 验证过长名称报错 func TestValidateEntryName_TooLong(t *testing.T) { longName := make([]byte, 256) for i := range longName { longName[i] = 'a' } if err := validateEntryName(string(longName)); err == nil { t.Error("超过 255 字节的名称应报错") } } // TestValidateEntryName_Exactly255 验证恰好 255 字节通过 func TestValidateEntryName_Exactly255(t *testing.T) { name := make([]byte, 255) for i := range name { name[i] = 'a' } if err := validateEntryName(string(name)); err != nil { t.Errorf("255 字节名称应通过, 实际错误: %v", err) } } // ─── validateBaseDir ────────────────────────────────────────────────────────── // TestValidateBaseDir_Valid 验证合法绝对路径通过 func TestValidateBaseDir_Valid(t *testing.T) { cases := []string{ "/tmp/memory", "/home/user/.flyto/projects/abc/memory", "/var/lib/flyto", } for _, dir := range cases { if err := validateBaseDir(dir); err != nil { t.Errorf("validateBaseDir(%q) 应通过, 实际错误: %v", dir, err) } } } // TestValidateBaseDir_Relative 验证相对路径报错 func TestValidateBaseDir_Relative(t *testing.T) { cases := []string{ "relative/path", "memory", "./memory", } for _, dir := range cases { if err := validateBaseDir(dir); err == nil { t.Errorf("相对路径 %q 应报错", dir) } } } // TestValidateBaseDir_DotDot 验证含 ".." 的路径报错 func TestValidateBaseDir_DotDot(t *testing.T) { cases := []string{ "/tmp/foo/../../etc", } for _, dir := range cases { if err := validateBaseDir(dir); err == nil { t.Errorf("含 '..' 的路径 %q 应报错", dir) } } } // ─── confinedPath ───────────────────────────────────────────────────────────── // TestConfinedPath_Normal 验证正常子路径通过 func TestConfinedPath_Normal(t *testing.T) { base := "/tmp/memory" cases := []struct { path string wantClean string }{ {"/tmp/memory/foo.md", "/tmp/memory/foo.md"}, {"/tmp/memory/sub/bar.md", "/tmp/memory/sub/bar.md"}, // 含 . 的路径应被清理 {"/tmp/memory/./foo.md", "/tmp/memory/foo.md"}, } for _, tc := range cases { got, err := confinedPath(base, tc.path) if err != nil { t.Errorf("confinedPath(%q, %q) 应通过, 实际错误: %v", base, tc.path, err) continue } if got != tc.wantClean { t.Errorf("confinedPath(%q, %q) = %q, 期望 %q", base, tc.path, got, tc.wantClean) } } } // TestConfinedPath_Escape 验证逃逸路径报错 func TestConfinedPath_Escape(t *testing.T) { base := "/tmp/memory" cases := []string{ "/tmp/other/file.md", "/tmp/memoryfoo/bar.md", // 前缀匹配但不是子目录 "/etc/passwd", "/tmp/memory/../other", } for _, path := range cases { if _, err := confinedPath(base, path); err == nil { t.Errorf("逃逸路径 %q 应报错", path) } } } // TestConfinedPath_SameAsBase 验证路径等于 baseDir 时通过(边界情况) func TestConfinedPath_SameAsBase(t *testing.T) { base := "/tmp/memory" got, err := confinedPath(base, "/tmp/memory") if err != nil { t.Errorf("路径等于 baseDir 应通过, 实际错误: %v", err) } if got != "/tmp/memory" { t.Errorf("返回路径应为 %q, 实际: %q", "/tmp/memory", got) } } // TestConfinedPath_PrefixNotSubdir 验证路径前缀匹配但非子目录时报错 func TestConfinedPath_PrefixNotSubdir(t *testing.T) { // /tmp/memory 不应该匹配 /tmp/memory2 _, err := confinedPath("/tmp/memory", "/tmp/memory2/foo.md") if err == nil { t.Error("/tmp/memory2 不是 /tmp/memory 的子目录,应报错") } } // ─── WithStrictSymlink + checkSymlinkWithMode ───────────────────────────────── // TestCheckSymlinkWithMode_NoSymlink 验证非符号链接不报错 func TestCheckSymlinkWithMode_NoSymlink(t *testing.T) { dir := t.TempDir() regularFile := filepath.Join(dir, "regular.md") os.WriteFile(regularFile, []byte("content"), 0644) // 宽松模式 if err := checkSymlinkWithMode(regularFile, nil, false); err != nil { t.Errorf("普通文件宽松模式应通过: %v", err) } // 严格模式 if err := checkSymlinkWithMode(regularFile, nil, true); err != nil { t.Errorf("普通文件严格模式应通过: %v", err) } } // TestCheckSymlinkWithMode_NotExist 验证文件不存在时不报错 func TestCheckSymlinkWithMode_NotExist(t *testing.T) { if err := checkSymlinkWithMode("/nonexistent/path.md", nil, true); err != nil { t.Errorf("文件不存在时不应报错: %v", err) } } // TestCheckSymlinkWithMode_SymlinkStrict 验证严格模式下符号链接被拒绝 func TestCheckSymlinkWithMode_SymlinkStrict(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "target.md") symlink := filepath.Join(dir, "link.md") os.WriteFile(target, []byte("content"), 0644) if err := os.Symlink(target, symlink); err != nil { t.Skipf("无法创建符号链接(可能在 Windows 无权限): %v", err) } // 严格模式应报错 if err := checkSymlinkWithMode(symlink, nil, true); err == nil { t.Error("严格模式下符号链接应返回错误") } // 宽松模式不应报错 if err := checkSymlinkWithMode(symlink, nil, false); err != nil { t.Errorf("宽松模式下符号链接不应报错: %v", err) } } // TestWithStrictSymlink_SaveRejectsSymlink 验证 WithStrictSymlink 下 Save 拒绝符号链接 func TestWithStrictSymlink_SaveRejectsSymlink(t *testing.T) { dir := t.TempDir() baseDir := filepath.Join(dir, "memory") os.MkdirAll(baseDir, 0755) // 预先建一个符号链接在记忆目录 target := filepath.Join(dir, "outside.md") os.WriteFile(target, []byte("outside content"), 0644) symlinkPath := filepath.Join(baseDir, "my_memory.md") if err := os.Symlink(target, symlinkPath); err != nil { t.Skipf("无法创建符号链接: %v", err) } store := &fileStore{ cwd: dir, baseDir: baseDir, strictSymlink: true, } err := store.Save(context.Background(), &Entry{ Name: "my memory", // sanitize 后 = my_memory Content: "content", }) if err == nil { t.Error("严格模式 + 符号链接路径,Save 应返回错误") } } // TestWithStrictSymlink_SavePassesWithoutSymlink 验证严格模式下无符号链接正常 Save func TestWithStrictSymlink_SavePassesWithoutSymlink(t *testing.T) { dir := t.TempDir() store := &fileStore{ cwd: dir, baseDir: filepath.Join(dir, "memory"), strictSymlink: true, } err := store.Save(context.Background(), &Entry{ Name: "safe-entry", Content: "no symlink here", }) if err != nil { t.Errorf("无符号链接时严格模式 Save 应成功: %v", err) } } // TestWithStrictSymlinkOption 验证 WithStrictSymlink option 正确设置字段 func TestWithStrictSymlinkOption(t *testing.T) { dir := t.TempDir() store := NewFileStoreWithOptions(dir, WithStrictSymlink()) fs, ok := store.(*fileStore) if !ok { t.Fatal("store 应为 *fileStore") } if !fs.strictSymlink { t.Error("WithStrictSymlink 应将 strictSymlink 设为 true") } } // ─── isCJK U+F900-U+FAFF 回归 ───────────────────────────────────────────────── // TestIsCJK_CompatibilityIdeographsExcluded 验证 U+F900-U+FAFF 不再被视为 CJK. // 这些字符是 CJK Compatibility Ideographs(规范等价副本), // 不是独立的语义单元,包含会破坏分词一致性. func TestIsCJK_CompatibilityIdeographsExcluded(t *testing.T) { compatCases := []rune{ 0xF900, // 豈 (CJK Compatibility Ideograph, 规范形式 U+FA00) 0xF901, // 更 0xFAFF, // 区块末尾 0xFA00, // 区块中间 } for _, r := range compatCases { if isCJK(r) { t.Errorf("U+%04X 是 CJK Compatibility Ideograph,不应被 isCJK 认定为 CJK", r) } } } // TestIsCJK_RealCJKStillRecognized 验证真正的 CJK 字符仍被正确识别 func TestIsCJK_RealCJKStillRecognized(t *testing.T) { realCJK := []rune{ 0x4E00, // 一(CJK Unified Ideographs 起始) 0x9FFF, // 鿿(CJK Unified Ideographs 结束) 0x4E2D, // 中 0x6587, // 文 0x3400, // 㐀(Extension A 起始) 0x4DBF, // Extension A 结束 '你', '好', '世', '界', } for _, r := range realCJK { if !isCJK(r) { t.Errorf("U+%04X 是真正的 CJK 字符,应被 isCJK 识别", r) } } }