Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
617 lines
22 KiB
Go
617 lines
22 KiB
Go
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)
|
|
}
|
|
}
|