package agent import ( "context" "errors" "os" "path/filepath" "runtime" "strings" "syscall" "testing" "time" "github.com/syncova/syncova/packages/repository" ) // TestInterruptedBackupLeavesNoVisibleBackup prüft den Abbruch eines Laufs. // // Das ist der Kern des Commit-Protokolls: Ein unterbrochener Lauf darf keine // Spur hinterlassen, die sich als gültiges Backup ausgibt. Übrig bleiben dürfen // Chunks — sie kosten Platz, aber ein späterer Lauf verwendet sie wieder. Was // niemals übrig bleiben darf, ist ein Manifest. func TestInterruptedBackupLeavesNoVisibleBackup(testInstance *testing.T) { backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) // Der Abbruch erfolgt sofort. Damit trifft er den Lauf mit Sicherheit vor // dem Setzen des Abschlussvermerks. backupContext, cancelBackup := context.WithCancel(context.Background()) cancelBackup() _, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{ BackupID: "abgebrochen", SourcePath: sourcePath, }) if backupError == nil { testInstance.Fatal("ein abgebrochener Lauf muss einen Fehler melden") } if !errors.Is(backupError, context.Canceled) { testInstance.Errorf("der Abbruch wurde nicht als solcher gemeldet: %v", backupError) } sourceRepository := backupRunner.engine.Repository() // Das entscheidende Kriterium: kein lesbares Manifest. if _, readError := sourceRepository.ReadManifest(context.Background(), "abgebrochen"); readError == nil { testInstance.Fatal("der abgebrochene Lauf hinterliess ein sichtbares Manifest") } catalogEntries, listError := sourceRepository.ListBackups(context.Background()) if listError != nil { testInstance.Fatalf("der Katalog konnte nicht gelesen werden: %v", listError) } for _, catalogEntry := range catalogEntries { if catalogEntry.BackupID == "abgebrochen" { testInstance.Fatal("der abgebrochene Lauf erscheint im Katalog") } } } // TestBackupAfterInterruptionSucceeds prüft, dass ein Abbruch das Repository // nicht dauerhaft blockiert. // // Die Schreibsperre eines abgebrochenen Laufs muss fallen. Bliebe sie liegen, // wäre nach jedem Stromausfall ein Eingriff von Hand nötig — und das Backup // fiele genau dann aus, wenn man es am nötigsten hätte. func TestBackupAfterInterruptionSucceeds(testInstance *testing.T) { backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) backupContext, cancelBackup := context.WithCancel(context.Background()) cancelBackup() if _, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{ BackupID: "abgebrochen", SourcePath: sourcePath, }); backupError == nil { testInstance.Fatal("ein abgebrochener Lauf muss einen Fehler melden") } // Derselbe Bestand, neuer Lauf, unbeschädigtes Verhalten. secondResult, secondError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "nach-abbruch", SourcePath: sourcePath, }) if secondError != nil { testInstance.Fatalf("nach einem Abbruch schlug der nächste Lauf fehl: %v", secondError) } if secondResult.IsPartialFailure() { testInstance.Errorf("der Lauf nach dem Abbruch war ein Teilfehler: %v", secondResult.Problems) } } // TestIncrementalBackupRefusesMissingParentChunk prüft die Existenzprüfung // übernommener Blöcke. // // Ein Manifest, das auf fehlende Blöcke verweist, sähe wie ein vollständiges // Backup aus. Der Fehler fiele erst bei der Wiederherstellung auf — also genau // dann, wenn es zu spät ist. func TestIncrementalBackupRefusesMissingParentChunk(testInstance *testing.T) { backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) backupContext := context.Background() if _, fullError := backupRunner.RunBackup(backupContext, BackupRunOptions{ BackupID: "voll-1", SourcePath: sourcePath, }); fullError != nil { testInstance.Fatalf("die Vollsicherung schlug fehl: %v", fullError) } sourceRepository := backupRunner.engine.Repository() parentManifest, readError := sourceRepository.ReadManifest(backupContext, "voll-1") if readError != nil { testInstance.Fatalf("das Manifest konnte nicht gelesen werden: %v", readError) } // Ein Block wird aus der Ablage entfernt — der Fall eines beschädigten oder // versehentlich aufgeräumten Repositorys. removedIdentifier := findFirstChunkIdentifier(testInstance, parentManifest) removeChunkFromDisk(testInstance, sourceRepository.RootPath(), removedIdentifier) _, incrementalError := backupRunner.RunBackup(backupContext, BackupRunOptions{ BackupID: "zusatz-1", SourcePath: sourcePath, Incremental: true, }) if incrementalError == nil { testInstance.Fatal("eine Zusatzsicherung mit fehlendem Elternblock muss fehlschlagen") } if !strings.Contains(incrementalError.Error(), "block") { testInstance.Errorf("die Fehlermeldung benennt die Ursache nicht: %v", incrementalError) } if _, manifestError := sourceRepository.ReadManifest(backupContext, "zusatz-1"); manifestError == nil { testInstance.Fatal("der fehlgeschlagene Lauf hinterliess ein sichtbares Manifest") } } // findFirstChunkIdentifier liefert die Kennung eines beliebigen Blocks. func findFirstChunkIdentifier(testInstance *testing.T, sourceManifest *repository.Manifest) string { testInstance.Helper() for chunkIdentifier := range sourceManifest.UniqueChunkIdentifiers() { return chunkIdentifier } testInstance.Fatal("das Manifest enthält keine Blöcke") return "" } // removeChunkFromDisk löscht einen Block aus der Ablage. func removeChunkFromDisk(testInstance *testing.T, repositoryRoot string, chunkIdentifier string) { testInstance.Helper() // Die Ablage staffelt nach den ersten Zeichen der Kennung, damit ein // Verzeichnis nicht mit Millionen Einträgen überläuft. chunkPath := filepath.Join(repositoryRoot, "chunks", chunkIdentifier[0:2], chunkIdentifier[2:4], chunkIdentifier) if removeError := os.Remove(chunkPath); removeError != nil { testInstance.Fatalf("der Block %s konnte nicht entfernt werden: %v", chunkIdentifier, removeError) } } // TestDiscoveryDoesNotBlockOnNamedPipe prüft den Umgang mit Sonderdateien. // // Eine benannte Pipe zu öffnen blockiert, bis jemand hineinschreibt. Ein Backup, // das das täte, bliebe für immer stehen — ohne Fehlermeldung, ohne Fortschritt. // Deshalb wird sie erfasst, gemeldet und nicht geöffnet. func TestDiscoveryDoesNotBlockOnNamedPipe(testInstance *testing.T) { if runtime.GOOS == "windows" { testInstance.Skip("benannte Pipes verhalten sich unter Windows grundlegend anders") } sourcePath := testInstance.TempDir() if writeError := os.WriteFile(filepath.Join(sourcePath, "gewoehnlich.txt"), []byte("Eine ganz normale Datei."), 0o644); writeError != nil { testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError) } pipePath := filepath.Join(sourcePath, "warteschlange.fifo") if pipeError := syscall.Mkfifo(pipePath, 0o644); pipeError != nil { testInstance.Skipf("eine benannte Pipe liess sich nicht anlegen: %v", pipeError) } backupRunner, _ := newRoundTripEnvironment(testInstance) // Die Zeitgrenze ist der eigentliche Nachweis: ohne sie liefe der Test bei // einem Rückschritt endlos, statt zu scheitern. backupContext, cancelBackup := context.WithTimeout(context.Background(), 30*time.Second) defer cancelBackup() backupResult, backupError := backupRunner.RunBackup(backupContext, BackupRunOptions{ BackupID: "mit-pipe", SourcePath: sourcePath, }) if backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } if backupResult.FilesBackedUp != 1 { testInstance.Errorf("es sollte genau eine gewöhnliche Datei gesichert werden, es waren %d", backupResult.FilesBackedUp) } // Die uebergangene Pipe muss **ausgewiesen** sein — aber sie macht den Lauf // **nicht** zum Teilfehler. // // Diese Unterscheidung wurde in Phase 6 nachgezogen. Zuvor galt jedes // Erfassungsproblem als uebergangenes Objekt, und damit war jede Sicherung // eines Linux-Systems ein Teilfehler: In /var/run und /tmp liegen staendig // Sockets. Nach einer Woche klickt niemand mehr einen Teilfehler an — und // dann faellt auch der echte nicht mehr auf (dieselbe Ueberlegung wie beim // Meldungswesen, Phase 14). // // Eine benannte Pipe *fehlt* nicht im Backup, sie *gehoert* nicht hinein: // Sie hat keinen Inhalt, den man sichern und zurueckschreiben koennte. if backupResult.IsPartialFailure() { testInstance.Error("eine Sonderdatei ohne sicherbaren Inhalt macht den Lauf zum Teilfehler") } if backupResult.SkippedObjectCount() != 1 { testInstance.Errorf("es wurden %d Objekte ohne sicherbaren Inhalt gezaehlt, erwartet wurde eines", backupResult.SkippedObjectCount()) } // Verschwiegen werden darf sie trotzdem nicht: Wer einen Socket im // Quellverzeichnis hat, soll erfahren, dass er nicht im Backup ist. if !strings.Contains(backupResult.Summary(), "ohne sicherbaren Inhalt") { testInstance.Errorf("die Zusammenfassung verschweigt das uebergangene Objekt: %q", backupResult.Summary()) } var pipeReported bool for _, discoveryProblem := range backupResult.Problems { if strings.HasSuffix(discoveryProblem.Path, "warteschlange.fifo") { pipeReported = true } } if !pipeReported { testInstance.Errorf("die übergangene Pipe wurde nicht benannt: %v", backupResult.Problems) } }