syncova-backup/packages/backupformat/writer.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

332 lines
12 KiB
Go

package backupformat
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
"io"
"time"
)
// Writer schreibt einen Backup Container.
//
// Der Schreibvorgang läuft als Datenstrom: der Container entsteht Abschnitt für
// Abschnitt, ohne dass das Backup jemals vollständig im Arbeitsspeicher liegt
// (PROMPT.md §80). Erst der abschließende Footer macht ihn gültig.
type Writer struct {
// outputWriter ist die Senke des Containers.
outputWriter io.Writer
// contentDigest berechnet die Gesamtprüfsumme über Header und Abschnitte mit.
contentDigest hash.Hash
// bytesWritten zählt die geschriebenen Byte ohne den Footer.
bytesWritten int64
// sectionCount zählt die geschriebenen Abschnitte.
sectionCount int
// manifestHash ist die Prüfsumme des Manifestabschnitts.
manifestHash string
// writtenSectionTypes hält fest, welche Abschnitte bereits vorliegen.
writtenSectionTypes map[SectionType]bool
// isClosed meldet, ob der Container bereits abgeschlossen ist.
isClosed bool
// timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar.
timeSource func() time.Time
}
// NewWriter beginnt einen Container und schreibt dessen Header.
//
// Der Header wird sofort ausgegeben, damit ein Leser schon während des
// Schreibens Version und Kennung erkennen kann.
func NewWriter(outputWriter io.Writer, containerHeader ContainerHeader) (*Writer, error) {
containerWriter := &Writer{
outputWriter: outputWriter,
contentDigest: sha256.New(),
writtenSectionTypes: make(map[SectionType]bool),
timeSource: time.Now,
}
if writeError := containerWriter.writeContainerHeader(containerHeader); writeError != nil {
return nil, writeError
}
return containerWriter, nil
}
// writeContainerHeader schreibt Kennung, Version und Header.
func (containerWriter *Writer) writeContainerHeader(containerHeader ContainerHeader) error {
encodedHeader, marshalError := json.Marshal(containerHeader)
if marshalError != nil {
return fmt.Errorf("der container-header konnte nicht erzeugt werden: %w", marshalError)
}
if len(encodedHeader) > maximumHeaderLength {
return fmt.Errorf("der container-header ist mit %d byte zu groß", len(encodedHeader))
}
// Kennung
if writeError := containerWriter.writeAndDigest(containerMagic[:]); writeError != nil {
return writeError
}
// Version, durchgehend als Big Endian - die Byte-Reihenfolge ist Teil des
// Formats und darf nicht von der Rechnerarchitektur abhängen.
versionBytes := make([]byte, versionLength)
binary.BigEndian.PutUint16(versionBytes, FormatVersion)
if writeError := containerWriter.writeAndDigest(versionBytes); writeError != nil {
return writeError
}
// Headerlänge
headerLengthBytes := make([]byte, headerLengthFieldLength)
binary.BigEndian.PutUint32(headerLengthBytes, uint32(len(encodedHeader)))
if writeError := containerWriter.writeAndDigest(headerLengthBytes); writeError != nil {
return writeError
}
return containerWriter.writeAndDigest(encodedHeader)
}
// WriteSection schreibt einen Abschnitt mit vorliegendem Inhalt.
//
// Für den Datenbereich, der beliebig groß werden kann, steht stattdessen
// WriteSectionStream bereit.
func (containerWriter *Writer) WriteSection(sectionType SectionType, sectionFlags SectionFlags, sectionContent []byte) error {
if containerWriter.isClosed {
return ErrWriterClosed
}
sectionDigest := sha256.Sum256(sectionContent)
if writeError := containerWriter.writeSectionHeader(sectionType, sectionFlags,
int64(len(sectionContent)), sectionDigest[:]); writeError != nil {
return writeError
}
if writeError := containerWriter.writeAndDigest(sectionContent); writeError != nil {
return writeError
}
// Die Prüfsumme des Manifests wandert in den Footer: sie ist die Kennung
// des Backup-Inhalts und wird beim Lesen gegengeprüft.
if sectionType == SectionManifest {
containerWriter.manifestHash = hex.EncodeToString(sectionDigest[:])
}
containerWriter.sectionCount++
containerWriter.writtenSectionTypes[sectionType] = true
return nil
}
// WriteJSONSection schreibt einen Abschnitt mit JSON-Inhalt.
func (containerWriter *Writer) WriteJSONSection(sectionType SectionType, sectionFlags SectionFlags, sectionValue any) error {
encodedValue, marshalError := json.Marshal(sectionValue)
if marshalError != nil {
return fmt.Errorf("der abschnitt %s konnte nicht erzeugt werden: %w", sectionType, marshalError)
}
return containerWriter.WriteSection(sectionType, sectionFlags, encodedValue)
}
// SectionStreamWriter nimmt den Inhalt eines Abschnitts als Datenstrom auf.
type SectionStreamWriter struct {
// containerWriter ist der zugehörige Container.
containerWriter *Writer
// sectionDigest berechnet die Prüfsumme des Abschnitts mit.
sectionDigest hash.Hash
// declaredLength ist die angekündigte Länge des Abschnitts.
declaredLength int64
// writtenLength ist die bisher geschriebene Länge.
writtenLength int64
// sectionType ist die Art des Abschnitts.
sectionType SectionType
// isFinished meldet, ob der Abschnitt bereits abgeschlossen wurde.
isFinished bool
}
// BeginSectionStream beginnt einen Abschnitt, dessen Inhalt als Datenstrom folgt.
//
// Die Länge muss vorab feststehen, weil sie im Abschnittskopf steht — nur so
// kann ein Leser einen unbekannten Abschnitt überspringen, ohne ihn zu deuten.
// Beim Export aus einem Repository ist sie aus dem Chunk-Verzeichnis bekannt.
//
// Der Abschnitt trägt FlagDeferredDigest: seine Prüfsumme steht zum Zeitpunkt
// des Kopfes noch nicht fest. Der Weg arbeitet deshalb auf jeder Senke und
// setzt kein Rückspulen voraus (PROMPT.md §80).
func (containerWriter *Writer) BeginSectionStream(sectionType SectionType, sectionFlags SectionFlags, declaredLength int64) (*SectionStreamWriter, error) {
if containerWriter.isClosed {
return nil, ErrWriterClosed
}
if declaredLength < 0 || declaredLength > maximumSectionLength {
return nil, fmt.Errorf("%w: %d byte", ErrSectionTooLarge, declaredLength)
}
// Die Prüfsumme im Kopf bleibt leer und wird beim Lesen nicht herangezogen.
emptyDigest := make([]byte, digestLength)
if writeError := containerWriter.writeSectionHeader(sectionType,
sectionFlags|FlagDeferredDigest, declaredLength, emptyDigest); writeError != nil {
return nil, writeError
}
return &SectionStreamWriter{
containerWriter: containerWriter,
sectionDigest: sha256.New(),
declaredLength: declaredLength,
sectionType: sectionType,
}, nil
}
// Write nimmt Daten des Abschnitts auf.
func (streamWriter *SectionStreamWriter) Write(sectionData []byte) (int, error) {
if streamWriter.isFinished {
return 0, fmt.Errorf("der abschnitt %s wurde bereits abgeschlossen", streamWriter.sectionType)
}
// Mehr als angekündigt zu schreiben würde den Container unlesbar machen:
// der folgende Abschnittskopf läge dann an der falschen Stelle.
if streamWriter.writtenLength+int64(len(sectionData)) > streamWriter.declaredLength {
return 0, fmt.Errorf("der abschnitt %s würde seine angekündigte länge von %d byte überschreiten",
streamWriter.sectionType, streamWriter.declaredLength)
}
bytesWritten, writeError := streamWriter.containerWriter.outputWriter.Write(sectionData)
if bytesWritten > 0 {
// Der Fehler von Write kann bei einem Hash nicht auftreten.
_, _ = streamWriter.sectionDigest.Write(sectionData[:bytesWritten])
_, _ = streamWriter.containerWriter.contentDigest.Write(sectionData[:bytesWritten])
streamWriter.writtenLength += int64(bytesWritten)
streamWriter.containerWriter.bytesWritten += int64(bytesWritten)
}
if writeError != nil {
return bytesWritten, fmt.Errorf("der abschnitt %s konnte nicht geschrieben werden: %w",
streamWriter.sectionType, writeError)
}
return bytesWritten, nil
}
// Finish schließt den Abschnitt ab.
//
// Die gebildete Prüfsumme wandert nicht in den bereits geschriebenen Kopf,
// sondern wird vom Aufrufer im Chunk-Verzeichnis abgelegt. Sie ist über
// Digest() abrufbar.
func (streamWriter *SectionStreamWriter) Finish() error {
if streamWriter.isFinished {
return nil
}
// Weniger als angekündigt zu schreiben ergäbe einen Abschnitt, dessen
// Längenangabe nicht zum Inhalt passt — der folgende Abschnittskopf läge
// dann an der falschen Stelle und der Container wäre unlesbar.
if streamWriter.writtenLength != streamWriter.declaredLength {
return fmt.Errorf("der abschnitt %s wurde mit %d von %d angekündigten byte geschrieben",
streamWriter.sectionType, streamWriter.writtenLength, streamWriter.declaredLength)
}
streamWriter.containerWriter.sectionCount++
streamWriter.containerWriter.writtenSectionTypes[streamWriter.sectionType] = true
streamWriter.isFinished = true
return nil
}
// Digest liefert die Prüfsumme des geschriebenen Abschnittsinhalts.
func (streamWriter *SectionStreamWriter) Digest() string {
return hex.EncodeToString(streamWriter.sectionDigest.Sum(nil))
}
// writeSectionHeader schreibt den Kopf eines Abschnitts.
func (containerWriter *Writer) writeSectionHeader(sectionType SectionType, sectionFlags SectionFlags, sectionLength int64, sectionDigest []byte) error {
if sectionLength < 0 || sectionLength > maximumSectionLength {
return fmt.Errorf("%w: %d byte", ErrSectionTooLarge, sectionLength)
}
headerBuffer := make([]byte, 0, sectionHeaderLength)
headerBuffer = append(headerBuffer, byte(sectionType), byte(sectionFlags))
lengthBytes := make([]byte, 8)
binary.BigEndian.PutUint64(lengthBytes, uint64(sectionLength))
headerBuffer = append(headerBuffer, lengthBytes...)
headerBuffer = append(headerBuffer, sectionDigest...)
return containerWriter.writeAndDigest(headerBuffer)
}
// writeAndDigest schreibt Daten und führt die Gesamtprüfsumme mit.
func (containerWriter *Writer) writeAndDigest(outputData []byte) error {
bytesWritten, writeError := containerWriter.outputWriter.Write(outputData)
if bytesWritten > 0 {
_, _ = containerWriter.contentDigest.Write(outputData[:bytesWritten])
containerWriter.bytesWritten += int64(bytesWritten)
}
if writeError != nil {
return fmt.Errorf("der container konnte nicht geschrieben werden: %w", writeError)
}
return nil
}
// Close schreibt den Footer und schließt den Container ab.
//
// Erst danach ist der Container gültig. Ein Abbruch vor diesem Aufruf
// hinterlässt eine Datei ohne Abschlussvermerk, die beim Lesen zuverlässig als
// unvollständig erkannt wird.
func (containerWriter *Writer) Close() error {
if containerWriter.isClosed {
return ErrWriterClosed
}
// Ein Container ohne Manifest beschreibt kein Backup. Ihn abzuschließen
// hiesse, ein leeres Gebilde als gültig auszugeben (PROMPT.md §138).
if !containerWriter.writtenSectionTypes[SectionManifest] {
return fmt.Errorf("%w: es fehlt der Abschnitt %s", ErrMissingSection, SectionManifest)
}
containerFooter := ContainerFooter{
ManifestHash: containerWriter.manifestHash,
ContentHash: hex.EncodeToString(containerWriter.contentDigest.Sum(nil)),
SectionCount: containerWriter.sectionCount,
TotalBytes: containerWriter.bytesWritten,
CompletedAt: containerWriter.timeSource().UTC(),
Complete: true,
}
encodedFooter, marshalError := json.Marshal(containerFooter)
if marshalError != nil {
return fmt.Errorf("der container-footer konnte nicht erzeugt werden: %w", marshalError)
}
// Der Footer wird nicht in die Gesamtprüfsumme aufgenommen: er enthält sie.
if _, writeError := containerWriter.outputWriter.Write(footerMagic[:]); writeError != nil {
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
}
footerLengthBytes := make([]byte, headerLengthFieldLength)
binary.BigEndian.PutUint32(footerLengthBytes, uint32(len(encodedFooter)))
if _, writeError := containerWriter.outputWriter.Write(footerLengthBytes); writeError != nil {
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
}
if _, writeError := containerWriter.outputWriter.Write(encodedFooter); writeError != nil {
return fmt.Errorf("der container-footer konnte nicht geschrieben werden: %w", writeError)
}
containerWriter.isClosed = true
return nil
}
// BytesWritten liefert die bisher geschriebene Menge ohne den Footer.
func (containerWriter *Writer) BytesWritten() int64 {
return containerWriter.bytesWritten
}