diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go index 07125eb..40217a7 100644 --- a/cmd/verstak-gui/bindings_nodes.go +++ b/cmd/verstak-gui/bindings_nodes.go @@ -4,12 +4,13 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "verstak/internal/core/activity" "verstak/internal/core/nodes" - "verstak/internal/core/templates" syncsvc "verstak/internal/core/sync" + "verstak/internal/core/templates" ) func (a *App) ListWorkspaceTree() ([]NodeDTO, error) { @@ -142,64 +143,7 @@ func (a *App) DeleteNode(id string) error { _ = a.activity.Record(pid, targetType, id, "", evType, n.Title, "") _ = a.sync.RecordOp(entity, id, syncsvc.OpDelete, nil) - // Handle physical trash based on node type - isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile - - if isFolderLike && n.FsPath != "" { - // Move entire folder to trash - src := filepath.Join(a.vault, n.FsPath) - if info, err := os.Stat(src); err == nil && info.IsDir() { - trashDir := filepath.Join(a.vault, ".verstak", "trash") - _ = os.MkdirAll(trashDir, 0o750) - trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) - _ = os.Rename(src, trashPath) - } - - // Recursively soft-delete all descendants - a.softDeleteDescendants(id) - return a.nodes.SoftDelete(id) - } - - // For notes/files: move associated file to trash, then soft-delete - if err := a.deleteNodeFileRecord(id); err != nil { - // Log but continue with soft-delete - } - return a.nodes.SoftDelete(id) -} - -// softDeleteDescendants recursively soft-deletes all descendant nodes. -func (a *App) softDeleteDescendants(parentID string) { - children, err := a.nodes.ListChildren(parentID, false) - if err != nil { - return - } - for _, child := range children { - a.softDeleteDescendants(child.ID) - _ = a.nodes.SoftDelete(child.ID) - } -} - -// deleteNodeFileRecord moves the file record for a node to trash. -func (a *App) deleteNodeFileRecord(nodeID string) error { - rows, err := a.db.Query(`SELECT id, path, storage_mode FROM files WHERE node_id=?`, nodeID) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var id, path, mode string - if err := rows.Scan(&id, &path, &mode); err != nil { - continue - } - if mode == "vault" { - src := filepath.Join(a.vault, path) - trashDir := filepath.Join(a.vault, ".verstak", "trash") - _ = os.MkdirAll(trashDir, 0o750) - _ = os.Rename(src, filepath.Join(trashDir, id+"_"+filepath.Base(path))) - } - } - _, err = a.db.Exec(`DELETE FROM files WHERE node_id=?`, nodeID) - return err + return a.files.DeleteNodeAndChildren(id) } func (a *App) RenameNode(nodeID, newTitle string) error { @@ -275,35 +219,68 @@ func (a *App) RenameNode(nodeID, newTitle string) error { return err } - // Update file record filename/path - rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID) - if err != nil { - return nil // non-fatal + // Collect file records first to avoid connection deadlock (SetMaxOpenConns=1) + type fileRec struct { + id, path, filename string } - defer rows.Close() - for rows.Next() { - var fileID, filePath, oldFilename string - if err := rows.Scan(&fileID, &filePath, &oldFilename); err != nil { + var records []fileRec + rows, err := a.db.Query(`SELECT id, path, filename FROM files WHERE node_id=?`, nodeID) + if err == nil { + for rows.Next() { + var r fileRec + if err := rows.Scan(&r.id, &r.path, &r.filename); err != nil { + continue + } + records = append(records, r) + } + rows.Close() + } + + for _, r := range records { + if r.path == "" { continue } - if filePath == "" { - continue + oldAbs := filepath.Join(a.vault, r.path) + dir := filepath.Dir(r.path) + var newFilename string + if n.Type == nodes.TypeNote { + ext := filepath.Ext(r.filename) + if ext == "" { + ext = ".md" + } + newFilename = seg + ext + } else { + // File node: title is the full filename + newFilename = newTitle } - oldAbs := filepath.Join(a.vault, filePath) - ext := filepath.Ext(oldFilename) - newFilename := seg + ext - dir := filepath.Dir(filePath) newRelPath := filepath.Join(dir, newFilename) newAbs := filepath.Join(a.vault, newRelPath) + // Check for collision — generate unique name if needed + if _, err := os.Stat(newAbs); err == nil { + ext := filepath.Ext(newFilename) + base := strings.TrimSuffix(newFilename, ext) + baseSeg := templates.SafeDisplayNameToPathSegment(base) + if baseSeg == "" { + baseSeg = "renamed" + } + for n := 2; ; n++ { + candidate := fmt.Sprintf("%s (%d)%s", baseSeg, n, ext) + candidatePath := filepath.Join(dir, candidate) + if _, err := os.Stat(filepath.Join(a.vault, candidatePath)); os.IsNotExist(err) { + newFilename = candidate + newRelPath = candidatePath + newAbs = filepath.Join(a.vault, newRelPath) + break + } + } + } + // Rename physical file if _, err := os.Stat(oldAbs); err == nil { - // Check for collision - if _, err := os.Stat(newAbs); os.IsNotExist(err) { - if err := os.Rename(oldAbs, newAbs); err == nil { - _, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`, - newFilename, newRelPath, fileID) - } + if err := os.Rename(oldAbs, newAbs); err == nil { + _, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`, + newFilename, newRelPath, r.id) } } } @@ -314,15 +291,18 @@ func (a *App) RenameNode(nodeID, newTitle string) error { } var evType string var targetType string + var syncEntity string if n.Type == nodes.TypeNote { evType = activity.TypeNoteUpdated targetType = activity.TargetNote + syncEntity = syncsvc.EntityNote } else { evType = activity.TypeFileRenamed targetType = activity.TargetFile + syncEntity = syncsvc.EntityFile } _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, "") - _ = a.sync.RecordOp(syncsvc.EntityNote, nodeID, syncsvc.OpUpdate, map[string]interface{}{ + _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ "title": newTitle, "updated_at": time.Now().UTC().Format(time.RFC3339), }) @@ -406,6 +386,8 @@ func (a *App) MoveNode(nodeID, newParentID string) error { return fmt.Errorf("move folder: %w", err) } } + + node.FsPath = newFsPath } else { // Note/file node: update parent_id and move physical file if newParentID == "" { @@ -419,30 +401,39 @@ func (a *App) MoveNode(nodeID, newParentID string) error { } // Update file record path to reflect new parent - if parent != nil && parent.FsPath != "" { - rows, err := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID) - if err == nil { - defer rows.Close() - for rows.Next() { - var fileID, filePath string - if err := rows.Scan(&fileID, &filePath); err != nil { - continue - } - if filePath == "" { - continue - } - filename := filepath.Base(filePath) - newRelPath := filepath.Join(parent.FsPath, filename) - oldAbs := filepath.Join(a.vault, filePath) - newAbs := filepath.Join(a.vault, newRelPath) + type fileMove struct { + id, path string + } + var fileMoves []fileMove + frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID) + if ferr == nil { + for frows.Next() { + var fm fileMove + if err := frows.Scan(&fm.id, &fm.path); err != nil { + continue + } + fileMoves = append(fileMoves, fm) + } + frows.Close() + } - if _, err := os.Stat(oldAbs); err == nil { - _ = os.MkdirAll(filepath.Dir(newAbs), 0o750) - if err := os.Rename(oldAbs, newAbs); err == nil { - _, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`, - newRelPath, fileID) - } - } + for _, fm := range fileMoves { + if fm.path == "" { + continue + } + filename := filepath.Base(fm.path) + newRelPath := filename + if parent != nil && parent.FsPath != "" { + newRelPath = filepath.Join(parent.FsPath, filename) + } + oldAbs := filepath.Join(a.vault, fm.path) + newAbs := filepath.Join(a.vault, newRelPath) + + if _, err := os.Stat(oldAbs); err == nil { + _ = os.MkdirAll(filepath.Dir(newAbs), 0o750) + if err := os.Rename(oldAbs, newAbs); err == nil { + _, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`, + newRelPath, fm.id) } } } @@ -469,9 +460,13 @@ func (a *App) MoveNode(nodeID, newParentID string) error { syncEntity = syncsvc.EntityFile } _ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`) + var opFsPath string + if isFolderLike && node.FsPath != "" { + opFsPath = node.FsPath + } _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{ "parent_id": newParentID, - "fs_path": node.FsPath, + "fs_path": opFsPath, "updated_at": time.Now().UTC().Format(time.RFC3339), }) return nil diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go index fcbb7e7..061ac37 100644 --- a/cmd/verstak-gui/sync_apply.go +++ b/cmd/verstak-gui/sync_apply.go @@ -11,6 +11,7 @@ import ( "verstak/internal/core/config" "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" + "verstak/internal/core/templates" "verstak/internal/core/util" ) @@ -49,14 +50,18 @@ func (a *App) applyRemoteNodeOp(op syncsvc.Op) error { func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { var payload struct { - ID string `json:"id"` - ParentID string `json:"parent_id"` - Type string `json:"type"` - Title string `json:"title"` - Slug string `json:"slug"` - Section string `json:"section"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + ParentID string `json:"parent_id"` + Type string `json:"type"` + Title string `json:"title"` + Slug string `json:"slug"` + TemplateID string `json:"template_id"` + FsPath string `json:"fs_path"` + Section string `json:"section"` + SortOrder int `json:"sort_order"` + Archived bool `json:"archived"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal node create: %w", err) @@ -88,19 +93,68 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { if slug == "" { slug = nodes.Slugify(payload.Title) } + + // Determine fs_path for folder-like nodes + fsPath := payload.FsPath + if fsPath == "" { + isFolderLike := payload.Type != "note" && payload.Type != "file" + if isFolderLike { + seg := templates.SafeDisplayNameToPathSegment(payload.Title) + if seg == "" { + seg = "node" + } + if payload.ParentID != "" { + if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" { + fsPath = filepath.Join(parent.FsPath, seg) + } + } + if fsPath == "" { + fsPath = filepath.Join(".verstak", "remote-inbox") + } + // Ensure unique path + fullPath := filepath.Join(a.vault, fsPath) + fullPath = templates.UniquePath(fullPath) + rel, _ := filepath.Rel(a.vault, fullPath) + fsPath = rel + } + } + + archived := 0 + if payload.Archived { + archived = 1 + } _, err := a.db.Exec( `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id) - VALUES (?,?,?,?,?,?,?,?,0,0,?,?,1,NULL)`, - payload.ID, parent, payload.Type, payload.Title, slug, "", "", - section, payload.CreatedAt, payload.UpdatedAt, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`, + payload.ID, parent, payload.Type, payload.Title, slug, + payload.TemplateID, fsPath, section, payload.SortOrder, archived, + payload.CreatedAt, payload.UpdatedAt, ) - return err + if err != nil { + return err + } + + // Create physical folder for folder-like nodes + if fsPath != "" { + isFolderLike := payload.Type != "note" && payload.Type != "file" + if isFolderLike { + physPath := filepath.Join(a.vault, fsPath) + if err := os.MkdirAll(physPath, 0o755); err != nil { + log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err) + } + } + } + + return nil } func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error { var payload struct { - Title string `json:"title"` - UpdatedAt string `json:"updated_at"` + Title string `json:"title"` + FsPath string `json:"fs_path"` + TemplateID string `json:"template_id"` + Archived *bool `json:"archived,omitempty"` + UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal node update: %w", err) @@ -109,20 +163,76 @@ func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error { if payload.UpdatedAt != "" { now = payload.UpdatedAt } - if payload.Title != "" { - slug := nodes.Slugify(payload.Title) + + // Get current node to check if it's folder-like + n, err := a.nodes.Get(op.EntityID) + if err != nil { + return nil + } + + isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile + + if payload.Title != "" || payload.FsPath != "" { + if payload.Title != "" { + slug := nodes.Slugify(payload.Title) + if _, err := a.db.Exec( + `UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`, + payload.Title, slug, now, op.EntityID); err != nil { + return err + } + } + + if payload.FsPath != "" && isFolderLike { + oldFsPath := n.FsPath + if _, err := a.db.Exec( + `UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`, + payload.FsPath, now, op.EntityID); err != nil { + return err + } + + // Physically rename folder if old path existed + if oldFsPath != "" { + oldPhys := filepath.Join(a.vault, oldFsPath) + newPhys := filepath.Join(a.vault, payload.FsPath) + if _, err := os.Stat(oldPhys); err == nil { + _ = os.MkdirAll(filepath.Dir(newPhys), 0o750) + if err := os.Rename(oldPhys, newPhys); err != nil { + log.Printf("[sync] rename folder %s -> %s: %v", oldPhys, newPhys, err) + } + } + } + } + + if payload.TemplateID != "" { + if _, err := a.db.Exec( + `UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`, + payload.TemplateID, now, op.EntityID); err != nil { + return err + } + } + + return nil + } + + if payload.Archived != nil { + v := 0 + if *payload.Archived { + v = 1 + } _, err := a.db.Exec( - `UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`, - payload.Title, slug, now, op.EntityID) + `UPDATE nodes SET archived=?, updated_at=? WHERE id=?`, + v, now, op.EntityID) return err } - _, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID) + + _, err = a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID) return err } func (a *App) applyRemoteNodeMove(op syncsvc.Op) error { var payload struct { ParentID string `json:"parent_id"` + FsPath string `json:"fs_path"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { @@ -132,14 +242,82 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error { if payload.UpdatedAt != "" { now = payload.UpdatedAt } + + n, err := a.nodes.Get(op.EntityID) + if err != nil { + return nil + } + isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile + + // Update parent_id var parent interface{} if payload.ParentID != "" { parent = payload.ParentID } - _, err := a.db.Exec( + if _, err := a.db.Exec( `UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`, - parent, now, op.EntityID) - return err + parent, now, op.EntityID); err != nil { + return err + } + + if payload.FsPath != "" { + oldFsPath := n.FsPath + + if _, err := a.db.Exec( + `UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`, + payload.FsPath, now, op.EntityID); err != nil { + return err + } + + if isFolderLike && oldFsPath != "" { + // Physically move folder + oldPhys := filepath.Join(a.vault, oldFsPath) + newPhys := filepath.Join(a.vault, payload.FsPath) + if _, err := os.Stat(oldPhys); err == nil { + _ = os.MkdirAll(filepath.Dir(newPhys), 0o750) + if err := os.Rename(oldPhys, newPhys); err != nil { + log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err) + } + } + } else if !isFolderLike { + // Move file record path for notes/files + type fm struct { + id, path string + } + var fileMoves []fm + frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, op.EntityID) + if ferr == nil { + for frows.Next() { + var m fm + if err := frows.Scan(&m.id, &m.path); err != nil { + continue + } + fileMoves = append(fileMoves, m) + } + frows.Close() + } + + for _, m := range fileMoves { + if m.path == "" { + continue + } + filename := filepath.Base(m.path) + newRelPath := filepath.Join(payload.FsPath, filename) + oldAbs := filepath.Join(a.vault, m.path) + newAbs := filepath.Join(a.vault, newRelPath) + + if _, err := os.Stat(oldAbs); err == nil { + _ = os.MkdirAll(filepath.Dir(newAbs), 0o750) + if err := os.Rename(oldAbs, newAbs); err == nil { + _, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`, + newRelPath, m.id) + } + } + } + } + } + + return nil } func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error { diff --git a/cmd/verstak-gui/vault_check.go b/cmd/verstak-gui/vault_check.go index abdf332..91b6bd1 100644 --- a/cmd/verstak-gui/vault_check.go +++ b/cmd/verstak-gui/vault_check.go @@ -10,18 +10,18 @@ import ( ) type VaultCheckResult struct { - TotalNodes int `json:"total_nodes"` - TotalFiles int `json:"total_files"` - NodesWithFsPath int `json:"nodes_with_fs_path"` - FoldersOnDisk int `json:"folders_on_disk"` - FilesOnDisk int `json:"files_on_disk"` - FilesMissing int `json:"files_missing"` - PathEscapeCount int `json:"path_escape_count"` - ParentIDEmptyCount int `json:"parent_id_empty_count"` - OrphanDescendantCount int `json:"orphan_descendant_count"` - Errors []string `json:"errors,omitempty"` - Details []string `json:"details,omitempty"` - Healthy bool `json:"healthy"` + TotalNodes int `json:"total_nodes"` + TotalFiles int `json:"total_files"` + NodesWithFsPath int `json:"nodes_with_fs_path"` + FoldersOnDisk int `json:"folders_on_disk"` + FilesOnDisk int `json:"files_on_disk"` + FilesMissing int `json:"files_missing"` + PathEscapeCount int `json:"path_escape_count"` + ParentIDEmptyCount int `json:"parent_id_empty_count"` + OrphanDescendantCount int `json:"orphan_descendant_count"` + Errors []string `json:"errors,omitempty"` + Details []string `json:"details,omitempty"` + Healthy bool `json:"healthy"` } func (a *App) VaultCheck() (*VaultCheckResult, error) { @@ -103,7 +103,7 @@ func (a *App) VaultCheck() (*VaultCheckResult, error) { } // Check file records - rows, err := a.db.Query(`SELECT id, node_id, path, storage_mode FROM files`) + rows, err := a.db.Query(`SELECT f.id, f.node_id, f.path, f.storage_mode FROM files f LEFT JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL`) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("query files: %v", err)) result.Healthy = false diff --git a/cmd/verstak-gui/vault_layout_test.go b/cmd/verstak-gui/vault_layout_test.go index 657fcb0..3180370 100644 --- a/cmd/verstak-gui/vault_layout_test.go +++ b/cmd/verstak-gui/vault_layout_test.go @@ -347,6 +347,191 @@ func TestVaultLayout_VaultCheck(t *testing.T) { } } +func TestVaultLayout_RenameFileNodeUsesEntityFile(t *testing.T) { + app, vault := setupTestApp(t) + + parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default") + + // Create a file node with file record + fileNode, err := app.nodes.Create(&parent.ID, "file", "test.txt", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + src := filepath.Join(vault, "src.txt") + os.WriteFile(src, []byte("data"), 0o640) + _, err = app.files.CopyIntoVault(fileNode.ID, src, parent.FsPath) + if err != nil { + t.Fatalf("copy into vault: %v", err) + } + + // Rename the file node + if err := app.RenameNode(fileNode.ID, "renamed.txt"); err != nil { + t.Fatalf("rename file node: %v", err) + } + + // Check the recorded sync op + ops, err := app.sync.GetUnpushedOps() + if err != nil { + t.Fatalf("get ops: %v", err) + } + + var found bool + for _, op := range ops { + if op.EntityID == fileNode.ID && op.OpType == syncsvc.OpUpdate { + if op.EntityType != syncsvc.EntityFile { + t.Errorf("expected EntityFile, got %q", op.EntityType) + } + found = true + break + } + } + if !found { + t.Error("expected sync OpUpdate for file node") + } + + // Verify physical file was renamed on disk + records, _ := app.files.ListByNode(fileNode.ID) + if len(records) > 0 && records[0].Filename != "renamed.txt" { + t.Errorf("expected filename 'renamed.txt', got %q", records[0].Filename) + } + oldPath := filepath.Join(vault, parent.FsPath, "test.txt") + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Error("expected old file name to not exist") + } +} + +func TestVaultLayout_MoveNoteToRoot(t *testing.T) { + app, vault := setupTestApp(t) + + parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default") + noteNode, _, err := app.notes.Create(parent.ID, "MyNote", "") + if err != nil { + t.Fatalf("create note: %v", err) + } + + // Verify note file exists in parent folder + notePath := filepath.Join(vault, "Parent", "MyNote.md") + altPath := filepath.Join(vault, "Parent", "Моя_заметка.md") + if _, err := os.Stat(notePath); os.IsNotExist(err) { + if _, err2 := os.Stat(altPath); os.IsNotExist(err2) { + // Try other variants + entries, _ := os.ReadDir(filepath.Join(vault, "Parent")) + t.Logf("note file candidates: %v", listNames(entries)) + } + } + + // Move note to root + if err := app.MoveNode(noteNode.ID, ""); err != nil { + t.Fatalf("move note to root: %v", err) + } + + // Verify note node has no parent + n, err := app.nodes.GetActive(noteNode.ID) + if err != nil { + t.Fatalf("get note: %v", err) + } + if !n.IsRoot() { + t.Error("expected note to be root after move") + } + + // Verify file record path was updated + records, _ := app.files.ListByNode(noteNode.ID) + if len(records) > 0 { + expectedFileBase := filepath.Base(records[0].Path) + // The file should be at vault root, not inside Parent/ + if records[0].Path != expectedFileBase { + t.Errorf("expected file path at root, got %q", records[0].Path) + } + } +} + +func TestVaultLayout_DeleteFolderLeavesVaultCheckHealthy(t *testing.T) { + app, vault := setupTestApp(t) + + // Create folder structure with files + parent, _ := app.CreateNodeFromTemplate("", "ToDelete", "folder.default") + child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default") + + // Create a file inside the folder + src := filepath.Join(vault, "test.txt") + os.WriteFile(src, []byte("data"), 0o640) + fileNode, err := app.nodes.Create(&child.ID, "file", "file.txt", 0, "", "") + if err != nil { + t.Fatalf("create file node: %v", err) + } + app.files.CopyIntoVault(fileNode.ID, src, child.FsPath) + + // Create a note inside the folder + app.notes.Create(child.ID, "Note", "") + + // Delete via GUI + if err := app.DeleteNode(parent.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + // VaultCheck should be healthy + result, err := app.VaultCheck() + if err != nil { + t.Fatalf("vault check: %v", err) + } + if !result.Healthy { + t.Errorf("expected healthy vault after delete, errors: %v", result.Errors) + } +} + +func TestVaultLayout_SyncNodeCreatePreservesFields(t *testing.T) { + app, _ := setupTestApp(t) + + // Simulate a remote node create op with all fields + op := syncsvc.Op{ + EntityType: syncsvc.EntityNode, + EntityID: "test-remote-id-1", + OpType: syncsvc.OpCreate, + PayloadJSON: `{ + "id": "test-remote-id-1", + "parent_id": "", + "type": "folder", + "title": "RemoteFolder", + "slug": "remote-folder", + "template_id": "folder.default", + "fs_path": "RemoteFolder", + "section": "", + "sort_order": 5, + "archived": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }`, + } + + if err := app.applyRemoteNodeCreate(op); err != nil { + t.Fatalf("apply remote create: %v", err) + } + + // Verify the node was created with all fields + n, err := app.nodes.Get("test-remote-id-1") + if err != nil { + t.Fatalf("get node: %v", err) + } + if n.TemplateID != "folder.default" { + t.Errorf("expected template_id 'folder.default', got %q", n.TemplateID) + } + if n.FsPath != "RemoteFolder" { + t.Errorf("expected fs_path 'RemoteFolder', got %q", n.FsPath) + } + if n.SortOrder != 5 { + t.Errorf("expected sort_order 5, got %d", n.SortOrder) + } + if n.Archived { + t.Error("expected archived false") + } + + // Verify physical folder was created + physPath := filepath.Join(app.vault, "RemoteFolder") + if _, err := os.Stat(physPath); os.IsNotExist(err) { + t.Error("expected physical folder to be created") + } +} + // --- helpers --- func listNames(entries []os.DirEntry) []string { diff --git a/internal/core/files/file.go b/internal/core/files/file.go index 55673c3..dc4f420 100644 --- a/internal/core/files/file.go +++ b/internal/core/files/file.go @@ -102,12 +102,24 @@ func validateName(name string) error { // vaultPath resolves a relative vault path and checks it stays within jail. func (s *Service) vaultPath(rel string) (string, error) { - abs := filepath.Join(s.vaultRoot, rel) - cleaned := filepath.Clean(abs) - if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) { - return "", fmt.Errorf("path escapes vault root") + if rel == "" { + return "", fmt.Errorf("empty path") } - return cleaned, nil + if filepath.IsAbs(rel) { + return "", fmt.Errorf("absolute path not allowed: %s", rel) + } + cleaned := filepath.Clean(rel) + joined := filepath.Join(s.vaultRoot, cleaned) + joinedClean := filepath.Clean(joined) + + relToVault, err := filepath.Rel(s.vaultRoot, joinedClean) + if err != nil { + return "", fmt.Errorf("path escapes vault root: %s", rel) + } + if relToVault == ".." || strings.HasPrefix(relToVault, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes vault root: %s", rel) + } + return joinedClean, nil } // absPathSafe resolves an absolute path and checks jail if it's under vault. @@ -447,11 +459,13 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error { } } _ = s.deleteFileRecords(nodeID) - // Move physical folder to trash if the node has fs_path n, err := s.nodes.GetActive(nodeID) if err == nil && n.FsPath != "" { - src := filepath.Join(s.vaultRoot, n.FsPath) - if info, err := os.Stat(src); err == nil && info.IsDir() { + src, vaultErr := s.vaultPath(n.FsPath) + if vaultErr != nil { + src = filepath.Join(s.vaultRoot, n.FsPath) + } + if info, statErr := os.Stat(src); statErr == nil && info.IsDir() { trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash") os.MkdirAll(trashDir, 0o750) trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title)) diff --git a/internal/core/files/file_test.go b/internal/core/files/file_test.go index 212644c..d308779 100644 --- a/internal/core/files/file_test.go +++ b/internal/core/files/file_test.go @@ -305,6 +305,42 @@ func TestPreviewImportDir(t *testing.T) { } } +func TestVaultPathSiblingPrefixEscape(t *testing.T) { + vaultRoot := "/tmp/vault" + svc := &Service{vaultRoot: vaultRoot} + + // Normal path should pass + _, err := svc.vaultPath("some/file.txt") + if err != nil { + t.Fatalf("expected OK, got: %v", err) + } + + // Sibling-prefix escape should fail + // vault=/tmp/vault, path goes to /tmp/vault_evil -> should be rejected + _, err = svc.vaultPath("../vault_evil/file.txt") + if err == nil { + t.Error("expected error for sibling-prefix escape, got nil") + } + + // Direct escape with ../.. + _, err = svc.vaultPath("../../etc/passwd") + if err == nil { + t.Error("expected error for path escape, got nil") + } + + // Absolute path + _, err = svc.vaultPath("/etc/passwd") + if err == nil { + t.Error("expected error for absolute path, got nil") + } + + // Empty path + _, err = svc.vaultPath("") + if err == nil { + t.Error("expected error for empty path, got nil") + } +} + func TestGuessMIME(t *testing.T) { cases := map[string]string{ "a.md": "text/plain",