package vault import ( "errors" "os" "path/filepath" "reflect" "strings" "testing" ) func setIcon(t *testing.T, v *Vault, path string, icon *TreeIcon) { t.Helper() if err := v.SetTreeIcon(path, icon); err != nil { t.Fatal(err) } } func treeIcons(t *testing.T, v *Vault) map[string]TreeIcon { t.Helper() entries, err := v.Tree() if err != nil { t.Fatal(err) } result := map[string]TreeIcon{} for _, entry := range entries { if entry.Icon != nil { result[entry.Path] = *entry.Icon } } return result } func TestTreeIconsPersistFollowSubtreeAndTrash(t *testing.T) { v := newVault(t) if _, err := os.Stat(filepath.Join(v.root, metaDir, "tree-icons.json")); !errors.Is(err, os.ErrNotExist) { t.Fatal("未修改时不应生成图标文件", err) } if _, err := v.Write("folder/nested/note.md", []byte("# 正文\n"), Precondition{}); err != nil { t.Fatal(err) } folder, note := TreeIcon{"emoji", "📚"}, TreeIcon{"builtin", "task"} setIcon(t, v, "folder", &folder) setIcon(t, v, "folder/nested/note.md", ¬e) body, rev, err := v.Read("folder/nested/note.md") if err != nil || string(body) != "# 正文\n" || rev != Rev([]byte("# 正文\n")) { t.Fatal("图标修改了正文/版本", err) } v, err = Open(v.root) if err != nil { t.Fatal(err) } if _, err := v.Move("folder/nested/note.md", "folder/nested/renamed.md"); err != nil { t.Fatal(err) } if _, err := v.Move("folder", "destination/moved"); err != nil { t.Fatal(err) } want := map[string]TreeIcon{"destination/moved": folder, "destination/moved/nested/renamed.md": note} if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { t.Fatalf("改名/移动后图标不符: %v", got) } item, err := v.Delete("destination/moved") if err != nil { t.Fatal(err) } if got := treeIcons(t, v); len(got) != 0 { t.Fatalf("删除后保留了属性: %v", got) } if _, err := v.Write("destination/moved/nested/renamed.md", []byte("新内容"), Precondition{}); err != nil { t.Fatal(err) } if got := treeIcons(t, v); len(got) != 0 { t.Fatalf("同名新文件继承了属性: %v", got) } if _, err := v.Restore(item.ID); !errors.Is(err, ErrExists) { t.Fatal("恢复冲突应失败", err) } if _, err := v.Move("destination/moved", "replacement"); err != nil { t.Fatal(err) } if _, err := v.Restore(item.ID); err != nil { t.Fatal(err) } if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { t.Fatalf("恢复后图标不符: %v", got) } setIcon(t, v, "destination/moved", nil) delete(want, "destination/moved") if got := treeIcons(t, v); !reflect.DeepEqual(got, want) { t.Fatalf("恢复默认失败: %v", got) } // 原有无图标的回收站条目也可恢复。 item, err = v.Delete("replacement") if err != nil { t.Fatal(err) } if _, err := v.Restore(item.ID); err != nil { t.Fatal(err) } } func TestTreeIconValidationAndCorruptMetadata(t *testing.T) { v := newVault(t) if _, err := v.Write("a.md", []byte("body"), Precondition{}); err != nil { t.Fatal(err) } for _, value := range []string{"📂", "👨‍👩‍👧‍👦", "👩🏽‍💻", "🇨🇳", "1️⃣", "©️", "ℹ️", "↔️", "◼️", "‼️", "〰️", "🏴\U000e0067\U000e0062\U000e0065\U000e006e\U000e0067\U000e007f"} { setIcon(t, v, "a.md", &TreeIcon{"emoji", value}) } for _, icon := range []TreeIcon{{"url", "https://example.com"}, {"builtin", "__proto__"}, {"builtin", "folder note"}, {"emoji", ""}, {"emoji", "hello"}, {"emoji", ""}, {"emoji", "\u200d"}, {"emoji", strings.Repeat("📂", 17)}} { if err := v.SetTreeIcon("a.md", &icon); !errors.Is(err, ErrInvalidIcon) { t.Fatalf("应拒绝 %+v: %v", icon, err) } } if err := v.SetTreeIcon("../a.md", nil); !errors.Is(err, ErrInvalidPath) { t.Fatal(err) } if err := v.SetTreeIcon("missing.md", nil); !errors.Is(err, ErrNotFound) { t.Fatal(err) } if err := os.Symlink(filepath.Join(v.root, "a.md"), filepath.Join(v.root, "link.md")); err != nil { t.Fatal(err) } if err := v.SetTreeIcon("link.md", nil); !errors.Is(err, ErrInvalidPath) { t.Fatal("不应设置符号链接图标", err) } for _, data := range []string{"{", "null", `{"../a.md":{"kind":"emoji","value":"📂"}}`, `{"a.md":{"kind":"builtin","value":"absent"}}`} { if err := os.WriteFile(filepath.Join(v.root, metaDir, "tree-icons.json"), []byte(data), 0644); err != nil { t.Fatal(err) } if _, err := v.Tree(); err == nil { t.Fatal("损坏元数据不应静默清空") } if _, err := v.Move("a.md", "b.md"); err == nil { t.Fatal("元数据异常时应停止移动") } if _, err := v.Delete("a.md"); err == nil { t.Fatal("元数据异常时应停止删除") } if _, _, err := v.Read("a.md"); err != nil { t.Fatal("原件应保持可用", err) } } } func TestTreeIconWriteFailureRollsBack(t *testing.T) { if os.Geteuid() == 0 { t.Skip("目录写权限测试需以普通用户运行;root 可绕过文件权限") } for _, operation := range []string{"set", "move", "delete", "restore"} { t.Run(operation, func(t *testing.T) { v := newVault(t) if _, err := v.Write("a.md", []byte("[link](other.md)"), Precondition{}); err != nil { t.Fatal(err) } legacyAsset(t, v, "a.md", "p.png", "image") icon := TreeIcon{"emoji", "📚"} setIcon(t, v, "a.md", &icon) var item TrashItem var err error if operation == "restore" { item, err = v.Delete("a.md") if err != nil { t.Fatal(err) } } before, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json")) if err != nil { t.Fatal(err) } if err := os.Chmod(filepath.Join(v.root, metaDir), 0555); err != nil { t.Fatal(err) } defer os.Chmod(filepath.Join(v.root, metaDir), 0755) switch operation { case "set": err = v.SetTreeIcon("a.md", &TreeIcon{"builtin", "folder"}) case "move": _, err = v.Move("a.md", "new/b.md") case "delete": _, err = v.Delete("a.md") case "restore": _, err = v.Restore(item.ID) } if err == nil { t.Fatal("图标元数据写失败却返回成功") } after, err := os.ReadFile(filepath.Join(v.root, metaDir, "tree-icons.json")) if err != nil || string(before) != string(after) { t.Fatal("原有元数据被改变", err) } if operation == "restore" { if _, err := v.Stat("a.md"); !errors.Is(err, ErrNotFound) { t.Fatal("恢复应撤回", err) } metadata, err := v.readTrashMetadata(item.ID) if err != nil || metadata.Icons["a.md"] != icon { t.Fatal("回收站图标丢失", err) } } else { body, _, err := v.Read("a.md") if err != nil || string(body) != "[link](other.md)" { t.Fatal("原件或正文未回滚", err) } if _, err := os.Stat(filepath.Join(v.root, "a.assets")); err != nil { t.Fatal("附件未回滚", err) } if _, err := v.Stat("new/b.md"); !errors.Is(err, ErrNotFound) { t.Fatal("移动目标未撤回", err) } } }) } }