verstak/internal/core/watcher/scanner.go

181 lines
4.6 KiB
Go

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
}