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

254 lines
9.1 KiB
Go

package backupformat
import (
"bytes"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
)
// fixtureFileName ist der eingefrorene Beispielcontainer.
//
// Er wurde mit Formatversion 1 geschrieben und liegt als Datei im Repository.
// Das ist der Unterschied zu einem gewöhnlichen Rundlauftest: Der schreibt und
// liest mit **demselben** Code und bliebe deshalb auch dann grün, wenn sich
// beide Seiten gemeinsam ändern.
const fixtureFileName = "testdata/container-v1.syncova"
// TestFormatVersionIsFrozen haelt die Formatversion fest (Phase 22).
//
// Die Version ist ausgeliefert. Sie zu erhoehen ist erlaubt — aber niemals
// nebenbei: Ein Container traegt sie im Kopf, und ein Leser entscheidet daran,
// ob er ihn ueberhaupt anfassen darf.
func TestFormatVersionIsFrozen(testInstance *testing.T) {
if FormatVersion != 1 {
testInstance.Errorf("die geschriebene Formatversion ist %d statt 1.\n"+
" Eine neue Version verlangt einen Leser für die alte — sonst sind "+
"bestehende Backups nicht mehr lesbar.", FormatVersion)
}
if MinimumReadableVersion != 1 {
testInstance.Errorf("die älteste lesbare Version ist %d statt 1.\n"+
" Sie anzuheben macht ausgelieferte Backups unlesbar.", MinimumReadableVersion)
}
// Die Kennung am Anfang jedes Containers. Sie zu ändern hieße: Kein
// bestehender Container wird mehr als Syncova-Container erkannt.
if string(containerMagic[:]) != "SYNCOVA1" {
testInstance.Errorf("die Containerkennung ist %q statt \"SYNCOVA1\"", string(containerMagic[:]))
}
if string(footerMagic[:]) != "SYNFOOT1" {
testInstance.Errorf("die Footerkennung ist %q statt \"SYNFOOT1\"", string(footerMagic[:]))
}
}
// TestSectionTypeValuesAreFrozen haelt die Abschnittsnummern fest.
//
// Die Werte sind Teil des Formatvertrags. Eine neu belegte Nummer deutet einen
// bestehenden Container **falsch** — und zwar ohne Fehler: Der Leser findet
// einen gueltigen Abschnitt vor und haelt ihn fuer etwas anderes.
func TestSectionTypeValuesAreFrozen(testInstance *testing.T) {
frozenValues := map[SectionType]uint8{
SectionManifest: 1,
SectionChunkIndex: 2,
SectionBlockMap: 3,
SectionDataChunks: 4,
SectionSourceMetadata: 5,
SectionIntegrityInfo: 6,
}
for sectionType, expectedValue := range frozenValues {
if uint8(sectionType) != expectedValue {
testInstance.Errorf("der Abschnitt %s hat die Nummer %d statt %d.\n"+
" Eine neu belegte Nummer deutet bestehende Container falsch — ohne Fehlermeldung.",
sectionType, uint8(sectionType), expectedValue)
}
}
frozenFlags := map[string]SectionFlags{
"FlagRequired": 1,
"FlagCompressed": 2,
"FlagEncrypted": 4,
"FlagDeferredDigest": 8,
}
actualFlags := map[string]SectionFlags{
"FlagRequired": FlagRequired,
"FlagCompressed": FlagCompressed,
"FlagEncrypted": FlagEncrypted,
"FlagDeferredDigest": FlagDeferredDigest,
}
for flagName, expectedValue := range frozenFlags {
if actualFlags[flagName] != expectedValue {
testInstance.Errorf("das Kennzeichen %s hat den Wert %d statt %d",
flagName, actualFlags[flagName], expectedValue)
}
}
}
// TestFrozenContainerStaysReadable liest den eingefrorenen Beispielcontainer.
//
// Das ist der eigentliche Vertragstest des Formats: eine **Datei von gestern**,
// gelesen vom Code von heute. Bricht er, ist ein bestehendes Backup unlesbar
// geworden — und das ist der schwerste denkbare Fehler dieser Anlage.
func TestFrozenContainerStaysReadable(testInstance *testing.T) {
fixtureContent, readError := os.ReadFile(fixtureFileName)
if readError != nil {
testInstance.Fatalf("der eingefrorene Container ließ sich nicht lesen: %v\n"+
" Erzeugen mit: go test -run TestWriteFrozenContainerFixture -args -schreibe-fixture", readError)
}
containerReader, openError := NewReader(bytes.NewReader(fixtureContent))
if openError != nil {
testInstance.Fatalf("der eingefrorene Container ließ sich nicht öffnen: %v", openError)
}
containerHeader := containerReader.Header()
if containerHeader.BackupID != "eingefroren-phase-22" {
testInstance.Errorf("die Backup-Kennung ist %q", containerHeader.BackupID)
}
if containerHeader.SourceType != "filesystem" {
testInstance.Errorf("die Quellart ist %q", containerHeader.SourceType)
}
if containerReader.FormatVersion() != 1 {
testInstance.Errorf("die Formatversion ist %d", containerReader.FormatVersion())
}
sectionsSeen := make(map[SectionType][]byte, 4)
for {
nextSection, sectionError := containerReader.NextSection()
if errors.Is(sectionError, io.EOF) {
break
}
if sectionError != nil {
testInstance.Fatalf("ein Abschnitt ließ sich nicht lesen: %v", sectionError)
}
sectionsSeen[nextSection.Type] = nextSection.Content
}
// Der Footer entscheidet über die Vollständigkeit: Ein abgeschnittener
// Container hat keinen und wird dadurch zuverlässig erkannt.
if !containerReader.IsComplete() {
testInstance.Fatal("der eingefrorene Container gilt als unvollständig")
}
if string(sectionsSeen[SectionDataChunks]) != "eingefrorene nutzdaten der phase 22" {
testInstance.Errorf("die Datenblöcke weichen ab: %q", string(sectionsSeen[SectionDataChunks]))
}
if len(sectionsSeen[SectionManifest]) == 0 {
testInstance.Error("im eingefrorenen Container fehlt das Manifest")
}
// Ein Abschnitt, den es damals noch nicht gab, darf einen alten Leser nicht
// stören — solange er nicht als erforderlich gekennzeichnet ist. Das ist
// die Erweiterbarkeit, die das Format überhaupt zukunftsfähig macht.
if len(sectionsSeen[SectionIntegrityInfo]) == 0 {
testInstance.Error("der optionale Zusatzabschnitt wurde nicht gelesen")
}
}
// TestTruncatedFrozenContainerIsRejected prueft die Gegenprobe.
//
// Ohne sie belegte der Fixture-Test nur, dass irgendetwas gelesen wird. Ein
// abgeschnittener Container **muss** auffallen: Er sieht bis zur letzten
// gelesenen Stelle einwandfrei aus.
func TestTruncatedFrozenContainerIsRejected(testInstance *testing.T) {
fixtureContent, readError := os.ReadFile(fixtureFileName)
if readError != nil {
testInstance.Skipf("der eingefrorene Container fehlt: %v", readError)
}
truncatedContent := fixtureContent[:len(fixtureContent)-16]
containerReader, openError := NewReader(bytes.NewReader(truncatedContent))
if openError != nil {
// Schon das Öffnen darf scheitern — dann ist die Sache erledigt.
return
}
for {
_, sectionError := containerReader.NextSection()
if sectionError != nil {
break
}
}
if containerReader.IsComplete() {
testInstance.Error("ein abgeschnittener Container wurde als vollständig gemeldet")
}
}
// TestWriteFrozenContainerFixture erzeugt den eingefrorenen Container neu.
//
// Er laeuft nur mit ausdruecklichem Schalter. Ihn versehentlich mitlaufen zu
// lassen hiesse, den Vertrag jedes Mal auf den aktuellen Stand zu heben — und
// damit genau die Pruefung abzuschalten, um die es geht.
//
// go test ./packages/backupformat -run TestWriteFrozenContainerFixture -schreibe-fixture
func TestWriteFrozenContainerFixture(testInstance *testing.T) {
if os.Getenv("SYNCOVA_WRITE_FIXTURE") != "ja" {
testInstance.Skip("nur mit SYNCOVA_WRITE_FIXTURE=ja; sonst würde der eingefrorene Vertrag überschrieben")
}
var containerBuffer bytes.Buffer
containerWriter, writerError := NewWriter(&containerBuffer, ContainerHeader{
BackupID: "eingefroren-phase-22",
ChainID: "kette-phase-22",
SourceID: "/daten",
SourceType: "filesystem",
CreatedAt: time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC),
CreatedByVersion: "0.1.0-dev",
RepositoryID: "repository-phase-22",
})
if writerError != nil {
testInstance.Fatalf("der Container ließ sich nicht anlegen: %v", writerError)
}
if sectionError := containerWriter.WriteJSONSection(SectionManifest, FlagRequired, map[string]any{
"manifest_version": 1,
"backup_id": "eingefroren-phase-22",
"entries": []any{},
}); sectionError != nil {
testInstance.Fatalf("das Manifest ließ sich nicht schreiben: %v", sectionError)
}
if sectionError := containerWriter.WriteSection(SectionDataChunks, FlagRequired,
[]byte("eingefrorene nutzdaten der phase 22")); sectionError != nil {
testInstance.Fatalf("die Datenblöcke ließen sich nicht schreiben: %v", sectionError)
}
if sectionError := containerWriter.WriteSection(SectionIntegrityInfo, 0,
[]byte("optionaler zusatzabschnitt")); sectionError != nil {
testInstance.Fatalf("der Zusatzabschnitt ließ sich nicht schreiben: %v", sectionError)
}
if closeError := containerWriter.Close(); closeError != nil {
testInstance.Fatalf("der Container ließ sich nicht abschließen: %v", closeError)
}
if makeError := os.MkdirAll(filepath.Dir(fixtureFileName), 0o755); makeError != nil {
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
}
if writeError := os.WriteFile(fixtureFileName, containerBuffer.Bytes(), 0o644); writeError != nil {
testInstance.Fatalf("der Container ließ sich nicht ablegen: %v", writeError)
}
testInstance.Logf("eingefrorener Container geschrieben: %d Byte, Anfang %s",
containerBuffer.Len(), hex.EncodeToString(containerBuffer.Bytes()[:8]))
}