package watcher import ( "crypto/sha256" "encoding/hex" "fmt" "io" "io/fs" "log" "os" "path/filepath" "strings" "time" "verstak/internal/core/activity" "verstak/internal/core/files" "verstak/internal/core/nodes" ) // SnapshotResult summarises what the snapshot scanner found. type SnapshotResult struct { MissingFiles int `json:"missing_files"` RestoredFiles int `json:"restored_files"` ModifiedFiles int `json:"modified_files"` NewFiles int `json:"new_files"` NodesScanned int `json:"nodes_scanned"` } // Scanner performs a one-shot scan of the vault filesystem. type Scanner struct { vaultRoot string nodes *nodes.Repository files *files.Service activity *activity.Service } // NewScanner creates a snapshot scanner. func NewScanner(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Scanner { return &Scanner{ vaultRoot: vaultRoot, nodes: nr, files: fs, activity: as, } } // Run performs one full scan and returns a summary. func (s *Scanner) Run() (*SnapshotResult, error) { result := &SnapshotResult{} // 1. Collect all file records in the DB (including missing). dbFiles, err := s.files.ListAllVaultWithTrashed() if err != nil { return nil, fmt.Errorf("list vault files: %w", err) } // Index by path for O(1) lookup. byPath := make(map[string]*files.Record, len(dbFiles)) for i := range dbFiles { rec := &dbFiles[i] byPath[rec.Path] = rec } // 2. Scan all nodes with FsPath to discover files on disk. allNodes, err := s.nodes.ListAllWithFsPath() if err != nil { return nil, fmt.Errorf("list nodes: %w", err) } scannedPaths := make(map[string]bool) for _, node := range allNodes { absDir := filepath.Join(s.vaultRoot, node.FsPath) info, err := os.Stat(absDir) if err != nil || !info.IsDir() { continue } result.NodesScanned++ err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil // skip unreadable entries } if d.IsDir() { // Skip .verstak and hidden dirs. if strings.HasPrefix(d.Name(), ".") { return filepath.SkipDir } return nil } rel, err := filepath.Rel(s.vaultRoot, path) if err != nil { return nil } scannedPaths[rel] = true if rec, exists := byPath[rel]; exists { if rec.Missing { // File existed in DB as missing, now found on disk. _ = s.files.MarkMissing(rec.ID, false) s.logActivity(rec.NodeID, activity.TypeFileRestored, rec.Filename, rel) result.RestoredFiles++ } else { // File exists in both places — check if content changed. fi, err := os.Stat(path) if err != nil { return nil } if fi.Size() != rec.Size || contentChanged(path, rec.SHA256) { sha, size := hashFile(path) // Update in DB. We don't have an Update method exposed, // but we can mark and re-add via the watcher state. _ = s.updateFileRecord(rec.ID, rec.NodeID, rec.Filename, rel, size, sha) s.logActivity(rec.NodeID, activity.TypeFileModified, rec.Filename, rel) result.ModifiedFiles++ } } delete(byPath, rel) } else { // File on disk but no record in DB — new file. result.NewFiles++ } return nil }) if err != nil { log.Printf("[watcher] scan node %s error: %v", node.ID, err) } } // 3. Any remaining byPath entries are files in DB but missing on disk. for _, rec := range byPath { if !rec.Missing { _ = s.files.MarkMissing(rec.ID, true) s.logActivity(rec.NodeID, activity.TypeFileDeleted, rec.Filename, rec.Path) result.MissingFiles++ } } return result, nil } func (s *Scanner) logActivity(nodeID, eventType, title, path string) { _ = s.activity.Record(nodeID, activity.TargetFile, "", path, eventType, title, "") } func (s *Scanner) updateFileRecord(id, nodeID, filename, path string, size int64, sha string) error { // Direct SQL update since files.Service doesn't expose an update method. _, err := s.files.DB().Exec( `UPDATE files SET size=?, sha256=?, updated_at=?, missing=0 WHERE id=?`, size, sha, time.Now().UTC().Format(time.RFC3339), id) return err } // hashFile computes SHA256 and returns size and hex hash. func hashFile(absPath string) (string, int64) { f, err := os.Open(absPath) if err != nil { return "", 0 } defer f.Close() h := sha256.New() n, err := io.Copy(h, f) if err != nil { return "", 0 } return hex.EncodeToString(h.Sum(nil)), n } // contentChanged returns true if the file's SHA256 differs from the stored hash. func contentChanged(absPath, storedHash string) bool { if storedHash == "" { return true } h, _ := hashFile(absPath) return h != storedHash }