package repository import ( "context" "os" "path/filepath" "sort" "strings" "testing" ) // frozenRepositoryPath ist das eingefrorene Beispiel-Repository. // // Es liegt als echtes Verzeichnis im Quellbestand und wurde mit // Repository-Formatversion 1 angelegt. Der Unterschied zu einem gewöhnlichen // Rundlauftest ist derselbe wie beim Containerformat: Der schreibt und liest // mit demselben Code und bliebe grün, wenn sich beide Seiten gemeinsam ändern. const frozenRepositoryPath = "testdata/frozen-repository" // TestRepositoryLayoutIsFrozen haelt das Repository-Protokoll fest (Phase 22). // // Die Verzeichnis- und Dateinamen sind kein Umsetzungsdetail, sondern der // Vertrag: Ein Repository muss ohne Control Server und ohne Datenbank deutbar // bleiben (`Attach → Discover Format → Scan Manifests → Rebuild Catalog → // Restore`). Wer hier umbenennt, macht bestehende Repositories unauffindbar — // die Daten liegen noch da und niemand findet sie mehr. func TestRepositoryLayoutIsFrozen(testInstance *testing.T) { frozenNames := map[string]string{ "directoryFormat": directoryFormat, "directoryManifests": directoryManifests, "directoryChunks": directoryChunks, "directoryIndexes": directoryIndexes, "directoryJournals": directoryJournals, "directoryVerification": directoryVerification, "directoryMetadata": directoryMetadata, "directoryStaging": directoryStaging, "fileDescriptor": fileDescriptor, "fileCatalog": fileCatalog, "fileLock": fileLock, "manifestExtension": manifestExtension, "journalExtension": journalExtension, "retentionHoldExtension": retentionHoldExtension, } expectedNames := map[string]string{ "directoryFormat": "format", "directoryManifests": "manifests", "directoryChunks": "chunks", "directoryIndexes": "indexes", "directoryJournals": "journals", "directoryVerification": "verification", "directoryMetadata": "metadata", "directoryStaging": "staging", "fileDescriptor": "repository.json", "fileCatalog": "catalog.json", "fileLock": "repository.lock", "manifestExtension": ".manifest.json", "journalExtension": ".journal.json", "retentionHoldExtension": ".hold.json", } for constantName, expectedValue := range expectedNames { if frozenNames[constantName] != expectedValue { testInstance.Errorf("%s heißt %q statt %q.\n"+ " Bestehende Repositories werden damit unauffindbar — die Daten liegen noch da.", constantName, frozenNames[constantName], expectedValue) } } if ManifestVersion != 1 { testInstance.Errorf("die Manifestversion ist %d statt 1", ManifestVersion) } } // TestFrozenRepositoryStaysReadable oeffnet das eingefrorene Repository. // // Der Test geht den Weg, den ein Notfall geht: anhaengen, Format erkennen, // Manifeste lesen, Katalog **ohne Datenbank** neu aufbauen, Blöcke pruefen. Ist // hier etwas kaputt, ist ein bestehendes Backup verloren — auch wenn jeder // andere Test gruen bleibt. func TestFrozenRepositoryStaysReadable(testInstance *testing.T) { repositoryCopy := copyFrozenRepository(testInstance) openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger()) if openError != nil { testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v\n"+ " Erzeugen mit: SYNCOVA_WRITE_FIXTURE=ja go test ./packages/repository "+ "-run TestWriteFrozenRepositoryFixture", openError) } defer func() { _ = openedRepository.Close() }() repositoryDescriptor := openedRepository.Descriptor() if repositoryDescriptor.FormatVersion != 1 { testInstance.Errorf("die Formatversion des Repositorys ist %d statt 1", repositoryDescriptor.FormatVersion) } if repositoryDescriptor.HashAlgorithm != HashAlgorithmSHA256 { testInstance.Errorf("das Hashverfahren ist %q — die Chunk-Kennungen wären damit andere", repositoryDescriptor.HashAlgorithm) } catalogEntries, listError := openedRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("die Backups ließen sich nicht auflisten: %v", listError) } if len(catalogEntries) != 1 { testInstance.Fatalf("es liegen %d Backups im eingefrorenen Repository statt eines", len(catalogEntries)) } storedManifest, manifestError := openedRepository.ReadManifest(context.Background(), catalogEntries[0].BackupID) if manifestError != nil { testInstance.Fatalf("das Manifest ließ sich nicht lesen: %v", manifestError) } if storedManifest.ManifestVersion != 1 { testInstance.Errorf("die Manifestversion ist %d", storedManifest.ManifestVersion) } if len(storedManifest.Entries) == 0 { testInstance.Fatal("das eingefrorene Manifest enthält keine Objekte") } // Jeder Block muss noch da sein und seiner Kennung entsprechen. Ein Manifest // allein belegt nur, dass jemand einmal etwas gesichert hat. for _, chunkReference := range storedManifest.UniqueChunkReferences() { storedChunk, chunkError := openedRepository.ReadStoredChunk(context.Background(), chunkReference.Identifier, chunkReference.StoredDigest) if chunkError != nil { testInstance.Errorf("der Block %s ließ sich nicht lesen: %v", chunkReference.Identifier, chunkError) continue } if len(storedChunk) == 0 { testInstance.Errorf("der Block %s ist leer", chunkReference.Identifier) } } } // TestFrozenRepositoryRebuildsWithoutCatalog prueft die Kernzusage. // // „Bliebe das Repository nutzbar, wenn Control Server und Datenbank ersatzlos // verschwinden?" — hier wird zusaetzlich der Katalog entfernt, also der letzte // Beschleuniger. Was bleibt, sind die Manifeste, und aus ihnen allein muss sich // alles wieder aufbauen lassen. func TestFrozenRepositoryRebuildsWithoutCatalog(testInstance *testing.T) { repositoryCopy := copyFrozenRepository(testInstance) // Das ganze Verzeichnis entfernen, nicht nur die Datei: „Katalog verloren" // heißt nicht immer „Datei gelöscht" (der Fund aus Phase 18). if removeError := os.RemoveAll(filepath.Join(repositoryCopy, directoryIndexes)); removeError != nil { testInstance.Fatalf("der Katalog ließ sich nicht entfernen: %v", removeError) } openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger()) if openError != nil { testInstance.Fatalf("das Repository ließ sich ohne Katalog nicht öffnen: %v", openError) } defer func() { _ = openedRepository.Close() }() rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(context.Background()) if rebuildError != nil { testInstance.Fatalf("der Katalog ließ sich nicht neu aufbauen: %v", rebuildError) } if len(rebuiltCatalog.Entries) != 1 { testInstance.Fatalf("der neu gebaute Katalog enthält %d Backups statt eines", len(rebuiltCatalog.Entries)) } } // TestFrozenRepositoryPassesDeepScan prueft jeden Block gegen seine Pruefsumme. func TestFrozenRepositoryPassesDeepScan(testInstance *testing.T) { repositoryCopy := copyFrozenRepository(testInstance) openedRepository, openError := Open(context.Background(), repositoryCopy, OpenOptions{}, newTestLogger()) if openError != nil { testInstance.Fatalf("das eingefrorene Repository ließ sich nicht öffnen: %v", openError) } defer func() { _ = openedRepository.Close() }() scanReport, scanError := openedRepository.Scan(context.Background(), ScanOptions{VerifyChunkContents: true}) if scanError != nil { testInstance.Fatalf("der Integritätslauf schlug fehl: %v", scanError) } if !scanReport.IsHealthy() { testInstance.Errorf("der Integritätslauf meldet Befunde: %s", scanReport.Summary()) } if scanReport.ChunksChecked != 2 { testInstance.Errorf("es wurden %d Blöcke geprüft statt zwei", scanReport.ChunksChecked) } } // copyFrozenRepository legt eine Arbeitskopie an. // // Das eingefrorene Repository selbst wird nie geöffnet: Ein Öffnen legt eine // Sperrdatei an und schreibt womöglich den Katalog fort — der Vertrag änderte // sich dann durch seine eigene Prüfung. func copyFrozenRepository(testInstance *testing.T) string { testInstance.Helper() if _, statError := os.Stat(frozenRepositoryPath); statError != nil { testInstance.Skipf("das eingefrorene Repository fehlt: %v", statError) } targetRoot := filepath.Join(testInstance.TempDir(), "frozen") copyError := filepath.WalkDir(frozenRepositoryPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error { if walkError != nil { return walkError } relativePath, relativeError := filepath.Rel(frozenRepositoryPath, currentPath) if relativeError != nil { return relativeError } targetPath := filepath.Join(targetRoot, relativePath) if directoryEntry.IsDir() { return os.MkdirAll(targetPath, 0o700) } fileContent, readError := os.ReadFile(currentPath) if readError != nil { return readError } return os.WriteFile(targetPath, fileContent, 0o600) }) if copyError != nil { testInstance.Fatalf("die Arbeitskopie ließ sich nicht anlegen: %v", copyError) } // Leere Verzeichnisse überleben kein Git. Sie werden hier ergänzt, weil ein // Repository sie erwartet — das ist kein Formatverstoß, sondern eine // Eigenheit der Ablage im Quellbestand. for _, requiredDirectory := range allRepositoryDirectories { if makeError := os.MkdirAll(filepath.Join(targetRoot, requiredDirectory), 0o700); makeError != nil { testInstance.Fatalf("das Verzeichnis %s ließ sich nicht anlegen: %v", requiredDirectory, makeError) } } return targetRoot } // TestWriteFrozenRepositoryFixture erzeugt das eingefrorene Repository neu. // // Nur mit ausdruecklichem Schalter — es versehentlich mitlaufen zu lassen hiesse, // den Vertrag jedes Mal auf den aktuellen Stand zu heben und damit genau die // Pruefung abzuschalten, um die es geht. func TestWriteFrozenRepositoryFixture(testInstance *testing.T) { if os.Getenv("SYNCOVA_WRITE_FIXTURE") != "ja" { testInstance.Skip("nur mit SYNCOVA_WRITE_FIXTURE=ja; sonst würde der eingefrorene Vertrag überschrieben") } if removeError := os.RemoveAll(frozenRepositoryPath); removeError != nil { testInstance.Fatalf("das alte Fixture ließ sich nicht entfernen: %v", removeError) } createdRepository, createError := Create(context.Background(), frozenRepositoryPath, CreateOptions{ Name: "Eingefrorenes Beispiel (Phase 22)", CreatedByVersion: "0.1.0-dev", }, newTestLogger()) if createError != nil { testInstance.Fatalf("das Repository ließ sich nicht anlegen: %v", createError) } writeSession, beginError := createdRepository.BeginBackup(context.Background(), "eingefroren-phase-22") if beginError != nil { testInstance.Fatalf("die Schreibsession ließ sich nicht öffnen: %v", beginError) } firstChunk := []byte("erster eingefrorener block der phase 22") secondChunk := []byte("zweiter eingefrorener block der phase 22") firstReference, _, firstError := writeSession.WriteChunk(context.Background(), firstChunk) if firstError != nil { testInstance.Fatalf("der erste Block ließ sich nicht schreiben: %v", firstError) } secondReference, _, secondError := writeSession.WriteChunk(context.Background(), secondChunk) if secondError != nil { testInstance.Fatalf("der zweite Block ließ sich nicht schreiben: %v", secondError) } backupManifest := Manifest{ BackupID: "eingefroren-phase-22", ChainID: "kette-phase-22", BackupType: BackupTypeFull, ConsistencyLevel: ConsistencyCrash, Source: SourceInformation{ SourceType: "filesystem", SourceID: "/daten", SourceName: "Eingefrorene Quelle", }, Entries: []ManifestEntry{ { Path: "daten/erste-datei.txt", EntryType: "file", SizeBytes: int64(len(firstChunk)), Mode: "0644", Chunks: []ChunkReference{firstReference}, }, { Path: "daten/zweite-datei.txt", EntryType: "file", SizeBytes: int64(len(secondChunk)), Mode: "0644", Chunks: []ChunkReference{secondReference}, }, }, CreatedByVersion: "0.1.0-dev", } if commitError := writeSession.Commit(context.Background(), &backupManifest); commitError != nil { testInstance.Fatalf("der Commit schlug fehl: %v", commitError) } if closeError := createdRepository.Close(); closeError != nil { testInstance.Fatalf("das Repository ließ sich nicht schließen: %v", closeError) } // Die Sperrdatei gehört nicht in den Quellbestand: Sie beschreibt einen // laufenden Zugriff, nicht das Format. _ = os.Remove(filepath.Join(frozenRepositoryPath, fileLock)) writtenFiles := listFixtureFiles(testInstance, frozenRepositoryPath) testInstance.Logf("eingefrorenes Repository geschrieben:\n %s", strings.Join(writtenFiles, "\n ")) } // listFixtureFiles zaehlt die geschriebenen Dateien auf. func listFixtureFiles(testInstance *testing.T, rootPath string) []string { testInstance.Helper() collectedFiles := make([]string, 0, 16) _ = filepath.WalkDir(rootPath, func(currentPath string, directoryEntry os.DirEntry, walkError error) error { if walkError != nil || directoryEntry.IsDir() { return nil } relativePath, _ := filepath.Rel(rootPath, currentPath) collectedFiles = append(collectedFiles, relativePath) return nil }) sort.Strings(collectedFiles) return collectedFiles }