verstak/cmd/verstak-gui/vault_check.go

147 lines
4.1 KiB
Go

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 id, node_id, path, storage_mode FROM files`)
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
}