syncova-backup/packages/repository/immutability_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

454 lines
18 KiB
Go

package repository
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// newHardenedTestRepository legt ein geharztetes Repository an.
func newHardenedTestRepository(testInstance *testing.T, createOptions CreateOptions) *LocalRepository {
testInstance.Helper()
createOptions.Kind = KindLocal
createOptions.CreatedByVersion = "test"
createdRepository, createError := Create(context.Background(), testInstance.TempDir(),
createOptions, newTestLogger())
if createError != nil {
testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError)
}
testInstance.Cleanup(func() { _ = createdRepository.Close() })
// Ohne dieses Aufraeumen scheitert t.TempDir: Geschuetzte Dateien lassen
// sich nicht loeschen — was genau der Zweck der Uebung ist. Der Testlauf
// hinterliesse sonst Verzeichnisse, die auch von Hand kaum wegzubekommen
// sind. Dieselbe Beobachtung gilt im Betrieb: Ein geharztetes Repository
// laesst sich nicht mit rm -rf entfernen.
testInstance.Cleanup(func() { releaseAllImmutableFlagsForTest(testInstance, createdRepository.RootPath()) })
return createdRepository
}
// releaseAllImmutableFlagsForTest hebt jeden Loeschschutz unterhalb eines Pfads auf.
//
// Bewusst nur im Test: Eine Funktion, die jeden Aufbewahrungsschutz eines
// Repositorys in einem Aufruf aufhebt, waere im Produktivcode genau das
// Werkzeug, das ein Angreifer sucht.
func releaseAllImmutableFlagsForTest(testInstance *testing.T, rootPath string) {
testInstance.Helper()
if !immutableFlagSupported() {
return
}
_ = filepath.WalkDir(rootPath, func(entryPath string, directoryEntry fs.DirEntry, walkError error) error {
if walkError != nil || directoryEntry.IsDir() {
return nil
}
_ = setImmutableFlag(entryPath, false)
return nil
})
}
// writeProtectedTestBackup schreibt ein Backup und liefert seine Kennung.
func writeProtectedTestBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string) string {
testInstance.Helper()
writeTestBackup(testInstance, testRepository, backupID, [][]byte{[]byte("geschuetzte nutzdaten")})
return backupID
}
// TestReadOnlyPermissionsDoNotPreventDeletion haelt den zentralen Irrtum fest.
//
// Der urspruengliche geharrtete Modus legte Manifeste mit den Rechten 0400 ab
// und nannte das Loeschschutz. Unter POSIX haengt das Entfernen einer Datei aber
// am Schreibrecht ihres **Verzeichnisses**. Dieser Test steht hier, damit die
// Annahme nie wieder unbemerkt einzieht.
func TestReadOnlyPermissionsDoNotPreventDeletion(testInstance *testing.T) {
temporaryDirectory := testInstance.TempDir()
probePath := filepath.Join(temporaryDirectory, "geschuetzt.json")
if writeError := os.WriteFile(probePath, []byte("inhalt"), 0o400); writeError != nil {
testInstance.Fatalf("die Probedatei liess sich nicht anlegen: %v", writeError)
}
if removeError := os.Remove(probePath); removeError != nil {
testInstance.Fatalf("unerwartet: 0400 verhinderte die Loeschung (%v). "+
"Falls das auf einer Plattform zutrifft, muss die Messung das erkennen — "+
"behaupten darf sie es weiterhin nicht.", removeError)
}
}
// TestMeasureEnforcementReportsWhatItObserved prueft die gemessene Stufe.
func TestMeasureEnforcementReportsWhatItObserved(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "messung", Immutable: true,
})
enforcementReport, measureError := MeasureEnforcement(context.Background(),
createdRepository.RootPath(), newTestLogger())
if measureError != nil {
testInstance.Fatalf("die Messung schlug fehl: %v", measureError)
}
if len(enforcementReport.Observations) != 2 {
testInstance.Fatalf("es wurden %d Beobachtungen gemeldet, erwartet wurden 2",
len(enforcementReport.Observations))
}
// Die erste Beobachtung darf niemals „verhindert" melden — sonst stimmt die
// Messung nicht mit dem ueberein, was das Betriebssystem tut.
if enforcementReport.Observations[0].Prevented {
testInstance.Error("die Messung meldete Loeschschutz durch blosse Dateirechte")
}
// Die Stufe muss zur Beobachtung passen. Behauptet werden darf nur, was
// gemessen wurde.
expectedLevel := EnforcementAdvisory
if enforcementReport.Observations[1].Prevented {
expectedLevel = EnforcementFilesystem
}
if enforcementReport.Level != expectedLevel {
testInstance.Errorf("Stufe war %q, aus der Beobachtung folgt %q",
enforcementReport.Level, expectedLevel)
}
// Die Speicherebene wird nie gemeldet: Sie ist nicht umgesetzt.
if enforcementReport.Level == EnforcementStorage {
testInstance.Error("die Messung meldete eine Durchsetzung durch die Speicherebene, die es nicht gibt")
}
if enforcementReport.Explanation == "" {
testInstance.Error("die Stufe wurde nicht erklaert")
}
}
// TestMeasureEnforcementLeavesNoProbeFiles prueft das Aufraeumen.
//
// Eine liegengebliebene Probedatei mit gesetztem Unveraenderlich-Kennzeichen
// liesse sich von Hand kaum mehr entfernen.
func TestMeasureEnforcementLeavesNoProbeFiles(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "aufraeumen", Immutable: true,
})
if _, measureError := MeasureEnforcement(context.Background(),
createdRepository.RootPath(), newTestLogger()); measureError != nil {
testInstance.Fatalf("die Messung schlug fehl: %v", measureError)
}
metadataEntries, readError := os.ReadDir(filepath.Join(createdRepository.RootPath(), directoryMetadata))
if readError != nil {
testInstance.Fatalf("das Metadatenverzeichnis liess sich nicht lesen: %v", readError)
}
for _, metadataEntry := range metadataEntries {
if strings.HasPrefix(metadataEntry.Name(), probeFilePrefix) {
testInstance.Errorf("die Messung liess %q zurueck", metadataEntry.Name())
}
}
}
// TestHardenedRepositoryUsesConfiguredRetention prueft die konfigurierbare Frist.
func TestHardenedRepositoryUsesConfiguredRetention(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "frist",
Immutable: true,
Retention: 90 * 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "frist-backup")
protectionStatus, statusError := createdRepository.ProtectionStatusOf(context.Background(), backupIdentifier)
if statusError != nil {
testInstance.Fatalf("die Schutzlage liess sich nicht lesen: %v", statusError)
}
if protectionStatus.ImmutableUntil == nil {
testInstance.Fatal("das Backup steht ohne Aufbewahrungsschutz")
}
protectionDuration := time.Until(*protectionStatus.ImmutableUntil)
// Erwartet werden 90 Tage, nicht die fruehere feste Vorgabe von 30.
if protectionDuration < 89*24*time.Hour || protectionDuration > 91*24*time.Hour {
testInstance.Errorf("der Schutz laeuft %s, erwartet wurden rund 90 Tage", protectionDuration)
}
}
// TestRetentionCannotBeShortened prueft die Sperre gegen Verkuerzung.
func TestRetentionCannotBeShortened(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "verkuerzung", Immutable: true, Retention: 30 * 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "verkuerzung-backup")
shorterEnd := time.Now().Add(time.Hour)
if _, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
shorterEnd, "admin", "brauche platz"); !errors.Is(extendError, ErrRetentionCannotBeShortened) {
testInstance.Fatalf("die Verkuerzung haette abgelehnt werden muessen, Fehler war: %v", extendError)
}
// Verlaengern muss dagegen gelingen.
longerEnd := time.Now().Add(365 * 24 * time.Hour)
extendedStatus, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
longerEnd, "admin", "aufbewahrungspflicht")
if extendError != nil {
testInstance.Fatalf("die Verlaengerung schlug fehl: %v", extendError)
}
if !extendedStatus.ExtendedBeyondManifest {
testInstance.Error("die Verlaengerung wurde nicht als solche ausgewiesen")
}
if extendedStatus.ImmutableUntil == nil || extendedStatus.ImmutableUntil.Before(longerEnd.Add(-time.Minute)) {
testInstance.Error("die verlaengerte Frist wurde nicht uebernommen")
}
}
// TestLegalHoldOutlivesRetentionPeriod prueft den unbefristeten Schutz.
//
// Der Fall ist der eigentliche Zweck eines Legal Holds: Die Frist ist abgelaufen,
// das Backup wird trotzdem gebraucht.
func TestLegalHoldOutlivesRetentionPeriod(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "beweis", Immutable: true, Retention: time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "beweis-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Rechtsstreit 2026/117"); holdError != nil {
testInstance.Fatalf("der Legal Hold liess sich nicht setzen: %v", holdError)
}
// Die Zeit wird ueber die Frist hinaus vorgestellt.
createdRepository.timeSource = func() time.Time { return time.Now().Add(48 * time.Hour) }
deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier)
if !errors.Is(deleteError, ErrLegalHold) {
testInstance.Fatalf("die Loeschung haette am Legal Hold scheitern muessen, Fehler war: %v", deleteError)
}
// Nach der Aufhebung — und erst nach Fristablauf — gelingt sie.
if _, releaseError := createdRepository.ReleaseLegalHold(context.Background(), backupIdentifier,
"auditor", "Verfahren abgeschlossen"); releaseError != nil {
testInstance.Fatalf("der Legal Hold liess sich nicht aufheben: %v", releaseError)
}
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError != nil {
testInstance.Fatalf("nach Aufhebung und Fristablauf schlug die Loeschung fehl: %v", deleteError)
}
}
// TestLegalHoldRequiresReason prueft die Begruendungspflicht.
func TestLegalHoldRequiresReason(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "begruendung", Immutable: true,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "begruendung-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"admin", ""); holdError == nil {
testInstance.Fatal("ein Legal Hold ohne Begruendung wurde angenommen")
}
}
// TestRetentionHoldKeepsHistory prueft die Nachvollziehbarkeit im Repository.
//
// Die Historie liegt im Repository und nicht nur in der Datenbank: Wer das
// Repository an einen fremden Server anhaengt, muss erkennen koennen, warum ein
// Backup gehalten wird.
func TestRetentionHoldKeepsHistory(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "historie", Immutable: true, Retention: 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "historie-backup")
if _, extendError := createdRepository.ExtendRetention(context.Background(), backupIdentifier,
time.Now().Add(72*time.Hour), "operator", "Quartalsabschluss"); extendError != nil {
testInstance.Fatalf("die Verlaengerung schlug fehl: %v", extendError)
}
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Pruefung"); holdError != nil {
testInstance.Fatalf("der Legal Hold schlug fehl: %v", holdError)
}
retentionHold, readError := createdRepository.ReadRetentionHold(backupIdentifier)
if readError != nil {
testInstance.Fatalf("der Schutzvermerk liess sich nicht lesen: %v", readError)
}
if len(retentionHold.History) != 2 {
testInstance.Fatalf("die Historie hat %d Einträge, erwartet wurden 2", len(retentionHold.History))
}
if retentionHold.History[0].Actor != "operator" || retentionHold.History[0].Reason != "Quartalsabschluss" {
testInstance.Error("die Verlaengerung wurde nicht mit Verursacher und Grund festgehalten")
}
}
// TestUnreadableHoldKeepsBackupProtected prueft den gefaehrlichsten Fehlerfall.
//
// Ein beschaedigter Schutzvermerk darf niemals als „kein Schutz" gedeutet
// werden. Diese eine Zeile entscheidet, ob eine kaputte Datei ein geschuetztes
// Backup zum Loeschen freigibt.
func TestUnreadableHoldKeepsBackupProtected(testInstance *testing.T) {
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "kaputt", Immutable: true, Retention: time.Minute,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "kaputt-backup")
if _, holdError := createdRepository.PlaceLegalHold(context.Background(), backupIdentifier,
"auditor", "Pruefung"); holdError != nil {
testInstance.Fatalf("der Legal Hold schlug fehl: %v", holdError)
}
holdFilePath := createdRepository.retentionHoldPath(backupIdentifier)
_ = releaseManifestFile(holdFilePath)
if writeError := os.WriteFile(holdFilePath, []byte("{ kein gueltiges json"), 0o600); writeError != nil {
testInstance.Fatalf("der Schutzvermerk liess sich nicht beschaedigen: %v", writeError)
}
createdRepository.timeSource = func() time.Time { return time.Now().Add(24 * time.Hour) }
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError == nil {
testInstance.Fatal("ein Backup mit unlesbarem Schutzvermerk wurde geloescht")
}
}
// TestHardenedRepositorySurvivesRecursiveDelete ist der Angriffstest der Phase.
//
// Er stellt den Fall nach, gegen den Unveraenderlichkeit ueberhaupt existiert:
// Jemand mit Dateizugriff — Ransomware, ein Bedienfehler, ein kompromittierter
// Control Server — versucht, das Repository zu loeschen.
//
// Zwei Funde stammen aus genau diesem Versuch, und beide waren zunaechst
// erfolgreich:
//
// 1. Das Manifest ueberlebte, seine Bloecke nicht. Zurueck blieb ein Backup,
// das sich fuer vollstaendig ausgibt und leer ist.
// 2. Manifest und Bloecke ueberlebten, der Descriptor und der Datenschluessel
// nicht. Die Daten waren da und weder als Repository erkennbar noch
// entschluesselbar.
func TestHardenedRepositorySurvivesRecursiveDelete(testInstance *testing.T) {
if !immutableFlagSupported() {
testInstance.Skip("dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen")
}
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "angriff", Immutable: true, Retention: 30 * 24 * time.Hour,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "angriff-backup")
repositoryRoot := createdRepository.RootPath()
manifestBeforeAttack, manifestError := createdRepository.ReadManifest(context.Background(), backupIdentifier)
if manifestError != nil {
testInstance.Fatalf("das Manifest liess sich vor dem Angriff nicht lesen: %v", manifestError)
}
chunkIdentifiersBeforeAttack := manifestBeforeAttack.UniqueChunkIdentifiers()
if closeError := createdRepository.Close(); closeError != nil {
testInstance.Fatalf("das Repository liess sich nicht schliessen: %v", closeError)
}
// Der Angriff. Dass er einen Fehler meldet, ist genau der Zweck.
if removeError := os.RemoveAll(repositoryRoot); removeError == nil {
testInstance.Fatal("rm -rf auf ein geschuetztes Repository gelang vollstaendig")
}
// Der Descriptor: ohne ihn waeren die Daten nicht mehr als Repository
// erkennbar.
if _, statError := os.Stat(filepath.Join(repositoryRoot, directoryFormat, fileDescriptor)); statError != nil {
testInstance.Error("der Descriptor hat den Angriff nicht ueberlebt")
}
reopenedRepository, openError := Open(context.Background(), repositoryRoot,
OpenOptions{ReadOnly: true}, newTestLogger())
if openError != nil {
testInstance.Fatalf("das Repository liess sich nach dem Angriff nicht oeffnen: %v", openError)
}
testInstance.Cleanup(func() {
_ = reopenedRepository.Close()
releaseAllImmutableFlagsForTest(testInstance, repositoryRoot)
})
manifestAfterAttack, readError := reopenedRepository.ReadManifest(context.Background(), backupIdentifier)
if readError != nil {
testInstance.Fatalf("das Manifest hat den Angriff nicht ueberlebt: %v", readError)
}
if manifestAfterAttack.ContentHash != manifestBeforeAttack.ContentHash {
testInstance.Error("das Manifest wurde veraendert")
}
// Der entscheidende Teil: Ein Manifest ohne seine Bloecke waere ein Backup,
// das sich fuer vollstaendig ausgibt und keines ist.
for chunkIdentifier := range chunkIdentifiersBeforeAttack {
if _, chunkError := reopenedRepository.ReadChunk(context.Background(), chunkIdentifier); chunkError != nil {
testInstance.Errorf("der Block %s hat den Angriff nicht ueberlebt: %v", chunkIdentifier, chunkError)
}
}
}
// TestPruneReleasesProtectedOrphanedChunks prueft die Bereinigung im gehaerteten Betrieb.
//
// Ohne Freigabe des Kennzeichens liefe die Bereinigung in einem gehaerteten
// Repository in einen Fehler — und ein Repository, das nie aufraeumen kann,
// laeuft irgendwann voll.
func TestPruneReleasesProtectedOrphanedChunks(testInstance *testing.T) {
if !immutableFlagSupported() {
testInstance.Skip("dieses Betriebssystem kennt kein Unveraenderlich-Kennzeichen")
}
createdRepository := newHardenedTestRepository(testInstance, CreateOptions{
Name: "bereinigung", Immutable: true, Retention: time.Minute,
})
backupIdentifier := writeProtectedTestBackup(testInstance, createdRepository, "bereinigung-backup")
// Nach Fristablauf wird das Backup geloescht; seine Bloecke sind danach
// verwaist, tragen aber weiterhin das Kennzeichen.
createdRepository.timeSource = func() time.Time { return time.Now().Add(2 * time.Hour) }
if deleteError := createdRepository.DeleteBackup(context.Background(), backupIdentifier); deleteError != nil {
testInstance.Fatalf("die Loeschung nach Fristablauf schlug fehl: %v", deleteError)
}
removedCount, freedBytes, pruneError := createdRepository.PruneOrphanedChunks(context.Background(), false)
if pruneError != nil {
testInstance.Fatalf("die Bereinigung schlug fehl: %v", pruneError)
}
if removedCount == 0 {
testInstance.Error("die Bereinigung entfernte keinen einzigen verwaisten Block")
}
if freedBytes == 0 {
testInstance.Error("die Bereinigung meldete keinen freigegebenen Speicher")
}
}