- dms/ai/workflow: qa0x-Pakete nach internal/gates/<thema> verschoben, Paketname = Thema
- internal/migration{acl,audit,...} -> internal/migration/{acl,audit,...}
- Importpfade, Gate-Skripte und zwei relative Migrationspfade angepasst
- keine Logikaenderung; mail-Gates bewusst unveraendert (pfadabhaengig)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
// Package migrationverify implementiert VAL-02: Stichprobenweiser
|
|
// Hash-Vergleich migrierter Binaerdateien (Objekt-Storage) gegen die
|
|
// Quelle. Source und ObjectStore sind bewusst generische Interfaces statt
|
|
// konkreter Typen aus dms/mail (Go "internal"-Sichtbarkeit verbietet den
|
|
// Import von dms/internal/... bzw. mail/internal/... aus dem Root-Modul
|
|
// heraus) — ein Aufrufer in dms oder mail kann dmscrypto.Service bzw. den
|
|
// jeweiligen storage.Service strukturell als Decryptor/ObjectStore
|
|
// uebergeben, ohne dass dieses Paket davon abhaengt.
|
|
package migrationverify
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
type Sample struct {
|
|
Label string
|
|
SourceObjectKey string
|
|
StorageKey string
|
|
WrappedDEK []byte
|
|
}
|
|
|
|
type ObjectSource interface {
|
|
OpenObject(ctx context.Context, key string) (io.ReadCloser, error)
|
|
}
|
|
|
|
type ObjectStore interface {
|
|
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
|
}
|
|
|
|
// Decryptor entschluesselt ein Objekt, das im Ziel-Storage verschluesselt
|
|
// abgelegt wurde (z.B. dms/internal/crypto.Service.Open), nicht jedes
|
|
// Objekt im Ziel liegt verschluesselt vor (siehe mail: Storage-Service
|
|
// statt encstorage, MAI-02).
|
|
type Decryptor interface {
|
|
Open(ctx context.Context, tenantSlug string, wrappedDEK []byte, ciphertext io.Reader) (io.Reader, error)
|
|
}
|
|
|
|
type FileResult struct {
|
|
Label string
|
|
SourceHash string
|
|
StorageHash string
|
|
Match bool
|
|
Err error
|
|
}
|
|
|
|
func CompareSample(ctx context.Context, tenantSlug string, source ObjectSource, store ObjectStore, decrypt Decryptor, sample Sample) FileResult {
|
|
result := FileResult{Label: sample.Label}
|
|
|
|
srcReader, err := source.OpenObject(ctx, sample.SourceObjectKey)
|
|
if err != nil {
|
|
result.Err = fmt.Errorf("migrationverify: quellobjekt %q oeffnen: %w", sample.Label, err)
|
|
return result
|
|
}
|
|
defer func() { _ = srcReader.Close() }()
|
|
srcHash, err := hashAll(srcReader)
|
|
if err != nil {
|
|
result.Err = fmt.Errorf("migrationverify: quellobjekt %q hashen: %w", sample.Label, err)
|
|
return result
|
|
}
|
|
result.SourceHash = srcHash
|
|
|
|
storedReader, err := store.Get(ctx, sample.StorageKey)
|
|
if err != nil {
|
|
result.Err = fmt.Errorf("migrationverify: ziel-objekt %q lesen: %w", sample.Label, err)
|
|
return result
|
|
}
|
|
defer func() { _ = storedReader.Close() }()
|
|
|
|
var plain io.Reader = storedReader
|
|
if sample.WrappedDEK != nil {
|
|
if decrypt == nil {
|
|
result.Err = fmt.Errorf("migrationverify: objekt %q ist verschluesselt abgelegt, aber kein Decryptor uebergeben", sample.Label)
|
|
return result
|
|
}
|
|
plain, err = decrypt.Open(ctx, tenantSlug, sample.WrappedDEK, storedReader)
|
|
if err != nil {
|
|
result.Err = fmt.Errorf("migrationverify: objekt %q entschluesseln: %w", sample.Label, err)
|
|
return result
|
|
}
|
|
}
|
|
|
|
dstHash, err := hashAll(plain)
|
|
if err != nil {
|
|
result.Err = fmt.Errorf("migrationverify: ziel-objekt %q hashen: %w", sample.Label, err)
|
|
return result
|
|
}
|
|
result.StorageHash = dstHash
|
|
result.Match = srcHash == dstHash
|
|
return result
|
|
}
|
|
|
|
func CompareSamples(ctx context.Context, tenantSlug string, source ObjectSource, store ObjectStore, decrypt Decryptor, samples []Sample) []FileResult {
|
|
results := make([]FileResult, 0, len(samples))
|
|
for _, s := range samples {
|
|
results = append(results, CompareSample(ctx, tenantSlug, source, store, decrypt, s))
|
|
}
|
|
return results
|
|
}
|
|
|
|
func hashAll(r io.Reader) (string, error) {
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, r); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
}
|
|
|
|
// RenderReport erzeugt den in VAL-02 geforderten Hash-Vergleichsbericht je
|
|
// Mandant als Markdown-Tabelle (Akzeptanzkriterium 1).
|
|
func RenderReport(tenantSlug string, results []FileResult) string {
|
|
var mismatches, errored int
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "# Hash-Vergleichsbericht: Mandant %s\n\n", tenantSlug)
|
|
fmt.Fprintf(&b, "| Datei | Quell-Hash (SHA-256) | Ziel-Hash (SHA-256) | Ergebnis |\n|---|---|---|---|\n")
|
|
for _, r := range results {
|
|
status := "OK"
|
|
switch {
|
|
case r.Err != nil:
|
|
status = "FEHLER: " + r.Err.Error()
|
|
errored++
|
|
case !r.Match:
|
|
status = "ABWEICHUNG"
|
|
mismatches++
|
|
}
|
|
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", r.Label, r.SourceHash, r.StorageHash, status)
|
|
}
|
|
fmt.Fprintf(&b, "\nStichprobe: %d Dateien, %d Abweichungen, %d Fehler.\n", len(results), mismatches, errored)
|
|
return b.String()
|
|
}
|