package verification import ( "bytes" "context" "io" "log/slog" "os" "path/filepath" "strings" "testing" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/repository" ) // discardLogger unterdrueckt die Protokollausgabe der Tests. func discardLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } // buildTestSecretStore erzeugt einen Schluesselspeicher. func buildTestSecretStore(testInstance *testing.T) crypto.SecretStore { testInstance.Helper() builtStore, storeError := crypto.NewLocalSecretStore( map[string][]byte{"v1": bytes.Repeat([]byte{0x77}, 32)}, "v1") if storeError != nil { testInstance.Fatalf("der Schluesselspeicher liess sich nicht bauen: %v", storeError) } return builtStore } // verificationFixture ist ein Repository mit einem Backup. type verificationFixture struct { // RepositoryPath ist der Pfad des Repositorys. RepositoryPath string // Repository ist das geoeffnete Repository. Repository *repository.LocalRepository // SecretStore entschluesselt die Datenschluessel. SecretStore crypto.SecretStore // BackupID ist die Kennung des Backups. BackupID string // SourceContents sind die gesicherten Inhalte je Pfad. SourceContents map[string][]byte } // buildFixture legt ein Repository mit einem verschluesselten Backup an. func buildFixture(testInstance *testing.T) *verificationFixture { testInstance.Helper() repositoryPath := filepath.Join(testInstance.TempDir(), "repository") createdRepository, createError := repository.Create(context.Background(), repositoryPath, repository.CreateOptions{Name: "Pruefung", CreatedByVersion: "test"}, discardLogger()) if createError != nil { testInstance.Fatalf("das Repository liess sich nicht anlegen: %v", createError) } testInstance.Cleanup(func() { _ = createdRepository.Close() }) secretStore := buildTestSecretStore(testInstance) engine := backupengine.NewEngine(createdRepository, secretStore, discardLogger()) sourceContents := map[string][]byte{ "dokumente/bericht.txt": []byte(strings.Repeat("Ein Bericht. ", 400)), "dokumente/notiz.txt": []byte("Kurze Notiz."), "bilder/foto.bin": buildIncompressibleData(48 << 10), } backupSources := []backupengine.BackupSource{ {Path: "bilder", EntryType: "directory", Mode: "0755"}, {Path: "dokumente", EntryType: "directory", Mode: "0755"}, } for entryPath, entryContent := range sourceContents { backupSources = append(backupSources, backupengine.BackupSource{ Path: entryPath, EntryType: "file", Mode: "0644", Reader: bytes.NewReader(entryContent), }) } if _, backupError := engine.Backup(context.Background(), backupengine.BackupOptions{ BackupID: "pruef-1", BackupType: repository.BackupTypeFull, Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"}, CompressionLevel: backupengine.CompressionOff, EncryptionEnabled: true, }, backupSources); backupError != nil { testInstance.Fatalf("das Backup liess sich nicht anlegen: %v", backupError) } return &verificationFixture{ RepositoryPath: repositoryPath, Repository: createdRepository, SecretStore: secretStore, BackupID: "pruef-1", SourceContents: sourceContents, } } // buildIncompressibleData erzeugt reproduzierbare, kaum komprimierbare Daten. func buildIncompressibleData(byteCount int) []byte { generatedData := make([]byte, byteCount) var generatorState uint64 = 0x9E3779B97F4A7C15 for byteIndex := range generatedData { generatorState ^= generatorState << 13 generatorState ^= generatorState >> 7 generatorState ^= generatorState << 17 generatedData[byteIndex] = byte(generatorState) } return generatedData } // chunkPathOf bildet den Ablagepfad eines Blocks. func chunkPathOf(repositoryPath string, chunkIdentifier string) string { return filepath.Join(repositoryPath, "chunks", chunkIdentifier[0:2], chunkIdentifier[2:4], chunkIdentifier) } // anyChunkIdentifier liefert eine beliebige Blockkennung des Backups. func anyChunkIdentifier(testInstance *testing.T, fixture *verificationFixture) string { testInstance.Helper() backupManifest, readError := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID) if readError != nil { testInstance.Fatalf("das Manifest liess sich nicht lesen: %v", readError) } for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() { return chunkIdentifier } testInstance.Fatal("das Backup enthaelt keine Bloecke") return "" } // findFinding sucht einen Befund anhand seines Codes. func findFinding(report *Report, findingCode string) *Finding { for findingIndex := range report.Findings { if report.Findings[findingIndex].Code == findingCode { return &report.Findings[findingIndex] } } return nil } // TestVerifyIntactBackup prueft den Normalfall. func TestVerifyIntactBackup(testInstance *testing.T) { fixture := buildFixture(testInstance) verifier := NewVerifier(fixture.Repository) report, verifyError := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkIntegrity) if verifyError != nil { testInstance.Fatalf("die Pruefung schlug fehl: %v", verifyError) } if !report.IsClean() { testInstance.Fatalf("ein unversehrtes Backup wurde beanstandet: %+v", report.Findings) } if report.ChunksChecked == 0 { testInstance.Error("es wurden keine Bloecke geprueft; die Pruefung ist ohne Aussage") } if report.BytesRead == 0 { testInstance.Error("es wurden keine Bytes gelesen; die Inhaltspruefung fand nicht statt") } } // TestVerifyDetectsCorruptedChunk ist der wichtigste Test dieser Datei. // // Ein Datentraeger kann Daten still verfaelschen. Nur eine Pruefung, die den // Inhalt liest und neu hasht, deckt das auf. func TestVerifyDetectsCorruptedChunk(testInstance *testing.T) { fixture := buildFixture(testInstance) chunkIdentifier := anyChunkIdentifier(testInstance, fixture) chunkPath := chunkPathOf(fixture.RepositoryPath, chunkIdentifier) originalData, readError := os.ReadFile(chunkPath) if readError != nil { testInstance.Fatalf("der Block liess sich nicht lesen: %v", readError) } // Ein einziges Bit kippen — die leiseste denkbare Beschaedigung. corruptedData := make([]byte, len(originalData)) copy(corruptedData, originalData) corruptedData[len(corruptedData)/2] ^= 0x01 if writeError := os.WriteFile(chunkPath, corruptedData, 0o600); writeError != nil { testInstance.Fatalf("der Block liess sich nicht veraendern: %v", writeError) } verifier := NewVerifier(fixture.Repository) // Die Existenzpruefung merkt nichts — der Block ist ja da. presenceReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence) if !presenceReport.IsClean() { testInstance.Error("die Existenzpruefung meldete einen Fehler, obwohl der Block vorhanden ist") } // Erst die Inhaltspruefung deckt es auf. integrityReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkIntegrity) if !integrityReport.HasCorruption() { testInstance.Fatal("ein gekipptes Bit wurde nicht erkannt") } corruptionFinding := findFinding(integrityReport, "CHUNKS_CORRUPTED") if corruptionFinding == nil { testInstance.Fatalf("die Beschaedigung wurde nicht benannt: %+v", integrityReport.Findings) } // Der Befund muss das betroffene Objekt nennen. if corruptionFinding.Detail == "" { testInstance.Error("der Befund nennt kein betroffenes Objekt") } if !strings.Contains(integrityReport.Summary(), "BESCHAEDIGT") { testInstance.Errorf("die Zusammenfassung beschoenigt den Befund: %q", integrityReport.Summary()) } } // TestVerifyDetectsMissingChunk prueft den fehlenden Block. func TestVerifyDetectsMissingChunk(testInstance *testing.T) { fixture := buildFixture(testInstance) chunkIdentifier := anyChunkIdentifier(testInstance, fixture) if removeError := os.Remove(chunkPathOf(fixture.RepositoryPath, chunkIdentifier)); removeError != nil { testInstance.Fatalf("der Block liess sich nicht entfernen: %v", removeError) } verifier := NewVerifier(fixture.Repository) report, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence) if report.IsClean() { testInstance.Fatal("ein fehlender Block wurde nicht erkannt") } // Fehlend ist nicht beschaedigt: Der Block koennte anderswo noch existieren. if report.HasCorruption() { testInstance.Error("ein fehlender Block wurde als Beschaedigung gemeldet") } if findFinding(report, "CHUNKS_MISSING") == nil { testInstance.Errorf("der fehlende Block wurde nicht benannt: %+v", report.Findings) } } // TestManifestVerificationIsHonestAboutItsLimits prueft die schwache Aussage. // // Die Manifestpruefung stellt fest, dass das Backup abgeschlossen wurde — nicht, // ob die Daten noch da sind. Das muss in der Zusammenfassung stehen. func TestManifestVerificationIsHonestAboutItsLimits(testInstance *testing.T) { fixture := buildFixture(testInstance) // Alle Bloecke entfernen: Das Manifest bleibt stimmig, die Daten sind weg. backupManifest, _ := fixture.Repository.ReadManifest(context.Background(), fixture.BackupID) for chunkIdentifier := range backupManifest.UniqueChunkIdentifiers() { _ = os.Remove(chunkPathOf(fixture.RepositoryPath, chunkIdentifier)) } verifier := NewVerifier(fixture.Repository) manifestReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeManifest) // Die Manifestpruefung findet nichts — sie hat die Bloecke nie angesehen. if !manifestReport.IsClean() { testInstance.Error("die Manifestpruefung beanstandete etwas, das sie nicht geprueft hat") } // Sie sagt aber, dass sie nichts ueber die Daten weiss. if !strings.Contains(manifestReport.Summary(), "nicht geprueft") { testInstance.Fatalf("die Manifestpruefung verschweigt ihre Grenze: %q", manifestReport.Summary()) } // Und die Blockpruefung deckt den Verlust sofort auf. presenceReport, _ := verifier.VerifyBackup(context.Background(), fixture.BackupID, TypeChunkPresence) if presenceReport.IsClean() { testInstance.Error("die Blockpruefung uebersah ein vollstaendig geleertes Repository") } } // TestVerifyChainFindsBrokenParent prueft die Kettenpruefung. func TestVerifyChainFindsBrokenParent(testInstance *testing.T) { fixture := buildFixture(testInstance) engine := backupengine.NewEngine(fixture.Repository, fixture.SecretStore, discardLogger()) // Eine Zusatzsicherung auf das vorhandene Backup. if _, backupError := engine.Backup(context.Background(), backupengine.BackupOptions{ BackupID: "pruef-2", BackupType: repository.BackupTypeIncremental, ParentBackupID: fixture.BackupID, Source: repository.SourceInformation{SourceType: "filesystem", SourceID: "/test"}, CompressionLevel: backupengine.CompressionOff, EncryptionEnabled: true, }, []backupengine.BackupSource{ {Path: "neu.txt", EntryType: "file", Mode: "0644", Reader: bytes.NewReader([]byte("neu"))}, }); backupError != nil { testInstance.Fatalf("die Zusatzsicherung liess sich nicht anlegen: %v", backupError) } verifier := NewVerifier(fixture.Repository) intactReport, _ := verifier.VerifyChain(context.Background(), "pruef-2") if !intactReport.IsClean() { testInstance.Fatalf("eine vollstaendige Kette wurde beanstandet: %+v", intactReport.Findings) } if intactReport.ChainLength != 2 { testInstance.Errorf("die Kette wurde mit %d Backups gezaehlt, erwartet waren 2", intactReport.ChainLength) } // Das Elternbackup verschwindet. if removeError := os.Remove(filepath.Join(fixture.RepositoryPath, "manifests", fixture.BackupID+".manifest.json")); removeError != nil { testInstance.Fatalf("das Elternmanifest liess sich nicht entfernen: %v", removeError) } brokenReport, _ := verifier.VerifyChain(context.Background(), "pruef-2") if brokenReport.IsClean() { testInstance.Fatal("eine unterbrochene Kette wurde nicht erkannt") } if findFinding(brokenReport, "CHAIN_BROKEN") == nil { testInstance.Errorf("die Unterbrechung wurde nicht benannt: %+v", brokenReport.Findings) } } // TestRestoreTestSucceedsOnIntactBackup prueft den Nachweis. func TestRestoreTestSucceedsOnIntactBackup(testInstance *testing.T) { fixture := buildFixture(testInstance) tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger()) workingDirectory := testInstance.TempDir() report, testError := tester.RunTest(context.Background(), RestoreTestOptions{ BackupID: fixture.BackupID, WorkingDirectory: workingDirectory, }) if testError != nil { testInstance.Fatalf("der Test schlug fehl: %v", testError) } if !report.Succeeded() { testInstance.Fatalf("ein unversehrtes Backup bestand den Test nicht: %+v", report.Findings) } if report.FilesCompared != len(fixture.SourceContents) { testInstance.Errorf("es wurden %d Dateien verglichen, gesichert waren %d", report.FilesCompared, len(fixture.SourceContents)) } if report.FilesMismatched != 0 { testInstance.Errorf("%d Dateien wichen ab", report.FilesMismatched) } } // TestRestoreTestCleansUpAfterItself prueft das Aufraeumen. // // Ein Test, der Platz belegt, wird bald abgeschaltet. func TestRestoreTestCleansUpAfterItself(testInstance *testing.T) { fixture := buildFixture(testInstance) tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger()) workingDirectory := testInstance.TempDir() if _, testError := tester.RunTest(context.Background(), RestoreTestOptions{ BackupID: fixture.BackupID, WorkingDirectory: workingDirectory, }); testError != nil { testInstance.Fatalf("der Test schlug fehl: %v", testError) } remainingEntries, readError := os.ReadDir(workingDirectory) if readError != nil { testInstance.Fatalf("das Arbeitsverzeichnis liess sich nicht lesen: %v", readError) } if len(remainingEntries) != 0 { testInstance.Fatalf("der Test hinterliess %d Objekte im Arbeitsverzeichnis", len(remainingEntries)) } } // TestRestoreTestDetectsCorruption ist der Nachweis, dass der Test etwas taugt. // // Er muss aufdecken, was die Blockpruefung erkennt — und mehr: Fehler in // Entschluesselung, Dekompression und Zusammensetzung fallen nur hier auf. func TestRestoreTestDetectsCorruption(testInstance *testing.T) { fixture := buildFixture(testInstance) chunkIdentifier := anyChunkIdentifier(testInstance, fixture) chunkPath := chunkPathOf(fixture.RepositoryPath, chunkIdentifier) originalData, _ := os.ReadFile(chunkPath) corruptedData := make([]byte, len(originalData)) copy(corruptedData, originalData) corruptedData[len(corruptedData)/2] ^= 0xFF if writeError := os.WriteFile(chunkPath, corruptedData, 0o600); writeError != nil { testInstance.Fatalf("der Block liess sich nicht veraendern: %v", writeError) } tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger()) report, testError := tester.RunTest(context.Background(), RestoreTestOptions{ BackupID: fixture.BackupID, WorkingDirectory: testInstance.TempDir(), }) if testError != nil { testInstance.Fatalf("der Test brach ab, statt zu berichten: %v", testError) } if report.Succeeded() { testInstance.Fatal("ein beschaedigtes Backup bestand den Wiederherstellungstest") } } // TestPartialRestoreTestSaysSo prueft die Ehrlichkeit des Teiltests. // // Ein Teiltest belegt nicht die Wiederherstellbarkeit des gesamten Backups. func TestPartialRestoreTestSaysSo(testInstance *testing.T) { fixture := buildFixture(testInstance) tester := NewRestoreTester(fixture.Repository, fixture.SecretStore, discardLogger()) report, testError := tester.RunTest(context.Background(), RestoreTestOptions{ BackupID: fixture.BackupID, PathPrefix: "dokumente", WorkingDirectory: testInstance.TempDir(), }) if testError != nil { testInstance.Fatalf("der Teiltest schlug fehl: %v", testError) } if !report.Succeeded() { testInstance.Fatalf("der Teiltest schlug fehl: %+v", report.Findings) } if !report.WasPartialTest { testInstance.Error("der Teiltest wurde nicht als solcher gefuehrt") } partialFinding := findFinding(&report.Report, "PARTIAL_TEST") if partialFinding == nil { testInstance.Fatalf("der Teiltest wurde nicht ausgewiesen: %+v", report.Findings) } if !strings.Contains(partialFinding.Message, "nicht die Wiederherstellbarkeit des gesamten") { testInstance.Errorf("der Hinweis ist zu schwach: %q", partialFinding.Message) } // Nur die zwei Dateien unter dokumente/ wurden verglichen. if report.FilesCompared != 2 { testInstance.Errorf("es wurden %d Dateien verglichen, erwartet waren 2", report.FilesCompared) } } // TestVerifyUnknownBackupReportsInsteadOfFailing prueft die Fehlerbehandlung. func TestVerifyUnknownBackupReportsInsteadOfFailing(testInstance *testing.T) { fixture := buildFixture(testInstance) verifier := NewVerifier(fixture.Repository) report, verifyError := verifier.VerifyBackup(context.Background(), "gibt-es-nicht", TypeChunkIntegrity) // Ein unbekanntes Backup ist ein Befund, kein Programmfehler. if verifyError != nil { testInstance.Fatalf("die Pruefung brach ab, statt zu berichten: %v", verifyError) } if report.IsClean() { testInstance.Fatal("ein unbekanntes Backup wurde als einwandfrei gemeldet") } if findFinding(report, "MANIFEST_UNREADABLE") == nil { testInstance.Errorf("das fehlende Manifest wurde nicht benannt: %+v", report.Findings) } }