package main import ( "fmt" "os" "path/filepath" "strings" "verstak/internal/core/nodes" ) 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"` } func (a *App) VaultCheck() (*VaultCheckResult, error) { result := &VaultCheckResult{Healthy: true} // Build a set of all node IDs for ancestor check allNodes := make(map[string]*nodes.Node) roots, err := a.nodes.ListRoots(true) if err != nil { return nil, fmt.Errorf("list roots: %w", err) } var collectDescendants func(id string) collectDescendants = func(id string) { n, err := a.nodes.Get(id) if err == nil { allNodes[n.ID] = n } children, _ := a.nodes.ListChildren(id, true) for _, c := range children { allNodes[c.ID] = &c collectDescendants(c.ID) } } for _, n := range roots { allNodes[n.ID] = &n collectDescendants(n.ID) } // Check parent_id consistency for id, n := range allNodes { if !n.IsRoot() && n.ParentID != nil && *n.ParentID == "" { result.ParentIDEmptyCount++ result.Errors = append(result.Errors, fmt.Sprintf("node %s (%s): parent_id is empty string, should be nil", id, n.Title)) result.Healthy = false } } // Check each node for _, n := range allNodes { if n.IsDeleted() { continue } result.TotalNodes++ // Check if ancestor is deleted if n.ParentID != nil && *n.ParentID != "" { if parent, ok := allNodes[*n.ParentID]; ok && parent.IsDeleted() { result.OrphanDescendantCount++ result.Errors = append(result.Errors, fmt.Sprintf("node %s (%s) is active but parent %s is deleted", n.ID, n.Title, *n.ParentID)) result.Healthy = false } } // Check fs_path isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile if isFolderLike && n.FsPath != "" { result.NodesWithFsPath++ physPath := filepath.Join(a.vault, n.FsPath) rel, err := filepath.Rel(a.vault, physPath) if err != nil || strings.HasPrefix(rel, "..") { result.PathEscapeCount++ result.Errors = append(result.Errors, fmt.Sprintf("node %s (%s): fs_path escapes vault: %s", n.ID, n.Title, n.FsPath)) result.Healthy = false continue } if info, err := os.Stat(physPath); err == nil { if info.IsDir() { result.FoldersOnDisk++ } } else { result.FilesMissing++ result.Details = append(result.Details, fmt.Sprintf("node %s (%s): folder missing on disk: %s", n.ID, n.Title, physPath)) } } } // Check file records 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 return result, nil } defer rows.Close() for rows.Next() { var id, nodeID, path, mode string if err := rows.Scan(&id, &nodeID, &path, &mode); err != nil { continue } result.TotalFiles++ if mode == "vault" { absPath := filepath.Join(a.vault, path) rel, err := filepath.Rel(a.vault, absPath) if err != nil || strings.HasPrefix(rel, "..") { result.PathEscapeCount++ result.Errors = append(result.Errors, fmt.Sprintf("file %s: path escapes vault: %s", id, path)) result.Healthy = false continue } if _, err := os.Stat(absPath); err == nil { result.FilesOnDisk++ } else { result.FilesMissing++ result.Details = append(result.Details, fmt.Sprintf("file %s (%s): missing on disk: %s", id, path, absPath)) } } } if result.FilesMissing > 0 || result.PathEscapeCount > 0 || result.ParentIDEmptyCount > 0 || result.OrphanDescendantCount > 0 { result.Healthy = false } return result, nil }