// Package verification prueft Backups und bewertet ihre Wiederherstellbarkeit. // // Der Zweck ist eine einzige ehrliche Aussage je Backup: Laesst es sich // wiederherstellen? Dafuer gibt es genau drei Erkenntnisquellen, in // aufsteigender Aussagekraft: // // 1. **Das Manifest** sagt, was gesichert werden sollte. // 2. **Die Blockpruefung** sagt, ob die Daten noch da und unbeschaedigt sind. // 3. **Der Wiederherstellungstest** sagt, dass es tatsaechlich geht. // // Nur die dritte ist ein Nachweis. Die ersten beiden sind Indizien — gute, aber // eben Indizien. Diese Unterscheidung traegt das ganze Paket, und sie ist der // Grund, warum ein Backup „erfolgreich" und trotzdem nicht „wiederherstellbar" // sein kann. package verification import ( "context" "errors" "fmt" "time" "github.com/syncova/syncova/packages/repository" ) // VerificationType benennt die Art einer Pruefung. type VerificationType string const ( // TypeManifest prueft nur das Manifest. // // Schnell und schwach: Sie stellt fest, dass das Backup abgeschlossen wurde // und in sich stimmig ist — nicht, ob die Daten noch da sind. TypeManifest VerificationType = "manifest" // TypeChunkPresence prueft, ob jeder benoetigte Block vorhanden ist. // // Ohne die Bloecke zu lesen: Ein Verzeichniseintrag genuegt. Damit faellt // ein aufgeraeumtes oder unvollstaendig kopiertes Repository auf. TypeChunkPresence VerificationType = "chunk_presence" // TypeChunkIntegrity liest jeden Block und prueft seine Pruefsumme. // // Die teuerste Pruefung ohne Wiederherstellung — und die einzige, die // stille Datenverfaelschung auf dem Datentraeger aufdeckt. TypeChunkIntegrity VerificationType = "chunk_integrity" // TypeChain prueft die gesamte Kette bis zur Vollsicherung. TypeChain VerificationType = "chain" // TypeRestoreTest stellt das Backup tatsaechlich wieder her. TypeRestoreTest VerificationType = "restore_test" ) // Severity ist das Gewicht eines Befunds. type Severity string const ( // SeverityCorruption meldet beschaedigte Daten. SeverityCorruption Severity = "corruption" // SeverityMissing meldet fehlende Daten. SeverityMissing Severity = "missing" // SeverityWarning verlangt Aufmerksamkeit. SeverityWarning Severity = "warning" // SeverityInformation ist ein Hinweis. SeverityInformation Severity = "information" ) // Finding ist ein einzelner Befund einer Pruefung. type Finding struct { // Code ist die maschinenlesbare Kennung. Code string `json:"code"` // Severity ist das Gewicht. Severity Severity `json:"severity"` // Message erklaert den Befund verstaendlich. Message string `json:"message"` // Detail nennt das betroffene Objekt. Detail string `json:"detail,omitempty"` } // Report ist das Ergebnis einer Pruefung. type Report struct { // BackupID ist das gepruefte Backup im Repository. BackupID string `json:"backup_id"` // VerificationType ist die durchgefuehrte Art. VerificationType VerificationType `json:"verification_type"` // Findings sind die Befunde. Findings []Finding `json:"findings"` // ChunksChecked ist die Zahl gepruefter Bloecke. ChunksChecked int `json:"chunks_checked"` // ChunksMissing ist die Zahl fehlender Bloecke. ChunksMissing int `json:"chunks_missing"` // ChunksCorrupted ist die Zahl beschaedigter Bloecke. ChunksCorrupted int `json:"chunks_corrupted"` // BytesRead ist die gelesene Datenmenge. BytesRead int64 `json:"bytes_read"` // ChainLength ist die Zahl der Backups in der Kette. ChainLength int `json:"chain_length,omitempty"` // StartedAt ist der Beginn in UTC. StartedAt time.Time `json:"started_at"` // CompletedAt ist das Ende in UTC. CompletedAt time.Time `json:"completed_at"` // DurationSeconds ist die Dauer. DurationSeconds float64 `json:"duration_seconds"` } // IsClean meldet eine Pruefung ohne Beanstandung. func (report *Report) IsClean() bool { for _, finding := range report.Findings { if finding.Severity == SeverityCorruption || finding.Severity == SeverityMissing { return false } } return true } // HasCorruption meldet beschaedigte Daten. // // Der Unterschied zu fehlenden Daten ist wesentlich: Fehlende Bloecke koennen // aus einem unvollstaendig kopierten Repository stammen und anderswo noch // existieren. Beschaedigte Bloecke sind verloren — und ihr Vorhandensein // deutet auf einen Datentraeger, dem nicht mehr zu trauen ist. func (report *Report) HasCorruption() bool { for _, finding := range report.Findings { if finding.Severity == SeverityCorruption { return true } } return false } // Summary fasst das Ergebnis in einem Satz zusammen. func (report *Report) Summary() string { // Der Wiederherstellungstest zaehlt keine Bloecke, sondern Dateien. Ohne // diesen Zweig meldete er „0 von 0 Bloecken sind beschaedigt" — eine // Meldung, die mehr verwirrt als sie sagt. if report.VerificationType == TypeRestoreTest { if !report.IsClean() { return "FEHLGESCHLAGEN: Das Backup liess sich nicht vollstaendig zurueckschreiben. " + "Die Befunde nennen die betroffenen Objekte." } return "Das Backup wurde zurueckgeschrieben und gegen das Original verglichen." } if report.HasCorruption() { return fmt.Sprintf("BESCHAEDIGT: %d von %d Bloecken sind beschaedigt.", report.ChunksCorrupted, report.ChunksChecked) } if !report.IsClean() { return fmt.Sprintf("UNVOLLSTAENDIG: %d von %d Bloecken fehlen.", report.ChunksMissing, report.ChunksChecked) } switch report.VerificationType { case TypeManifest: return "Das Manifest ist stimmig. Ob die Daten vorhanden sind, wurde nicht geprueft." case TypeChunkPresence: return fmt.Sprintf("Alle %d Bloecke sind vorhanden. Ihr Inhalt wurde nicht gelesen.", report.ChunksChecked) case TypeChunkIntegrity: return fmt.Sprintf("Alle %d Bloecke sind vorhanden und unbeschaedigt.", report.ChunksChecked) case TypeChain: return fmt.Sprintf("Die Kette aus %d Backups ist vollstaendig.", report.ChainLength) default: return "Die Pruefung verlief ohne Beanstandung." } } // Verifier prueft Backups eines Repositorys. type Verifier struct { // sourceRepository ist das zu pruefende Repository. sourceRepository *repository.LocalRepository } // NewVerifier erzeugt die Pruefmaschine. func NewVerifier(sourceRepository *repository.LocalRepository) *Verifier { return &Verifier{sourceRepository: sourceRepository} } // maximumNamedObjects begrenzt die im Befund genannten Objekte. // // Bei einem verlorenen Verzeichnis waeren es sonst tausende Zeilen. Die Zahl // im Bericht bleibt vollstaendig. const maximumNamedObjects = 5 // VerifyBackup prueft ein einzelnes Backup. func (verifier *Verifier) VerifyBackup(verifyContext context.Context, backupIdentifier string, verificationType VerificationType) (*Report, error) { startTime := time.Now() report := &Report{ BackupID: backupIdentifier, VerificationType: verificationType, Findings: make([]Finding, 0, 2), StartedAt: time.Now().UTC(), } backupManifest, readError := verifier.sourceRepository.ReadManifest(verifyContext, backupIdentifier) if readError != nil { report.Findings = append(report.Findings, Finding{ Code: "MANIFEST_UNREADABLE", Severity: SeverityMissing, Message: "Das Manifest ist nicht lesbar. Ohne es ist nicht feststellbar, was gesichert wurde.", Detail: readError.Error(), }) finalizeReport(report, startTime) return report, nil } verifier.checkManifest(backupManifest, report) if verificationType == TypeChunkPresence || verificationType == TypeChunkIntegrity { verifier.checkChunks(verifyContext, backupManifest, verificationType, report) } finalizeReport(report, startTime) return report, nil } // checkManifest prueft das Manifest auf Stimmigkeit. func (verifier *Verifier) checkManifest(backupManifest *repository.Manifest, report *Report) { if !backupManifest.Complete { report.Findings = append(report.Findings, Finding{ Code: "BACKUP_INCOMPLETE", Severity: SeverityMissing, Message: "Das Backup traegt keinen Abschlussvermerk und ist damit unvollstaendig.", }) } if len(backupManifest.Entries) == 0 { // Ein Backup ohne Eintraege ist ein Gebilde, das sich als erfolgreiche // Sicherung ausgibt, ohne etwas zu enthalten. report.Findings = append(report.Findings, Finding{ Code: "MANIFEST_EMPTY", Severity: SeverityWarning, Message: "Das Backup enthaelt keine Objekte.", }) } // Ein Eintrag mit Groesse, aber ohne Blockverweise, beschreibt Daten, die // es im Repository nicht gibt. var entriesWithoutChunks int for _, manifestEntry := range backupManifest.Entries { if manifestEntry.EntryType == "file" && manifestEntry.SizeBytes > 0 && len(manifestEntry.Chunks) == 0 { entriesWithoutChunks++ } } if entriesWithoutChunks > 0 { report.Findings = append(report.Findings, Finding{ Code: "ENTRIES_WITHOUT_CHUNKS", Severity: SeverityCorruption, Message: fmt.Sprintf("%d Eintraege nennen eine Groesse, aber keine Bloecke. Das Manifest "+ "beschreibt Daten, die es nicht gibt.", entriesWithoutChunks), }) } } // checkChunks prueft die Bloecke eines Backups. // // Bei TypeChunkPresence genuegt der Verzeichniseintrag; bei TypeChunkIntegrity // wird jeder Block gelesen und neu gehasht. // // Der Vergleich geschieht gegen **StoredDigest**, nicht gegen die Kennung: Bei // einem verschluesselten Block beschreibt die Kennung den Klartext, nicht die // abgelegten Bytes. Ohne diese Unterscheidung meldete die Pruefung jeden // verschluesselten Block als beschaedigt — ein Fehlalarm, der das Werkzeug // wertlos macht. func (verifier *Verifier) checkChunks(checkContext context.Context, backupManifest *repository.Manifest, verificationType VerificationType, report *Report) { chunkReferences := backupManifest.UniqueChunkReferences() report.ChunksChecked = len(chunkReferences) missingPaths := make([]string, 0, maximumNamedObjects) corruptedPaths := make([]string, 0, maximumNamedObjects) ownerByChunk := buildChunkOwnerIndex(backupManifest) for chunkIdentifier, chunkReference := range chunkReferences { if contextError := checkContext.Err(); contextError != nil { report.Findings = append(report.Findings, Finding{ Code: "VERIFICATION_CANCELLED", Severity: SeverityWarning, Message: "Die Pruefung wurde abgebrochen und ist damit ohne Aussage.", }) return } if verificationType == TypeChunkPresence { chunkExists, existenceError := verifier.sourceRepository.HasChunk(checkContext, chunkIdentifier) if existenceError != nil || !chunkExists { report.ChunksMissing++ if len(missingPaths) < maximumNamedObjects { missingPaths = append(missingPaths, ownerByChunk[chunkIdentifier]) } } continue } // ReadStoredChunk statt ReadChunk oder OpenChunk: Beide pruefen gegen // die **Kennung**, die bei einem verschluesselten Block den Klartext // beschreibt — sie meldeten jeden solchen Block als beschaedigt. // ReadStoredChunk vergleicht gegen StoredDigest, also gegen die // Pruefsumme der tatsaechlich abgelegten Form, und kommt deshalb ohne // Schluessel aus (PROMPT.md §14). expectedDigest := chunkReference.StoredDigest if expectedDigest == "" { // Ohne Transformation beschreibt die Kennung die abgelegten Bytes. expectedDigest = chunkIdentifier } storedData, readError := verifier.sourceRepository.ReadStoredChunk( checkContext, chunkIdentifier, expectedDigest) if readError != nil { // Fehlend und beschaedigt sind zu unterscheiden: Ein fehlender // Block koennte anderswo noch existieren, ein beschaedigter ist // verloren und macht den Datentraeger verdaechtig. if errors.Is(readError, repository.ErrChunkCorrupted) { report.ChunksCorrupted++ if len(corruptedPaths) < maximumNamedObjects { corruptedPaths = append(corruptedPaths, ownerByChunk[chunkIdentifier]) } continue } report.ChunksMissing++ if len(missingPaths) < maximumNamedObjects { missingPaths = append(missingPaths, ownerByChunk[chunkIdentifier]) } continue } report.BytesRead += int64(len(storedData)) } if report.ChunksMissing > 0 { report.Findings = append(report.Findings, Finding{ Code: "CHUNKS_MISSING", Severity: SeverityMissing, Message: fmt.Sprintf("%d von %d Bloecken fehlen im Repository.", report.ChunksMissing, report.ChunksChecked), Detail: formatAffectedPaths(missingPaths, report.ChunksMissing), }) } if report.ChunksCorrupted > 0 { report.Findings = append(report.Findings, Finding{ Code: "CHUNKS_CORRUPTED", Severity: SeverityCorruption, Message: fmt.Sprintf("%d von %d Bloecken sind beschaedigt: Ihr Inhalt passt nicht zu ihrer "+ "Pruefsumme. Der Datentraeger ist verdaechtig.", report.ChunksCorrupted, report.ChunksChecked), Detail: formatAffectedPaths(corruptedPaths, report.ChunksCorrupted), }) } } // VerifyChain prueft ein Backup samt seiner Elternbackups. // // Eine Zusatzsicherung mit vollstaendigem Manifest braucht ihre Kette zum // Wiederherstellen nicht — die Kette bleibt trotzdem pruefenswert: Sie zeigt, // ob die Aufbewahrung ein Elternbackup entfernt hat, dessen Bloecke noch // gebraucht werden. func (verifier *Verifier) VerifyChain(verifyContext context.Context, backupIdentifier string) (*Report, error) { startTime := time.Now() report := &Report{ BackupID: backupIdentifier, VerificationType: TypeChain, Findings: make([]Finding, 0, 2), StartedAt: time.Now().UTC(), } // Die Grenze schuetzt vor einer im Kreis verweisenden Kette. Sie darf nicht // vorkommen, wuerde hier aber sonst eine Endlosschleife ergeben. const maximumChainLength = 1000 currentIdentifier := backupIdentifier visitedIdentifiers := make(map[string]struct{}) for chainStep := 0; chainStep < maximumChainLength; chainStep++ { if _, alreadyVisited := visitedIdentifiers[currentIdentifier]; alreadyVisited { report.Findings = append(report.Findings, Finding{ Code: "CHAIN_CYCLE", Severity: SeverityCorruption, Message: "Die Sicherungskette verweist im Kreis.", Detail: currentIdentifier, }) break } visitedIdentifiers[currentIdentifier] = struct{}{} currentManifest, readError := verifier.sourceRepository.ReadManifest(verifyContext, currentIdentifier) if readError != nil { report.Findings = append(report.Findings, Finding{ Code: "CHAIN_BROKEN", Severity: SeverityMissing, Message: fmt.Sprintf("Das Elternbackup %s der Kette fehlt. Die Kette ist unterbrochen.", currentIdentifier), Detail: currentIdentifier, }) break } report.ChainLength++ if !currentManifest.Complete { report.Findings = append(report.Findings, Finding{ Code: "CHAIN_MEMBER_INCOMPLETE", Severity: SeverityMissing, Message: fmt.Sprintf("Das Backup %s in der Kette traegt keinen Abschlussvermerk.", currentIdentifier), Detail: currentIdentifier, }) } if currentManifest.ParentBackupID == "" { // Die Vollsicherung ist erreicht: Die Kette hat einen Anfang. break } currentIdentifier = currentManifest.ParentBackupID } finalizeReport(report, startTime) return report, nil } // buildChunkOwnerIndex ordnet jedem Block ein Objekt zu. // // Ein Befund, der nur eine Blockkennung nennt, hilft niemandem weiter. Der // Pfad des Objekts sagt, was verloren ist. func buildChunkOwnerIndex(backupManifest *repository.Manifest) map[string]string { ownerByChunk := make(map[string]string) for _, manifestEntry := range backupManifest.Entries { for _, chunkReference := range manifestEntry.Chunks { if _, alreadyKnown := ownerByChunk[chunkReference.Identifier]; alreadyKnown { continue } ownerByChunk[chunkReference.Identifier] = manifestEntry.Path } } return ownerByChunk } // formatAffectedPaths beschreibt die betroffenen Objekte. func formatAffectedPaths(namedPaths []string, totalCount int) string { if len(namedPaths) == 0 { return "" } detailText := namedPaths[0] for _, additionalPath := range namedPaths[1:] { detailText += ", " + additionalPath } if totalCount > len(namedPaths) { detailText += fmt.Sprintf(" und %d weitere", totalCount-len(namedPaths)) } return detailText } // finalizeReport schliesst einen Bericht ab. func finalizeReport(report *Report, startTime time.Time) { report.CompletedAt = time.Now().UTC() report.DurationSeconds = time.Since(startTime).Seconds() }