package agent import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "io" "log/slog" "os" "path/filepath" "runtime" "sort" "strings" "testing" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/repository" ) // newRoundTripEnvironment baut Repository, Engine und Läufer für einen Test. func newRoundTripEnvironment(testInstance *testing.T) (*BackupRunner, *RestoreRunner) { testInstance.Helper() testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil)) testRepository, createError := repository.Create(context.Background(), testInstance.TempDir(), repository.CreateOptions{Name: "Rundreise", CreatedByVersion: "test"}, testLogger) if createError != nil { testInstance.Fatalf("das Repository konnte nicht angelegt werden: %v", createError) } testInstance.Cleanup(func() { _ = testRepository.Close() }) secretStore, storeError := crypto.NewLocalSecretStore( map[string][]byte{"v1": bytes.Repeat([]byte{0x33}, 32)}, "v1") if storeError != nil { testInstance.Fatalf("der Secret Store konnte nicht erstellt werden: %v", storeError) } engine := backupengine.NewEngine(testRepository, secretStore, testLogger) return NewBackupRunner(engine, testLogger), NewRestoreRunner(engine, testLogger) } // buildRealisticTree legt einen Verzeichnisbaum mit gemischtem Inhalt an. func buildRealisticTree(testInstance *testing.T) string { testInstance.Helper() rootPath := testInstance.TempDir() fileContents := map[string]string{ "dokumente/bericht.txt": strings.Repeat("Ein Bericht mit wiederholtem Inhalt. ", 500), "dokumente/notiz.txt": "Eine kurze Notiz.", "dokumente/unterordner/tief.md": "# Überschrift\n\nInhalt in der Tiefe.", "bilder/foto.bin": string(buildIncompressibleData(200 * 1024)), "leer.txt": "", } for relativePath, fileContent := range fileContents { fullPath := filepath.Join(rootPath, relativePath) if directoryError := os.MkdirAll(filepath.Dir(fullPath), 0o755); directoryError != nil { testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError) } if writeError := os.WriteFile(fullPath, []byte(fileContent), 0o644); writeError != nil { testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError) } } // Eine Datei mit abweichenden Rechten belegt, dass diese erhalten bleiben. if chmodError := os.Chmod(filepath.Join(rootPath, "dokumente/notiz.txt"), 0o600); chmodError != nil { testInstance.Fatalf("die Rechte konnten nicht gesetzt werden: %v", chmodError) } // Ein leeres Verzeichnis geht ohne eigenen Manifesteintrag verloren. if directoryError := os.MkdirAll(filepath.Join(rootPath, "leerer-ordner"), 0o755); directoryError != nil { testInstance.Fatalf("das Verzeichnis konnte nicht angelegt werden: %v", directoryError) } if runtime.GOOS != "windows" { if linkError := os.Symlink("../dokumente/notiz.txt", filepath.Join(rootPath, "bilder", "verweis.txt")); linkError != nil { testInstance.Fatalf("der Verweis konnte nicht angelegt werden: %v", linkError) } } return rootPath } // buildIncompressibleData erzeugt reproduzierbare, kaum komprimierbare Daten. func buildIncompressibleData(byteCount int) []byte { generatedData := make([]byte, byteCount) var generatorState uint64 = 0x2545F4914F6CDD1D for byteIndex := range generatedData { generatorState ^= generatorState << 13 generatorState ^= generatorState >> 7 generatorState ^= generatorState << 17 generatedData[byteIndex] = byte(generatorState) } return generatedData } // treeFingerprint bildet eine Prüfsumme über Struktur, Inhalt und Rechte. // // Sie ist der eigentliche Nachweis: nur wenn zwei Bäume denselben Fingerabdruck // ergeben, wurde tatsächlich alles zurückgewonnen. func treeFingerprint(testInstance *testing.T, rootPath string, includePermissions bool) string { testInstance.Helper() var collectedLines []string walkError := filepath.Walk(rootPath, func(currentPath string, entryInformation os.FileInfo, walkError error) error { if walkError != nil { return walkError } relativePath, relativeError := filepath.Rel(rootPath, currentPath) if relativeError != nil { return relativeError } if relativePath == "." { return nil } relativePath = filepath.ToSlash(relativePath) // Lstat statt Stat: ein symbolischer Verweis soll als solcher erscheinen, // nicht als sein Ziel. linkInformation, statError := os.Lstat(currentPath) if statError != nil { return statError } permissionPart := "" if includePermissions { permissionPart = entryInformation.Mode().Perm().String() } switch { case linkInformation.Mode()&os.ModeSymlink != 0: linkTarget, _ := os.Readlink(currentPath) collectedLines = append(collectedLines, "symlink "+relativePath+" -> "+linkTarget) case entryInformation.IsDir(): collectedLines = append(collectedLines, "dir "+relativePath+" "+permissionPart) default: fileContent, readError := os.ReadFile(currentPath) if readError != nil { return readError } contentDigest := sha256.Sum256(fileContent) collectedLines = append(collectedLines, "file "+relativePath+" "+permissionPart+" "+hex.EncodeToString(contentDigest[:])) } return nil }) if walkError != nil { testInstance.Fatalf("der Baum konnte nicht gelesen werden: %v", walkError) } sort.Strings(collectedLines) overallDigest := sha256.Sum256([]byte(strings.Join(collectedLines, "\n"))) return hex.EncodeToString(overallDigest[:]) } // --------------------------------------------------------------------------- // Die vertikale Scheibe // --------------------------------------------------------------------------- func TestBackupAndRestoreWholeTree(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) originalFingerprint := treeFingerprint(testInstance, sourcePath, true) backupResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "rundreise", SourcePath: sourcePath, SourceName: "Testquelle", CompressionLevel: backupengine.CompressionBalanced, EncryptionEnabled: true, CreatedByVersion: "test", }) if backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } // Ohne Probleme muss der Lauf als voller Erfolg gelten. if backupResult.IsPartialFailure() { testInstance.Fatalf("die Sicherung meldete Probleme: %+v", backupResult.Problems) } if backupResult.FilesBackedUp != 5 { testInstance.Errorf("es sollen 5 Dateien gesichert sein, waren %d", backupResult.FilesBackedUp) } // Das leere Verzeichnis muss mitgesichert sein, sonst ginge es verloren. if backupResult.DirectoriesRecorded < 4 { testInstance.Errorf("es sollen mindestens 4 Verzeichnisse vermerkt sein, waren %d", backupResult.DirectoriesRecorded) } // Die Wiederherstellung erfolgt an einen völlig anderen Ort. targetPath := filepath.Join(testInstance.TempDir(), "wiederhergestellt") restoreResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "rundreise", TargetPath: targetPath, RestorePermissions: true, }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } if restoreResult.FilesRestored != backupResult.FilesBackedUp { testInstance.Errorf("es sollen %d Dateien zurückkommen, waren %d", backupResult.FilesBackedUp, restoreResult.FilesRestored) } // Das entscheidende Kriterium: Struktur, Inhalt und Rechte müssen // vollständig übereinstimmen. restoredFingerprint := treeFingerprint(testInstance, targetPath, true) if restoredFingerprint != originalFingerprint { testInstance.Fatalf("der wiederhergestellte Baum weicht ab.\n Quelle: %s\n Ziel: %s", originalFingerprint, restoredFingerprint) } } func TestRestoreAfterSourceDeletion(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) originalFingerprint := treeFingerprint(testInstance, sourcePath, true) if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "vor-verlust", SourcePath: sourcePath, CompressionLevel: backupengine.CompressionBalanced, EncryptionEnabled: true, }); backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } // Der Ernstfall: die Quelle ist weg. if removeError := os.RemoveAll(sourcePath); removeError != nil { testInstance.Fatalf("die Quelle konnte nicht gelöscht werden: %v", removeError) } if _, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "vor-verlust", TargetPath: sourcePath, RestorePermissions: true, }); restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } // Der wiederhergestellte Baum muss dem verlorenen gleichen. if treeFingerprint(testInstance, sourcePath, true) != originalFingerprint { testInstance.Fatal("der wiederhergestellte Baum weicht vom verlorenen ab") } } func TestRestoreOfSubtreeOnly(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "teilbaum", SourcePath: sourcePath, CompressionLevel: backupengine.CompressionBalanced, EncryptionEnabled: true, }); backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } targetPath := filepath.Join(testInstance.TempDir(), "nur-dokumente") restoreResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "teilbaum", TargetPath: targetPath, PathPrefix: "dokumente", }) if restoreError != nil { testInstance.Fatalf("die Wiederherstellung schlug fehl: %v", restoreError) } // Nur der gewählte Teilbaum darf zurückkommen. if restoreResult.FilesRestored != 3 { testInstance.Errorf("es sollen 3 Dateien zurückkommen, waren %d", restoreResult.FilesRestored) } if _, statError := os.Stat(filepath.Join(targetPath, "bilder")); statError == nil { testInstance.Error("ein nicht gewählter Teilbaum wurde wiederhergestellt") } } // --------------------------------------------------------------------------- // Schutz vor Datenverlust // --------------------------------------------------------------------------- func TestRestoreRefusesNonEmptyTargetByDefault(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "schutz", SourcePath: sourcePath, }); backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } // Ein Zielverzeichnis mit vorhandenen Daten. targetPath := testInstance.TempDir() wichtigeDatei := filepath.Join(targetPath, "wichtig.txt") if writeError := os.WriteFile(wichtigeDatei, []byte("Produktionsdaten"), 0o644); writeError != nil { testInstance.Fatalf("die Datei konnte nicht angelegt werden: %v", writeError) } // Ohne ausdrückliche Erlaubnis darf nichts überschrieben werden. _, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "schutz", TargetPath: targetPath, }) if !errors.Is(restoreError, ErrTargetNotEmpty) { testInstance.Fatalf("ein volles Zielverzeichnis muss abgelehnt werden, war: %v", restoreError) } // Die vorhandenen Daten müssen unangetastet sein. existingContent, _ := os.ReadFile(wichtigeDatei) if string(existingContent) != "Produktionsdaten" { testInstance.Fatal("die vorhandenen Daten wurden verändert") } } func TestRestoreSkipsExistingFilesWithoutOverwrite(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "teilweise", SourcePath: sourcePath, }); backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } targetPath := filepath.Join(testInstance.TempDir(), "ziel") // Der erste Lauf schreibt alles. firstResult, _ := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "teilweise", TargetPath: targetPath, }) // Der zweite Lauf trifft auf vorhandene Dateien und übergeht sie. secondResult, restoreError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "teilweise", TargetPath: targetPath, OverwriteExisting: true, }) if restoreError != nil { testInstance.Fatalf("der zweite Lauf schlug fehl: %v", restoreError) } // Mit ausdrücklicher Erlaubnis werden sie ersetzt. if secondResult.FilesRestored != firstResult.FilesRestored { testInstance.Errorf("mit Erlaubnis sollen alle %d Dateien geschrieben werden, waren %d", firstResult.FilesRestored, secondResult.FilesRestored) } } func TestRestoreRejectsPathEscapingManifest(testInstance *testing.T) { targetPath := testInstance.TempDir() // Ein Manifest kann aus einem fremden Repository stammen. Ein Pfad wie // "../../etc/passwd" schriebe an eine beliebige Stelle des Dateisystems. maliciousPaths := []string{ "../ausserhalb.txt", "../../etc/passwd", "unterordner/../../ausbruch.txt", "/absoluter/pfad.txt", } for _, maliciousPath := range maliciousPaths { validationError := validateManifestPath(maliciousPath, targetPath) if !errors.Is(validationError, ErrUnsafeManifestPath) { testInstance.Errorf("der Pfad %q wurde akzeptiert", maliciousPath) } } // Zulässige Pfade müssen weiterhin durchgehen. for _, harmlessPath := range []string{"datei.txt", "ordner/datei.txt", "a/b/c/tief.txt"} { if validationError := validateManifestPath(harmlessPath, targetPath); validationError != nil { testInstance.Errorf("der zulässige Pfad %q wurde abgelehnt: %v", harmlessPath, validationError) } } } // --------------------------------------------------------------------------- // Teilfehler // --------------------------------------------------------------------------- func TestBackupWithUnreadableFileIsPartialFailure(testInstance *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { testInstance.Skip("der Test setzt Unix-Rechte und ein unprivilegiertes Konto voraus") } backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildTestTree(testInstance, map[string]string{ "lesbar.txt": "kann gelesen werden", "gesperrt/geheim.txt": "kein Zugriff", }) gesperrtesVerzeichnis := filepath.Join(sourcePath, "gesperrt") _ = os.Chmod(gesperrtesVerzeichnis, 0o000) testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) }) backupResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "teilfehler", SourcePath: sourcePath, }) if backupError != nil { testInstance.Fatalf("die Sicherung soll trotz Rechtefehler durchlaufen: %v", backupError) } // Ein Lauf mit übergangenen Objekten ist ein Teilfehler, kein Erfolg. if !backupResult.IsPartialFailure() { testInstance.Fatal("die Sicherung wurde fälschlich als voller Erfolg gemeldet") } if !strings.Contains(backupResult.Summary(), "TEILWEISE FEHLGESCHLAGEN") { testInstance.Errorf("die Zusammenfassung beschönigt den Ausgang: %s", backupResult.Summary()) } } func TestBackupCanAbortOnProblems(testInstance *testing.T) { if runtime.GOOS == "windows" || os.Geteuid() == 0 { testInstance.Skip("der Test setzt Unix-Rechte und ein unprivilegiertes Konto voraus") } backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildTestTree(testInstance, map[string]string{ "lesbar.txt": "ok", "gesperrt/geheim.txt": "kein Zugriff", }) gesperrtesVerzeichnis := filepath.Join(sourcePath, "gesperrt") _ = os.Chmod(gesperrtesVerzeichnis, 0o000) testInstance.Cleanup(func() { _ = os.Chmod(gesperrtesVerzeichnis, 0o755) }) // Wer eine vollständige Sicherung verlangt, soll bei Problemen abbrechen können. _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "streng", SourcePath: sourcePath, AbortOnProblems: true, }) if !errors.Is(backupError, ErrBackupIncompleteDueToProblems) { testInstance.Fatalf("die Sicherung soll bei Problemen abbrechen, war: %v", backupError) } } func TestBackupRefusesEmptySource(testInstance *testing.T) { backupRunner, _ := newRoundTripEnvironment(testInstance) // Ein leeres Backup wäre ein Gebilde, das sich als erfolgreiche Sicherung // ausgibt, ohne etwas zu enthalten. if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "leer", SourcePath: testInstance.TempDir(), }); backupError == nil { testInstance.Fatal("eine leere Quelle muss abgelehnt werden") } } // --------------------------------------------------------------------------- // Deduplizierung über Läufe hinweg // --------------------------------------------------------------------------- func TestSecondBackupOfUnchangedTreeIsDeduplicated(testInstance *testing.T) { backupRunner, _ := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) firstResult, _ := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "lauf-1", SourcePath: sourcePath, CompressionLevel: backupengine.CompressionBalanced, EncryptionEnabled: true, }) secondResult, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "lauf-2", SourcePath: sourcePath, CompressionLevel: backupengine.CompressionBalanced, EncryptionEnabled: true, }) if backupError != nil { testInstance.Fatalf("der zweite Lauf schlug fehl: %v", backupError) } // Ein unveränderter Baum darf beim zweiten Lauf nichts neu schreiben — // auch nicht bei eingeschalteter Verschlüsselung. if secondResult.Progress.ChunksWritten != 0 { testInstance.Errorf("der zweite Lauf soll nichts neu schreiben, waren %d Blöcke", secondResult.Progress.ChunksWritten) } if secondResult.Progress.ChunksDeduplicated != firstResult.Progress.ChunksWritten { testInstance.Errorf("es sollen %d Blöcke wiederverwendet werden, waren %d", firstResult.Progress.ChunksWritten, secondResult.Progress.ChunksDeduplicated) } } // TestResumeOverwritesOwnLeftoversAndCleansUp prueft die Fortsetzung nach einem Absturz. // // Regressionstest zu zwei Funden aus dem Nachweis der Phase 18: // // Erstens meldete eine Fortsetzung einen **Teilfehler**, obwohl das Ergebnis // vollstaendig war: Die Dateien zwischen Pruefpunkt und tatsaechlichem // Fortschritt lagen bereits am Platz und wurden uebergangen. Im Nachweis traf // das 85 von 1500 Dateien. Ein Teilfehler, der keiner ist, ist genau die // Meldung, die beim naechsten Mal niemand mehr liest. // // Zweitens blieb die beim harten Ende gerade geschriebene Datei unter ihrem // temporaeren Namen liegen — dauerhaft. Im Nachweis lagen 1501 Dateien in einem // Ziel, das 1500 enthalten sollte. func TestResumeOverwritesOwnLeftoversAndCleansUp(testInstance *testing.T) { backupRunner, restoreRunner := newRoundTripEnvironment(testInstance) sourcePath := buildRealisticTree(testInstance) if _, backupError := backupRunner.RunBackup(context.Background(), BackupRunOptions{ BackupID: "fortsetzung", SourcePath: sourcePath, }); backupError != nil { testInstance.Fatalf("die Sicherung schlug fehl: %v", backupError) } targetPath := filepath.Join(testInstance.TempDir(), "ziel") // Ein vollstaendiger Lauf stellt die Ausgangslage her. firstResult, firstError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "fortsetzung", TargetPath: targetPath, }) if firstError != nil { testInstance.Fatalf("der erste Lauf schlug fehl: %v", firstError) } // Ein Rest, wie ihn ein harter Abbruch hinterlaesst. leftoverPath := filepath.Join(targetPath, ".syncova-restore-1234567") if writeError := os.WriteFile(leftoverPath, []byte("halbe Datei"), 0o600); writeError != nil { testInstance.Fatalf("der Rest ließ sich nicht anlegen: %v", writeError) } // Die Fortsetzung setzt vor dem ersten Eintrag an: Damit liegen **alle** // Dateien bereits am Platz — der schaerfste Fall des Fundes. resumeResult, resumeError := restoreRunner.RunRestore(context.Background(), RestoreRunOptions{ BackupID: "fortsetzung", TargetPath: targetPath, ResumeAfterPath: " ", // vor jedem echten Pfad, aber nicht leer }) if resumeError != nil { testInstance.Fatalf("die Fortsetzung schlug fehl: %v", resumeError) } // Kein uebergangenes Objekt: Die vorhandenen Dateien stammen aus dem // eigenen Lauf und werden ersetzt, nicht uebersprungen. if resumeResult.SkippedExisting != 0 { testInstance.Errorf("die Fortsetzung übersprang %d vorhandene Objekte; sie stammen aus "+ "dem eigenen abgebrochenen Lauf und machen aus einer vollständigen "+ "Wiederherstellung einen Teilfehler", resumeResult.SkippedExisting) } if resumeResult.FilesRestored != firstResult.FilesRestored { testInstance.Errorf("die Fortsetzung schrieb %d Dateien, der erste Lauf %d", resumeResult.FilesRestored, firstResult.FilesRestored) } // Der Rest muss verschwunden sein. if _, statError := os.Stat(leftoverPath); statError == nil { testInstance.Error("der Rest des abgebrochenen Laufs liegt noch im Zielverzeichnis") } if resumeResult.LeftoversRemoved != 1 { testInstance.Errorf("es wurden %d Reste entfernt, erwartet wurde einer", resumeResult.LeftoversRemoved) } }