- archive/internal/wormstore.Store: Put schreibt einmalig (chmod 0400 danach, ErrAlreadyExists bei Ueberschreibversuch inkl. DB-seitiger Sperre gegen Wettlaufsituationen), GetVerified prueft SHA-256 bei jedem Zugriff, KEINE Delete-Funktion (strukturelle API-Grenze) - migrations/0007_worm_objects: append-only Metadatentabelle - AC2/Pruefung 3 vor Umsetzung praezisiert: Schutz ueber Go-API, kein absoluter Schutz gegen root (Nutzerentscheidung: kein chattr +i, nicht portabel/nicht ehrlich als absolut behauptbar) - Reflection-Test beweist strukturell: keine Loesch-Methode vorhanden - zusaetzlich echter Nachweis auf 131 als Nicht-Root-Betriebsnutzer (sudo -u nexarch): direkter Ueberschreibversuch scheitert real, Automatiktest selbst laeuft als root und uebersprang diesen Teil bewusst Pruefungen siehe archive/docs/RET-04-PRUEFPROTOKOLL.md
148 lines
5.3 KiB
Go
148 lines
5.3 KiB
Go
// 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
|
|
}
|