feat: ШАГ 1 — Bridge HTTP-сервер для браузерного расширения
- internal/core/bridge/ — лёгкий HTTP-сервер на 127.0.0.1 - POST /api/events — приём батча событий от расширения - GET /api/ping — healthcheck для расширения - X-Verstak-Secret — аутентификация по shared-secret - AutoGenPort — случайный порт если 9786 занят - config.BridgeConfig — порт, секрет, auto_gen_port - App: интеграция startBridge/stopBridge при open/close vault - bindings_bridge.go — BridgeInfo(), startBridge(), saveBridgeConfig() - Тесты: ping, auth, success, empty batch, secret gen, auto-port
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
// Service wraps both the snapshot scanner and the fsnotify watcher.
|
||||
// It provides a single entry point for the app to start/stop file watching.
|
||||
type Service struct {
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
activity *activity.Service
|
||||
|
||||
mu sync.Mutex
|
||||
watcher *Watcher
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewService creates a combined watcher service.
|
||||
// It does not start watching until Start is called.
|
||||
func NewService(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Service {
|
||||
return &Service{
|
||||
vaultRoot: vaultRoot,
|
||||
nodes: nr,
|
||||
files: fs,
|
||||
activity: as,
|
||||
}
|
||||
}
|
||||
|
||||
// Start performs a snapshot scan and then starts the real-time watcher.
|
||||
// If enabled is false, only the snapshot scan runs (one-shot).
|
||||
func (s *Service) Start(enabled bool) (*SnapshotResult, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.enabled = enabled
|
||||
|
||||
// Always run snapshot scan first.
|
||||
scanner := NewScanner(s.vaultRoot, s.nodes, s.files, s.activity)
|
||||
result, err := scanner.Run()
|
||||
if err != nil {
|
||||
log.Printf("[watcher] snapshot scan error: %v", err)
|
||||
result = &SnapshotResult{}
|
||||
}
|
||||
|
||||
if result.MissingFiles > 0 || result.RestoredFiles > 0 || result.ModifiedFiles > 0 {
|
||||
log.Printf("[watcher] snapshot scan: %d missing, %d restored, %d modified, %d new, %d nodes",
|
||||
result.MissingFiles, result.RestoredFiles, result.ModifiedFiles, result.NewFiles, result.NodesScanned)
|
||||
}
|
||||
|
||||
if enabled {
|
||||
w := NewWatcher(s.vaultRoot, s.nodes, s.files, s.activity)
|
||||
if err := w.Start(); err != nil {
|
||||
log.Printf("[watcher] failed to start real-time watcher: %v", err)
|
||||
return result, nil
|
||||
}
|
||||
s.watcher = w
|
||||
log.Printf("[watcher] real-time watcher started")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Stop shuts down the real-time watcher if running.
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.watcher != nil {
|
||||
s.watcher.Stop()
|
||||
s.watcher = nil
|
||||
}
|
||||
s.enabled = false
|
||||
}
|
||||
|
||||
// IsWatching returns whether the real-time watcher is active.
|
||||
func (s *Service) IsWatching() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.watcher != nil && s.watcher.IsWatching()
|
||||
}
|
||||
|
||||
// RunScanner performs a one-shot snapshot scan (even if watcher is active).
|
||||
func (s *Service) RunScanner() (*SnapshotResult, error) {
|
||||
scanner := NewScanner(s.vaultRoot, s.nodes, s.files, s.activity)
|
||||
return scanner.Run()
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
)
|
||||
|
||||
// DebounceWindow is how long we wait for a burst of fsnotify events to settle.
|
||||
const DebounceWindow = 2 * time.Second
|
||||
|
||||
// Watcher wraps fsnotify to track filesystem changes in real time.
|
||||
type Watcher struct {
|
||||
vaultRoot string
|
||||
nodes *nodes.Repository
|
||||
files *files.Service
|
||||
activity *activity.Service
|
||||
|
||||
w *fsnotify.Watcher
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
watching bool
|
||||
|
||||
// debounce buffers
|
||||
pending map[string][]fsnotify.Event // key = fsPath
|
||||
debounceT *time.Timer
|
||||
}
|
||||
|
||||
// NewWatcher creates but does not start the watcher.
|
||||
func NewWatcher(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Watcher {
|
||||
return &Watcher{
|
||||
vaultRoot: vaultRoot,
|
||||
nodes: nr,
|
||||
files: fs,
|
||||
activity: as,
|
||||
pending: make(map[string][]fsnotify.Event),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins watching all node directories. Returns an error if fsnotify fails.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.watching {
|
||||
return nil
|
||||
}
|
||||
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fsnotify: %w", err)
|
||||
}
|
||||
w.w = fw
|
||||
w.done = make(chan struct{})
|
||||
|
||||
// Collect all node directories to watch.
|
||||
allNodes, err := w.nodes.ListAllWithFsPath()
|
||||
if err != nil {
|
||||
fw.Close()
|
||||
return fmt.Errorf("list nodes: %w", err)
|
||||
}
|
||||
|
||||
watched := 0
|
||||
for _, node := range allNodes {
|
||||
absDir := filepath.Join(w.vaultRoot, node.FsPath)
|
||||
if info, statErr := os.Stat(absDir); statErr == nil && info.IsDir() {
|
||||
// Watch the directory and its direct subdirectories.
|
||||
if err := w.addRecursive(absDir); err != nil {
|
||||
log.Printf("[watcher] add watch %s: %v", node.FsPath, err)
|
||||
continue
|
||||
}
|
||||
watched++
|
||||
}
|
||||
}
|
||||
|
||||
w.watching = true
|
||||
log.Printf("[watcher] started watching %d directories", watched)
|
||||
|
||||
go w.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the watcher.
|
||||
func (w *Watcher) Stop() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if !w.watching {
|
||||
return
|
||||
}
|
||||
w.watching = false
|
||||
if w.debounceT != nil {
|
||||
w.debounceT.Stop()
|
||||
}
|
||||
close(w.done)
|
||||
if w.w != nil {
|
||||
w.w.Close()
|
||||
}
|
||||
log.Printf("[watcher] stopped")
|
||||
}
|
||||
|
||||
// IsWatching returns whether the watcher is active.
|
||||
func (w *Watcher) IsWatching() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.watching
|
||||
}
|
||||
|
||||
// addRecursive adds watchers for dir and all its non-hidden subdirectories.
|
||||
func (w *Watcher) addRecursive(dir string) error {
|
||||
return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if d.Name() != "." && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return w.w.Add(path)
|
||||
})
|
||||
}
|
||||
|
||||
// loop is the main event processing goroutine.
|
||||
func (w *Watcher) loop() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-w.w.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.bufferEvent(event)
|
||||
|
||||
case err, ok := <-w.w.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("[watcher] error: %v", err)
|
||||
|
||||
case <-w.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bufferEvent adds an event to the debounce buffer.
|
||||
func (w *Watcher) bufferEvent(event fsnotify.Event) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
// Skip hidden files and .verstak directory.
|
||||
if isHiddenOrMeta(event.Name) {
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(event.Name)
|
||||
w.pending[dir] = append(w.pending[dir], event)
|
||||
|
||||
if w.debounceT != nil {
|
||||
w.debounceT.Stop()
|
||||
}
|
||||
w.debounceT = time.AfterFunc(DebounceWindow, w.flushPending)
|
||||
}
|
||||
|
||||
// flushPending processes all buffered events.
|
||||
func (w *Watcher) flushPending() {
|
||||
w.mu.Lock()
|
||||
events := w.pending
|
||||
w.pending = make(map[string][]fsnotify.Event)
|
||||
w.mu.Unlock()
|
||||
|
||||
// Group by file path, keeping the most recent event of each kind.
|
||||
byFile := make(map[string]fsnotify.Event)
|
||||
for _, evts := range events {
|
||||
for _, e := range evts {
|
||||
byFile[e.Name] = e
|
||||
}
|
||||
}
|
||||
|
||||
for absPath, ev := range byFile {
|
||||
rel, err := filepath.Rel(w.vaultRoot, absPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
w.handleEvent(rel, absPath, ev)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvent processes a single filesystem event.
|
||||
func (w *Watcher) handleEvent(rel, absPath string, ev fsnotify.Event) {
|
||||
switch {
|
||||
case ev.Has(fsnotify.Create):
|
||||
// File created — check for existing record, add if not present.
|
||||
fi, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if fi.IsDir() {
|
||||
// New directory appeared — add watcher.
|
||||
_ = w.addRecursive(absPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the parent node by FsPath.
|
||||
parentDir := filepath.Dir(rel)
|
||||
node, err := w.findNodeByFsPath(parentDir)
|
||||
if err != nil {
|
||||
log.Printf("[watcher] no node for path %s: %v", parentDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file record already exists (race with scanner).
|
||||
existing, _ := w.files.ListByNode(node.ID)
|
||||
for _, rec := range existing {
|
||||
if rec.Path == rel {
|
||||
// Already tracked — just mark as not missing.
|
||||
if rec.Missing {
|
||||
_ = w.files.MarkMissing(rec.ID, false)
|
||||
w.logActivity(node.ID, activity.TypeFileRestored, rec.Filename, rel)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// New file — create record.
|
||||
_, err = w.files.CopyIntoVault(node.ID, absPath, parentDir)
|
||||
if err != nil {
|
||||
log.Printf("[watcher] auto-add file %s: %v", rel, err)
|
||||
return
|
||||
}
|
||||
w.logActivity(node.ID, activity.TypeFileAdded, fi.Name(), rel)
|
||||
|
||||
case ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename):
|
||||
// File deleted or renamed away.
|
||||
filename := filepath.Base(rel)
|
||||
|
||||
rec, err := w.findRecordByPath(rel)
|
||||
if err != nil {
|
||||
return // unknown file, not tracked
|
||||
}
|
||||
if !rec.Missing {
|
||||
_ = w.files.MarkMissing(rec.ID, true)
|
||||
w.logActivity(rec.NodeID, activity.TypeFileDeleted, filename, rel)
|
||||
}
|
||||
|
||||
case ev.Has(fsnotify.Write):
|
||||
// Content modified.
|
||||
rec, err := w.findRecordByPath(rel)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
sha, size := hashFileFast(absPath)
|
||||
_ = w.updateFileRecord(rec.ID, size, sha)
|
||||
w.logActivity(rec.NodeID, activity.TypeFileModified, rec.Filename, rel)
|
||||
|
||||
case ev.Has(fsnotify.Chmod):
|
||||
// Permission changes — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// findNodeByFsPath finds the node whose FsPath matches the given relative directory.
|
||||
func (w *Watcher) findNodeByFsPath(relDir string) (*nodes.Node, error) {
|
||||
all, err := w.nodes.ListAllWithFsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range all {
|
||||
if all[i].FsPath == relDir {
|
||||
return &all[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no node with fs_path=%s", relDir)
|
||||
}
|
||||
|
||||
// findRecordByPath finds a file record by its vault-relative path.
|
||||
// Checks active records first, then trashed.
|
||||
func (w *Watcher) findRecordByPath(relPath string) (*files.Record, error) {
|
||||
all, err := w.files.ListAllVaultWithTrashed()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range all {
|
||||
if all[i].Path == relPath {
|
||||
return &all[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no record for path %s", relPath)
|
||||
}
|
||||
|
||||
func (w *Watcher) logActivity(nodeID, eventType, title, path string) {
|
||||
_ = w.activity.Record(nodeID, activity.TargetFile, "", path, eventType, title, "")
|
||||
}
|
||||
|
||||
func (w *Watcher) updateFileRecord(id string, size int64, sha string) error {
|
||||
_, err := w.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
|
||||
}
|
||||
|
||||
// isHiddenOrMeta returns true for files in .verstak or hidden directories.
|
||||
func isHiddenOrMeta(path string) bool {
|
||||
parts := strings.Split(filepath.Clean(path), string(filepath.Separator))
|
||||
for _, p := range parts {
|
||||
if p == ".verstak" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(p, ".") && p != "." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hashFileFast computes SHA256 without stat (caller already has fi).
|
||||
func hashFileFast(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
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func setupWatcherTest(t *testing.T) (string, *storage.DB, *nodes.Repository, *files.Service, *activity.Service, func()) {
|
||||
t.Helper()
|
||||
|
||||
vaultRoot, err := os.MkdirTemp("", "verstak-watcher-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(vaultRoot, ".verstak")
|
||||
if err := os.MkdirAll(dbDir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := storage.Open(filepath.Join(dbDir, "index.db"))
|
||||
if err != nil {
|
||||
os.RemoveAll(vaultRoot)
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
|
||||
activitySvc := activity.NewService(db)
|
||||
|
||||
cleanup := func() {
|
||||
db.Close()
|
||||
os.RemoveAll(vaultRoot)
|
||||
}
|
||||
|
||||
return vaultRoot, db, nodeRepo, fileSvc, activitySvc, cleanup
|
||||
}
|
||||
|
||||
// tempFileOutsideVault creates a temp file not inside the vault for import.
|
||||
func tempFileOutsideVault(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "verstak-import-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestScanner_NoChanges(t *testing.T) {
|
||||
vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t)
|
||||
defer cleanup()
|
||||
|
||||
node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(vaultRoot, "test-folder")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Import a file into vault (creates record + copies file).
|
||||
src := tempFileOutsideVault(t, "hello world")
|
||||
defer os.Remove(src)
|
||||
_, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc)
|
||||
result, err := scanner.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.MissingFiles != 0 {
|
||||
t.Errorf("expected 0 missing, got %d", result.MissingFiles)
|
||||
}
|
||||
if result.RestoredFiles != 0 {
|
||||
t.Errorf("expected 0 restored, got %d", result.RestoredFiles)
|
||||
}
|
||||
if result.ModifiedFiles != 0 {
|
||||
t.Errorf("expected 0 modified, got %d", result.ModifiedFiles)
|
||||
}
|
||||
if result.NewFiles != 0 {
|
||||
t.Errorf("expected 0 new, got %d", result.NewFiles)
|
||||
}
|
||||
if result.NodesScanned != 1 {
|
||||
t.Errorf("expected 1 node scanned, got %d", result.NodesScanned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_MissingFile(t *testing.T) {
|
||||
vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t)
|
||||
defer cleanup()
|
||||
|
||||
node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(vaultRoot, "test-folder")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Import a file.
|
||||
src := tempFileOutsideVault(t, "bye")
|
||||
defer os.Remove(src)
|
||||
_, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Now remove the physical file from vault.
|
||||
vaultFiles, err := fileSvc.ListByNode(node.ID)
|
||||
if err != nil || len(vaultFiles) == 0 {
|
||||
t.Fatal("no vault files found")
|
||||
}
|
||||
rec := vaultFiles[0]
|
||||
absPath := filepath.Join(vaultRoot, rec.Path)
|
||||
if err := os.Remove(absPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc)
|
||||
result, err := scanner.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.MissingFiles != 1 {
|
||||
t.Errorf("expected 1 missing, got %d", result.MissingFiles)
|
||||
}
|
||||
|
||||
// Verify the file record is now marked missing.
|
||||
trashed, err := fileSvc.ListTrashedByNode(node.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(trashed) != 1 {
|
||||
t.Errorf("expected 1 trashed record, got %d", len(trashed))
|
||||
}
|
||||
if !trashed[0].Missing {
|
||||
t.Error("expected record to be marked missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_RestoredFile(t *testing.T) {
|
||||
vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t)
|
||||
defer cleanup()
|
||||
|
||||
node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(vaultRoot, "test-folder")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Import a file.
|
||||
src := tempFileOutsideVault(t, "back")
|
||||
defer os.Remove(src)
|
||||
rec, err := fileSvc.CopyIntoVault(node.ID, src, "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Mark as missing and remove from disk.
|
||||
if err := fileSvc.MarkMissing(rec.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absPath := filepath.Join(vaultRoot, rec.Path)
|
||||
if err := os.Remove(absPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Re-create the file.
|
||||
if err := os.WriteFile(absPath, []byte("back again"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc)
|
||||
result, err := scanner.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.RestoredFiles != 1 {
|
||||
t.Errorf("expected 1 restored, got %d", result.RestoredFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanner_ModifiedFile(t *testing.T) {
|
||||
vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t)
|
||||
defer cleanup()
|
||||
|
||||
node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(vaultRoot, "test-folder")
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Import a file.
|
||||
src := tempFileOutsideVault(t, "original")
|
||||
defer os.Remove(src)
|
||||
_, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Get the record to know the vault path.
|
||||
vaultFiles, err := fileSvc.ListByNode(node.ID)
|
||||
if err != nil || len(vaultFiles) == 0 {
|
||||
t.Fatal("no vault files found")
|
||||
}
|
||||
rec := vaultFiles[0]
|
||||
absPath := filepath.Join(vaultRoot, rec.Path)
|
||||
|
||||
// Modify the file content.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if err := os.WriteFile(absPath, []byte("modified"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc)
|
||||
result, err := scanner.Run()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result.ModifiedFiles != 1 {
|
||||
t.Errorf("expected 1 modified, got %d", result.ModifiedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHiddenOrMeta(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
{"/vault/.verstak/config.yml", true},
|
||||
{"/vault/.verstak/trash/file.txt", true},
|
||||
{"/vault/my-project/file.txt", false},
|
||||
{"/vault/.hidden/file.txt", true},
|
||||
{"/vault/project/.hidden/file.txt", true},
|
||||
{"/vault/project/file.txt", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := isHiddenOrMeta(tc.path)
|
||||
if got != tc.expected {
|
||||
t.Errorf("isHiddenOrMeta(%q) = %v, want %v", tc.path, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user