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") } }