package repository import ( "context" "errors" "fmt" "log/slog" "os" "time" ) // ScanFindingSeverity ist die Schwere eines Befunds. type ScanFindingSeverity string const ( // ScanSeverityWarning meldet ein Problem ohne unmittelbaren Datenverlust. ScanSeverityWarning ScanFindingSeverity = "warning" // ScanSeverityCritical meldet einen Datenverlust oder eine Beschädigung. ScanSeverityCritical ScanFindingSeverity = "critical" ) // ScanFinding ist ein einzelner Befund eines Integritätslaufs. type ScanFinding struct { // Severity ist die Schwere des Befunds. Severity ScanFindingSeverity `json:"severity"` // BackupID benennt das betroffene Backup. BackupID string `json:"backup_id,omitempty"` // ChunkIdentifier benennt den betroffenen Chunk. ChunkIdentifier string `json:"chunk_id,omitempty"` // Message erklärt den Befund verständlich. Message string `json:"message"` // RecommendedAction nennt den nächsten sinnvollen Schritt. RecommendedAction string `json:"recommended_action,omitempty"` } // ScanReport ist das Ergebnis eines Integritätslaufs. type ScanReport struct { // RepositoryID benennt das geprüfte Repository. RepositoryID string `json:"repository_id"` // StartedAt ist der Beginn des Laufs in UTC. StartedAt time.Time `json:"started_at"` // CompletedAt ist das Ende des Laufs in UTC. CompletedAt time.Time `json:"completed_at"` // VerifiedChunkContents meldet, ob die Chunk-Inhalte neu gehasht wurden. VerifiedChunkContents bool `json:"verified_chunk_contents"` // BackupsChecked ist die Zahl geprüfter Backups. BackupsChecked int `json:"backups_checked"` // BackupsHealthy ist die Zahl einwandfreier Backups. BackupsHealthy int `json:"backups_healthy"` // ChunksChecked ist die Zahl geprüfter Chunks. ChunksChecked int64 `json:"chunks_checked"` // BytesChecked ist die Menge gelesener Daten. BytesChecked int64 `json:"bytes_checked"` // MissingChunks ist die Zahl fehlender Chunks. MissingChunks int64 `json:"missing_chunks"` // CorruptedChunks ist die Zahl beschädigter Chunks. CorruptedChunks int64 `json:"corrupted_chunks"` // OrphanedChunks ist die Zahl von keinem Backup benutzter Chunks. OrphanedChunks int64 `json:"orphaned_chunks"` // Findings sind die Einzelbefunde. Findings []ScanFinding `json:"findings"` // AffectedBackupIDs sind Backups, die nicht vollständig wiederherstellbar sind. AffectedBackupIDs []string `json:"affected_backup_ids,omitempty"` } // IsHealthy meldet, ob der Lauf ohne kritischen Befund endete. func (report *ScanReport) IsHealthy() bool { return report.MissingChunks == 0 && report.CorruptedChunks == 0 && len(report.AffectedBackupIDs) == 0 } // Summary fasst das Ergebnis in einem Satz zusammen. // // Die Formulierung ist bewusst deutlich: ein beschädigtes Repository darf nicht // beschönigt werden (PROMPT.md §14, §140). func (report *ScanReport) Summary() string { if report.IsHealthy() { return fmt.Sprintf("Alle %d Backups sind vollständig; %d Chunks geprüft.", report.BackupsChecked, report.ChunksChecked) } return fmt.Sprintf("%d von %d Backups sind NICHT vollständig wiederherstellbar (%d Chunks fehlen, %d sind beschädigt).", len(report.AffectedBackupIDs), report.BackupsChecked, report.MissingChunks, report.CorruptedChunks) } // Scan prüft die Unversehrtheit des gesamten Repositorys. // // Geprüft wird von den Manifesten aus: für jedes Backup wird festgestellt, ob // alle benötigten Chunks vorhanden und - auf Wunsch - unbeschädigt sind. Der // umgekehrte Weg über die Chunk-Ablage würde nicht zeigen, ob ein Backup // wiederherstellbar ist. func (localRepository *LocalRepository) Scan(scanContext context.Context, scanOptions ScanOptions) (*ScanReport, error) { scanReport := &ScanReport{ RepositoryID: localRepository.descriptor.RepositoryID, StartedAt: localRepository.timeSource().UTC(), VerifiedChunkContents: scanOptions.VerifyChunkContents, Findings: make([]ScanFinding, 0), } backupIDs, listError := localRepository.listManifestBackupIDs() if listError != nil { return nil, listError } // referencedChunks sammelt alle benutzten Chunks, um am Ende verwaiste zu erkennen. referencedChunks := make(map[string]struct{}) // checkedChunks verhindert, dass ein von mehreren Backups genutzter Chunk // mehrfach gelesen wird. Bei guter Deduplizierung spart das den Großteil der Arbeit. checkedChunks := make(map[string]error) for backupIndex, backupID := range backupIDs { if contextError := scanContext.Err(); contextError != nil { return nil, contextError } scanReport.BackupsChecked++ manifest, manifestError := localRepository.ReadManifest(scanContext, backupID) if manifestError != nil { scanReport.Findings = append(scanReport.Findings, ScanFinding{ Severity: ScanSeverityCritical, BackupID: backupID, Message: fmt.Sprintf("Das Manifest ist nicht verwendbar: %v", manifestError), RecommendedAction: "Backup als nicht wiederherstellbar behandeln und erneut sichern.", }) scanReport.AffectedBackupIDs = append(scanReport.AffectedBackupIDs, backupID) continue } backupIsIntact := true for chunkIdentifier, chunkReference := range manifest.UniqueChunkReferences() { if contextError := scanContext.Err(); contextError != nil { return nil, contextError } referencedChunks[chunkIdentifier] = struct{}{} // Ein bereits geprüfter Chunk wird nicht erneut gelesen. previousResult, alreadyChecked := checkedChunks[chunkIdentifier] if alreadyChecked { if previousResult != nil { backupIsIntact = false } continue } chunkError := localRepository.verifySingleChunk(scanContext, chunkReference, scanOptions, scanReport) checkedChunks[chunkIdentifier] = chunkError if chunkError != nil { backupIsIntact = false scanReport.Findings = append(scanReport.Findings, ScanFinding{ Severity: ScanSeverityCritical, BackupID: backupID, ChunkIdentifier: chunkIdentifier, Message: chunkError.Error(), RecommendedAction: "Betroffenes Backup erneut erstellen; eine Wiederherstellung ist unvollständig.", }) } } if backupIsIntact { scanReport.BackupsHealthy++ } else { scanReport.AffectedBackupIDs = append(scanReport.AffectedBackupIDs, backupID) } if scanOptions.ProgressCallback != nil { scanOptions.ProgressCallback(ScanProgress{ BackupsChecked: backupIndex + 1, BackupsTotal: len(backupIDs), ChunksChecked: scanReport.ChunksChecked, BytesChecked: scanReport.BytesChecked, }) } } // Verwaiste Chunks sind kein Datenverlust, aber belegter Speicherplatz. orphanedCount, orphanError := localRepository.countOrphanedChunks(scanContext, referencedChunks) if orphanError != nil { localRepository.logger.Warn("verwaiste chunks konnten nicht ermittelt werden", slog.String("error", orphanError.Error())) } else { scanReport.OrphanedChunks = orphanedCount if orphanedCount > 0 { scanReport.Findings = append(scanReport.Findings, ScanFinding{ Severity: ScanSeverityWarning, Message: fmt.Sprintf("%d Chunks werden von keinem Backup mehr benötigt und belegen unnötig Speicherplatz.", orphanedCount), RecommendedAction: "Bereinigung ausführen, sobald keine Sicherung läuft.", }) } } scanReport.CompletedAt = localRepository.timeSource().UTC() localRepository.logger.Info("integritätslauf abgeschlossen", slog.Int("backups_geprueft", scanReport.BackupsChecked), slog.Int("backups_intakt", scanReport.BackupsHealthy), slog.Int64("chunks_geprueft", scanReport.ChunksChecked), slog.Int64("chunks_fehlend", scanReport.MissingChunks), slog.Int64("chunks_beschaedigt", scanReport.CorruptedChunks), ) if persistError := localRepository.persistScanReport(scanReport); persistError != nil { localRepository.logger.Warn("der prüfbericht konnte nicht abgelegt werden", slog.String("error", persistError.Error())) } return scanReport, nil } // verifySingleChunk prüft einen einzelnen Chunk. // // Womit verglichen wird, hängt von der Ablageform ab: // // - Ein untransformierter Block wird gegen seine Kennung geprüft; sie ist der // Hash seines Inhalts. // - Ein komprimierter oder verschlüsselter Block wird gegen die im Manifest // vermerkte Prüfsumme der abgelegten Form geprüft. Seine Kennung beschreibt // den Klartext und passt nicht zu den abgelegten Bytes. // // Würde in beiden Fällen gegen die Kennung geprüft, meldete der Lauf für jeden // verschlüsselten Block einen Fehlalarm — und ein Prüfwerkzeug, das ständig // grundlos Alarm schlägt, wird bald nicht mehr ernst genommen. func (localRepository *LocalRepository) verifySingleChunk(scanContext context.Context, chunkReference ChunkReference, scanOptions ScanOptions, scanReport *ScanReport) error { chunkIdentifier := chunkReference.Identifier targetPath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return pathError } fileInformation, statError := os.Stat(targetPath) if statError != nil { if errors.Is(statError, os.ErrNotExist) { scanReport.MissingChunks++ return fmt.Errorf("Der benötigte Chunk fehlt im Repository (%s).", chunkIdentifier) } return fmt.Errorf("Der Chunk konnte nicht geprüft werden: %v", statError) } scanReport.ChunksChecked++ // Ohne Inhaltsprüfung ist nur belegt, dass der Chunk existiert. if !scanOptions.VerifyChunkContents { return nil } chunkData, readError := os.ReadFile(targetPath) if readError != nil { return fmt.Errorf("Der Chunk konnte nicht gelesen werden: %v", readError) } scanReport.BytesChecked += int64(len(chunkData)) actualDigest := computeChunkIdentifier(chunkData) // Die erwartete Prüfsumme richtet sich nach der Ablageform. expectedDigest := chunkReference.StoredDigest if expectedDigest == "" { expectedDigest = chunkIdentifier } if actualDigest != expectedDigest { scanReport.CorruptedChunks++ return fmt.Errorf("Der Chunk ist beschädigt: sein Inhalt passt nicht zu seiner Prüfsumme (%s, %d Byte).", chunkIdentifier, fileInformation.Size()) } return nil } // countOrphanedChunks zählt Chunks, die kein Backup mehr benötigt. func (localRepository *LocalRepository) countOrphanedChunks(scanContext context.Context, referencedChunks map[string]struct{}) (int64, error) { var orphanedCount int64 walkError := localRepository.walkChunks(scanContext, func(chunkIdentifier string, chunkSize int64) error { if _, isReferenced := referencedChunks[chunkIdentifier]; !isReferenced { orphanedCount++ } return nil }) return orphanedCount, walkError } // walkChunks ruft eine Funktion für jeden abgelegten Chunk auf. func (localRepository *LocalRepository) walkChunks(walkContext context.Context, visitChunk func(chunkIdentifier string, chunkSize int64) error) error { chunksRoot := localRepository.rootPath + string(os.PathSeparator) + directoryChunks return walkChunkDirectory(walkContext, chunksRoot, 0, visitChunk) } // walkChunkDirectory durchläuft die Chunk-Ablage rekursiv. func walkChunkDirectory(walkContext context.Context, directoryPath string, currentDepth int, visitChunk func(chunkIdentifier string, chunkSize int64) error) error { directoryEntries, readError := os.ReadDir(directoryPath) if readError != nil { if errors.Is(readError, os.ErrNotExist) { return nil } return fmt.Errorf("das chunk-verzeichnis konnte nicht gelesen werden: %w", readError) } for _, directoryEntry := range directoryEntries { if contextError := walkContext.Err(); contextError != nil { return contextError } entryPath := directoryPath + string(os.PathSeparator) + directoryEntry.Name() if directoryEntry.IsDir() { if walkError := walkChunkDirectory(walkContext, entryPath, currentDepth+1, visitChunk); walkError != nil { return walkError } continue } // Nur wohlgeformte Kennungen zählen als Chunk; temporäre Reste eines // abgebrochenen Schreibvorgangs werden übergangen. if validateChunkIdentifier(directoryEntry.Name()) != nil { continue } entryInformation, infoError := directoryEntry.Info() if infoError != nil { continue } if visitError := visitChunk(directoryEntry.Name(), entryInformation.Size()); visitError != nil { return visitError } } return nil } // persistScanReport legt einen Prüfbericht im Repository ab. // // Der Bericht gehört ins Repository und nicht nur in die Datenbank: nach einem // Wiederaufbau soll erkennbar bleiben, wann zuletzt geprüft wurde. func (localRepository *LocalRepository) persistScanReport(scanReport *ScanReport) error { encodedReport, encodeError := jsonMarshalIndent(scanReport) if encodeError != nil { return encodeError } reportName := fmt.Sprintf("scan-%s.json", scanReport.StartedAt.Format("20060102-150405")) reportPath := localRepository.rootPath + string(os.PathSeparator) + directoryVerification + string(os.PathSeparator) + reportName return writeFileAtomically(reportPath, encodedReport, dataFilePermissions) } // PruneOrphanedChunks entfernt Chunks, die kein Backup mehr benötigt. // // Der Vorgang ist potenziell datenzerstörend und deshalb ausdrücklich getrennt // vom Scan (PROMPT.md §141). Er darf niemals laufen, während ein Backup // schreibt: ein gerade abgelegter Chunk hätte noch kein Manifest und würde als // verwaist gelten. Die Schreibsperre des Repositorys verhindert das. func (localRepository *LocalRepository) PruneOrphanedChunks(pruneContext context.Context, dryRun bool) (removedCount int64, freedBytes int64, pruneError error) { if localRepository.lockHandle == nil { return 0, 0, fmt.Errorf("die bereinigung verlangt eine schreibsperre; das repository wurde nur lesend geöffnet") } backupIDs, listError := localRepository.listManifestBackupIDs() if listError != nil { return 0, 0, listError } referencedChunks := make(map[string]struct{}) for _, backupID := range backupIDs { manifest, manifestError := localRepository.ReadManifest(pruneContext, backupID) if manifestError != nil { // Ein unlesbares Manifest bedeutet: die Menge der benötigten Chunks // ist unbekannt. Es wird nichts gelöscht, denn eine Bereinigung auf // unvollständiger Grundlage könnte Daten vernichten. return 0, 0, fmt.Errorf("die bereinigung wurde abgebrochen: das manifest von %s ist nicht lesbar (%w)", backupID, manifestError) } for chunkIdentifier := range manifest.UniqueChunkIdentifiers() { referencedChunks[chunkIdentifier] = struct{}{} } } walkError := localRepository.walkChunks(pruneContext, func(chunkIdentifier string, chunkSize int64) error { if _, isReferenced := referencedChunks[chunkIdentifier]; isReferenced { return nil } removedCount++ freedBytes += chunkSize if dryRun { return nil } chunkFilePath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return pathError } // In einem gehärteten Repository trägt jeder Block ein Löschkennzeichen. // Es wird hier aufgehoben — und nur hier, für Blöcke, die nachweislich // kein Manifest mehr referenziert. if releaseError := localRepository.releaseStoredChunk(chunkFilePath); releaseError != nil { return fmt.Errorf("ein verwaister chunk liess sich nicht zum entfernen freigeben: %w", releaseError) } if removeError := os.Remove(chunkFilePath); removeError != nil && !errors.Is(removeError, os.ErrNotExist) { return fmt.Errorf("ein verwaister chunk konnte nicht entfernt werden: %w", removeError) } return nil }) if walkError != nil { return 0, 0, walkError } localRepository.logger.Info("verwaiste chunks bereinigt", slog.Int64("chunks", removedCount), slog.Int64("freigegebene_bytes", freedBytes), slog.Bool("nur_simulation", dryRun)) return removedCount, freedBytes, nil }