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>
561 lines
21 KiB
Go
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
|
|
}
|