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])) }