package virusscan import ( "context" "crypto/sha256" _ "embed" "encoding/hex" "errors" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/0001_mail_quarantine.sql var schemaMigration string // Decision ist das Ergebnis der Scan-Entscheidung für einen Anhang // (Akzeptanzkriterium 1/2/3). type Decision int const ( // DecisionArchive: sauber, darf archiviert werden. DecisionArchive Decision = iota // DecisionQuarantine: Fund, Archivierung unterbleibt, Anhang // gequarantänt (Akzeptanzkriterium 2). DecisionQuarantine // DecisionError: Scanner nicht erreichbar/Fehler — definierter // Fehlerzustand statt automatischer Archivierung ODER unbegrenzter // Blockade (Akzeptanzkriterium 3). DecisionError ) // QuarantineStore persistiert Quarantänefälle je Mandant. type QuarantineStore struct { pool *pgxpool.Pool } func NewQuarantineStore(pool *pgxpool.Pool) *QuarantineStore { return &QuarantineStore{pool: pool} } // EnsureSchema legt die Tabelle an, falls sie noch nicht existiert. func (s *QuarantineStore) EnsureSchema(ctx context.Context) error { if _, err := s.pool.Exec(ctx, schemaMigration); err != nil { return fmt.Errorf("virusscan: schema anlegen: %w", err) } return nil } func (s *QuarantineStore) record(ctx context.Context, tenantSlug, filename, contentHash, signatureName string) error { if _, err := s.pool.Exec(ctx, ` INSERT INTO mail_quarantine (tenant_slug, filename, content_hash, signature_name) VALUES ($1, $2, $3, $4) `, tenantSlug, filename, contentHash, signatureName); err != nil { return fmt.Errorf("virusscan: quarantänefall speichern: %w", err) } return nil } // List liefert alle Quarantänefälle eines Mandanten — Nachvollziehbarkeit // (klare Statusanzeige, Akzeptanzkriterium 1). func (s *QuarantineStore) List(ctx context.Context, tenantSlug string) ([]QuarantineEntry, error) { rows, err := s.pool.Query(ctx, ` SELECT filename, content_hash, signature_name, quarantined_at FROM mail_quarantine WHERE tenant_slug = $1 ORDER BY quarantined_at DESC `, tenantSlug) if err != nil { return nil, fmt.Errorf("virusscan: quarantänefälle lesen: %w", err) } defer rows.Close() var entries []QuarantineEntry for rows.Next() { var e QuarantineEntry if err := rows.Scan(&e.Filename, &e.ContentHash, &e.SignatureName, &e.QuarantinedAt); err != nil { return nil, fmt.Errorf("virusscan: quarantänezeile lesen: %w", err) } entries = append(entries, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("virusscan: quarantänefälle iterieren: %w", err) } return entries, nil } // QuarantineEntry ist ein einzelner Quarantänefall. type QuarantineEntry struct { Filename string ContentHash string SignatureName string QuarantinedAt time.Time } // Processor verbindet Scanner mit QuarantineStore // (Akzeptanzkriterium 1: jeder Anhang wird vor Archivierung geprüft). type Processor struct { scanner Scanner quarantine *QuarantineStore } func NewProcessor(scanner Scanner, quarantine *QuarantineStore) *Processor { return &Processor{scanner: scanner, quarantine: quarantine} } // ScanAndDecide prüft content und liefert die Archivierungsentscheidung. // Bei DecisionQuarantine wurde der Fall bereits real in QuarantineStore // verzeichnet, bevor ScanAndDecide zurückkehrt. func (p *Processor) ScanAndDecide(ctx context.Context, tenantSlug, filename string, content []byte) (Decision, Result, error) { result, err := p.scanner.Scan(ctx, content) if err != nil { if errors.Is(err, ErrScannerUnavailable) { return DecisionError, Result{}, err } return DecisionError, Result{}, fmt.Errorf("virusscan: scan fehlgeschlagen: %w", err) } if result.Clean { return DecisionArchive, result, nil } hash := sha256.Sum256(content) if err := p.quarantine.record(ctx, tenantSlug, filename, hex.EncodeToString(hash[:]), result.SignatureName); err != nil { return DecisionError, result, err } return DecisionQuarantine, result, nil }