package backupengine import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log/slog" "time" "github.com/syncova/syncova/packages/repository" ) // ErrManifestEntryNotFound meldet ein im Backup nicht enthaltenes Objekt. var ErrManifestEntryNotFound = errors.New("das objekt ist in diesem backup nicht enthalten") // ErrRestoreVerificationFailed meldet eine Wiederherstellung, deren Ergebnis // nicht zu den gesicherten Daten passt. // // Das ist der schwerste denkbare Fall: die Daten sind zurückgeschrieben, aber // nicht die richtigen. Er darf niemals als Erfolg gelten (PROMPT.md §140). var ErrRestoreVerificationFailed = errors.New("die wiederhergestellten daten stimmen nicht mit dem gesicherten stand überein") // RestoreResult beschreibt eine abgeschlossene Wiederherstellung. type RestoreResult struct { // BackupID ist das verwendete Backup. BackupID string `json:"backup_id"` // Path ist das wiederhergestellte Objekt. Path string `json:"path"` // BytesRestored ist die Menge zurückgeschriebener Daten. BytesRestored int64 `json:"bytes_restored"` // ChunksRead ist die Zahl gelesener Blöcke. ChunksRead int64 `json:"chunks_read"` // Verified meldet, ob der Inhaltshash gegengeprüft wurde. Verified bool `json:"verified"` // Duration ist die Gesamtdauer. Duration time.Duration `json:"duration"` } // RestoreOptions steuern eine Wiederherstellung. type RestoreOptions struct { // BackupID ist das zu verwendende Backup. BackupID string // Path benennt das wiederherzustellende Objekt. Path string // ProgressCallback meldet den Fortschritt. ProgressCallback ProgressCallback } // Restore schreibt ein gesichertes Objekt in den übergebenen Datenstrom. // // Der Rückweg ist die Umkehrung des Backups: entschlüsseln, entpacken, Prüfsumme // gegen die Kennung halten. Die letzte Prüfung ist die wichtigste — sie belegt, // dass der zurückgewonnene Block bitgenau dem gesicherten entspricht // (PROMPT.md §14). func (engine *Engine) Restore(restoreContext context.Context, restoreOptions RestoreOptions, outputWriter io.Writer) (RestoreResult, error) { startTime := time.Now() backupManifest, manifestError := engine.targetRepository.ReadManifest(restoreContext, restoreOptions.BackupID) if manifestError != nil { return RestoreResult{}, manifestError } manifestEntry, entryFound := findManifestEntry(backupManifest, restoreOptions.Path) if !entryFound { return RestoreResult{}, fmt.Errorf("%w: %q", ErrManifestEntryNotFound, restoreOptions.Path) } chunkTransformer, transformerError := engine.buildRestoreTransformer(backupManifest) if transformerError != nil { return RestoreResult{}, transformerError } defer chunkTransformer.Close() progressReporter := NewProgressReporter(restoreOptions.ProgressCallback, 0) // Der Inhaltshash wird während des Schreibens gebildet und am Ende gegen // den im Manifest vermerkten gehalten. contentDigest := sha256.New() verifyingWriter := io.MultiWriter(outputWriter, contentDigest) var bytesRestored int64 var chunksRead int64 for _, chunkReference := range manifestEntry.Chunks { if contextError := restoreContext.Err(); contextError != nil { return RestoreResult{}, contextError } plaintextChunk, chunkError := engine.restoreSingleChunk(restoreContext, chunkReference, chunkTransformer) if chunkError != nil { return RestoreResult{}, chunkError } writtenBytes, writeError := verifyingWriter.Write(plaintextChunk) if writeError != nil { return RestoreResult{}, fmt.Errorf("die wiederhergestellten daten konnten nicht geschrieben werden: %w", writeError) } bytesRestored += int64(writtenBytes) chunksRead++ progressReporter.recordWrittenChunk(int64(writtenBytes), chunkReference.StoredLength) } // Der Gesamtvergleich deckt auch eine falsche Reihenfolge der Blöcke auf, // die den Einzelprüfungen entginge. wasVerified := false if manifestEntry.ContentHash != "" { actualContentHash := hex.EncodeToString(contentDigest.Sum(nil)) if actualContentHash != manifestEntry.ContentHash { engine.logger.Error("wiederherstellung fehlgeschlagen: inhaltsprüfsumme weicht ab", slog.String("backup_id", restoreOptions.BackupID), slog.String("path", restoreOptions.Path), slog.String("expected", manifestEntry.ContentHash), slog.String("actual", actualContentHash)) return RestoreResult{}, fmt.Errorf("%w (objekt %q)", ErrRestoreVerificationFailed, restoreOptions.Path) } wasVerified = true } progressReporter.ReportFinal() engine.logger.Info("wiederherstellung abgeschlossen", slog.String("backup_id", restoreOptions.BackupID), slog.String("path", restoreOptions.Path), slog.Int64("bytes", bytesRestored), slog.Int64("chunks", chunksRead), slog.Bool("geprueft", wasVerified)) return RestoreResult{ BackupID: restoreOptions.BackupID, Path: restoreOptions.Path, BytesRestored: bytesRestored, ChunksRead: chunksRead, Verified: wasVerified, Duration: time.Since(startTime), }, nil } // restoreSingleChunk gewinnt den Klartext eines Blocks zurück. func (engine *Engine) restoreSingleChunk(restoreContext context.Context, chunkReference repository.ChunkReference, chunkTransformer *ChunkTransformer) ([]byte, error) { // Die Prüfsumme der abgelegten Form deckt eine Beschädigung auf dem // Datenträger auf, bevor überhaupt entschlüsselt wird. storedChunk, readError := engine.targetRepository.ReadStoredChunk(restoreContext, chunkReference.Identifier, chunkReference.StoredDigest) if readError != nil { return nil, readError } plaintextChunk, restoreError := chunkTransformer.Restore(storedChunk) if restoreError != nil { return nil, fmt.Errorf("der block %s konnte nicht zurückgewonnen werden: %w", chunkReference.Identifier, restoreError) } // Die abschließende Prüfung: der zurückgewonnene Klartext muss exakt die // Kennung ergeben, unter der er abgelegt wurde. Sie deckt jeden Fehler in // Entschlüsselung, Dekompression und Ablage auf. plaintextDigest := sha256.Sum256(plaintextChunk) actualIdentifier := hex.EncodeToString(plaintextDigest[:]) if actualIdentifier != chunkReference.Identifier { return nil, fmt.Errorf("%w: der block %s ergab nach der rückgewinnung die kennung %s", repository.ErrChunkCorrupted, chunkReference.Identifier, actualIdentifier) } return plaintextChunk, nil } // buildRestoreTransformer baut den Transformer zu einem gesicherten Backup. // // Die Angaben stammen ausschließlich aus dem Manifest: Kompressionsverfahren, // Schlüsselversion und verschlüsselter Datenschlüssel. Damit lässt sich ein // Backup auch dann wiederherstellen, wenn die ursprünglichen Einstellungen // längst geändert wurden (PROMPT.md §143). func (engine *Engine) buildRestoreTransformer(backupManifest *repository.Manifest) (*ChunkTransformer, error) { transformerOptions := TransformerOptions{} if backupManifest.CompressionAlgorithm != "" { if backupManifest.CompressionAlgorithm != compressionAlgorithm { return nil, fmt.Errorf("das backup wurde mit dem unbekannten verfahren %q komprimiert", backupManifest.CompressionAlgorithm) } // Die Stufe ist beim Entpacken ohne Belang; entscheidend ist nur, dass // ein Dekompressor eingerichtet wird. transformerOptions.CompressionLevel = CompressionBalanced } _, isEncrypted := backupManifest.Source.Attributes[encryptionAlgorithmAttribute] if !isEncrypted { return NewChunkTransformer(transformerOptions) } if engine.secretStore == nil { return nil, fmt.Errorf("%w: das backup ist verschlüsselt", ErrEncryptionUnavailable) } // Der Datenschlüssel liegt im Repository und gilt für alle seine Backups. // Ohne den übergeordneten Schlüssel ist er nicht zu öffnen - und damit das // gesamte Repository unlesbar (PROMPT.md §142). dataEncryptionKey, keyError := engine.targetRepository.LoadOrCreateDataKey(context.Background(), engine.secretStore) if keyError != nil { return nil, fmt.Errorf("der datenschlüssel des repositorys konnte nicht geöffnet werden "+ "(schlüsselversion %q): %w", backupManifest.EncryptionKeyVersion, keyError) } transformerOptions.DataEncryptionKey = dataEncryptionKey return NewChunkTransformer(transformerOptions) } // findManifestEntry sucht ein Objekt im Manifest. func findManifestEntry(backupManifest *repository.Manifest, objectPath string) (repository.ManifestEntry, bool) { for _, manifestEntry := range backupManifest.Entries { if manifestEntry.Path == objectPath { return manifestEntry, true } } return repository.ManifestEntry{}, false } // ListEntries liefert die in einem Backup enthaltenen Objekte. func (engine *Engine) ListEntries(listContext context.Context, backupID string) ([]repository.ManifestEntry, error) { backupManifest, manifestError := engine.targetRepository.ReadManifest(listContext, backupID) if manifestError != nil { return nil, manifestError } return backupManifest.Entries, nil }