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

656 lines
24 KiB
Go

package backupformat
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"io"
"testing"
"time"
)
// buildTestHeader liefert einen vollständigen Container-Header.
func buildTestHeader() ContainerHeader {
return ContainerHeader{
BackupID: "backup-1",
ChainID: "kette-1",
SourceID: "quelle-1",
SourceType: "filesystem",
CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
CreatedByVersion: "test",
RepositoryID: "repo-1",
}
}
// writeTestContainer baut einen vollständigen, gültigen Container.
func writeTestContainer(testInstance *testing.T) []byte {
testInstance.Helper()
var containerBuffer bytes.Buffer
containerWriter, writerError := NewWriter(&containerBuffer, buildTestHeader())
if writerError != nil {
testInstance.Fatalf("der Container konnte nicht begonnen werden: %v", writerError)
}
if writeError := containerWriter.WriteJSONSection(SectionManifest, FlagRequired,
map[string]any{"backup_id": "backup-1", "entries": 3}); writeError != nil {
testInstance.Fatalf("das Manifest konnte nicht geschrieben werden: %v", writeError)
}
if writeError := containerWriter.WriteJSONSection(SectionChunkIndex, FlagRequired, ChunkIndex{
Entries: []ChunkIndexEntry{
{Identifier: "aaaa", ContainerOffset: 0, StoredLength: 5, LogicalLength: 5},
},
}); writeError != nil {
testInstance.Fatalf("das Chunk-Verzeichnis konnte nicht geschrieben werden: %v", writeError)
}
if writeError := containerWriter.WriteSection(SectionDataChunks, FlagRequired, []byte("daten")); writeError != nil {
testInstance.Fatalf("die Datenblöcke konnten nicht geschrieben werden: %v", writeError)
}
if closeError := containerWriter.Close(); closeError != nil {
testInstance.Fatalf("der Container konnte nicht abgeschlossen werden: %v", closeError)
}
return containerBuffer.Bytes()
}
// readAllSections liest einen Container vollständig.
func readAllSections(containerBytes []byte) ([]*Section, *Reader, error) {
containerReader, readerError := NewReader(bytes.NewReader(containerBytes))
if readerError != nil {
return nil, nil, readerError
}
var readSections []*Section
for {
nextSection, sectionError := containerReader.NextSection()
if errors.Is(sectionError, io.EOF) {
return readSections, containerReader, nil
}
if sectionError != nil {
return readSections, containerReader, sectionError
}
readSections = append(readSections, nextSection)
}
}
// ---------------------------------------------------------------------------
// Serialisieren und Deserialisieren
// ---------------------------------------------------------------------------
func TestSerializeAndDeserializeRoundTrip(testInstance *testing.T) {
containerBytes := writeTestContainer(testInstance)
readSections, containerReader, readError := readAllSections(containerBytes)
if readError != nil {
testInstance.Fatalf("der Container konnte nicht gelesen werden: %v", readError)
}
if len(readSections) != 3 {
testInstance.Fatalf("es sollen 3 Abschnitte gelesen werden, waren %d", len(readSections))
}
// Der Header muss unverändert zurückkommen.
containerHeader := containerReader.Header()
if containerHeader.BackupID != "backup-1" || containerHeader.SourceType != "filesystem" {
testInstance.Errorf("der Header weicht ab: %+v", containerHeader)
}
if !containerHeader.CreatedAt.Equal(time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)) {
testInstance.Errorf("der Zeitstempel weicht ab: %v", containerHeader.CreatedAt)
}
// Der Abschlussvermerk muss vorliegen.
if !containerReader.IsComplete() {
testInstance.Fatal("der Container gilt nicht als vollständig")
}
if containerReader.Footer().SectionCount != 3 {
testInstance.Errorf("der Footer soll 3 Abschnitte nennen, waren %d", containerReader.Footer().SectionCount)
}
}
func TestSectionContentIsPreservedExactly(testInstance *testing.T) {
var containerBuffer bytes.Buffer
// Ein Inhalt mit allen Bytewerten deckt Kodierungsfehler auf.
originalContent := make([]byte, 256)
for byteIndex := range originalContent {
originalContent[byteIndex] = byte(byteIndex)
}
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.WriteSection(SectionDataChunks, FlagRequired, originalContent)
_ = containerWriter.Close()
readSections, _, readError := readAllSections(containerBuffer.Bytes())
if readError != nil {
testInstance.Fatalf("der Container konnte nicht gelesen werden: %v", readError)
}
if !bytes.Equal(readSections[1].Content, originalContent) {
testInstance.Fatal("der Inhalt des Datenabschnitts weicht ab")
}
}
func TestStreamedSectionRoundTrip(testInstance *testing.T) {
var containerBuffer bytes.Buffer
// Ein Datenbereich, der in mehreren Schritten geschrieben wird.
firstBlock := bytes.Repeat([]byte("A"), 4096)
secondBlock := bytes.Repeat([]byte("B"), 4096)
totalLength := int64(len(firstBlock) + len(secondBlock))
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
streamWriter, streamError := containerWriter.BeginSectionStream(SectionDataChunks, FlagRequired, totalLength)
if streamError != nil {
testInstance.Fatalf("der Datenstrom konnte nicht begonnen werden: %v", streamError)
}
if _, writeError := streamWriter.Write(firstBlock); writeError != nil {
testInstance.Fatalf("der erste Block konnte nicht geschrieben werden: %v", writeError)
}
if _, writeError := streamWriter.Write(secondBlock); writeError != nil {
testInstance.Fatalf("der zweite Block konnte nicht geschrieben werden: %v", writeError)
}
if finishError := streamWriter.Finish(); finishError != nil {
testInstance.Fatalf("der Datenstrom konnte nicht abgeschlossen werden: %v", finishError)
}
if closeError := containerWriter.Close(); closeError != nil {
testInstance.Fatalf("der Container konnte nicht abgeschlossen werden: %v", closeError)
}
readSections, containerReader, readError := readAllSections(containerBuffer.Bytes())
if readError != nil {
testInstance.Fatalf("der gestreamte Container konnte nicht gelesen werden: %v", readError)
}
if !containerReader.IsComplete() {
testInstance.Fatal("der gestreamte Container gilt nicht als vollständig")
}
expectedContent := append(append([]byte{}, firstBlock...), secondBlock...)
if !bytes.Equal(readSections[1].Content, expectedContent) {
testInstance.Fatal("der gestreamte Inhalt weicht ab")
}
}
func TestStreamedSectionRejectsWrongLength(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
streamWriter, _ := containerWriter.BeginSectionStream(SectionDataChunks, FlagRequired, 100)
_, _ = streamWriter.Write(bytes.Repeat([]byte("A"), 50))
// Weniger als angekündigt zu schreiben ergäbe einen unlesbaren Container:
// der folgende Abschnittskopf läge an der falschen Stelle.
if finishError := streamWriter.Finish(); finishError == nil {
testInstance.Fatal("ein zu kurz geschriebener Abschnitt muss abgelehnt werden")
}
}
func TestStreamedSectionRejectsOverlongWrite(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
streamWriter, _ := containerWriter.BeginSectionStream(SectionDataChunks, FlagRequired, 10)
// Mehr als angekündigt zu schreiben würde den Container zerstören.
if _, writeError := streamWriter.Write(bytes.Repeat([]byte("A"), 20)); writeError == nil {
testInstance.Fatal("ein zu langer Schreibvorgang muss abgelehnt werden")
}
}
// ---------------------------------------------------------------------------
// Versionskompatibilität
// ---------------------------------------------------------------------------
func TestReaderRejectsNewerFormatVersion(testInstance *testing.T) {
containerBytes := writeTestContainer(testInstance)
// Die Version wird auf eine künftige Fassung gesetzt.
tamperedContainer := bytes.Clone(containerBytes)
binary.BigEndian.PutUint16(tamperedContainer[magicLength:], MaximumReadableVersion+1)
// Ein neueres Format darf nicht gedeutet werden: diese Programmversion
// kennt seine Regeln nicht.
_, readerError := NewReader(bytes.NewReader(tamperedContainer))
if !errors.Is(readerError, ErrUnsupportedVersion) {
testInstance.Fatalf("eine neuere Version muss abgelehnt werden, war: %v", readerError)
}
}
func TestVersionMismatchExplainsDirection(testInstance *testing.T) {
// Der Unterschied zwischen zu alt und zu neu entscheidet über die Abhilfe.
newerVersionError := describeVersionMismatch(MaximumReadableVersion + 1)
if !bytes.Contains([]byte(newerVersionError.Error()), []byte("aktualisieren")) {
testInstance.Errorf("bei einer neueren Version soll ein Update empfohlen werden: %v", newerVersionError)
}
olderVersionError := describeVersionMismatch(0)
if bytes.Contains([]byte(olderVersionError.Error()), []byte("aktualisieren")) {
testInstance.Errorf("bei einer älteren Version hilft kein Update: %v", olderVersionError)
}
}
func TestUnknownOptionalSectionIsSkipped(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
// Ein Abschnittstyp, den diese Programmversion nicht kennt, aber der nicht
// als erforderlich gilt - so ergänzt eine spätere Version das Format.
const unknownOptionalType SectionType = 200
_ = containerWriter.WriteSection(unknownOptionalType, 0, []byte("zukunftsdaten"))
_ = containerWriter.Close()
readSections, containerReader, readError := readAllSections(containerBuffer.Bytes())
if readError != nil {
testInstance.Fatalf("ein unbekannter optionaler Abschnitt darf das Lesen nicht verhindern: %v", readError)
}
if len(readSections) != 2 {
testInstance.Fatalf("es sollen 2 Abschnitte gelesen werden, waren %d", len(readSections))
}
if !containerReader.IsComplete() {
testInstance.Error("der Container soll trotz unbekanntem Abschnitt vollständig sein")
}
}
func TestUnknownRequiredSectionIsRejected(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
// Ein unbekannter, aber als erforderlich gekennzeichneter Abschnitt darf
// nicht übergangen werden: es fehlte etwas Wesentliches.
const unknownRequiredType SectionType = 201
_ = containerWriter.WriteSection(unknownRequiredType, FlagRequired, []byte("wichtig"))
_ = containerWriter.Close()
_, _, readError := readAllSections(containerBuffer.Bytes())
if !errors.Is(readError, ErrUnknownRequiredSection) {
testInstance.Fatalf("ein unbekannter Pflichtabschnitt muss abgelehnt werden, war: %v", readError)
}
}
// ---------------------------------------------------------------------------
// Ungültiger Container
// ---------------------------------------------------------------------------
func TestReaderRejectsForeignData(testInstance *testing.T) {
foreignInputs := map[string][]byte{
"leer": {},
"zu kurz": []byte("SYN"),
"falsche Kennung": []byte("NICHTSYNCOVA-DATEN-DIE-LAENGER-SIND"),
"ZIP-Datei": {0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0, 0, 0},
}
for inputDescription, foreignInput := range foreignInputs {
// Ein beliebiges Datenpaket darf nicht als Container durchgehen.
if _, readerError := NewReader(bytes.NewReader(foreignInput)); readerError == nil {
testInstance.Errorf("%s: die Daten wurden als Container akzeptiert", inputDescription)
}
}
}
func TestReaderRejectsUnreadableHeader(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerBuffer.Write(containerMagic[:])
versionBytes := make([]byte, versionLength)
binary.BigEndian.PutUint16(versionBytes, FormatVersion)
containerBuffer.Write(versionBytes)
// Eine Längenangabe, der kein gültiges JSON folgt.
lengthBytes := make([]byte, headerLengthFieldLength)
binary.BigEndian.PutUint32(lengthBytes, 10)
containerBuffer.Write(lengthBytes)
containerBuffer.Write([]byte("kein-json!"))
if _, readerError := NewReader(bytes.NewReader(containerBuffer.Bytes())); !errors.Is(readerError, ErrNotAContainer) {
testInstance.Fatalf("ein unlesbarer Header muss abgelehnt werden, war: %v", readerError)
}
}
func TestReaderRejectsImplausibleHeaderLength(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerBuffer.Write(containerMagic[:])
versionBytes := make([]byte, versionLength)
binary.BigEndian.PutUint16(versionBytes, FormatVersion)
containerBuffer.Write(versionBytes)
// Eine verfälschte Längenangabe darf keine unbegrenzte Speicheranforderung auslösen.
lengthBytes := make([]byte, headerLengthFieldLength)
binary.BigEndian.PutUint32(lengthBytes, 4_000_000_000)
containerBuffer.Write(lengthBytes)
if _, readerError := NewReader(bytes.NewReader(containerBuffer.Bytes())); readerError == nil {
testInstance.Fatal("eine unplausible Headerlänge muss abgelehnt werden")
}
}
// ---------------------------------------------------------------------------
// Unvollständiges Backup
// ---------------------------------------------------------------------------
func TestTruncatedContainerIsDetected(testInstance *testing.T) {
containerBytes := writeTestContainer(testInstance)
// Der Container wird an verschiedenen Stellen abgeschnitten, so wie es ein
// abgebrochener Übertragungsvorgang hinterlässt.
truncationPoints := map[string]int{
"nach dem Header": 40,
"mitten im Abschnitt": len(containerBytes) / 2,
"kurz vor dem Footer": len(containerBytes) - 20,
}
for pointDescription, truncationPoint := range truncationPoints {
if truncationPoint <= 0 || truncationPoint >= len(containerBytes) {
continue
}
_, _, readError := readAllSections(containerBytes[:truncationPoint])
// Ein abgeschnittener Container darf niemals als vollständig gelten.
if readError == nil {
testInstance.Errorf("%s: der abgeschnittene Container wurde akzeptiert", pointDescription)
}
}
}
func TestContainerWithoutFooterIsIncomplete(testInstance *testing.T) {
var containerBuffer bytes.Buffer
// Ein Container, dessen Schreiber vor dem Abschluss abbrach.
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
// Close wird bewusst nicht aufgerufen.
_, containerReader, readError := readAllSections(containerBuffer.Bytes())
if readError == nil {
testInstance.Fatal("ein Container ohne Footer darf nicht als gültig gelten")
}
if containerReader != nil && containerReader.IsComplete() {
testInstance.Fatal("ein Container ohne Footer darf nicht als vollständig gelten")
}
}
func TestWriterRefusesToCloseWithoutManifest(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionDataChunks, FlagRequired, []byte("daten"))
// Ohne Manifest beschreibt der Container kein Backup; ihn abzuschließen
// hiesse, ein leeres Gebilde als gültig auszugeben.
closeError := containerWriter.Close()
if !errors.Is(closeError, ErrMissingSection) {
testInstance.Fatalf("ein Container ohne Manifest darf nicht abgeschlossen werden, war: %v", closeError)
}
}
func TestFooterWithoutCompletionMarkerIsRejected(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.Close()
containerBytes := containerBuffer.Bytes()
// Im Footer wird der Abschlussvermerk auf false gesetzt.
footerStart := bytes.LastIndex(containerBytes, footerMagic[:])
if footerStart < 0 {
testInstance.Fatal("der Footer wurde nicht gefunden")
}
footerJSONStart := footerStart + magicLength + headerLengthFieldLength
var containerFooter ContainerFooter
if unmarshalError := json.Unmarshal(containerBytes[footerJSONStart:], &containerFooter); unmarshalError != nil {
testInstance.Fatalf("der Footer konnte nicht gelesen werden: %v", unmarshalError)
}
containerFooter.Complete = false
tamperedFooter, _ := json.Marshal(containerFooter)
tamperedContainer := bytes.Clone(containerBytes[:footerStart])
tamperedContainer = append(tamperedContainer, footerMagic[:]...)
lengthBytes := make([]byte, headerLengthFieldLength)
binary.BigEndian.PutUint32(lengthBytes, uint32(len(tamperedFooter)))
tamperedContainer = append(tamperedContainer, lengthBytes...)
tamperedContainer = append(tamperedContainer, tamperedFooter...)
_, _, readError := readAllSections(tamperedContainer)
if !errors.Is(readError, ErrIncompleteBackup) {
testInstance.Fatalf("ein Container ohne Abschlussvermerk muss abgelehnt werden, war: %v", readError)
}
}
// ---------------------------------------------------------------------------
// Beschädigter Inhalt
// ---------------------------------------------------------------------------
func TestCorruptedSectionIsDetected(testInstance *testing.T) {
containerBytes := writeTestContainer(testInstance)
// Ein einzelnes verändertes Byte im Manifestbereich.
tamperedContainer := bytes.Clone(containerBytes)
manifestPosition := bytes.Index(tamperedContainer, []byte(`"backup_id":"backup-1","entries"`))
if manifestPosition < 0 {
testInstance.Fatal("der Manifestabschnitt wurde nicht gefunden")
}
tamperedContainer[manifestPosition+5] ^= 0xff
_, _, readError := readAllSections(tamperedContainer)
if !errors.Is(readError, ErrSectionCorrupted) {
testInstance.Fatalf("ein verfälschter Abschnitt muss erkannt werden, war: %v", readError)
}
}
func TestCorruptedDataSectionIsDetected(testInstance *testing.T) {
containerBytes := writeTestContainer(testInstance)
tamperedContainer := bytes.Clone(containerBytes)
dataPosition := bytes.Index(tamperedContainer, []byte("daten"))
if dataPosition < 0 {
testInstance.Fatal("der Datenabschnitt wurde nicht gefunden")
}
tamperedContainer[dataPosition] = 'X'
_, _, readError := readAllSections(tamperedContainer)
if readError == nil {
testInstance.Fatal("ein verfälschter Datenabschnitt muss erkannt werden")
}
}
func TestRemovedSectionIsDetectedByFooter(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.WriteSection(SectionSourceMetadata, 0, []byte("angaben"))
_ = containerWriter.Close()
containerBytes := containerBuffer.Bytes()
// Der optionale Abschnitt wird samt Kopf entfernt - ein Angreifer könnte
// so Angaben unterschlagen.
sectionStart := bytes.Index(containerBytes, []byte("angaben")) - sectionHeaderLength
sectionEnd := bytes.Index(containerBytes, []byte("angaben")) + len("angaben")
tamperedContainer := bytes.Clone(containerBytes[:sectionStart])
tamperedContainer = append(tamperedContainer, containerBytes[sectionEnd:]...)
// Die Abschnittszahl und die Gesamtprüfsumme im Footer decken das auf.
_, _, readError := readAllSections(tamperedContainer)
if !errors.Is(readError, ErrIntegrityMismatch) {
testInstance.Fatalf("ein entfernter Abschnitt muss erkannt werden, war: %v", readError)
}
}
func TestSwappedSectionWithValidDigestIsDetected(testInstance *testing.T) {
// Ein Angreifer tauscht einen Abschnitt samt passender Prüfsumme aus.
// Die Gesamtprüfsumme im Footer deckt das dennoch auf.
var originalBuffer bytes.Buffer
originalWriter, _ := NewWriter(&originalBuffer, buildTestHeader())
_ = originalWriter.WriteSection(SectionManifest, FlagRequired, []byte(`{"echt":true}`))
_ = originalWriter.Close()
var forgedBuffer bytes.Buffer
forgedWriter, _ := NewWriter(&forgedBuffer, buildTestHeader())
_ = forgedWriter.WriteSection(SectionManifest, FlagRequired, []byte(`{"echt":fals}`))
_ = forgedWriter.Close()
originalBytes := originalBuffer.Bytes()
forgedBytes := forgedBuffer.Bytes()
// Der gefälschte Abschnitt samt Kopf ersetzt den echten, der Footer bleibt.
originalFooterStart := bytes.LastIndex(originalBytes, footerMagic[:])
forgedFooterStart := bytes.LastIndex(forgedBytes, footerMagic[:])
tamperedContainer := bytes.Clone(forgedBytes[:forgedFooterStart])
tamperedContainer = append(tamperedContainer, originalBytes[originalFooterStart:]...)
_, _, readError := readAllSections(tamperedContainer)
if !errors.Is(readError, ErrIntegrityMismatch) {
testInstance.Fatalf("ein ausgetauschter Abschnitt muss erkannt werden, war: %v", readError)
}
}
// ---------------------------------------------------------------------------
// Chunk-Verzeichnis
// ---------------------------------------------------------------------------
func TestChunkIndexLookup(testInstance *testing.T) {
chunkIndex := &ChunkIndex{
Entries: []ChunkIndexEntry{
{Identifier: "aaa", ContainerOffset: 0, StoredLength: 100, LogicalLength: 100},
{Identifier: "bbb", ContainerOffset: 100, StoredLength: 200, LogicalLength: 200},
},
}
foundEntry, wasFound := chunkIndex.FindByIdentifier("bbb")
if !wasFound {
testInstance.Fatal("der vorhandene Chunk wurde nicht gefunden")
}
if foundEntry.ContainerOffset != 100 {
testInstance.Errorf("die Position soll 100 sein, war %d", foundEntry.ContainerOffset)
}
// Ein nicht enthaltener Chunk darf nicht gemeldet werden.
if _, wasFound := chunkIndex.FindByIdentifier("ccc"); wasFound {
testInstance.Error("ein nicht vorhandener Chunk wurde gemeldet")
}
if chunkIndex.TotalStoredBytes() != 300 {
testInstance.Errorf("die Gesamtgröße soll 300 sein, war %d", chunkIndex.TotalStoredBytes())
}
}
// ---------------------------------------------------------------------------
// Metadaten
// ---------------------------------------------------------------------------
func TestEncryptionMetadataSurvivesRoundTrip(testInstance *testing.T) {
var containerBuffer bytes.Buffer
headerWithEncryption := buildTestHeader()
headerWithEncryption.Encryption = EncryptionMetadata{
Algorithm: "aes-256-gcm",
KeyVersion: "v2",
KeyDerivation: "hkdf-sha256",
}
headerWithEncryption.Compression = CompressionMetadata{Algorithm: "zstd", Level: 3}
containerWriter, _ := NewWriter(&containerBuffer, headerWithEncryption)
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.Close()
_, containerReader, readError := readAllSections(containerBuffer.Bytes())
if readError != nil {
testInstance.Fatalf("der Container konnte nicht gelesen werden: %v", readError)
}
readHeader := containerReader.Header()
// Ohne die Schlüsselversion wäre der Container nach einer Rotation unlesbar.
if readHeader.Encryption.KeyVersion != "v2" {
testInstance.Errorf("die Schlüsselversion weicht ab: %q", readHeader.Encryption.KeyVersion)
}
if !readHeader.Encryption.IsEncrypted() {
testInstance.Error("der Container soll als verschlüsselt gelten")
}
if !readHeader.Compression.IsCompressed() {
testInstance.Error("der Container soll als komprimiert gelten")
}
}
func TestParentBackupIsPreserved(testInstance *testing.T) {
var containerBuffer bytes.Buffer
headerWithParent := buildTestHeader()
headerWithParent.ParentBackupID = "backup-0"
containerWriter, _ := NewWriter(&containerBuffer, headerWithParent)
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.Close()
_, containerReader, _ := readAllSections(containerBuffer.Bytes())
// Ohne diese Angabe liesse sich eine Kette aus einzelnen Containern nicht
// wieder zusammensetzen.
if containerReader.Header().ParentBackupID != "backup-0" {
testInstance.Errorf("das Elternbackup weicht ab: %q", containerReader.Header().ParentBackupID)
}
}
func TestWriterRejectsUseAfterClose(testInstance *testing.T) {
var containerBuffer bytes.Buffer
containerWriter, _ := NewWriter(&containerBuffer, buildTestHeader())
_ = containerWriter.WriteSection(SectionManifest, FlagRequired, []byte("{}"))
_ = containerWriter.Close()
// Nach dem Abschluss darf nichts mehr hinzukommen: der Footer ist geschrieben.
if writeError := containerWriter.WriteSection(SectionDataChunks, 0, []byte("zu spät")); !errors.Is(writeError, ErrWriterClosed) {
testInstance.Fatalf("ein Schreibvorgang nach dem Abschluss muss abgelehnt werden, war: %v", writeError)
}
if closeError := containerWriter.Close(); !errors.Is(closeError, ErrWriterClosed) {
testInstance.Fatalf("ein zweiter Abschluss muss abgelehnt werden, war: %v", closeError)
}
}