package repository import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "time" ) // ManifestVersion ist die Version des Manifestformats. const ManifestVersion = 1 // BackupType ist die Art eines Backups (PROMPT.md §7). type BackupType string const ( // BackupTypeFull ist eine vollständige Sicherung. BackupTypeFull BackupType = "full" // BackupTypeIncremental enthält nur die Änderungen seit dem Elternbackup. BackupTypeIncremental BackupType = "incremental" // BackupTypeSyntheticFull entsteht durch Zusammenführen bestehender Backups. BackupTypeSyntheticFull BackupType = "synthetic_full" ) // ConsistencyLevel beschreibt die Konsistenz der gesicherten Daten (PROMPT.md §147). // // Die Angabe muss im Backup sichtbar sein: sie entscheidet darüber, ob eine // Anwendung nach der Wiederherstellung sauber startet. type ConsistencyLevel string const ( // ConsistencyCrash entspricht dem Zustand nach einem Stromausfall. ConsistencyCrash ConsistencyLevel = "crash_consistent" // ConsistencyApplication bedeutet, dass die Anwendung ihre Daten vorher stillgelegt hat. ConsistencyApplication ConsistencyLevel = "application_consistent" // ConsistencyVerified bedeutet, dass eine Wiederherstellung erfolgreich geprüft wurde. ConsistencyVerified ConsistencyLevel = "verified" ) // SourceInformation beschreibt die Herkunft eines Backups. // // Die Angaben liegen bewusst im Manifest und nicht nur in der Datenbank: nach // einem Verlust des Control Servers muss erkennbar bleiben, wovon ein Backup // stammt (PROMPT.md §47). type SourceInformation struct { // SourceType benennt die Art der Quelle, z. B. proxmox_vm oder filesystem. SourceType string `json:"source_type"` // SourceID ist die Kennung der Quelle in der Control Plane. SourceID string `json:"source_id"` // SourceName ist der sprechende Name der Quelle. SourceName string `json:"source_name"` // Hostname ist der Rechnername der Quelle, sofern bekannt. Hostname string `json:"hostname,omitempty"` // OperatingSystem beschreibt das Betriebssystem der Quelle. OperatingSystem string `json:"operating_system,omitempty"` // Attributes trägt weitere quellenspezifische Angaben. Attributes map[string]string `json:"attributes,omitempty"` } // ManifestEntry beschreibt ein gesichertes Objekt, etwa eine Datei oder Disk. type ManifestEntry struct { // Path ist der Pfad des Objekts in der Quelle. Path string `json:"path"` // EntryType benennt die Art des Objekts (file, directory, disk, symlink). EntryType string `json:"type"` // SizeBytes ist die ursprüngliche Größe in Byte. SizeBytes int64 `json:"size_bytes"` // ModifiedAt ist der Änderungszeitpunkt in UTC. ModifiedAt time.Time `json:"modified_at,omitempty"` // Mode sind die Dateirechte in oktaler Schreibweise. Mode string `json:"mode,omitempty"` // LinkTarget ist das Ziel eines symbolischen Verweises. LinkTarget string `json:"link_target,omitempty"` // Chunks sind die Datenblöcke des Objekts in ihrer Reihenfolge. Chunks []ChunkReference `json:"chunks,omitempty"` // ContentHash ist die Prüfsumme des Gesamtinhalts. // // Sie erlaubt es, die Wiederherstellung eines Objekts zu prüfen, ohne alle // Chunk-Prüfsummen einzeln nachzurechnen. ContentHash string `json:"content_hash,omitempty"` } // Manifest beschreibt ein vollständiges Backup. // // Es ist die maßgebliche Beschreibung eines Backups. PostgreSQL hält davon nur // eine Kopie zur schnellen Abfrage; verbindlich ist das Manifest im Repository // (PROMPT.md §2.4). type Manifest struct { // ManifestVersion ist die Version des Manifestformats. ManifestVersion int `json:"manifest_version"` // BackupID ist die Kennung dieses Backups. BackupID string `json:"backup_id"` // ChainID verbindet ein Backup mit seiner Kette aus Voll- und Zusatzsicherungen. ChainID string `json:"chain_id"` // ParentBackupID benennt das Elternbackup einer Zusatzsicherung. ParentBackupID string `json:"parent_backup_id,omitempty"` // BackupType ist die Art des Backups. BackupType BackupType `json:"backup_type"` // ConsistencyLevel beschreibt die Konsistenz der Daten. ConsistencyLevel ConsistencyLevel `json:"consistency_level"` // Source beschreibt die Herkunft der Daten. Source SourceInformation `json:"source"` // Entries sind die gesicherten Objekte. Entries []ManifestEntry `json:"entries"` // StartedAt ist der Beginn des Backups in UTC. StartedAt time.Time `json:"started_at"` // CompletedAt ist der Abschluss des Backups in UTC. CompletedAt time.Time `json:"completed_at"` // Statistics sind die Kennzahlen des Laufs. Statistics SessionStatistics `json:"statistics"` // EncryptionKeyVersion benennt den zur Entschlüsselung nötigen Schlüssel. // // Das Feld bleibt bis Phase 4 leer, ist aber Teil des Formats: eine spätere // Ergänzung wäre ein Formatbruch. EncryptionKeyVersion string `json:"encryption_key_version,omitempty"` // CompressionAlgorithm benennt das verwendete Kompressionsverfahren. CompressionAlgorithm string `json:"compression_algorithm,omitempty"` // ImmutableUntil ist das Ende der Aufbewahrungspflicht in UTC. ImmutableUntil *time.Time `json:"immutable_until,omitempty"` // SelfContainedRestore meldet ein Manifest, das ohne seine Kette ausreicht. // // Bei Syncova ist das Manifest einer Zusatzsicherung **vollständig**: // unveränderte Objekte tragen die Blockverweise des Elternbackups. Ein // Restore liest deshalb genau ein Manifest, und das Löschen eines alten // Backups kann ein neueres nicht beschädigen. // // Das Feld hält diese Zusicherung im Manifest selbst fest, statt sie // vorauszusetzen. Der Grund ist die Aufbewahrung: Ohne die Angabe müsste sie // jedes Elternbackup einer Kette behalten — und räumte damit **nie** auf. // Ein fehlendes Feld (ältere Manifeste, fremde Repositories) bedeutet // „unbekannt" und schützt die Kette weiterhin. SelfContainedRestore bool `json:"self_contained_restore,omitempty"` // CreatedByVersion ist die Programmversion, die das Backup erzeugt hat. CreatedByVersion string `json:"created_by_version"` // ContentHash ist die Prüfsumme über alle Felder außer diesem und dem Abschlussvermerk. // // Er ist der Abschlussvermerk des Manifests: fehlt er oder passt er nicht, // gilt das Backup als unvollständig (SYNCOVA_ARCHITECTURE.md §10). ContentHash string `json:"content_hash"` // Complete ist der ausdrückliche Abschlussvermerk. Complete bool `json:"complete"` } // TotalChunkCount zählt alle Chunk-Verweise des Manifests. func (manifest *Manifest) TotalChunkCount() int64 { var chunkCount int64 for _, manifestEntry := range manifest.Entries { chunkCount += int64(len(manifestEntry.Chunks)) } return chunkCount } // UniqueChunkIdentifiers liefert die Menge aller im Manifest benannten Chunks. // // Mehrfach verwendete Chunks erscheinen nur einmal — genau das ist der Zweck // der Deduplizierung. func (manifest *Manifest) UniqueChunkIdentifiers() map[string]struct{} { uniqueIdentifiers := make(map[string]struct{}) for _, manifestEntry := range manifest.Entries { for _, chunkReference := range manifestEntry.Chunks { uniqueIdentifiers[chunkReference.Identifier] = struct{}{} } } return uniqueIdentifiers } // UniqueChunkReferences liefert je Chunk einen Verweis samt Prüfsumme der // abgelegten Form. // // Der Unterschied zu UniqueChunkIdentifiers ist für die Integritätsprüfung // entscheidend: Bei einem transformierten Block beschreibt die Kennung den // Klartext, nicht die abgelegten Bytes. Ohne StoredDigest liesse sich ein // verschlüsselter Block nicht prüfen — jeder Vergleich gegen die Kennung // schlüge fehl und meldete einen Fehlalarm. func (manifest *Manifest) UniqueChunkReferences() map[string]ChunkReference { uniqueReferences := make(map[string]ChunkReference) for _, manifestEntry := range manifest.Entries { for _, chunkReference := range manifestEntry.Chunks { if _, alreadySeen := uniqueReferences[chunkReference.Identifier]; alreadySeen { continue } uniqueReferences[chunkReference.Identifier] = chunkReference } } return uniqueReferences } // IsRetentionLocked meldet, ob das Backup noch unter Aufbewahrungsschutz steht. func (manifest *Manifest) IsRetentionLocked(referenceTime time.Time) bool { if manifest.ImmutableUntil == nil { return false } return referenceTime.Before(*manifest.ImmutableUntil) } // computeManifestHash bildet die Prüfsumme eines Manifests. // // ContentHash und Complete bleiben ausgespart: sie werden erst aus dem Ergebnis // gesetzt und dürfen es deshalb nicht beeinflussen. func computeManifestHash(manifest *Manifest) (string, error) { // Die Kopie erhält die auszusparenden Felder in ihrem Nullwert. manifestForHashing := *manifest manifestForHashing.ContentHash = "" manifestForHashing.Complete = false // json.Marshal sortiert Struktur-Felder in Deklarationsreihenfolge und // Map-Schlüssel alphabetisch. Die Ausgabe ist damit reproduzierbar, was // Voraussetzung für einen stabilen Hash ist. encodedManifest, marshalError := json.Marshal(manifestForHashing) if marshalError != nil { return "", fmt.Errorf("das manifest konnte nicht für die prüfsumme serialisiert werden: %w", marshalError) } manifestDigest := sha256.Sum256(encodedManifest) return hex.EncodeToString(manifestDigest[:]), nil } // sealManifest versieht ein Manifest mit Prüfsumme und Abschlussvermerk. // // Erst danach gilt ein Backup als abgeschlossen. func sealManifest(manifest *Manifest) error { manifestHash, hashError := computeManifestHash(manifest) if hashError != nil { return hashError } manifest.ContentHash = manifestHash manifest.Complete = true return nil } // VerifyManifest prüft Prüfsumme und Abschlussvermerk eines Manifests. // // Ein Manifest ohne gültigen Abschluss beschreibt kein verwendbares Backup. // Es als erfolgreich zu behandeln wäre der schwerste denkbare Fehler dieses // Produkts (PROMPT.md §140). func VerifyManifest(manifest *Manifest) error { if manifest.ManifestVersion > ManifestVersion { return fmt.Errorf("%w: das manifest hat Version %d, unterstützt wird bis Version %d", ErrUnsupportedFormat, manifest.ManifestVersion, ManifestVersion) } if !manifest.Complete { return ErrBackupIncomplete } if manifest.ContentHash == "" { return fmt.Errorf("%w: dem manifest fehlt die prüfsumme", ErrBackupIncomplete) } expectedHash, hashError := computeManifestHash(manifest) if hashError != nil { return hashError } if expectedHash != manifest.ContentHash { return fmt.Errorf("%w: die prüfsumme des manifests stimmt nicht", ErrManifestCorrupted) } return nil } // encodeManifest serialisiert ein Manifest. func encodeManifest(manifest *Manifest) ([]byte, error) { // Eingerückt, damit ein Administrator das Manifest bei einer Notfallanalyse // von Hand lesen kann. encodedManifest, marshalError := json.MarshalIndent(manifest, "", " ") if marshalError != nil { return nil, fmt.Errorf("das manifest konnte nicht erzeugt werden: %w", marshalError) } return append(encodedManifest, '\n'), nil } // decodeManifest liest ein Manifest. func decodeManifest(rawManifest []byte) (*Manifest, error) { var manifest Manifest if unmarshalError := json.Unmarshal(rawManifest, &manifest); unmarshalError != nil { return nil, fmt.Errorf("%w: das manifest ist unlesbar", ErrManifestCorrupted) } return &manifest, nil }