syncova-backup/packages/repository/atomicfile.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

125 lines
4.8 KiB
Go

package repository
import (
"fmt"
"os"
"path/filepath"
)
// Dateirechte innerhalb eines Repositorys.
//
// Backup-Daten gehen niemandem außer dem Dienst etwas an; die Rechte sind
// deshalb so eng wie möglich gewählt (PROMPT.md §2.1, Least Privilege).
const (
// dataFilePermissions gelten für Chunks, Manifeste und den Katalog.
dataFilePermissions os.FileMode = 0o600
// immutableFilePermissions gelten für Daten unter Retention Lock.
//
// **Sie verhindern kein Löschen.** Unter POSIX hängt das Entfernen einer
// Datei am Schreibrecht ihres *Verzeichnisses*, nicht an ihren eigenen
// Rechten; real gemessen in `MeasureEnforcement`. Was sie verhindern, ist
// das versehentliche Überschreiben durch einen schreibenden Open.
//
// Den Löschschutz leistet das Unveränderlich-Kennzeichen des Dateisystems
// (siehe immutability.go) — und auch das nur gegenüber dem Dienstbenutzer,
// nicht gegenüber der Systemverwaltung.
immutableFilePermissions os.FileMode = 0o400
// directoryPermissions gelten für alle Verzeichnisse des Repositorys.
directoryPermissions os.FileMode = 0o700
)
// writeFileAtomically schreibt eine Datei so, dass sie niemals halb sichtbar wird.
//
// Der Ablauf ist für die Absturzsicherheit entscheidend (PROMPT.md §129:
// Datenintegrität vor Geschwindigkeit):
//
// 1. In eine temporäre Datei im Zielverzeichnis schreiben.
// 2. fsync auf die Datei — erst danach liegen die Daten wirklich auf dem Datenträger.
// 3. rename auf den Zielnamen — unter POSIX ein atomarer Vorgang.
// 4. fsync auf das Verzeichnis — erst danach überlebt auch der Namenseintrag
// einen Stromausfall.
//
// Ohne Schritt 2 und 4 könnte nach einem Absturz eine Datei existieren, deren
// Inhalt leer oder unvollständig ist — ein beschädigtes Backup, das sich als
// vollständig ausgibt.
func writeFileAtomically(targetPath string, fileContent []byte, filePermissions os.FileMode) error {
targetDirectory := filepath.Dir(targetPath)
// Die temporäre Datei liegt im Zielverzeichnis, damit rename nicht über eine
// Dateisystemgrenze läuft — sonst wäre er nicht atomar.
temporaryFile, createError := os.CreateTemp(targetDirectory, ".tmp-*")
if createError != nil {
return fmt.Errorf("die temporäre datei konnte nicht angelegt werden: %w", createError)
}
temporaryPath := temporaryFile.Name()
// Bei jedem Fehler bleibt keine Bruchstückdatei zurück.
removeTemporaryFile := true
defer func() {
if removeTemporaryFile {
_ = temporaryFile.Close()
_ = os.Remove(temporaryPath)
}
}()
if _, writeError := temporaryFile.Write(fileContent); writeError != nil {
return fmt.Errorf("die daten konnten nicht geschrieben werden: %w", writeError)
}
// Die Rechte werden vor dem Sichtbarwerden gesetzt.
if chmodError := temporaryFile.Chmod(filePermissions); chmodError != nil {
return fmt.Errorf("die dateirechte konnten nicht gesetzt werden: %w", chmodError)
}
// Schritt 2: Ohne fsync bestätigt das Betriebssystem den Schreibvorgang,
// obwohl die Daten nur im Zwischenspeicher liegen.
if syncError := temporaryFile.Sync(); syncError != nil {
return fmt.Errorf("die daten konnten nicht dauerhaft gesichert werden: %w", syncError)
}
if closeError := temporaryFile.Close(); closeError != nil {
return fmt.Errorf("die temporäre datei konnte nicht geschlossen werden: %w", closeError)
}
// Schritt 3: Der Umbenennungsvorgang macht die fertige Datei in einem Zug sichtbar.
if renameError := os.Rename(temporaryPath, targetPath); renameError != nil {
return fmt.Errorf("die datei konnte nicht an ihren platz gebracht werden: %w", renameError)
}
removeTemporaryFile = false
// Schritt 4: Der Verzeichniseintrag selbst muss ebenfalls gesichert werden.
return syncDirectory(targetDirectory)
}
// syncDirectory sichert die Verzeichniseinträge eines Verzeichnisses.
//
// Ohne diesen Schritt kann nach einem Stromausfall der Dateiinhalt vorhanden,
// der Name aber verschwunden sein.
func syncDirectory(directoryPath string) error {
directoryHandle, openError := os.Open(directoryPath)
if openError != nil {
return fmt.Errorf("das verzeichnis konnte nicht geöffnet werden: %w", openError)
}
defer func() { _ = directoryHandle.Close() }()
if syncError := directoryHandle.Sync(); syncError != nil {
// Einige Dateisysteme lehnen fsync auf Verzeichnissen ab. Das ist kein
// Fehler der Anwendung, wird aber weitergereicht, damit der Aufrufer
// entscheiden kann.
return fmt.Errorf("das verzeichnis konnte nicht dauerhaft gesichert werden: %w", syncError)
}
return nil
}
// ensureDirectory legt ein Verzeichnis samt Elternverzeichnissen an.
func ensureDirectory(directoryPath string) error {
if makeError := os.MkdirAll(directoryPath, directoryPermissions); makeError != nil {
return fmt.Errorf("das verzeichnis %q konnte nicht angelegt werden: %w", directoryPath, makeError)
}
return nil
}