// Package audit provides tamper-evident audit logging with hash chaining package audit import ( "bufio" "encoding/json" "fmt" "os" "sync" "time" "github.com/jfraeys/fetch_ml/internal/fileutil" ) // StateEntry represents a sealed checkpoint entry type StateEntry struct { Seq uint64 `json:"seq"` Hash string `json:"hash"` Timestamp time.Time `json:"ts"` Type string `json:"type"` } // SealedStateManager maintains tamper-evident state checkpoints. // It writes to an append-only chain file and an overwritten current file. // The chain file is fsynced before returning to ensure crash safety. type SealedStateManager struct { chainFile string currentFile string mu sync.Mutex } // NewSealedStateManager creates a new sealed state manager func NewSealedStateManager(chainFile, currentFile string) *SealedStateManager { return &SealedStateManager{ chainFile: chainFile, currentFile: currentFile, } } // Checkpoint writes current state to sealed files. // It writes to the append-only chain file first, fsyncs it, then overwrites the current file. // This ordering ensures crash safety: the chain file is always the source of truth. func (ssm *SealedStateManager) Checkpoint(seq uint64, hash string) error { ssm.mu.Lock() defer ssm.mu.Unlock() entry := StateEntry{ Seq: seq, Hash: hash, Timestamp: time.Now().UTC(), Type: "fsync", } data, err := json.Marshal(entry) if err != nil { return fmt.Errorf("marshal state entry: %w", err) } // Write to append-only chain file first f, err := os.OpenFile(ssm.chainFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return fmt.Errorf("open chain file: %w", err) } if _, err := f.Write(append(data, '\n')); err != nil { if errClose := f.Close(); errClose != nil { return fmt.Errorf("write chain entry: %w, close: %v", err, errClose) } return fmt.Errorf("write chain entry: %w", err) } // CRITICAL: fsync chain before returning — crash safety if err := f.Sync(); err != nil { if errClose := f.Close(); errClose != nil { return fmt.Errorf("sync sealed chain: %w, close: %v", err, errClose) } return fmt.Errorf("sync sealed chain: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("close chain file: %w", err) } // Overwrite current-state file (fast lookup) with crash safety (fsync) if err := fileutil.WriteFileSafe(ssm.currentFile, data, 0o600); err != nil { return fmt.Errorf("write current file: %w", err) } return nil } // RecoverState reads last valid state from sealed files. // It tries the current file first (fast path), then falls back to scanning the chain file. func (ssm *SealedStateManager) RecoverState() (uint64, string, error) { // Try current file first (fast path) data, err := os.ReadFile(ssm.currentFile) if err == nil { var entry StateEntry if json.Unmarshal(data, &entry) == nil { return entry.Seq, entry.Hash, nil } } // Fall back to scanning chain file for last valid entry return ssm.scanChainFileForLastValid() } // scanChainFileForLastValid scans the chain file and returns the last valid entry func (ssm *SealedStateManager) scanChainFileForLastValid() (uint64, string, error) { f, err := os.Open(ssm.chainFile) if err != nil { if os.IsNotExist(err) { return 0, "", nil } return 0, "", fmt.Errorf("open chain file: %w", err) } defer f.Close() var lastEntry StateEntry scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() if line == "" { continue } var entry StateEntry if err := json.Unmarshal([]byte(line), &entry); err != nil { // Corrupted line - log but continue continue } lastEntry = entry } if err := scanner.Err(); err != nil { return 0, "", fmt.Errorf("scan chain file: %w", err) } return lastEntry.Seq, lastEntry.Hash, nil } // VerifyChainIntegrity checks that the chain file is intact and returns the number of valid entries func (ssm *SealedStateManager) VerifyChainIntegrity() (int, error) { f, err := os.Open(ssm.chainFile) if err != nil { if os.IsNotExist(err) { return 0, nil } return 0, fmt.Errorf("open chain file: %w", err) } defer f.Close() validCount := 0 scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if line == "" { continue } var entry StateEntry if err := json.Unmarshal([]byte(line), &entry); err != nil { continue // Skip corrupted lines } validCount++ } if err := scanner.Err(); err != nil { return validCount, fmt.Errorf("scan chain file: %w", err) } return validCount, nil } // Close is a no-op for SealedStateManager (state is written immediately) func (ssm *SealedStateManager) Close() error { return nil }