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