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>
656 lines
24 KiB
Go
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)
|
|
}
|
|
}
|