// Package wormstore implementiert RET-04: einen Speicherpfad für // archivierte Objektinhalte, der nach dem Schreiben weder überschrieben // noch (über dieses Paket) gelöscht werden kann — Write Once, Read Many. // // Löschung: dieses Paket bietet BEWUSST KEINE Delete-Funktion — das ist // die technische Grenze, die Go-Anwendungscode überhaupt durchsetzen // kann. Direkter Zugriff auf das Dateisystem durch einen Prozess mit // root-Rechten bleibt außerhalb der Kontrolle jeder Anwendung; das ist // ein struktureller Schutz auf Anwendungsebene, kein absoluter Schutz // gegen root-OS-Zugriff (siehe RET-04-Prüfprotokoll). Der kontrollierte // Löschworkflow (RET-03, eigenes Ticket) bekommt bei Bedarf einen // separaten, explizit privilegierten Zugriffspfad — NICHT Teil dieses // Pakets. package wormstore import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrAlreadyExists wird geliefert, wenn unter demselben Schlüssel bereits // ein Objekt geschrieben wurde (Akzeptanzkriterium 1: kein Überschreiben). var ErrAlreadyExists = errors.New("wormstore: objekt existiert bereits, überschreiben nicht erlaubt") // ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht existiert. var ErrNotFound = errors.New("wormstore: objekt nicht gefunden") // ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim Lesen // berechnete Hash nicht mit dem beim Schreiben gespeicherten übereinstimmt // (Akzeptanzkriterium 3: manipulierter Inhalt wird erkannt). var ErrChecksumMismatch = errors.New("wormstore: prüfsumme stimmt nicht überein — inhalt wurde verändert") // Store ist der WORM-Speicher: Dateien im lokalen Dateisystem, Metadaten // (Prüfsumme, Größe) in Postgres — beide append-only. type Store struct { baseDir string pool *pgxpool.Pool } func New(baseDir string, pool *pgxpool.Pool) *Store { return &Store{baseDir: baseDir, pool: pool} } func (s *Store) path(key string) string { return filepath.Join(s.baseDir, filepath.FromSlash(key)) } // Put schreibt r EINMALIG unter key. Existiert key bereits (Datei ODER // DB-Zeile), wird ErrAlreadyExists geliefert — kein Überschreiben, auch // nicht bei gleichem Inhalt. Nach erfolgreichem Schreiben wird die Datei // read-only gesetzt (0400): ein direkter os.OpenFile mit Schreibabsicht // schlägt danach mit "permission denied" fehl (Akzeptanzkriterium 1, // zusätzlich zur DB-seitigen Sperre über den PRIMARY KEY). func (s *Store) Put(ctx context.Context, key string, r io.Reader) (checksum string, size int64, err error) { full := s.path(key) if _, statErr := os.Stat(full); statErr == nil { return "", 0, ErrAlreadyExists } else if !os.IsNotExist(statErr) { return "", 0, fmt.Errorf("wormstore: existenz prüfen: %w", statErr) } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return "", 0, fmt.Errorf("wormstore: verzeichnis anlegen: %w", err) } tmp := full + ".tmp" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return "", 0, fmt.Errorf("wormstore: temporäre datei anlegen: %w", err) } hasher := sha256.New() written, err := io.Copy(io.MultiWriter(f, hasher), r) if err != nil { _ = f.Close() _ = os.Remove(tmp) return "", 0, fmt.Errorf("wormstore: schreiben: %w", err) } if err := f.Close(); err != nil { _ = os.Remove(tmp) return "", 0, fmt.Errorf("wormstore: datei schließen: %w", err) } if err := os.Rename(tmp, full); err != nil { _ = os.Remove(tmp) return "", 0, fmt.Errorf("wormstore: atomar platzieren: %w", err) } // Read-only NACH dem Schließen — verhindert jedes weitere Schreiben // (Akzeptanzkriterium 1), auch durch denselben Prozess. if err := os.Chmod(full, 0o400); err != nil { return "", 0, fmt.Errorf("wormstore: schreibschutz setzen: %w", err) } sum := hex.EncodeToString(hasher.Sum(nil)) tag, err := s.pool.Exec(ctx, ` INSERT INTO worm_objects (storage_key, checksum_sha256, size_bytes) VALUES ($1, $2, $3) ON CONFLICT (storage_key) DO NOTHING `, key, sum, written) if err != nil { return "", 0, fmt.Errorf("wormstore: metadaten speichern: %w", err) } if tag.RowsAffected() == 0 { // Datei wurde gerade erst neu geschrieben (s.o. Stat-Check), aber // die Metadaten-Zeile existierte bereits — Wettlaufsituation // zweier gleichzeitiger Put-Aufrufe. Kein Überschreiben zulassen. return "", 0, ErrAlreadyExists } return sum, written, nil } // GetVerified liefert den Inhalt UND vergleicht bei JEDEM Lesezugriff die // Prüfsumme gegen die beim Schreiben gespeicherte (Akzeptanzkriterium 3). func (s *Store) GetVerified(ctx context.Context, key string) ([]byte, error) { var expectedSum string err := s.pool.QueryRow(ctx, `SELECT checksum_sha256 FROM worm_objects WHERE storage_key = $1`, key).Scan(&expectedSum) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("wormstore: metadaten lesen: %w", err) } data, err := os.ReadFile(s.path(key)) if err != nil { if os.IsNotExist(err) { return nil, ErrNotFound } return nil, fmt.Errorf("wormstore: datei lesen: %w", err) } sum := sha256.Sum256(data) actualSum := hex.EncodeToString(sum[:]) if actualSum != expectedSum { return nil, ErrChecksumMismatch } return data, nil }