From 39110a26010140b985f734a86db3b754aebc44d7 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 29 Oct 2026 17:47:15 -0500 Subject: [PATCH] Fix(mcp): assistants stay inside the note folders zn mcp's note and folder tools only checked that a path stayed inside the vault. In a root-mode vault the inbox is the vault root, so .zennotes, the attachements folder and the other buckets were all "subfolders of the inbox", and the sources of moves and deletes were not classified at all. An assistant could move .zennotes/vault.json into a note folder and read it, plant notes in .zennotes, delete the attachments folder or the archive, move .zennotes into the notes, archive, trash or delete vault.json, and with delete_folder on "x/.." delete the whole vault: the subpath cleaned to the bucket root and RemoveAll took it. Every create, move, rename and delete of a note or folder now resolves both ends the way the listings classify them, through the vault's systemFolderPaths: no dot directory at any depth, and in root mode no internal dir, attachments dir or other bucket reached through a subpath (a case variant counts when it is the same directory on disk). The folder tools refuse a subpath that resolves to the bucket root. read_note reads Markdown notes only. A note outside the note folders is refused as before ("Note not in a known folder"); a folder gets "Not a note folder: ". zn's own commands share the vault code and get the same rules. rename_folder now carries the folder's icon and color with it, as desktop main and the server do, not only its Tasks entries. The desktop's bundled MCP engine gets the same rules (ZenNotes/zennotes, 2.80.0). Symlinks that stay inside the vault, case-variant note paths and absolute paths in errors are left for scoped access (#21). --- internal/mcp/path_safety_test.go | 75 +++++++++ internal/vault/path_safety_test.go | 260 +++++++++++++++++++++++++++++ internal/vault/paths.go | 95 +++++++++++ internal/vault/settings.go | 55 ++++-- internal/vault/vault.go | 134 ++++++--------- 5 files changed, 529 insertions(+), 90 deletions(-) create mode 100644 internal/mcp/path_safety_test.go create mode 100644 internal/vault/path_safety_test.go diff --git a/internal/mcp/path_safety_test.go b/internal/mcp/path_safety_test.go new file mode 100644 index 0000000..945a714 --- /dev/null +++ b/internal/mcp/path_safety_test.go @@ -0,0 +1,75 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/backend" +) + +// The ZenNotes/tui#21 probe cases, through the real tools on a root-mode +// vault, where the inbox is the vault root and the internal dir, the +// attachments dir and the other buckets are all one subpath away. Mirrors +// the path-safety cases in apps/desktop/src/mcp/server.test.ts in +// ZenNotes/zennotes. +func TestMCPToolsStayInsideTheNoteFolders(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + for rel, body := range map[string]string{ + ".zennotes/vault.json": `{"primaryNotesLocation":"root"}`, + "Projects/Plan.md": "# Plan\n", + "Projects/notes.txt": "plain text\n", + "attachements/img.png": "PNG", + "archive/Old.md": "# Old\n", + } { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(outside, "secret.md"), []byte("# OUTSIDE SECRET\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "Projects", "link")); err != nil { + t.Fatal(err) + } + cs := runServer(t, func() (backend.Target, error) { + return backend.Target{Kind: backend.KindLocal, Root: root}, nil + }) + + refused := func(name string, args map[string]any, want string) { + t.Helper() + text, isErr := callTool(t, cs, name, args) + if !isErr || !strings.Contains(text, want) { + t.Fatalf("%s %v = %q (error %v), want an error saying %q", name, args, text, isErr, want) + } + } + refused("move_note", map[string]any{"path": ".zennotes/vault.json", "targetFolder": "inbox", "targetSubpath": "Projects"}, "Note not in a known folder") + refused("read_note", map[string]any{"path": "Projects/vault.json"}, "Not a Markdown note: Projects/vault.json") + refused("create_note", map[string]any{"folder": "inbox", "subpath": ".zennotes", "title": "planted"}, "Not a note folder: .zennotes") + refused("delete_folder", map[string]any{"folder": "inbox", "subpath": "attachements", "confirm": true}, "Not a note folder: attachements") + refused("delete_folder", map[string]any{"folder": "inbox", "subpath": "archive", "confirm": true}, "Not a note folder: archive") + refused("delete_folder", map[string]any{"folder": "inbox", "subpath": "x/..", "confirm": true}, "Cannot delete the top-level folder") + refused("rename_folder", map[string]any{"folder": "inbox", "oldSubpath": ".zennotes", "newSubpath": "Projects/internals"}, "Not a note folder: .zennotes") + refused("delete_note", map[string]any{"path": ".zennotes/vault.json", "confirm": true}, "Note not in a known folder") + refused("archive_note", map[string]any{"path": ".zennotes/vault.json"}, "Note not in a known folder") + refused("read_note", map[string]any{"path": "Projects/link/secret.md"}, "Path escapes vault") + refused("read_note", map[string]any{"path": "Projects/notes.txt"}, "Not a Markdown note: Projects/notes.txt") + + for _, rel := range []string{".zennotes/vault.json", "Projects/Plan.md", "attachements/img.png", "archive/Old.md"} { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(rel))); err != nil { + t.Fatalf("%s must survive: %v", rel, err) + } + } + if _, err := os.Stat(filepath.Join(root, ".zennotes", "planted.md")); !os.IsNotExist(err) { + t.Fatalf("nothing may be planted in .zennotes: %v", err) + } + if text, isErr := callTool(t, cs, "read_note", map[string]any{"path": "Projects/Plan.md"}); isErr || !strings.Contains(text, "# Plan") { + t.Fatalf("read_note of a real note = %q (error %v)", text, isErr) + } +} diff --git a/internal/vault/path_safety_test.go b/internal/vault/path_safety_test.go new file mode 100644 index 0000000..0e22499 --- /dev/null +++ b/internal/vault/path_safety_test.go @@ -0,0 +1,260 @@ +package vault + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// The probe cases behind ZenNotes/tui#21's Phase 0: every tool that creates, +// moves, renames or deletes a note or folder stays inside the note folders. +// Mirrors apps/desktop/src/mcp/vault-ops-path-safety.test.ts in +// ZenNotes/zennotes. + +// newRootModeVault is a root-mode vault (the inbox is the vault root, so the +// internal dir, the attachments dir and the other buckets are all one +// subpath away from it) with a folder icon, color and Tasks entry to follow +// renames. +func newRootModeVault(t *testing.T) *Vault { + t.Helper() + root := t.TempDir() + writeFile(t, filepath.Join(root, ".zennotes", "vault.json"), `{ + "primaryNotesLocation": "root", + "folderIcons": {"inbox:Projects": "star", "inbox:Projects/Old": "box", "inbox:ProjectsX": "dot", "quick:Projects": "bolt"}, + "folderColors": {"inbox:Projects": "red"}, + "tasks": {"excludedFolders": ["Projects/Old"]}, + "favorites": ["Projects/Plan.md"] +}`) + writeFile(t, filepath.Join(root, ".zennotes", "templates", "Daily.md"), "- [ ] stand up\n") + writeFile(t, filepath.Join(root, "Projects", "Plan.md"), "# Plan\n") + writeFile(t, filepath.Join(root, "Projects", "Old", "Before.md"), "# Before\n") + writeFile(t, filepath.Join(root, "Projects", "notes.txt"), "plain text\n") + writeFile(t, filepath.Join(root, "Journal", "Diary.md"), "# Diary\n") + writeFile(t, filepath.Join(root, "attachements", "img.png"), "PNG") + writeFile(t, filepath.Join(root, "archive", "Old.md"), "# Old\n") + writeFile(t, filepath.Join(root, "quick", "Q.md"), "# Q\n") + v, err := Open(root) + if err != nil { + t.Fatal(err) + } + if !v.PrimaryAtRoot() { + t.Fatal("expected root mode") + } + return v +} + +func mustExist(t *testing.T, v *Vault, rel string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(v.Root(), filepath.FromSlash(rel))); err != nil { + t.Fatalf("%s must survive: %v", rel, err) + } +} + +func mustNotExist(t *testing.T, v *Vault, rel string) { + t.Helper() + if _, err := os.Lstat(filepath.Join(v.Root(), filepath.FromSlash(rel))); !os.IsNotExist(err) { + t.Fatalf("%s must not exist: %v", rel, err) + } +} + +func wantErr(t *testing.T, what string, err error, text string) { + t.Helper() + if err == nil { + t.Fatalf("%s must be refused", what) + } + if !strings.Contains(err.Error(), text) { + t.Fatalf("%s: error %q, want it to say %q", what, err, text) + } +} + +// G1 and the rest of the note tools: the source must be a note. Unchecked, +// move_note carried vault.json into a note folder for read_note to read, and +// archive_note, move_to_trash and delete_note took it away. +func TestNoteToolsRefuseFilesOutsideTheNoteFolders(t *testing.T) { + const internal = ".zennotes/vault.json" + const refused = "Note not in a known folder: " + internal + v := newRootModeVault(t) + _, err := v.MoveNote(internal, FolderInbox, "Projects") + wantErr(t, "move_note", err, refused) + _, err = v.ArchiveNote(internal) + wantErr(t, "archive_note", err, refused) + _, err = v.MoveToTrash(internal) + wantErr(t, "move_to_trash", err, refused) + _, err = v.RestoreFromTrash(internal) + wantErr(t, "restore_from_trash", err, refused) + _, err = v.RenameNote(internal, "settings") + wantErr(t, "rename_note", err, refused) + _, err = v.DuplicateNote(internal) + wantErr(t, "duplicate_note", err, refused) + _, err = v.AppendToNote(internal, "x") + wantErr(t, "append_to_note", err, refused) + _, err = v.ReadNote(internal) + wantErr(t, "read_note", err, refused) + wantErr(t, "delete_note", v.DeleteNote(internal), refused) + mustExist(t, v, internal) + mustNotExist(t, v, "Projects/vault.json") + mustNotExist(t, v, "archive/vault.md") + mustNotExist(t, v, "trash/vault.md") + + const template = ".zennotes/templates/Daily.md" + _, err = v.ToggleTask(template+"#0", DialectApp) + wantErr(t, "toggle_task in a template", err, "Note not in a known folder: "+template) + if body, _ := os.ReadFile(filepath.Join(v.Root(), filepath.FromSlash(template))); string(body) != "- [ ] stand up\n" { + t.Fatalf("template changed: %q", body) + } + + // A dot directory below the top is no note folder either: the listings + // never descend into one. + _, err = v.WriteNote("Projects/.hidden/x.md", "x") + wantErr(t, "write_note into a dot directory", err, "Note not in a known folder: Projects/.hidden/x.md") + mustNotExist(t, v, "Projects/.hidden") +} + +// G2 and friends: what a create or a move writes lands in a note folder of +// the bucket it names. +func TestCreatesAndMovesLandOnlyInNoteFolders(t *testing.T) { + v := newRootModeVault(t) + for _, tc := range []struct{ sub, want string }{ + {".zennotes", "Not a note folder: .zennotes"}, + {"attachements", "Not a note folder: attachements"}, + {"archive/Projects", "Not a note folder: archive/Projects"}, + {"trash", "Not a note folder: trash"}, + {"Projects/.hidden", "Not a note folder: Projects/.hidden"}, + {"Projects/../.zennotes", "Not a note folder: Projects/../.zennotes"}, + } { + _, err := v.CreateNote(FolderInbox, "planted", tc.sub, nil) + wantErr(t, "create_note in "+tc.sub, err, tc.want) + wantErr(t, "create_folder "+tc.sub, v.CreateFolder(FolderInbox, tc.sub), tc.want) + _, err = v.MoveNote("Projects/Plan.md", FolderInbox, tc.sub) + wantErr(t, "move_note into "+tc.sub, err, tc.want) + } + mustNotExist(t, v, ".zennotes/planted.md") + mustNotExist(t, v, "attachements/planted.md") + mustNotExist(t, v, "trash/planted.md") + mustExist(t, v, "Projects/Plan.md") + wantErr(t, "create_folder x/..", v.CreateFolder(FolderInbox, "x/.."), "Folder name is required") + + // Ordinary subfolders of either bucket still take notes. + created, err := v.CreateNote(FolderInbox, "Fine", "Projects/Sub", nil) + if err != nil || created.Path != "Projects/Sub/Fine.md" { + t.Fatalf("create in Projects/Sub = %+v, %v", created, err) + } + created, err = v.CreateNote(FolderQuick, "Fine", "Work", nil) + if err != nil || created.Path != "quick/Work/Fine.md" { + t.Fatalf("create in quick/Work = %+v, %v", created, err) + } +} + +// G3, G4 and the bucket root: folder operations act on a note folder of the +// bucket, never on the internal dir, the attachments dir, another bucket or +// the bucket root itself. `x/..` cleaned to the root, and in root mode +// delete_folder then removed the whole vault. +func TestFolderOpsRefuseSystemFoldersAndTheBucketRoot(t *testing.T) { + v := newRootModeVault(t) + wantErr(t, "delete attachements", v.DeleteFolder(FolderInbox, "attachements"), "Not a note folder: attachements") + wantErr(t, "delete archive", v.DeleteFolder(FolderInbox, "archive"), "Not a note folder: archive") + wantErr(t, "delete .zennotes", v.DeleteFolder(FolderInbox, ".zennotes"), "Not a note folder: .zennotes") + wantErr(t, "delete x/..", v.DeleteFolder(FolderInbox, "x/.."), "Cannot delete the top-level folder") + wantErr(t, "delete archive's x/..", v.DeleteFolder(FolderArchive, "x/.."), "Cannot delete the top-level folder") + _, err := v.RenameFolder(FolderInbox, ".zennotes", "Projects/internals") + wantErr(t, "rename .zennotes", err, "Not a note folder: .zennotes") + _, err = v.RenameFolder(FolderInbox, "Projects", ".zennotes/x") + wantErr(t, "rename into .zennotes", err, "Not a note folder: .zennotes/x") + _, err = v.RenameFolder(FolderInbox, "Projects", "x/..") + wantErr(t, "rename onto the bucket root", err, "Both old and new folder paths are required") + mustExist(t, v, ".zennotes/vault.json") + mustExist(t, v, "attachements/img.png") + mustExist(t, v, "archive/Old.md") + mustExist(t, v, "Projects/Plan.md") + mustNotExist(t, v, "Projects/internals") + + // A case variant of the attachments dir is that same directory on macOS + // and Windows, and is refused there; elsewhere it is a different folder. + if _, err := os.Stat(filepath.Join(v.Root(), "Attachements")); err == nil { + wantErr(t, "delete Attachements", v.DeleteFolder(FolderInbox, "Attachements"), "Not a note folder: Attachements") + mustExist(t, v, "attachements/img.png") + } + + // Ordinary folders still work. + if err := v.DeleteFolder(FolderInbox, "Journal"); err != nil { + t.Fatal(err) + } + mustNotExist(t, v, "Journal") +} + +// Classification follows the vault's systemFolderPaths, not literal names: +// once archive lives in `99 - Old`, an `archive/` folder at the root is the +// user's own, and `99 - Old` is the bucket. +func TestFolderGuardsFollowRemappedSystemFolders(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".zennotes", "vault.json"), `{"primaryNotesLocation":"root","systemFolderPaths":{"archive":"99 - Old"}}`) + writeFile(t, filepath.Join(root, "99 - Old", "Gone.md"), "# Gone\n") + writeFile(t, filepath.Join(root, "archive", "Mine.md"), "# Mine\n") + v, err := Open(root) + if err != nil { + t.Fatal(err) + } + wantErr(t, "delete the remapped archive", v.DeleteFolder(FolderInbox, "99 - Old"), "Not a note folder: 99 - Old") + mustExist(t, v, "99 - Old/Gone.md") + created, err := v.CreateNote(FolderInbox, "Note", "archive", nil) + if err != nil || created.Path != "archive/Note.md" || created.Folder != FolderInbox { + t.Fatalf("archive/ is a user folder once remapped: %+v, %v", created, err) + } + if err := v.DeleteFolder(FolderInbox, "archive"); err != nil { + t.Fatal(err) + } + mustNotExist(t, v, "archive") +} + +// In inbox mode the same rules hold one level down. +func TestFolderGuardsInInboxMode(t *testing.T) { + v := newTestVault(t) + writeFile(t, filepath.Join(v.Root(), "inbox", "Work", "Plan.md"), "# Plan\n") + _, err := v.CreateNote(FolderInbox, "planted", ".zennotes", nil) + wantErr(t, "create in inbox/.zennotes", err, "Not a note folder: .zennotes") + _, err = v.CreateNote(FolderQuick, "planted", "../.zennotes", nil) + wantErr(t, "create in quick/.zennotes", err, "Not a note folder: ../.zennotes") + wantErr(t, "delete inbox via ..", v.DeleteFolder(FolderInbox, ".."), "Cannot delete the top-level folder") + mustExist(t, v, "inbox/Work/Plan.md") + moved, err := v.MoveNote("inbox/Work/Plan.md", FolderArchive, "Work") + if err != nil || moved.Path != "archive/Work/Plan.md" { + t.Fatalf("move = %+v, %v", moved, err) + } +} + +// read_note reads notes, which are Markdown files; a root-mode vault can hold +// anything else beside them. +func TestReadNoteReadsOnlyMarkdown(t *testing.T) { + v := newRootModeVault(t) + _, err := v.ReadNote("Projects/notes.txt") + wantErr(t, "read_note of a text file", err, "Not a Markdown note: Projects/notes.txt") + if note, err := v.ReadNote("Projects/Plan.md"); err != nil || note.Body != "# Plan\n" { + t.Fatalf("read Plan = %+v, %v", note, err) + } +} + +// A folder's icon, color and Tasks entries follow it through a rename, as +// desktop main and the server rewrite them. +func TestRenameFolderRewritesFolderIconsAndColors(t *testing.T) { + v := newRootModeVault(t) + if got, err := v.RenameFolder(FolderInbox, "Projects", "Work"); err != nil || got != "Work" { + t.Fatalf("rename = %q, %v", got, err) + } + s := v.Settings() + icons := mapOf(s.Raw["folderIcons"]) + wantIcons := map[string]any{"inbox:Work": "star", "inbox:Work/Old": "box", "inbox:ProjectsX": "dot", "quick:Projects": "bolt"} + if !reflect.DeepEqual(icons, wantIcons) { + t.Errorf("folderIcons = %v, want %v", icons, wantIcons) + } + if colors := mapOf(s.Raw["folderColors"]); !reflect.DeepEqual(colors, map[string]any{"inbox:Work": "red"}) { + t.Errorf("folderColors = %v", colors) + } + if !reflect.DeepEqual(s.TasksExcludedFolders, []string{"Work/Old"}) { + t.Errorf("tasks excluded = %v", s.TasksExcludedFolders) + } + if !reflect.DeepEqual(s.Favorites, []string{"Projects/Plan.md"}) { + t.Errorf("the rewrite lost other vault.json keys: favorites = %v", s.Favorites) + } +} diff --git a/internal/vault/paths.go b/internal/vault/paths.go index 66ae8a6..074162f 100644 --- a/internal/vault/paths.go +++ b/internal/vault/paths.go @@ -2,6 +2,7 @@ package vault import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -120,6 +121,100 @@ func FolderForRelativePath(rel string, paths map[string]string) (NoteFolder, boo return FolderInbox, true } +// isHiddenTopName reports whether a top-level name is one the root walk +// hides: the internal dir, an attachments dir or another bucket's directory. +// A different spelling still is one when it names the same directory on +// disk, as a case variant does on macOS and Windows. +func (v *Vault) isHiddenTopName(name string, paths map[string]string) bool { + hidden := hiddenPrimaryRootNames(paths) + if _, ok := hidden[name]; ok { + return true + } + info, err := os.Lstat(filepath.Join(v.root, name)) + if err != nil { + return false + } + for h := range hidden { + if !strings.EqualFold(h, name) { + continue + } + if hInfo, err := os.Lstat(filepath.Join(v.root, h)); err == nil && os.SameFile(info, hInfo) { + return true + } + } + return false +} + +// classifyPath is the bucket a vault-relative note (isDir false) or folder +// (isDir true) belongs to as the listings see it. They never descend into a +// dot directory at any depth, nor show the internal dir, an attachments dir +// or another bucket's directory as part of the inbox, so neither does this. +func (v *Vault) classifyPath(rel string, isDir bool) (NoteFolder, bool) { + rel = NormalizeRelPath(ToPosix(rel)) + if rel == "" || rel == "." { + return "", false + } + segments := strings.Split(rel, "/") + dirs := segments + if !isDir { + dirs = segments[:len(segments)-1] + } + for _, segment := range dirs { + if strings.HasPrefix(segment, ".") { + return "", false + } + } + paths := v.Settings().SystemFolderPaths + folder, ok := FolderForRelativePath(rel, paths) + if !ok { + return "", false + } + if folder == FolderInbox && v.isHiddenTopName(segments[0], paths) { + return "", false + } + return folder, true +} + +// notePath resolves a note a tool reads, edits, moves or deletes, refusing +// one outside the note folders before anything touches it. +func (v *Vault) notePath(rel string) (string, NoteFolder, error) { + abs, err := v.AbsPath(rel) + if err != nil { + return "", "", err + } + folder, ok := v.classifyPath(v.relPosix(abs), false) + if !ok { + return "", "", errNotInFolder(rel) + } + return abs, folder, nil +} + +// bucketDir resolves the directory a create, move, rename or delete names by +// bucket and subpath. It must be a folder the listings show in that bucket: +// a subpath through a dot directory, or one that reaches the internal dir, an +// attachments dir or another bucket (in root primary mode the inbox IS the +// vault root, so all of them sit one level down) is refused. A subpath that +// cleans to nothing, `x/..` included, is the bucket root itself, which +// callers that need a subfolder refuse. +func (v *Vault) bucketDir(folder NoteFolder, subpath string) (string, error) { + base := v.FolderRoot(folder) + clean := cleanSubpath(subpath) + if clean == "" { + return base, nil + } + abs, err := SafeJoin(base, clean) + if err != nil { + return "", fmt.Errorf("Path escapes vault: %s", clean) + } + if abs == base { + return base, nil + } + if got, ok := v.classifyPath(v.relPosix(abs), true); !ok || got != folder { + return "", fmt.Errorf("Not a note folder: %s", clean) + } + return abs, nil +} + // FolderSubpathOf is the note's directory relative to its bucket root, or "" // at the bucket root. `inbox/Work/Note.md` gives `Work`; in root primary mode // `Work/Note.md` gives `Work`. diff --git a/internal/vault/settings.go b/internal/vault/settings.go index 2c57f45..0b50335 100644 --- a/internal/vault/settings.go +++ b/internal/vault/settings.go @@ -454,25 +454,58 @@ func listHasFolder(list []string, rel string) bool { return false } -// rewriteTasksFoldersForRename keeps the vault's Tasks folder lists naming the -// same folders after a rename or move, writing vault.json only when an entry -// moves. Mirrors rewriteTasksFoldersForRename in tasks-excluded-folders.ts. -func (v *Vault) rewriteTasksFoldersForRename(oldRel, newRel string) error { +// rewriteFolderKeyedMap repoints a `:` keyed map (the folder +// icons and colors) at a renamed folder and everything inside it. Mirrors +// rewriteFolderIconsForRename in the desktop's main/vault.ts; an entry that +// moves wins over a stale one already at its new key. +func rewriteFolderKeyedMap(m map[string]any, folder NoteFolder, oldSub, newSub string) (map[string]any, bool) { + exact := string(folder) + ":" + oldSub + moved := map[string]any{} + next := make(map[string]any, len(m)) + for key, value := range m { + if key == exact || strings.HasPrefix(key, exact+"/") { + moved[string(folder)+":"+newSub+key[len(exact):]] = value + continue + } + next[key] = value + } + if len(moved) == 0 { + return m, false + } + for key, value := range moved { + next[key] = value + } + return next, true +} + +// rewriteSettingsForFolderRename keeps vault.json's path-keyed settings naming +// the same folders after a rename or move: the Tasks folder lists, which hold +// vault-relative paths (mirrors rewriteTasksFoldersForRename in +// tasks-excluded-folders.ts), and the folder icons and colors, keyed by bucket +// and subpath, as desktop main and the server rewrite them. vault.json is +// written only when an entry moves. +func (v *Vault) rewriteSettingsForFolderRename(folder NoteFolder, oldSub, newSub, oldRel, newRel string) error { if oldRel == "" || newRel == "" || oldRel == newRel { return nil } s := v.Settings() - if !listHasFolder(s.TasksExcludedFolders, oldRel) && !listHasFolder(s.TasksIncludedFolders, oldRel) { + tasksMove := listHasFolder(s.TasksExcludedFolders, oldRel) || listHasFolder(s.TasksIncludedFolders, oldRel) + _, iconsMove := rewriteFolderKeyedMap(mapOf(s.Raw["folderIcons"]), folder, oldSub, newSub) + _, colorsMove := rewriteFolderKeyedMap(mapOf(s.Raw["folderColors"]), folder, oldSub, newSub) + if !tasksMove && !iconsMove && !colorsMove { return nil } return v.UpdateSettings(func(raw map[string]any) { - tasks, _ := raw["tasks"].(map[string]any) - if tasks == nil { - return + if tasks, _ := raw["tasks"].(map[string]any); tasks != nil { + for _, key := range []string{"excludedFolders", "includedFolders"} { + if list := NormalizeTasksExcludedFolders(stringsOf(tasks[key])); len(list) > 0 { + tasks[key] = rewriteFolderList(list, oldRel, newRel) + } + } } - for _, key := range []string{"excludedFolders", "includedFolders"} { - if list := NormalizeTasksExcludedFolders(stringsOf(tasks[key])); len(list) > 0 { - tasks[key] = rewriteFolderList(list, oldRel, newRel) + for _, key := range []string{"folderIcons", "folderColors"} { + if next, changed := rewriteFolderKeyedMap(mapOf(raw[key]), folder, oldSub, newSub); changed { + raw[key] = next } } }) diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 5c16b9d..cdbaa4b 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -439,13 +439,14 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { // ReadNote returns a note with its body. func (v *Vault) ReadNote(rel string) (NoteContent, error) { - abs, err := v.AbsPath(rel) + abs, folder, err := v.notePath(rel) if err != nil { return NoteContent{}, err } - folder, ok := v.folderOfAbs(abs) - if !ok { - return NoteContent{}, errNotInFolder(rel) + // A note is a Markdown file. Anything else in a note folder (a root-mode + // vault that is also a code checkout holds plenty) is not one to read. + if !strings.EqualFold(filepath.Ext(abs), ".md") { + return NoteContent{}, fmt.Errorf("Not a Markdown note: %s", rel) } body, err := os.ReadFile(abs) if err != nil { @@ -462,17 +463,13 @@ func (v *Vault) ReadNote(rel string) (NoteContent, error) { func (v *Vault) WriteNote(rel, body string) (NoteMeta, error) { v.mu.Lock() defer v.mu.Unlock() - abs, err := v.AbsPath(rel) - if err != nil { - return NoteMeta{}, err - } // Refuse anything outside the note folders BEFORE a byte lands. This used // to write first and check after, so write_note on `.zennotes/...` created // the file and only then failed: an assistant could plant comments, // creation dates or the agent edit records that say what a note was. - folder, ok := v.folderOfAbs(abs) - if !ok { - return NoteMeta{}, errNotInFolder(rel) + abs, folder, err := v.notePath(rel) + if err != nil { + return NoteMeta{}, err } if err := v.writeNoteFileAtomic(abs, []byte(body)); err != nil { return NoteMeta{}, err @@ -557,14 +554,9 @@ func (v *Vault) CreateNote(folder NoteFolder, title, subpath string, body *strin title = "Untitled" } base := SanitizeTitle(title) - clean := strings.Trim(strings.ReplaceAll(subpath, "\\", "/"), "/") - dir := v.FolderRoot(folder) - if clean != "" { - sub, err := SafeJoin(dir, clean) - if err != nil { - return NoteMeta{}, fmt.Errorf("Path escapes vault: %s", clean) - } - dir = sub + dir, err := v.bucketDir(folder, subpath) + if err != nil { + return NoteMeta{}, err } if err := os.MkdirAll(dir, v.dirMode); err != nil { return NoteMeta{}, err @@ -601,14 +593,10 @@ func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { v.mu.Lock() defer v.mu.Unlock() - abs, err := v.AbsPath(rel) + abs, folder, err := v.notePath(rel) if err != nil { return NoteMeta{}, err } - folder, ok := v.folderOfAbs(abs) - if !ok { - return NoteMeta{}, errNotInFolder(rel) - } dir := filepath.Dir(abs) trimmed := SanitizeTitle(nextTitle) target := filepath.Join(dir, trimmed+".md") @@ -690,21 +678,18 @@ func (v *Vault) rewriteInboundWikilinks(notesBefore []NoteMeta, oldPath, newTitl func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, error) { v.mu.Lock() defer v.mu.Unlock() - abs, err := v.AbsPath(rel) + // The source is classified first: unchecked, archive_note on + // `.zennotes/vault.json` carried the settings off to archive/vault.md. + abs, _, err := v.notePath(rel) if err != nil { return NoteMeta{}, err } if _, err := os.Stat(abs); err != nil { return NoteMeta{}, err } - subpath := v.SubpathOf(v.relPosix(abs)) - targetRoot := v.FolderRoot(target) - destDir := targetRoot - if subpath != "" { - destDir, err = SafeJoin(targetRoot, subpath) - if err != nil { - return NoteMeta{}, err - } + destDir, err := v.bucketDir(target, v.SubpathOf(v.relPosix(abs))) + if err != nil { + return NoteMeta{}, err } if err := os.MkdirAll(destDir, v.dirMode); err != nil { return NoteMeta{}, err @@ -748,23 +733,17 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N if !IsValidFolder(target) { return NoteMeta{}, fmt.Errorf("invalid folder: %s", target) } - oldAbs, err := v.AbsPath(rel) + // Both ends must be note folders: an unchecked source let move_note + // carry `.zennotes/vault.json` into a note folder, where read_note read it. + oldAbs, folder, err := v.notePath(rel) if err != nil { return NoteMeta{}, err } - cleanSub := strings.Trim(strings.ReplaceAll(targetSubpath, "\\", "/"), "/") - destDir := v.FolderRoot(target) - if cleanSub != "" { - destDir, err = SafeJoin(destDir, cleanSub) - if err != nil { - return NoteMeta{}, fmt.Errorf("Path escapes vault: %s", cleanSub) - } + destDir, err := v.bucketDir(target, targetSubpath) + if err != nil { + return NoteMeta{}, err } if filepath.Clean(filepath.Dir(oldAbs)) == filepath.Clean(destDir) { - folder, ok := v.folderOfAbs(oldAbs) - if !ok { - return NoteMeta{}, errNotInFolder(rel) - } return v.readMeta(oldAbs, folder, v.Settings().TypstPreambleFolder) } if err := os.MkdirAll(destDir, v.dirMode); err != nil { @@ -787,14 +766,10 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { v.mu.Lock() defer v.mu.Unlock() - abs, err := v.AbsPath(rel) + abs, folder, err := v.notePath(rel) if err != nil { return NoteMeta{}, err } - folder, ok := v.folderOfAbs(abs) - if !ok { - return NoteMeta{}, errNotInFolder(rel) - } dir := filepath.Dir(abs) ext := filepath.Ext(abs) baseTitle := strings.TrimSuffix(filepath.Base(abs), ext) @@ -815,7 +790,7 @@ func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { func (v *Vault) DeleteNote(rel string) error { v.mu.Lock() defer v.mu.Unlock() - abs, err := v.AbsPath(rel) + abs, _, err := v.notePath(rel) if err != nil { return err } @@ -870,13 +845,12 @@ func (v *Vault) CreateFolder(folder NoteFolder, subpath string) error { if !IsValidFolder(folder) { return fmt.Errorf("invalid folder: %s", folder) } - clean := cleanSubpath(subpath) - if clean == "" { - return errors.New("Folder name is required") - } - abs, err := SafeJoin(v.FolderRoot(folder), clean) + abs, err := v.bucketDir(folder, subpath) if err != nil { - return fmt.Errorf("Path escapes vault: %s", clean) + return err + } + if abs == v.FolderRoot(folder) { + return errors.New("Folder name is required") } if err := os.MkdirAll(abs, v.dirMode); err != nil { return err @@ -894,14 +868,20 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSubpath, newSubpath string) ( if oldClean == "" || newClean == "" { return "", errors.New("Both old and new folder paths are required") } + // Both ends are note folders of this bucket and neither is its root: the + // internal dir, an attachments dir or another bucket must not be moved + // into notes, nor a note folder into them. base := v.FolderRoot(folder) - oldAbs, err := SafeJoin(base, oldClean) + oldAbs, err := v.bucketDir(folder, oldClean) if err != nil { - return "", fmt.Errorf("Path escapes vault: %s", oldClean) + return "", err } - newAbs, err := SafeJoin(base, newClean) + newAbs, err := v.bucketDir(folder, newClean) if err != nil { - return "", fmt.Errorf("Path escapes vault: %s", newClean) + return "", err + } + if oldAbs == base || newAbs == base { + return "", errors.New("Both old and new folder paths are required") } if newAbs == oldAbs { return newClean, nil @@ -917,8 +897,11 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSubpath, newSubpath string) ( } v.invalidateLayout() // The Tasks folder lists name on-disk paths, so they follow the folder: a - // renamed Task folder left behind would empty Tasks (#962). - if err := v.rewriteTasksFoldersForRename(v.relPosix(oldAbs), v.relPosix(newAbs)); err != nil { + // renamed Task folder left behind would empty Tasks (#962). Its icon and + // color follow it too. + oldSub, _ := filepath.Rel(base, oldAbs) + newSub, _ := filepath.Rel(base, newAbs) + if err := v.rewriteSettingsForFolderRename(folder, ToPosix(oldSub), ToPosix(newSub), v.relPosix(oldAbs), v.relPosix(newAbs)); err != nil { return "", err } return newClean, nil @@ -928,13 +911,14 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSubpath, newSubpath string) ( func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { v.mu.Lock() defer v.mu.Unlock() - clean := cleanSubpath(subpath) - if clean == "" { - return errors.New("Cannot delete the top-level folder") - } - abs, err := SafeJoin(v.FolderRoot(folder), clean) + // `x/..` cleans to the bucket root, which in root primary mode is the + // whole vault: checked on the resolved directory, not the string. + abs, err := v.bucketDir(folder, subpath) if err != nil { - return fmt.Errorf("Path escapes vault: %s", clean) + return err + } + if abs == v.FolderRoot(folder) { + return errors.New("Cannot delete the top-level folder") } if err := os.RemoveAll(abs); err != nil { return err @@ -1071,7 +1055,7 @@ func (v *Vault) ToggleTask(taskID string, dialect TaskDialect) (*Task, error) { if err != nil { return nil, err } - abs, err := v.AbsPath(rel) + abs, folder, err := v.notePath(rel) if err != nil { return nil, err } @@ -1079,10 +1063,6 @@ func (v *Vault) ToggleTask(taskID string, dialect TaskDialect) (*Task, error) { if err != nil { return nil, err } - folder, ok := v.folderOfAbs(abs) - if !ok { - return nil, errNotInFolder(rel) - } relPosix := v.relPosix(abs) title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) opts := ParseTasksOptions{IncludeExcluded: true, Dialect: dialect} @@ -1124,7 +1104,7 @@ func (v *Vault) ToggleTask(taskID string, dialect TaskDialect) (*Task, error) { // --- convenience edits --- func (v *Vault) editNote(rel string, transform func(body string) (string, bool, error)) (NoteMeta, error) { - abs, err := v.AbsPath(rel) + abs, folder, err := v.notePath(rel) if err != nil { return NoteMeta{}, err } @@ -1136,10 +1116,6 @@ func (v *Vault) editNote(rel string, transform func(body string) (string, bool, if err != nil { return NoteMeta{}, err } - folder, ok := v.folderOfAbs(abs) - if !ok { - return NoteMeta{}, errNotInFolder(rel) - } if write { if _, err := v.WriteNote(v.relPosix(abs), next); err != nil { return NoteMeta{}, err