package repository import ( "bytes" "context" "errors" "io" "log/slog" "os" "path/filepath" "testing" "time" ) // newTestLogger liefert einen Logger, der ins Nichts schreibt. func newTestLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } // newTestRepository legt ein Repository in einem temporären Verzeichnis an. // // t.TempDir räumt das Verzeichnis nach dem Test automatisch weg. func newTestRepository(testInstance *testing.T) *LocalRepository { testInstance.Helper() testRepository, createError := Create(context.Background(), testInstance.TempDir(), CreateOptions{ Name: "Test-Repository", Kind: KindLocal, CreatedByVersion: "test", }, newTestLogger()) if createError != nil { testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError) } testInstance.Cleanup(func() { _ = testRepository.Close() }) return testRepository } // writeTestBackup schreibt ein Backup mit den übergebenen Datenblöcken. func writeTestBackup(testInstance *testing.T, testRepository *LocalRepository, backupID string, chunkContents [][]byte) *Manifest { testInstance.Helper() backupWriter, beginError := testRepository.BeginBackup(context.Background(), backupID) if beginError != nil { testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError) } chunkReferences := make([]ChunkReference, 0, len(chunkContents)) var logicalOffset int64 for _, chunkContent := range chunkContents { chunkReference, _, writeError := backupWriter.WriteChunk(context.Background(), chunkContent) if writeError != nil { testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError) } chunkReference.LogicalOffset = logicalOffset logicalOffset += chunkReference.LogicalLength chunkReferences = append(chunkReferences, chunkReference) } manifest := &Manifest{ Source: SourceInformation{ SourceType: "filesystem", SourceID: "quelle-1", SourceName: "Testquelle", }, Entries: []ManifestEntry{{ Path: "/daten/test.bin", EntryType: "file", SizeBytes: logicalOffset, Chunks: chunkReferences, }}, CreatedByVersion: "test", } if commitError := backupWriter.Commit(context.Background(), manifest); commitError != nil { testInstance.Fatalf("das Backup konnte nicht abgeschlossen werden: %v", commitError) } return manifest } // --------------------------------------------------------------------------- // Anlegen und Öffnen // --------------------------------------------------------------------------- func TestCreateProducesSelfDescribingRepository(testInstance *testing.T) { testRepository := newTestRepository(testInstance) // Alle Verzeichnisse des Formats müssen vorliegen. for _, expectedDirectory := range allRepositoryDirectories { directoryPath := filepath.Join(testRepository.RootPath(), expectedDirectory) if _, statError := os.Stat(directoryPath); statError != nil { testInstance.Errorf("das Verzeichnis %q fehlt: %v", expectedDirectory, statError) } } descriptor := testRepository.Descriptor() if descriptor.Identifier != FormatIdentifier { testInstance.Errorf("die Kennung soll %q sein, war %q", FormatIdentifier, descriptor.Identifier) } // Ohne Repository-Kennung liesse sich ein Katalog nicht zuordnen. if descriptor.RepositoryID == "" { testInstance.Error("dem Repository fehlt seine Kennung") } } func TestCreateRefusesToOverwriteExistingRepository(testInstance *testing.T) { repositoryPath := testInstance.TempDir() firstRepository, _ := Create(context.Background(), repositoryPath, CreateOptions{Name: "Erstes"}, newTestLogger()) _ = firstRepository.Close() // Ein bestehendes Repository zu überschreiben wäre vollständiger Datenverlust. _, secondError := Create(context.Background(), repositoryPath, CreateOptions{Name: "Zweites"}, newTestLogger()) if !errors.Is(secondError, ErrRepositoryExists) { testInstance.Fatalf("ein bestehendes Repository darf nicht überschrieben werden, war: %v", secondError) } } func TestOpenRejectsDirectoryWithoutRepository(testInstance *testing.T) { _, openError := Open(context.Background(), testInstance.TempDir(), OpenOptions{}, newTestLogger()) // Ein beliebiges Verzeichnis darf nicht als Repository durchgehen. if !errors.Is(openError, ErrNotARepository) { testInstance.Fatalf("erwartet wurde ErrNotARepository, war: %v", openError) } } func TestOpenRejectsNewerFormatVersion(testInstance *testing.T) { testRepository := newTestRepository(testInstance) repositoryPath := testRepository.RootPath() _ = testRepository.Close() // Der Descriptor wird auf eine künftige Formatversion gesetzt. descriptor := testRepository.Descriptor() descriptor.FormatVersion = FormatVersion + 1 encodedDescriptor, _ := encodeDescriptor(descriptor) descriptorPath := filepath.Join(repositoryPath, directoryFormat, fileDescriptor) if writeError := os.WriteFile(descriptorPath, encodedDescriptor, dataFilePermissions); writeError != nil { testInstance.Fatalf("der Descriptor konnte nicht geändert werden: %v", writeError) } // Eine ältere Programmversion darf ein neueres Format nicht anfassen. _, openError := Open(context.Background(), repositoryPath, OpenOptions{}, newTestLogger()) if !errors.Is(openError, ErrUnsupportedFormat) { testInstance.Fatalf("ein neueres Format muss abgelehnt werden, war: %v", openError) } } // --------------------------------------------------------------------------- // Chunks // --------------------------------------------------------------------------- func TestWriteAndReadChunk(testInstance *testing.T) { testRepository := newTestRepository(testInstance) originalData := []byte("Dies sind Testdaten für einen Chunk.") chunkIdentifier, wasNew, writeError := testRepository.writeChunk(context.Background(), originalData) if writeError != nil { testInstance.Fatalf("der Chunk konnte nicht geschrieben werden: %v", writeError) } if !wasNew { testInstance.Error("der erste Chunk soll als neu gelten") } readData, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier) if readError != nil { testInstance.Fatalf("der Chunk konnte nicht gelesen werden: %v", readError) } if !bytes.Equal(readData, originalData) { testInstance.Fatalf("der gelesene Inhalt weicht ab: %q", readData) } } func TestIdenticalContentIsStoredOnlyOnce(testInstance *testing.T) { testRepository := newTestRepository(testInstance) sameData := []byte("Derselbe Inhalt zweimal geschrieben.") firstIdentifier, firstWasNew, _ := testRepository.writeChunk(context.Background(), sameData) secondIdentifier, secondWasNew, _ := testRepository.writeChunk(context.Background(), sameData) // Inhaltsadressierung bedeutet: gleicher Inhalt, gleiche Kennung. if firstIdentifier != secondIdentifier { testInstance.Fatal("gleicher Inhalt muss dieselbe Kennung ergeben") } if !firstWasNew { testInstance.Error("der erste Schreibvorgang soll als neu gelten") } // Der zweite Vorgang darf nichts erneut schreiben - das ist die Deduplizierung. if secondWasNew { testInstance.Error("der zweite Schreibvorgang darf nicht als neu gelten") } } func TestReadChunkDetectsCorruption(testInstance *testing.T) { testRepository := newTestRepository(testInstance) originalData := []byte("Dieser Inhalt wird gleich manipuliert.") chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData) // Ein einzelnes verändertes Byte simuliert einen Bitfehler auf dem Datenträger. chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier) if chmodError := os.Chmod(chunkFilePath, 0o600); chmodError != nil { testInstance.Fatalf("die Dateirechte konnten nicht geändert werden: %v", chmodError) } corruptedData := bytes.Clone(originalData) corruptedData[0] ^= 0xff if writeError := os.WriteFile(chunkFilePath, corruptedData, 0o600); writeError != nil { testInstance.Fatalf("der Chunk konnte nicht manipuliert werden: %v", writeError) } // Ein beschädigter Chunk darf niemals unbemerkt ausgeliefert werden. _, readError := testRepository.ReadChunk(context.Background(), chunkIdentifier) if !errors.Is(readError, ErrChunkCorrupted) { testInstance.Fatalf("die Beschädigung muss erkannt werden, war: %v", readError) } } func TestReadChunkReportsMissingChunk(testInstance *testing.T) { testRepository := newTestRepository(testInstance) // Eine wohlgeformte, aber nicht vorhandene Kennung. missingIdentifier := "0000000000000000000000000000000000000000000000000000000000000000" _, readError := testRepository.ReadChunk(context.Background(), missingIdentifier) if !errors.Is(readError, ErrChunkNotFound) { testInstance.Fatalf("ein fehlender Chunk muss gemeldet werden, war: %v", readError) } } func TestChunkIdentifierIsValidatedAgainstPathTraversal(testInstance *testing.T) { testRepository := newTestRepository(testInstance) maliciousIdentifiers := []string{ "../../../etc/passwd", "..", "/absolute/pfad", "kurz", "GROSSBUCHSTABEN000000000000000000000000000000000000000000000000", "zzzz000000000000000000000000000000000000000000000000000000000000", } for _, maliciousIdentifier := range maliciousIdentifiers { // Eine manipulierte Kennung darf niemals aus dem Repository herausführen. if _, pathError := testRepository.chunkPath(maliciousIdentifier); pathError == nil { testInstance.Errorf("die Kennung %q wurde akzeptiert", maliciousIdentifier) } } } func TestOpenChunkVerifiesWhileStreaming(testInstance *testing.T) { testRepository := newTestRepository(testInstance) originalData := bytes.Repeat([]byte("Streaming-Daten. "), 1000) chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData) chunkStream, openError := testRepository.OpenChunk(context.Background(), chunkIdentifier) if openError != nil { testInstance.Fatalf("der Chunk konnte nicht geöffnet werden: %v", openError) } defer func() { _ = chunkStream.Close() }() streamedData, readError := io.ReadAll(chunkStream) if readError != nil { testInstance.Fatalf("der Datenstrom konnte nicht gelesen werden: %v", readError) } if !bytes.Equal(streamedData, originalData) { testInstance.Fatal("der gestreamte Inhalt weicht ab") } } func TestOpenChunkDetectsCorruptionAtEndOfStream(testInstance *testing.T) { testRepository := newTestRepository(testInstance) originalData := bytes.Repeat([]byte("A"), 4096) chunkIdentifier, _, _ := testRepository.writeChunk(context.Background(), originalData) chunkFilePath, _ := testRepository.chunkPath(chunkIdentifier) _ = os.Chmod(chunkFilePath, 0o600) corruptedData := bytes.Clone(originalData) corruptedData[2048] = 'B' _ = os.WriteFile(chunkFilePath, corruptedData, 0o600) chunkStream, _ := testRepository.OpenChunk(context.Background(), chunkIdentifier) defer func() { _ = chunkStream.Close() }() // Beim Streamen fällt die Beschädigung spätestens am Ende auf. _, readError := io.ReadAll(chunkStream) if !errors.Is(readError, ErrChunkCorrupted) { testInstance.Fatalf("die Beschädigung muss beim Streamen auffallen, war: %v", readError) } } // --------------------------------------------------------------------------- // Commit-Protokoll // --------------------------------------------------------------------------- func TestCommitMakesBackupVisible(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{ []byte("Erster Block"), []byte("Zweiter Block"), }) loadedManifest, readError := testRepository.ReadManifest(context.Background(), "backup-1") if readError != nil { testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError) } if !loadedManifest.Complete { testInstance.Error("das Manifest muss als abgeschlossen gekennzeichnet sein") } if loadedManifest.Statistics.ChunksWritten != 2 { testInstance.Errorf("es sollen 2 Chunks vermerkt sein, waren %d", loadedManifest.Statistics.ChunksWritten) } } func TestAbortLeavesNoVisibleBackup(testInstance *testing.T) { testRepository := newTestRepository(testInstance) backupWriter, beginError := testRepository.BeginBackup(context.Background(), "abgebrochen") if beginError != nil { testInstance.Fatalf("die Schreibsession konnte nicht geöffnet werden: %v", beginError) } _, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten eines abgebrochenen Laufs")) if abortError := backupWriter.Abort(context.Background()); abortError != nil { testInstance.Fatalf("der Abbruch schlug fehl: %v", abortError) } // Ohne Commit darf kein Backup sichtbar sein - ein halbes Backup wäre // schlimmer als gar keines. _, readError := testRepository.ReadManifest(context.Background(), "abgebrochen") if !errors.Is(readError, ErrBackupNotFound) { testInstance.Fatalf("ein abgebrochener Lauf darf kein Backup hinterlassen, war: %v", readError) } } func TestManifestWithoutCompletionMarkerIsRejected(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Daten")}) // Der Abschlussvermerk wird entfernt. manifestFilePath := testRepository.manifestPath("unvollstaendig") rawManifest, _ := os.ReadFile(manifestFilePath) manifest, _ := decodeManifest(rawManifest) manifest.Complete = false tamperedManifest, _ := encodeManifest(manifest) _ = os.Chmod(manifestFilePath, 0o600) _ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600) // Ohne Abschlussvermerk gilt das Backup als unvollständig. _, readError := testRepository.ReadManifest(context.Background(), "unvollstaendig") if !errors.Is(readError, ErrBackupIncomplete) { testInstance.Fatalf("ein Backup ohne Abschlussvermerk muss abgelehnt werden, war: %v", readError) } } func TestTamperedManifestIsDetected(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "manipuliert", [][]byte{[]byte("Daten")}) manifestFilePath := testRepository.manifestPath("manipuliert") rawManifest, _ := os.ReadFile(manifestFilePath) manifest, _ := decodeManifest(rawManifest) // Die Kennzahl wird verfälscht, die Prüfsumme bleibt die alte. manifest.Statistics.LogicalBytes = 999999 tamperedManifest, _ := encodeManifest(manifest) _ = os.Chmod(manifestFilePath, 0o600) _ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600) _, readError := testRepository.ReadManifest(context.Background(), "manipuliert") if !errors.Is(readError, ErrManifestCorrupted) { testInstance.Fatalf("ein verfälschtes Manifest muss erkannt werden, war: %v", readError) } } func TestExistingBackupIsNotOverwritten(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Original")}) // Ein zweites Backup gleicher Kennung würde das erste vernichten. _, beginError := testRepository.BeginBackup(context.Background(), "backup-1") if beginError == nil { testInstance.Fatal("eine bereits vergebene Backup-Kennung muss abgelehnt werden") } } func TestWriteAfterCommitIsRejected(testInstance *testing.T) { testRepository := newTestRepository(testInstance) backupWriter, _ := testRepository.BeginBackup(context.Background(), "geschlossen") _, _, _ = backupWriter.WriteChunk(context.Background(), []byte("Daten")) _ = backupWriter.Commit(context.Background(), &Manifest{Source: SourceInformation{SourceType: "filesystem"}}) // Nach dem Abschluss darf nichts mehr hinzukommen: das Manifest ist versiegelt. _, _, writeError := backupWriter.WriteChunk(context.Background(), []byte("Nachzügler")) if !errors.Is(writeError, ErrSessionClosed) { testInstance.Fatalf("ein Schreibvorgang nach dem Abschluss muss abgelehnt werden, war: %v", writeError) } } func TestDeduplicationAcrossBackups(testInstance *testing.T) { testRepository := newTestRepository(testInstance) sharedContent := []byte("Dieser Block kommt in beiden Backups vor.") writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{sharedContent, []byte("Nur im ersten")}) backupWriter, _ := testRepository.BeginBackup(context.Background(), "backup-2") _, firstWasNew, _ := backupWriter.WriteChunk(context.Background(), sharedContent) _, secondWasNew, _ := backupWriter.WriteChunk(context.Background(), []byte("Nur im zweiten")) // Der gemeinsame Block darf nicht erneut abgelegt werden. if firstWasNew { testInstance.Error("der bereits vorhandene Block darf nicht erneut geschrieben werden") } if !secondWasNew { testInstance.Error("der neue Block muss geschrieben werden") } sessionStatistics := backupWriter.Statistics() if sessionStatistics.ChunksDeduplicated != 1 { testInstance.Errorf("es soll 1 Chunk dedupliziert sein, waren %d", sessionStatistics.ChunksDeduplicated) } _ = backupWriter.Abort(context.Background()) } // --------------------------------------------------------------------------- // Katalog // --------------------------------------------------------------------------- func TestCatalogListsCommittedBackups(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")}) writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")}) catalogEntries, listError := testRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("der Katalog konnte nicht gelesen werden: %v", listError) } if len(catalogEntries) != 2 { testInstance.Fatalf("es sollen 2 Backups im Katalog stehen, waren %d", len(catalogEntries)) } } func TestRebuildCatalogWithoutAnyDatabase(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")}) writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")}) writeTestBackup(testInstance, testRepository, "backup-3", [][]byte{[]byte("Drei")}) // Der Katalog wird vollständig gelöscht - wie nach dem Verlust des Control Servers. if removeError := os.Remove(testRepository.catalogPath()); removeError != nil { testInstance.Fatalf("der Katalog konnte nicht gelöscht werden: %v", removeError) } rebuiltCatalog, rebuildError := testRepository.RebuildCatalog(context.Background()) if rebuildError != nil { testInstance.Fatalf("der Katalog konnte nicht wiederaufgebaut werden: %v", rebuildError) } if len(rebuiltCatalog.Entries) != 3 { testInstance.Fatalf("der wiederaufgebaute Katalog soll 3 Backups enthalten, waren %d", len(rebuiltCatalog.Entries)) } if !rebuiltCatalog.RebuiltFromManifests { testInstance.Error("der Katalog soll als wiederaufgebaut gekennzeichnet sein") } } func TestCorruptedCatalogIsRebuiltAutomatically(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")}) // Der Katalog wird durch Unsinn ersetzt. if writeError := os.WriteFile(testRepository.catalogPath(), []byte("kein gültiges JSON"), 0o600); writeError != nil { testInstance.Fatalf("der Katalog konnte nicht manipuliert werden: %v", writeError) } // Ein unlesbarer Katalog ist kein Datenverlust: er wird neu erzeugt. catalogEntries, listError := testRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError) } if len(catalogEntries) != 1 { testInstance.Fatalf("es soll 1 Backup gefunden werden, waren %d", len(catalogEntries)) } } func TestRebuildSkipsIncompleteBackups(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "vollstaendig", [][]byte{[]byte("Gut")}) writeTestBackup(testInstance, testRepository, "unvollstaendig", [][]byte{[]byte("Schlecht")}) // Dem zweiten Backup wird der Abschlussvermerk entzogen. manifestFilePath := testRepository.manifestPath("unvollstaendig") rawManifest, _ := os.ReadFile(manifestFilePath) manifest, _ := decodeManifest(rawManifest) manifest.Complete = false tamperedManifest, _ := encodeManifest(manifest) _ = os.Chmod(manifestFilePath, 0o600) _ = os.WriteFile(manifestFilePath, tamperedManifest, 0o600) rebuiltCatalog, _ := testRepository.RebuildCatalog(context.Background()) // Ein unvollständiges Backup darf nicht als wiederherstellbar erscheinen. if len(rebuiltCatalog.Entries) != 1 { testInstance.Fatalf("nur das vollständige Backup gehört in den Katalog, es waren %d", len(rebuiltCatalog.Entries)) } if rebuiltCatalog.Entries[0].BackupID != "vollstaendig" { testInstance.Errorf("das falsche Backup wurde aufgenommen: %s", rebuiltCatalog.Entries[0].BackupID) } } func TestCatalogOfForeignRepositoryIsRejected(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")}) // Ein Katalog mit fremder Repository-Kennung wird untergeschoben. foreignCatalog := &Catalog{ CatalogVersion: CatalogVersion, RepositoryID: "ein-fremdes-repository", Entries: []CatalogEntry{ {BackupID: "erfundenes-backup"}, }, GeneratedAt: time.Now().UTC(), } encodedCatalog, _ := encodeCatalog(foreignCatalog) _ = os.WriteFile(testRepository.catalogPath(), encodedCatalog, 0o600) catalogEntries, _ := testRepository.ListBackups(context.Background()) // Der fremde Katalog darf nicht übernommen werden. if len(catalogEntries) != 1 || catalogEntries[0].BackupID != "backup-1" { testInstance.Fatalf("der fremde Katalog wurde übernommen: %+v", catalogEntries) } } // TestRebuildAfterCatalogDirectoryIsGone prueft den Verlust des ganzen Verzeichnisses. // // „Katalog verloren" (Szenario C der Phase 18) heisst nicht immer „Datei // geloescht": Bei einem Teilausfall des Dateisystems oder nach einem beherzten // Aufraeumen fehlt das ganze Verzeichnis. Bis zum Nachweis der Phase 18 // scheiterte ausgerechnet der Neuaufbau, der den Verlust beheben soll — mit // einer Meldung ueber eine temporaere Datei, die nichts ueber das eigentliche // Problem sagte. // // Die bestehenden Tests loeschten stets nur die Katalogdatei und blieben // deshalb gruen. func TestRebuildAfterCatalogDirectoryIsGone(testInstance *testing.T) { testRepository := newTestRepository(testInstance) writeTestBackup(testInstance, testRepository, "backup-1", [][]byte{[]byte("Eins")}) writeTestBackup(testInstance, testRepository, "backup-2", [][]byte{[]byte("Zwei")}) // Nicht nur die Datei — das ganze Verzeichnis. if removeError := os.RemoveAll(filepath.Dir(testRepository.catalogPath())); removeError != nil { testInstance.Fatalf("das Katalogverzeichnis konnte nicht gelöscht werden: %v", removeError) } catalogEntries, listError := testRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("der Katalog wurde nicht wiederaufgebaut: %v", listError) } if len(catalogEntries) != 2 { testInstance.Fatalf("es sollen 2 Backups gefunden werden, waren %d", len(catalogEntries)) } // Und er muss danach wieder auf der Platte liegen: Ein Katalog, der bei // jedem Zugriff neu entsteht, macht aus einem Beschleuniger eine Bremse. if _, statError := os.Stat(testRepository.catalogPath()); statError != nil { testInstance.Errorf("der Katalog wurde nicht wieder abgelegt: %v", statError) } }