package agent import ( "context" "errors" "fmt" "io/fs" "log/slog" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/syncova/syncova/packages/backupengine" "github.com/syncova/syncova/packages/repository" ) // RestoreRunOptions steuern eine Wiederherstellung. type RestoreRunOptions struct { // BackupID ist das wiederherzustellende Backup. BackupID string // TargetPath ist das Zielverzeichnis. TargetPath string // PathPrefix beschränkt die Wiederherstellung auf einen Teilbaum. // // Leer bedeutet: alles wiederherstellen. PathPrefix string // OverwriteExisting erlaubt das Überschreiben vorhandener Dateien. // // Standardmäßig ausgeschaltet: eine Wiederherstellung darf niemals // unbeabsichtigt Produktionsdaten überschreiben (PROMPT.md §86). OverwriteExisting bool // RestorePermissions setzt die ursprünglichen Rechte. RestorePermissions bool // ProgressCallback meldet den Fortschritt. ProgressCallback backupengine.ProgressCallback // ResumeAfterPath setzt eine abgebrochene Wiederherstellung fort. // // Alle Dateien bis einschließlich dieses Pfades gelten als erledigt und // werden übersprungen. Das ist zulässig, weil die Reihenfolge der Objekte // im Manifest fest sortiert ist — ein einzelner Pfad genügt als Marke und // braucht keine Liste. // // Die Verzeichnisse werden dennoch erneut angelegt: Sie zu überspringen // spart nichts und ginge schief, wenn der Abbruch vor ihrer Rechtevergabe // lag. ResumeAfterPath string // EntryRestoredCallback meldet jedes fertig zurückgeschriebene Objekt. // // Darüber führt der Aufrufer seinen Prüfpunkt. Die Meldung erfolgt erst, // nachdem die Datei vollständig und umbenannt am Platz liegt — ein // Prüfpunkt auf eine halb geschriebene Datei wäre schlimmer als keiner. EntryRestoredCallback func(entryPath string, entryBytes int64) } // RestoreRunResult beschreibt eine abgeschlossene Wiederherstellung. type RestoreRunResult struct { // BackupID ist das verwendete Backup. BackupID string `json:"backup_id"` // TargetPath ist das Zielverzeichnis. TargetPath string `json:"target_path"` // FilesRestored ist die Zahl zurückgeschriebener Dateien. FilesRestored int `json:"files_restored"` // DirectoriesCreated ist die Zahl angelegter Verzeichnisse. DirectoriesCreated int `json:"directories_created"` // SymlinksCreated ist die Zahl angelegter symbolischer Verweise. SymlinksCreated int `json:"symlinks_created"` // BytesRestored ist die Menge zurückgeschriebener Daten. BytesRestored int64 `json:"bytes_restored"` // SkippedExisting ist die Zahl übergangener vorhandener Objekte. SkippedExisting int `json:"skipped_existing"` // LeftoversRemoved ist die Zahl entfernter Reste eines abgebrochenen Laufs. LeftoversRemoved int `json:"leftovers_removed"` // SkippedResumed ist die Zahl bei einer Fortsetzung übersprungener Objekte. // // Sie steht getrennt von SkippedExisting: Ein bei der Fortsetzung // übersprungenes Objekt wurde bereits zurückgeschrieben, ein übergangenes // vorhandenes dagegen nie. SkippedResumed int `json:"skipped_resumed"` // VerifiedFiles ist die Zahl gegen ihre Prüfsumme geprüfter Dateien. VerifiedFiles int `json:"verified_files"` // Duration ist die Gesamtdauer. Duration time.Duration `json:"duration"` } // Summary fasst das Ergebnis in einem Satz zusammen. func (restoreRunResult *RestoreRunResult) Summary() string { summary := fmt.Sprintf("%d Dateien (%d geprüft), %d Verzeichnisse wiederhergestellt.", restoreRunResult.FilesRestored, restoreRunResult.VerifiedFiles, restoreRunResult.DirectoriesCreated) if restoreRunResult.SkippedExisting > 0 { summary += fmt.Sprintf(" %d vorhandene Objekte wurden übergangen.", restoreRunResult.SkippedExisting) } return summary } // Fehler der Wiederherstellung. var ( // ErrTargetNotEmpty meldet ein nicht leeres Zielverzeichnis. // // Ohne diese Prüfung könnte eine Wiederherstellung unbemerkt vorhandene // Daten überschreiben (PROMPT.md §86, §141). ErrTargetNotEmpty = errors.New("das zielverzeichnis enthält bereits daten") // ErrUnsafeManifestPath meldet einen Pfad, der aus dem Ziel herausführt. ErrUnsafeManifestPath = errors.New("das manifest enthält einen pfad, der aus dem zielverzeichnis herausführt") ) // RestoreRunner führt Wiederherstellungen aus. type RestoreRunner struct { // engine ist die Backup Engine. engine *backupengine.Engine // logger protokolliert den Verlauf. logger *slog.Logger } // NewRestoreRunner erzeugt den Wiederherstellungslauf. func NewRestoreRunner(engine *backupengine.Engine, baseLogger *slog.Logger) *RestoreRunner { return &RestoreRunner{engine: engine, logger: baseLogger} } // RunRestore stellt einen Verzeichnisbaum wieder her. // // Die Reihenfolge ist wesentlich: erst Verzeichnisse, dann Dateien, zuletzt // symbolische Verweise. Eine Datei lässt sich nicht in ein noch nicht // vorhandenes Verzeichnis schreiben, und ein Verweis soll auf ein bereits // bestehendes Ziel zeigen. // // Die Rechte der Verzeichnisse werden ganz zuletzt gesetzt: ein nur lesbares // Verzeichnis liesse sich sonst nicht mehr befüllen. func (restoreRunner *RestoreRunner) RunRestore(restoreContext context.Context, runOptions RestoreRunOptions) (*RestoreRunResult, error) { startTime := time.Now() manifestEntries, listError := restoreRunner.engine.ListEntries(restoreContext, runOptions.BackupID) if listError != nil { return nil, listError } absoluteTarget, pathError := filepath.Abs(runOptions.TargetPath) if pathError != nil { return nil, fmt.Errorf("das zielverzeichnis konnte nicht aufgelöst werden: %w", pathError) } // Bei einer Fortsetzung ist das Ziel notwendigerweise nicht leer — dort // liegt der bereits zurückgeschriebene Teil. Der Schutz gegen ein volles // Zielverzeichnis greift dann nicht; er hat beim ersten Versuch gegriffen. // Ihn hier über --overwrite auszuhebeln wäre falsch: Das erlaubte zugleich // das Überschreiben fremder Daten. targetGuardApplies := runOptions.ResumeAfterPath == "" if guardError := restoreRunner.guardTargetDirectory(absoluteTarget, runOptions.OverwriteExisting || !targetGuardApplies); guardError != nil { return nil, guardError } // Die Einträge werden vorab geprüft: ein Manifest kann aus einem fremden // Repository stammen, und ein Pfad wie "../../etc/passwd" schriebe an eine // beliebige Stelle des Dateisystems. selectedEntries, selectionError := selectAndValidateEntries(manifestEntries, absoluteTarget, runOptions.PathPrefix) if selectionError != nil { return nil, selectionError } if len(selectedEntries) == 0 { return nil, fmt.Errorf("das backup enthält nichts, was auf %q passt", runOptions.PathPrefix) } if directoryError := os.MkdirAll(absoluteTarget, 0o755); directoryError != nil { return nil, fmt.Errorf("das zielverzeichnis konnte nicht angelegt werden: %w", directoryError) } restoreRunner.logger.Info("wiederherstellung gestartet", slog.String("backup_id", runOptions.BackupID), slog.String("ziel", absoluteTarget), slog.Int("objekte", len(selectedEntries))) runResult := &RestoreRunResult{BackupID: runOptions.BackupID, TargetPath: absoluteTarget} // Bei einer Fortsetzung werden zuerst die Reste des abgebrochenen Laufs // entfernt. // // Beim harten Ende eines Vorgangs bleibt die gerade geschriebene Datei // unter ihrem temporären Namen liegen. Sie ist unvollständig und gehört // niemandem — aber sie bleibt für immer im Zielverzeichnis stehen, wenn sie // niemand aufräumt, und der Betreiber weiß nicht, ob er sie löschen darf. // Im Nachweis der Phase 18 lagen nach dem Abbruch 1501 Dateien in einem // Ziel, das 1500 enthalten sollte. if runOptions.ResumeAfterPath != "" { runResult.LeftoversRemoved = removeRestoreLeftovers(absoluteTarget, restoreRunner.logger) } // Schritt 1: Verzeichnisse anlegen, zunächst mit weiten Rechten. directoryEntries := filterEntriesByType(selectedEntries, string(EntryTypeDirectory)) for _, manifestEntry := range directoryEntries { targetPath := filepath.Join(absoluteTarget, filepath.FromSlash(manifestEntry.Path)) if directoryError := os.MkdirAll(targetPath, 0o755); directoryError != nil { return nil, fmt.Errorf("das verzeichnis %q konnte nicht angelegt werden: %w", manifestEntry.Path, directoryError) } runResult.DirectoriesCreated++ } // Schritt 2: Dateien zurückschreiben. for _, manifestEntry := range filterEntriesByType(selectedEntries, string(EntryTypeFile)) { if contextError := restoreContext.Err(); contextError != nil { return nil, contextError } // Bei einer Fortsetzung wird alles bis zur Marke übersprungen. Der // Vergleich ist lexikographisch und damit von derselben Ordnung wie das // Manifest — deshalb genügt ein einzelner Pfad. if runOptions.ResumeAfterPath != "" && manifestEntry.Path <= runOptions.ResumeAfterPath { runResult.SkippedResumed++ continue } restored, restoreError := restoreRunner.restoreSingleFile(restoreContext, manifestEntry, absoluteTarget, runOptions) if restoreError != nil { return nil, restoreError } if !restored { runResult.SkippedExisting++ continue } runResult.FilesRestored++ runResult.BytesRestored += manifestEntry.SizeBytes if manifestEntry.ContentHash != "" { runResult.VerifiedFiles++ } // Der Prüfpunkt entsteht erst jetzt: Die Datei liegt vollständig und // unter ihrem endgültigen Namen am Platz. if runOptions.EntryRestoredCallback != nil { runOptions.EntryRestoredCallback(manifestEntry.Path, manifestEntry.SizeBytes) } } // Schritt 3: symbolische Verweise anlegen. for _, manifestEntry := range filterEntriesByType(selectedEntries, string(EntryTypeSymlink)) { targetPath := filepath.Join(absoluteTarget, filepath.FromSlash(manifestEntry.Path)) if _, statError := os.Lstat(targetPath); statError == nil { // Wie bei den Dateien: Bei einer Fortsetzung stammt ein // vorhandener Verweis aus dem eigenen abgebrochenen Lauf und wird // ersetzt. Ohne diesen Zweig blieb genau ein übergangenes Objekt // übrig — und ein einziges genügt für einen Teilfehler. if !runOptions.OverwriteExisting && runOptions.ResumeAfterPath == "" { runResult.SkippedExisting++ continue } if removeError := os.Remove(targetPath); removeError != nil { return nil, fmt.Errorf("der vorhandene verweis %q konnte nicht ersetzt werden: %w", manifestEntry.Path, removeError) } } if linkError := os.Symlink(manifestEntry.LinkTarget, targetPath); linkError != nil { return nil, fmt.Errorf("der verweis %q konnte nicht angelegt werden: %w", manifestEntry.Path, linkError) } runResult.SymlinksCreated++ } // Schritt 4: Verzeichnisrechte zuletzt setzen. // // Von innen nach außen, damit ein nur lesbares Elternverzeichnis nicht // verhindert, seine Kinder noch anzupassen. if runOptions.RestorePermissions { if permissionError := applyDirectoryPermissions(directoryEntries, absoluteTarget); permissionError != nil { return nil, permissionError } } runResult.Duration = time.Since(startTime) restoreRunner.logger.Info("wiederherstellung abgeschlossen", slog.String("backup_id", runOptions.BackupID), slog.Int("dateien", runResult.FilesRestored), slog.Int("verzeichnisse", runResult.DirectoriesCreated), slog.Int("uebergangen", runResult.SkippedExisting), slog.String("dauer", runResult.Duration.String())) return runResult, nil } // restoreSingleFile schreibt eine Datei zurück. // // Der Rückgabewert meldet, ob geschrieben wurde; false bedeutet, dass eine // vorhandene Datei bewusst übergangen wurde. func (restoreRunner *RestoreRunner) restoreSingleFile(restoreContext context.Context, manifestEntry repository.ManifestEntry, absoluteTarget string, runOptions RestoreRunOptions) (bool, error) { targetPath := filepath.Join(absoluteTarget, filepath.FromSlash(manifestEntry.Path)) if _, statError := os.Stat(targetPath); statError == nil && !runOptions.OverwriteExisting { // Bei einer Fortsetzung wird eine vorhandene Datei **hinter** dem // Prüfpunkt überschrieben, ohne dass es dafür eine Erlaubnis braucht. // // Sie kann nur aus dem eigenen abgebrochenen Lauf stammen: Alles vor // dem Prüfpunkt wurde bereits übersprungen, und ein leeres Ziel war die // Voraussetzung des ersten Versuchs. Sie zu übergehen machte aus einer // vollständigen Wiederherstellung einen Teilfehler — im Nachweis der // Phase 18 traf das 85 von 1500 Dateien, obwohl das Ergebnis // bitgenau stimmte. Ein Teilfehler, der keiner ist, ist genau die // Meldung, die man beim nächsten Mal nicht mehr liest. if runOptions.ResumeAfterPath == "" { return false, nil } } if directoryError := os.MkdirAll(filepath.Dir(targetPath), 0o755); directoryError != nil { return false, fmt.Errorf("das verzeichnis für %q konnte nicht angelegt werden: %w", manifestEntry.Path, directoryError) } // Die Datei entsteht zunächst unter einem temporären Namen. Bricht die // Wiederherstellung ab, bleibt keine halbe Datei unter dem echten Namen // zurück, die sich als vollständig ausgäbe. temporaryFile, createError := os.CreateTemp(filepath.Dir(targetPath), ".syncova-restore-*") if createError != nil { return false, fmt.Errorf("die zieldatei für %q konnte nicht angelegt werden: %w", manifestEntry.Path, createError) } temporaryPath := temporaryFile.Name() removeTemporaryFile := true defer func() { if removeTemporaryFile { _ = temporaryFile.Close() _ = os.Remove(temporaryPath) } }() // Die Engine prüft dabei jeden Block gegen seine Kennung und am Ende den // Gesamtinhalt gegen die Prüfsumme des Manifests. if _, restoreError := restoreRunner.engine.Restore(restoreContext, backupengine.RestoreOptions{ BackupID: runOptions.BackupID, Path: manifestEntry.Path, ProgressCallback: runOptions.ProgressCallback, }, temporaryFile); restoreError != nil { return false, fmt.Errorf("die datei %q konnte nicht wiederhergestellt werden: %w", manifestEntry.Path, restoreError) } if syncError := temporaryFile.Sync(); syncError != nil { return false, fmt.Errorf("die datei %q konnte nicht dauerhaft gesichert werden: %w", manifestEntry.Path, syncError) } if closeError := temporaryFile.Close(); closeError != nil { return false, fmt.Errorf("die datei %q konnte nicht geschlossen werden: %w", manifestEntry.Path, closeError) } if runOptions.RestorePermissions && manifestEntry.Mode != "" { if fileMode, parseError := parseFileMode(manifestEntry.Mode); parseError == nil { if chmodError := os.Chmod(temporaryPath, fileMode); chmodError != nil { return false, fmt.Errorf("die rechte von %q konnten nicht gesetzt werden: %w", manifestEntry.Path, chmodError) } } } // Erst das Umbenennen macht die fertige Datei unter ihrem Namen sichtbar. if renameError := os.Rename(temporaryPath, targetPath); renameError != nil { return false, fmt.Errorf("die datei %q konnte nicht an ihren platz gebracht werden: %w", manifestEntry.Path, renameError) } removeTemporaryFile = false // Der Änderungszeitpunkt gehört zum ursprünglichen Zustand. if !manifestEntry.ModifiedAt.IsZero() { _ = os.Chtimes(targetPath, manifestEntry.ModifiedAt, manifestEntry.ModifiedAt) } return true, nil } // guardTargetDirectory prüft, ob in das Ziel geschrieben werden darf. func (restoreRunner *RestoreRunner) guardTargetDirectory(absoluteTarget string, overwriteAllowed bool) error { directoryEntries, readError := os.ReadDir(absoluteTarget) if readError != nil { // Ein noch nicht vorhandenes Zielverzeichnis ist der Regelfall. if errors.Is(readError, os.ErrNotExist) { return nil } return fmt.Errorf("das zielverzeichnis konnte nicht geprüft werden: %w", readError) } if len(directoryEntries) == 0 || overwriteAllowed { return nil } // Ein volles Zielverzeichnis ohne ausdrückliche Erlaubnis ist fast immer // ein Versehen — und im schlimmsten Fall das Überschreiben von // Produktionsdaten (PROMPT.md §86). return fmt.Errorf("%w (%d objekte in %s). Zum Überschreiben ist eine ausdrückliche Bestätigung nötig", ErrTargetNotEmpty, len(directoryEntries), absoluteTarget) } // selectAndValidateEntries wählt die wiederherzustellenden Objekte aus und prüft ihre Pfade. func selectAndValidateEntries(manifestEntries []repository.ManifestEntry, absoluteTarget string, pathPrefix string) ([]repository.ManifestEntry, error) { normalizedPrefix := strings.TrimSuffix(filepath.ToSlash(pathPrefix), "/") selectedEntries := make([]repository.ManifestEntry, 0, len(manifestEntries)) for _, manifestEntry := range manifestEntries { // Jeder Pfad wird geprüft, bevor er zu einem Ziel wird. Ein Manifest // kann aus einem fremden Repository stammen; ein Eintrag wie // "../../etc/passwd" schriebe sonst an eine beliebige Stelle. if validationError := validateManifestPath(manifestEntry.Path, absoluteTarget); validationError != nil { return nil, validationError } if normalizedPrefix != "" { entryPath := filepath.ToSlash(manifestEntry.Path) if entryPath != normalizedPrefix && !strings.HasPrefix(entryPath, normalizedPrefix+"/") { continue } } selectedEntries = append(selectedEntries, manifestEntry) } return selectedEntries, nil } // validateManifestPath stellt sicher, dass ein Pfad im Zielverzeichnis bleibt. func validateManifestPath(manifestPath string, absoluteTarget string) error { if manifestPath == "" { return fmt.Errorf("%w: der pfad ist leer", ErrUnsafeManifestPath) } // Ein absoluter Pfad im Manifest ignorierte das Zielverzeichnis vollständig. if filepath.IsAbs(manifestPath) || strings.HasPrefix(manifestPath, "/") { return fmt.Errorf("%w: %q ist absolut", ErrUnsafeManifestPath, manifestPath) } resolvedPath := filepath.Join(absoluteTarget, filepath.FromSlash(manifestPath)) // Der Vergleich erfolgt nach der Auflösung: erst dann zeigt sich, ob // Punktfolgen aus dem Ziel herausführen. relativePath, relativeError := filepath.Rel(absoluteTarget, resolvedPath) if relativeError != nil { return fmt.Errorf("%w: %q", ErrUnsafeManifestPath, manifestPath) } if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(os.PathSeparator)) { return fmt.Errorf("%w: %q", ErrUnsafeManifestPath, manifestPath) } return nil } // filterEntriesByType wählt die Einträge einer Art aus. func filterEntriesByType(manifestEntries []repository.ManifestEntry, entryType string) []repository.ManifestEntry { filteredEntries := make([]repository.ManifestEntry, 0, len(manifestEntries)) for _, manifestEntry := range manifestEntries { if manifestEntry.EntryType == entryType { filteredEntries = append(filteredEntries, manifestEntry) } } return filteredEntries } // applyDirectoryPermissions setzt die Rechte der Verzeichnisse. // // Die Reihenfolge läuft von innen nach außen: ein nur lesbares // Elternverzeichnis verhinderte sonst, seine Kinder noch anzupassen. func applyDirectoryPermissions(directoryEntries []repository.ManifestEntry, absoluteTarget string) error { sortedEntries := make([]repository.ManifestEntry, len(directoryEntries)) copy(sortedEntries, directoryEntries) sort.Slice(sortedEntries, func(firstIndex int, secondIndex int) bool { return len(sortedEntries[firstIndex].Path) > len(sortedEntries[secondIndex].Path) }) for _, manifestEntry := range sortedEntries { if manifestEntry.Mode == "" { continue } directoryMode, parseError := parseFileMode(manifestEntry.Mode) if parseError != nil { continue } targetPath := filepath.Join(absoluteTarget, filepath.FromSlash(manifestEntry.Path)) if chmodError := os.Chmod(targetPath, directoryMode); chmodError != nil { return fmt.Errorf("die rechte von %q konnten nicht gesetzt werden: %w", manifestEntry.Path, chmodError) } } return nil } // parseFileMode liest Dateirechte aus ihrer oktalen Schreibweise. func parseFileMode(modeText string) (os.FileMode, error) { parsedMode, parseError := strconv.ParseUint(modeText, 8, 32) if parseError != nil { return 0, fmt.Errorf("die rechteangabe %q ist unlesbar", modeText) } return os.FileMode(parsedMode), nil } // restoreLeftoverPrefix ist der Namensanfang unfertiger Zieldateien. const restoreLeftoverPrefix = ".syncova-restore-" // removeRestoreLeftovers entfernt die Reste eines abgebrochenen Laufs. // // Gelöscht wird ausschließlich, was diesen Namensanfang trägt: Er stammt aus // os.CreateTemp mit unserem eigenen Muster und kann keine fremde Datei treffen. // Ein Fehler beim Löschen bricht die Wiederherstellung **nicht** ab — ein // liegengebliebener Rest ist ärgerlich, das Abbrechen der Wiederherstellung // deswegen wäre schlimmer. func removeRestoreLeftovers(absoluteTarget string, logger *slog.Logger) int { removedCount := 0 walkError := filepath.WalkDir(absoluteTarget, func(currentPath string, directoryEntry fs.DirEntry, walkError error) error { if walkError != nil { return nil } if directoryEntry.IsDir() || !strings.HasPrefix(directoryEntry.Name(), restoreLeftoverPrefix) { return nil } if removeError := os.Remove(currentPath); removeError != nil { logger.Warn("ein rest des abgebrochenen laufs liess sich nicht entfernen", slog.String("pfad", currentPath), slog.String("grund", removeError.Error())) return nil } removedCount++ return nil }) if walkError != nil { logger.Warn("die reste des abgebrochenen laufs liessen sich nicht durchsuchen", slog.String("grund", walkError.Error())) } return removedCount }