package verification import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "log/slog" "os" "path/filepath" "time" "github.com/syncova/syncova/packages/agent" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/repository" ) // RestoreTestOptions steuern einen Wiederherstellungstest. type RestoreTestOptions struct { // BackupID ist das zu pruefende Backup im Repository. BackupID string // PathPrefix beschraenkt den Test auf einen Teilbaum. // // Bei sehr grossen Backups ist ein Teiltest besser als keiner — er wird im // Bericht aber als solcher ausgewiesen. Ein Teiltest belegt nicht die // Wiederherstellbarkeit des ganzen Backups. PathPrefix string // WorkingDirectory ist der Ort des Wegwerfziels. // // Leer waehlt das Temporaerverzeichnis des Systems. Bei grossen Backups // gehoert hier ein Datentraeger mit genug Platz hin — das Temporaer- // verzeichnis liegt oft im Arbeitsspeicher. WorkingDirectory string // KeepRestoredData behaelt die zurueckgeschriebenen Daten. // // Nur zur Fehlersuche. Im Regelfall wird alles geloescht: Ein Test, der // Platz belegt, wird bald abgeschaltet. KeepRestoredData bool } // RestoreTestReport ist das Ergebnis eines Wiederherstellungstests. type RestoreTestReport struct { // Report ist der allgemeine Pruefbericht. Report // FilesRestored ist die Zahl zurueckgeschriebener Dateien. FilesRestored int `json:"files_restored"` // FilesCompared ist die Zahl gegen ihre Pruefsumme verglichener Dateien. FilesCompared int `json:"files_compared"` // FilesMismatched ist die Zahl abweichender Dateien. FilesMismatched int `json:"files_mismatched"` // BytesRestored ist die zurueckgeschriebene Datenmenge. BytesRestored int64 `json:"bytes_restored"` // WasPartialTest meldet einen auf einen Teilbaum beschraenkten Test. WasPartialTest bool `json:"was_partial_test"` // TestedPathPrefix ist der geprueft Teilbaum. TestedPathPrefix string `json:"tested_path_prefix,omitempty"` } // Succeeded meldet einen bestandenen Test. func (report *RestoreTestReport) Succeeded() bool { return report.IsClean() && report.FilesMismatched == 0 } // RestoreTester fuehrt Wiederherstellungstests aus. // // Der Test ist der einzige Nachweis, der zaehlt. Eine Blockpruefung sagt, dass // die Bytes da sind; sie sagt nicht, dass Entschluesselung, Dekompression, // Zusammensetzung und Dateisystem zusammen ein brauchbares Ergebnis liefern. // Genau dort sitzen die Fehler, die man erst im Ernstfall bemerkt. type RestoreTester struct { // sourceRepository ist das Repository mit dem Backup. sourceRepository *repository.LocalRepository // secretStore entschluesselt die Datenschluessel. secretStore crypto.SecretStore // logger protokolliert den Verlauf. logger *slog.Logger } // NewRestoreTester erzeugt den Wiederherstellungstest. func NewRestoreTester(sourceRepository *repository.LocalRepository, secretStore crypto.SecretStore, baseLogger *slog.Logger) *RestoreTester { return &RestoreTester{ sourceRepository: sourceRepository, secretStore: secretStore, logger: logging.WithComponent(baseLogger, "restore-test"), } } // RunTest stellt ein Backup an einen Wegwerfort wieder her und vergleicht es. // // Der Ablauf: Wegwerfziel anlegen → wiederherstellen → jede Datei gegen die // Pruefsumme im Manifest vergleichen → alles loeschen. Was bleibt, ist die // Aussage — nicht die Daten. func (tester *RestoreTester) RunTest(testContext context.Context, testOptions RestoreTestOptions) (*RestoreTestReport, error) { startTime := time.Now() report := &RestoreTestReport{ Report: Report{ BackupID: testOptions.BackupID, VerificationType: TypeRestoreTest, Findings: make([]Finding, 0, 2), StartedAt: time.Now().UTC(), }, WasPartialTest: testOptions.PathPrefix != "", TestedPathPrefix: testOptions.PathPrefix, } workingRoot := testOptions.WorkingDirectory if workingRoot == "" { workingRoot = os.TempDir() } // Ein eigenes Verzeichnis je Lauf: Zwei gleichzeitige Tests duerfen sich // nicht ins Gehege kommen, und das Aufraeumen bleibt eindeutig. testDirectory, createError := os.MkdirTemp(workingRoot, "syncova-restoretest-") if createError != nil { return nil, fmt.Errorf("das wegwerfziel konnte nicht angelegt werden: %w", createError) } if !testOptions.KeepRestoredData { // Das Aufraeumen laeuft auch bei einem Abbruch: Ein Test, der Platz // belegt, wird bald abgeschaltet. defer func() { if removeError := os.RemoveAll(testDirectory); removeError != nil { tester.logger.Error("das wegwerfziel konnte nicht geraeumt werden", slog.String("pfad", testDirectory), slog.String("grund", removeError.Error())) } }() } backupManifest, readError := tester.sourceRepository.ReadManifest(testContext, testOptions.BackupID) if readError != nil { report.Findings = append(report.Findings, Finding{ Code: "MANIFEST_UNREADABLE", Severity: SeverityMissing, Message: "Das Manifest ist nicht lesbar; ein Test ist nicht moeglich.", Detail: readError.Error(), }) finalizeReport(&report.Report, startTime) return report, nil } engine := backupengine.NewEngine(tester.sourceRepository, tester.secretStore, tester.logger) restoreRunner := agent.NewRestoreRunner(engine, tester.logger) tester.logger.Info("wiederherstellungstest gestartet", slog.String("backup_id", testOptions.BackupID), slog.String("wegwerfziel", testDirectory), slog.Bool("teiltest", report.WasPartialTest)) runResult, restoreError := restoreRunner.RunRestore(testContext, agent.RestoreRunOptions{ BackupID: testOptions.BackupID, TargetPath: testDirectory, PathPrefix: testOptions.PathPrefix, // Die Rechte gehoeren zum Ergebnis: Eine Datei mit falschen Rechten ist // nicht richtig wiederhergestellt. RestorePermissions: true, // Das Wegwerfziel ist frisch angelegt und damit leer; ueberschrieben // wird nichts. OverwriteExisting: false, }) if restoreError != nil { report.Findings = append(report.Findings, Finding{ Code: "RESTORE_FAILED", Severity: SeverityCorruption, Message: "Die Wiederherstellung schlug fehl. Das Backup laesst sich nicht zurueckholen — " + "unabhaengig davon, was Manifest und Blockpruefung sagen.", Detail: restoreError.Error(), }) finalizeReport(&report.Report, startTime) return report, nil } report.FilesRestored = runResult.FilesRestored report.BytesRestored = runResult.BytesRestored tester.compareRestoredFiles(testContext, backupManifest, testDirectory, testOptions.PathPrefix, report) finalizeReport(&report.Report, startTime) if report.Succeeded() { tester.logger.Info("wiederherstellungstest bestanden", slog.String("backup_id", testOptions.BackupID), slog.Int("dateien", report.FilesCompared), slog.String("dauer", time.Since(startTime).Round(time.Millisecond).String())) } else { tester.logger.Error("wiederherstellungstest NICHT bestanden", slog.String("backup_id", testOptions.BackupID), slog.Int("abweichend", report.FilesMismatched)) } return report, nil } // compareRestoredFiles vergleicht die zurueckgeschriebenen Dateien. // // Verglichen wird gegen den Inhaltshash im Manifest — nicht gegen die // Blockpruefsummen. Der Unterschied ist wesentlich: Die Blockpruefsummen // belegen, dass die Bloecke unversehrt sind; der Inhaltshash belegt, dass ihre // **Zusammensetzung** die urspruengliche Datei ergibt. Ein Fehler in der // Reihenfolge fiele nur hier auf. func (tester *RestoreTester) compareRestoredFiles(compareContext context.Context, backupManifest *repository.Manifest, testDirectory string, pathPrefix string, report *RestoreTestReport) { mismatchedPaths := make([]string, 0, maximumNamedObjects) missingPaths := make([]string, 0, maximumNamedObjects) for _, manifestEntry := range backupManifest.Entries { if compareContext.Err() != nil { report.Findings = append(report.Findings, Finding{ Code: "TEST_CANCELLED", Severity: SeverityWarning, Message: "Der Test wurde abgebrochen und ist damit ohne Aussage.", }) return } if manifestEntry.EntryType != "file" { continue } if !matchesPrefix(manifestEntry.Path, pathPrefix) { continue } // Ohne Inhaltshash im Manifest laesst sich nichts vergleichen. Das ist // keine Beanstandung, aber eine Luecke im Nachweis. if manifestEntry.ContentHash == "" { continue } // Die Wiederherstellung behaelt den vollen Pfad unterhalb des Ziels — // auch bei einem Teilbaum. Den Praefix abzuschneiden waere eine // Annahme ueber das Verhalten des Restores statt einer Beobachtung. restoredPath := filepath.Join(testDirectory, filepath.FromSlash(manifestEntry.Path)) actualHash, hashError := hashFileContent(restoredPath) if hashError != nil { report.ChunksMissing++ if len(missingPaths) < maximumNamedObjects { missingPaths = append(missingPaths, manifestEntry.Path) } continue } report.FilesCompared++ if actualHash != manifestEntry.ContentHash { report.FilesMismatched++ if len(mismatchedPaths) < maximumNamedObjects { mismatchedPaths = append(mismatchedPaths, manifestEntry.Path) } } } if len(missingPaths) > 0 || report.ChunksMissing > 0 { report.Findings = append(report.Findings, Finding{ Code: "RESTORED_FILE_MISSING", Severity: SeverityMissing, Message: fmt.Sprintf("%d Dateien fehlen nach der Wiederherstellung, obwohl sie im Manifest "+ "stehen.", report.ChunksMissing), Detail: formatAffectedPaths(missingPaths, report.ChunksMissing), }) } if report.FilesMismatched > 0 { report.Findings = append(report.Findings, Finding{ Code: "CONTENT_MISMATCH", Severity: SeverityCorruption, Message: fmt.Sprintf("%d von %d Dateien weichen nach der Wiederherstellung von ihrem "+ "urspruenglichen Inhalt ab. Das Backup ist nicht vertrauenswuerdig.", report.FilesMismatched, report.FilesCompared), Detail: formatAffectedPaths(mismatchedPaths, report.FilesMismatched), }) } if report.FilesCompared == 0 && report.FilesRestored > 0 { // Wiederhergestellt, aber nichts vergleichbar: Der Test lief durch, // belegt aber nichts. report.Findings = append(report.Findings, Finding{ Code: "NO_CONTENT_HASHES", Severity: SeverityWarning, Message: "Das Manifest enthaelt keine Inhaltspruefsummen. Die Wiederherstellung lief durch, " + "ihr Ergebnis liess sich aber nicht gegen das Original vergleichen.", }) } if report.WasPartialTest { report.Findings = append(report.Findings, Finding{ Code: "PARTIAL_TEST", Severity: SeverityInformation, Message: fmt.Sprintf("Geprueft wurde nur der Teilbaum %q. Ein Teiltest belegt nicht die "+ "Wiederherstellbarkeit des gesamten Backups.", report.TestedPathPrefix), }) } } // matchesPrefix prueft, ob ein Pfad in einem Teilbaum liegt. func matchesPrefix(entryPath string, pathPrefix string) bool { if pathPrefix == "" { return true } trimmedPrefix := trimSlashes(pathPrefix) return entryPath == trimmedPrefix || hasPathPrefix(entryPath, trimmedPrefix) } // hasPathPrefix prueft einen Praefix an der Verzeichnisgrenze. // // „dokumente" darf nicht auch „dokumentation" treffen. func hasPathPrefix(entryPath string, pathPrefix string) bool { return len(entryPath) > len(pathPrefix) && entryPath[:len(pathPrefix)] == pathPrefix && entryPath[len(pathPrefix)] == '/' } // trimSlashes entfernt fuehrende und abschliessende Schraegstriche. func trimSlashes(pathValue string) string { startIndex := 0 endIndex := len(pathValue) for startIndex < endIndex && pathValue[startIndex] == '/' { startIndex++ } for endIndex > startIndex && pathValue[endIndex-1] == '/' { endIndex-- } return pathValue[startIndex:endIndex] } // hashFileContent berechnet die Pruefsumme einer Datei. func hashFileContent(filePath string) (string, error) { openedFile, openError := os.Open(filePath) if openError != nil { return "", openError } defer func() { _ = openedFile.Close() }() contentDigest := sha256.New() if _, copyError := io.Copy(contentDigest, openedFile); copyError != nil { return "", copyError } return hex.EncodeToString(contentDigest.Sum(nil)), nil }