// SPDX-License-Identifier: GPL-3.0-or-later package pkgdb import ( "os" "path/filepath" "sync" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) // Manager owns the slow package-database fingerprint outside the polling path. // One check runs at a time; an unavailable pacman database or log fails open // rather than turning an environmental read error into a tamper claim. type Manager struct { Config config.Config Now func() time.Time Fingerprint func(string) (string, error) LastTransaction func(string) (time.Time, bool) DBDir string PacmanLog string mu sync.Mutex initialized bool lastRun time.Time running bool pendingAlerts []model.Alert } func New(cfg config.Config) *Manager { return &Manager{ Config: cfg, Now: time.Now, Fingerprint: Fingerprint, LastTransaction: LastTransactionTime, DBDir: defaultDBDir, PacmanLog: defaultPacmanLog, } } func (m *Manager) anchorPath() string { return filepath.Join(m.Config.LogDir, anchorFilename) } // Initialize records the first available fingerprint synchronously. This is a // one-time baseline action before events are emitted; subsequent checks stay // asynchronous and never rewrite unexplained drift. func (m *Manager) Initialize() error { if !m.Config.PackageDBVerify { return nil } m.mu.Lock() if m.initialized { m.mu.Unlock() return nil } m.mu.Unlock() if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil { return err } if err := m.evaluate(m.Now()); err != nil { return err } m.mu.Lock() m.initialized = true m.mu.Unlock() return nil } // MaybeStart starts a due fingerprint in the background. Setting lastRun // before launching makes a stalled disk scan self-throttling instead of // spawning one goroutine per polling interval. func (m *Manager) MaybeStart(now time.Time) { if !m.Config.PackageDBVerify { return } m.mu.Lock() defer m.mu.Unlock() if !m.initialized || m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.PackageDBInterval) { return } m.running, m.lastRun = true, now go m.run(now) } func (m *Manager) run(now time.Time) { _ = m.evaluate(now) // Collection errors are intentionally fail-open. m.mu.Lock() m.running = false m.mu.Unlock() } func (m *Manager) evaluate(now time.Time) error { current, err := m.Fingerprint(m.DBDir) if err != nil { return nil } anchor, hasAnchor := loadAnchor(m.anchorPath()) transaction, hasTransaction := m.LastTransaction(m.PacmanLog) alert, replacement := check(current, anchor, hasAnchor, transaction, hasTransaction, now) if replacement != nil { if err := saveAnchor(m.anchorPath(), *replacement); err != nil { return err } } if alert != nil { m.mu.Lock() m.pendingAlerts = append(m.pendingAlerts, *alert) m.mu.Unlock() } return nil } // Drain hands completed findings to Agent, which applies the shared cooldown, // snapshot, and event-stream ordering before any finding becomes observable. func (m *Manager) Drain() []model.Alert { m.mu.Lock() defer m.mu.Unlock() alerts := append([]model.Alert(nil), m.pendingAlerts...) m.pendingAlerts = nil return alerts }