181 lines
4.6 KiB
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
|
|
}
|