syncova-backup/packages/agent/restore.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

561 lines
21 KiB
Go

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
}