ARC-01: objekt-speicher-anbindung-fuer-mails-anhaenge

- mail/internal/storage: LocalDriver/S3Driver (bewaehrtes Muster aus
  DMS FDN-03, bewusste Neuimplementierung - Mail kann DMS nicht
  importieren), ObjectKey mit festem Pfadschema
- Service.Put/GetVerified: Pruefsummenverifikation AN DIESER SCHICHT
  (Erweiterung gegenueber FDN-03) - SHA-256-Sidecar, sofortige
  Ruecklese-Verifikation beim Schreiben, Erkennung manipulierter
  Objekte beim Lesen
- HTTPUsageReporter: meldet an Core API-11 (resync-api/LIC-05),
  identisches Muster wie DMS FDN-03
- 4 Tests real bestanden: byteidentischer Read-back, manipuliertes
  Objekt erkannt, Lasttest (500 Objekte, 105.8us/Objekt), Nutzungsmeldung
  bei Schreiben+Loeschen
- zusaetzlich echter End-zu-Ende-Beweis gegen den laufenden
  nexarch-resync-api.service: reales Service-Credential provisioniert,
  Put->GetVerified->Delete komplett durchlaufen, usage_counters zeigt
  reales +29/-29-Delta (beide Meldungen real angewendet)

Pruefungen siehe mail/docs/ARC-01-PRUEFPROTOKOLL.md
This commit is contained in:
sysops
2026-08-30 23:43:12 +02:00
parent dff6b8b7a4
commit ee98efb51e
9 changed files with 669 additions and 1 deletions
+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
)
// ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim
// Lesen berechnete Hash nicht mit der beim Schreiben gespeicherten
// Prüfsumme übereinstimmt (Akzeptanzkriterium 2 / Pflichtprüfung 2:
// ein absichtlich beschädigtes Objekt wird erkannt).
var ErrChecksumMismatch = errors.New("storage: prüfsumme stimmt nicht überein — objekt wurde verändert")
// Service verbindet einen Driver mit Prüfsummenverifikation
// (Akzeptanzkriterium 2) und der Nutzungsmeldung an Core LIC-05
// (Akzeptanzkriterium 4) — jeder Schreib-/Löschvorgang über Service
// löst GENAU EINE Meldung mit der tatsächlich geschriebenen/gelöschten
// Objektgröße aus. Aufrufer (spätere Tickets, z. B. IMP-*) rufen
// ausschließlich Service auf, nie einen Driver direkt.
type Service struct {
driver Driver
usage UsageReporter
tenantSlug string
}
func NewService(driver Driver, usage UsageReporter, tenantSlug string) *Service {
return &Service{driver: driver, usage: usage, tenantSlug: tenantSlug}
}
// Put legt den Inhalt ab UND verifiziert den Schreibvorgang durch
// Prüfsummenvergleich (Akzeptanzkriterium 2): der Inhalt wird
// geschrieben, die Prüfsumme als Sidecar-Objekt gespeichert, danach
// SOFORT zurückgelesen und erneut gehasht — weicht der Rückgelesene
// Hash vom beim Schreiben berechneten ab, meldet Put einen Fehler,
// statt eine unbemerkt fehlerhafte Ablage stehen zu lassen. Meldet die
// geschriebene Größe als positives Delta an Core LIC-05
// (Akzeptanzkriterium 4).
func (s *Service) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (checksum string, err error) {
hasher := sha256.New()
tee := io.TeeReader(r, hasher)
written, err := s.driver.Put(ctx, key, tee, size, contentType)
if err != nil {
return "", err
}
checksum = hex.EncodeToString(hasher.Sum(nil))
if _, err := s.driver.Put(ctx, checksumKey(key), bytes.NewReader([]byte(checksum)), int64(len(checksum)), "text/plain"); err != nil {
return "", fmt.Errorf("storage: prüfsumme speichern: %w", err)
}
// Sofortige Rücklese-Verifikation — beweist, dass der Schreibvorgang
// tatsächlich verifiziert wurde, nicht nur eine Prüfsumme abgelegt
// wurde, die nie geprüft wird.
if _, err := s.GetVerified(ctx, key); err != nil {
return "", fmt.Errorf("storage: schreibverifikation fehlgeschlagen: %w", err)
}
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, written); err != nil {
return checksum, fmt.Errorf("storage: objekt gespeichert, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return checksum, nil
}
// Get liefert den Inhalt UNVERIFIZIERT (Streaming, für große Objekte).
// Für die Pflichtprüfung "beschädigtes Objekt wird erkannt" GetVerified
// verwenden.
func (s *Service) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.driver.Get(ctx, key)
}
// GetVerified liest den vollständigen Inhalt UND vergleicht die beim
// Schreiben gespeicherte Prüfsumme gegen den beim Lesen berechneten
// Hash (Akzeptanzkriterium 2 / Pflichtprüfung 2).
func (s *Service) GetVerified(ctx context.Context, key string) ([]byte, error) {
sumReader, err := s.driver.Get(ctx, checksumKey(key))
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expectedRaw, err := io.ReadAll(sumReader)
_ = sumReader.Close()
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expected := string(expectedRaw)
contentReader, err := s.driver.Get(ctx, key)
if err != nil {
return nil, err
}
defer func() { _ = contentReader.Close() }()
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(contentReader, hasher))
if err != nil {
return nil, fmt.Errorf("storage: objekt lesen: %w", err)
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expected {
return nil, ErrChecksumMismatch
}
return content, nil
}
// Delete entfernt Inhalt UND Prüfsummen-Sidecar, meldet die Größe als
// negatives Delta an Core LIC-05 (Akzeptanzkriterium 4) — der Aufrufer
// muss die Größe kennen (Delete selbst kann sie nach dem Löschen nicht
// mehr ermitteln).
func (s *Service) Delete(ctx context.Context, key string, sizeBytes int64) error {
if err := s.driver.Delete(ctx, key); err != nil {
return err
}
// Sidecar-Löschung ist best effort — ein fehlendes Sidecar (z. B.
// bei einem sehr alten Objekt) darf den eigentlichen Löschvorgang
// nicht blockieren.
_ = s.driver.Delete(ctx, checksumKey(key))
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, -sizeBytes); err != nil {
return fmt.Errorf("storage: objekt gelöscht, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return nil
}