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

310 lines
11 KiB
Go

package backupformat
import (
"bytes"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
)
// Section ist ein gelesener Abschnitt eines Containers.
type Section struct {
// Type ist die Art des Abschnitts.
Type SectionType
// Flags beschreiben Eigenschaften des Abschnitts.
Flags SectionFlags
// Content ist der geprüfte Inhalt.
Content []byte
// Digest ist die Prüfsumme aus dem Abschnittskopf.
Digest string
}
// Reader liest einen Backup Container.
//
// Gelesen wird sequenziell und prüfend: jeder Abschnitt wird gegen seine
// Prüfsumme gehalten, bevor sein Inhalt weitergereicht wird. Ein beschädigter
// Container darf niemals unbemerkt in eine Wiederherstellung fließen
// (PROMPT.md §14).
type Reader struct {
// inputReader ist die Quelle des Containers.
inputReader io.Reader
// header ist der gelesene Container-Header.
header ContainerHeader
// formatVersion ist die Formatversion des Containers.
formatVersion uint16
// contentDigest führt die Gesamtprüfsumme mit.
contentDigest interface {
io.Writer
Sum([]byte) []byte
}
// sectionsRead zählt die gelesenen Abschnitte.
sectionsRead int
// bytesRead zählt die gelesenen Byte ohne den Footer.
bytesRead int64
// manifestDigest ist die Prüfsumme des Manifestabschnitts.
manifestDigest string
// footer ist der gelesene Footer; er liegt erst am Ende vor.
footer *ContainerFooter
}
// NewReader öffnet einen Container und liest dessen Header.
//
// Version und Kennung werden sofort geprüft: ein fremdes oder unlesbares
// Format wird abgelehnt, bevor irgendein Inhalt gedeutet wird.
func NewReader(inputReader io.Reader) (*Reader, error) {
containerReader := &Reader{
inputReader: inputReader,
contentDigest: sha256.New(),
}
if readError := containerReader.readContainerHeader(); readError != nil {
return nil, readError
}
return containerReader, nil
}
// readContainerHeader liest Kennung, Version und Header.
func (containerReader *Reader) readContainerHeader() error {
magicBuffer := make([]byte, magicLength)
if readError := containerReader.readFullAndDigest(magicBuffer); readError != nil {
// Zu wenige Daten für die Kennung bedeuten: das ist kein Container.
return ErrNotAContainer
}
if !bytes.Equal(magicBuffer, containerMagic[:]) {
return ErrNotAContainer
}
versionBuffer := make([]byte, versionLength)
if readError := containerReader.readFullAndDigest(versionBuffer); readError != nil {
return ErrContainerTruncated
}
containerReader.formatVersion = binary.BigEndian.Uint16(versionBuffer)
if !IsVersionReadable(containerReader.formatVersion) {
return describeVersionMismatch(containerReader.formatVersion)
}
headerLengthBuffer := make([]byte, headerLengthFieldLength)
if readError := containerReader.readFullAndDigest(headerLengthBuffer); readError != nil {
return ErrContainerTruncated
}
headerLength := binary.BigEndian.Uint32(headerLengthBuffer)
// Eine verfälschte Längenangabe darf keine unbegrenzte Speicheranforderung auslösen.
if headerLength > maximumHeaderLength {
return fmt.Errorf("%w: der header ist mit %d byte unplausibel groß", ErrNotAContainer, headerLength)
}
headerBuffer := make([]byte, headerLength)
if readError := containerReader.readFullAndDigest(headerBuffer); readError != nil {
return ErrContainerTruncated
}
if unmarshalError := json.Unmarshal(headerBuffer, &containerReader.header); unmarshalError != nil {
return fmt.Errorf("%w: der header ist unlesbar", ErrNotAContainer)
}
return nil
}
// Header liefert den Container-Header.
func (containerReader *Reader) Header() ContainerHeader {
return containerReader.header
}
// FormatVersion liefert die Formatversion des Containers.
func (containerReader *Reader) FormatVersion() uint16 {
return containerReader.formatVersion
}
// NextSection liest den nächsten Abschnitt.
//
// Am Ende der Abschnittskette wird io.EOF geliefert; der Footer ist dann
// gelesen und über Footer() abrufbar.
func (containerReader *Reader) NextSection() (*Section, error) {
sectionHeaderBuffer := make([]byte, sectionHeaderLength)
bytesRead, readError := io.ReadFull(containerReader.inputReader, sectionHeaderBuffer)
// Ein Abschnittskopf, der wie ein Footer beginnt, beendet die Kette.
if bytesRead >= magicLength && bytes.Equal(sectionHeaderBuffer[:magicLength], footerMagic[:]) {
return nil, containerReader.readFooter(sectionHeaderBuffer[:bytesRead])
}
if readError != nil {
if errors.Is(readError, io.EOF) || errors.Is(readError, io.ErrUnexpectedEOF) {
// Die Daten enden, ohne dass ein Footer kam.
return nil, ErrContainerTruncated
}
return nil, fmt.Errorf("der container konnte nicht gelesen werden: %w", readError)
}
// Der Kopf gehört zur Gesamtprüfsumme.
_, _ = containerReader.contentDigest.Write(sectionHeaderBuffer)
containerReader.bytesRead += int64(len(sectionHeaderBuffer))
sectionType := SectionType(sectionHeaderBuffer[0])
sectionFlags := SectionFlags(sectionHeaderBuffer[1])
sectionLength := binary.BigEndian.Uint64(sectionHeaderBuffer[2:10])
expectedDigest := sectionHeaderBuffer[10 : 10+digestLength]
if sectionLength > maximumSectionLength {
return nil, fmt.Errorf("%w: %d byte", ErrSectionTooLarge, sectionLength)
}
sectionContent := make([]byte, sectionLength)
if _, contentError := io.ReadFull(containerReader.inputReader, sectionContent); contentError != nil {
return nil, ErrContainerTruncated
}
containerReader.bytesRead += int64(sectionLength)
actualDigest := sha256.Sum256(sectionContent)
// Ein als Datenstrom geschriebener Abschnitt trägt keine Prüfsumme im Kopf:
// sie stand dort noch nicht fest. Seine Unversehrtheit sichern die
// Prüfsummen der einzelnen Chunks im Verzeichnis und die Gesamtprüfsumme
// im Footer, die beide weiter unten geprüft werden.
if !sectionFlags.Has(FlagDeferredDigest) {
// Der Vergleich läuft in konstanter Zeit: eine Prüfsumme ist ein
// Sicherheitsmerkmal, kein bloßer Vergleichswert.
if subtle.ConstantTimeCompare(actualDigest[:], expectedDigest) != 1 {
return nil, fmt.Errorf("%w: %s (erwartet %s, tatsächlich %s)",
ErrSectionCorrupted, sectionType,
hex.EncodeToString(expectedDigest), hex.EncodeToString(actualDigest[:]))
}
}
_, _ = containerReader.contentDigest.Write(sectionContent)
// Ein unbekannter Abschnitt darf nur übersprungen werden, wenn er nicht als
// erforderlich gekennzeichnet ist. Andernfalls fehlte etwas Wesentliches,
// und ein Weiterarbeiten täuschte Vollständigkeit vor (PROMPT.md §140).
if !isKnownSectionType(sectionType) && sectionFlags.Has(FlagRequired) {
return nil, fmt.Errorf("%w: Abschnittstyp %d. Bitte Syncova aktualisieren",
ErrUnknownRequiredSection, uint8(sectionType))
}
containerReader.sectionsRead++
if sectionType == SectionManifest {
containerReader.manifestDigest = hex.EncodeToString(actualDigest[:])
}
return &Section{
Type: sectionType,
Flags: sectionFlags,
Content: sectionContent,
Digest: hex.EncodeToString(actualDigest[:]),
}, nil
}
// readFooter liest den Footer und prüft den Abschlussvermerk.
func (containerReader *Reader) readFooter(alreadyRead []byte) error {
// Der Kennungsteil wurde bereits gelesen; es fehlen Längenfeld und Inhalt.
remainingHeader := alreadyRead[magicLength:]
footerLengthBuffer := make([]byte, headerLengthFieldLength)
copiedBytes := copy(footerLengthBuffer, remainingHeader)
if copiedBytes < headerLengthFieldLength {
if _, readError := io.ReadFull(containerReader.inputReader, footerLengthBuffer[copiedBytes:]); readError != nil {
return ErrContainerTruncated
}
}
footerLength := binary.BigEndian.Uint32(footerLengthBuffer)
if footerLength > maximumHeaderLength {
return fmt.Errorf("%w: der footer ist unplausibel groß", ErrIncompleteBackup)
}
footerBuffer := make([]byte, footerLength)
// Teile des Footers können bereits im Puffer des Abschnittskopfs liegen.
alreadyBuffered := remainingHeader[min(len(remainingHeader), headerLengthFieldLength):]
copiedFooterBytes := copy(footerBuffer, alreadyBuffered)
if copiedFooterBytes < int(footerLength) {
if _, readError := io.ReadFull(containerReader.inputReader, footerBuffer[copiedFooterBytes:]); readError != nil {
return ErrContainerTruncated
}
}
var containerFooter ContainerFooter
if unmarshalError := json.Unmarshal(footerBuffer, &containerFooter); unmarshalError != nil {
return fmt.Errorf("%w: der footer ist unlesbar", ErrIncompleteBackup)
}
// Ohne ausdrücklichen Abschlussvermerk gilt der Container als unvollständig.
if !containerFooter.Complete {
return ErrIncompleteBackup
}
// Die Gesamtprüfsumme deckt Header und alle Abschnitte ab. Sie deckt auch
// den Austausch eines vollständigen Abschnitts samt seiner Prüfsumme auf.
actualContentHash := hex.EncodeToString(containerReader.contentDigest.Sum(nil))
if subtle.ConstantTimeCompare([]byte(actualContentHash), []byte(containerFooter.ContentHash)) != 1 {
return fmt.Errorf("%w (erwartet %s, tatsächlich %s)",
ErrIntegrityMismatch, containerFooter.ContentHash, actualContentHash)
}
// Eine abweichende Abschnittszahl deckt einen entfernten Abschnitt auf.
if containerFooter.SectionCount != containerReader.sectionsRead {
return fmt.Errorf("%w: der footer nennt %d abschnitte, gelesen wurden %d",
ErrIntegrityMismatch, containerFooter.SectionCount, containerReader.sectionsRead)
}
if containerFooter.ManifestHash != containerReader.manifestDigest {
return fmt.Errorf("%w: die prüfsumme des manifests stimmt nicht", ErrIntegrityMismatch)
}
containerReader.footer = &containerFooter
return io.EOF
}
// Footer liefert den Footer, sofern der Container vollständig gelesen wurde.
//
// Vorher ist er nil: der Abschlussvermerk steht erst am Ende fest.
func (containerReader *Reader) Footer() *ContainerFooter {
return containerReader.footer
}
// IsComplete meldet, ob der Container einen gültigen Abschlussvermerk trägt.
func (containerReader *Reader) IsComplete() bool {
return containerReader.footer != nil && containerReader.footer.Complete
}
// readFullAndDigest liest genau die angeforderte Menge und führt die Prüfsumme mit.
func (containerReader *Reader) readFullAndDigest(targetBuffer []byte) error {
if _, readError := io.ReadFull(containerReader.inputReader, targetBuffer); readError != nil {
return readError
}
_, _ = containerReader.contentDigest.Write(targetBuffer)
containerReader.bytesRead += int64(len(targetBuffer))
return nil
}
// isKnownSectionType meldet, ob diese Programmversion einen Abschnittstyp kennt.
func isKnownSectionType(sectionType SectionType) bool {
switch sectionType {
case SectionManifest, SectionChunkIndex, SectionBlockMap,
SectionDataChunks, SectionSourceMetadata, SectionIntegrityInfo:
return true
default:
return false
}
}