fix: второй стабилизационный pass vault layout — sync payload, bindings, vaultPath, tests

sync_apply.go:
- applyRemoteNodeCreate: полный payload (template_id/fs_path/sort_order/archived),
  INSERT сохраняет все поля, для folder-like создаётся физическая папка.
- applyRemoteNodeUpdate: принимает fs_path/template_id/archived,
  физическое переименование папки при изменении title/fs_path.
- applyRemoteNodeMove: принимает fs_path, обновляет parent_id+fs_path,
  физически перемещает папку (folder-like) или file record (note/file).

bindings_nodes.go:
- MoveNode: node.FsPath = newFsPath после UpdateFsPath;
  sync.RecordOp отправляет новый fs_path; note/file move to root — файл в vault root.
- RenameNode: EntityFile для file, EntityNote для note;
  коллизия → генерация уникального имени; файл переименовывается только после os.Rename.
- DeleteNode: единый вызов a.files.DeleteNodeAndChildren(), дублирование удалено.
- Исправлен deadlock с SetMaxOpenConns(1) — Query/Exec больше не конфликтуют.

files.Service.vaultPath: filepath.Rel-based проверка,
  sibling-prefix escape (/tmp/vault vs /tmp/vault_evil) отклоняется.

VaultCheck: SQL JOIN с n.deleted_at IS NULL, чтобы удалённые узлы
  не показывались как missing files.

Добавлены тесты: RenameFileNodeUsesEntityFile, MoveNoteToRoot,
  DeleteFolderLeavesVaultCheckHealthy, SyncNodeCreatePreservesFields,
  VaultPathSiblingPrefixEscape.
This commit is contained in:
mirivlad 2026-06-02 17:03:05 +08:00
parent 66c5c81f39
commit 20a05569ac
6 changed files with 555 additions and 147 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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 {

View File

@ -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))

View File

@ -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",