package repository import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "sort" ) // jsonMarshalIndent serialisiert einen Wert lesbar. // // Der kleine Helfer hält die Formatierung im gesamten Paket einheitlich. func jsonMarshalIndent(value any) ([]byte, error) { encodedValue, marshalError := json.MarshalIndent(value, "", " ") if marshalError != nil { return nil, marshalError } return append(encodedValue, '\n'), nil } // jsonUnmarshal liest einen serialisierten Wert. func jsonUnmarshal(rawValue []byte, target any) error { return json.Unmarshal(rawValue, target) } // writeCatalog schreibt den Katalog atomar. func (localRepository *LocalRepository) writeCatalog(catalog *Catalog) error { encodedCatalog, encodeError := encodeCatalog(catalog) if encodeError != nil { return encodeError } // Das Verzeichnis wird angelegt, falls es fehlt. // // „Katalog verloren" (Szenario C der Phase 18) heißt nicht immer „Datei // gelöscht": Bei einem Teilausfall des Dateisystems oder nach einem // beherzten Aufräumen fehlt das ganze Verzeichnis. Ohne diesen Schritt // scheiterte ausgerechnet der Neuaufbau, der den Verlust beheben soll — // und die Fehlermeldung sprach von einer temporären Datei, nicht vom // eigentlichen Problem. Im Nachweis aufgefallen. if directoryError := os.MkdirAll(filepath.Dir(localRepository.catalogPath()), directoryPermissions); directoryError != nil { return fmt.Errorf("das katalogverzeichnis konnte nicht angelegt werden: %w", directoryError) } // Der Katalog bleibt auch in einem gehärteten Repository beschreibbar: // er ist ein Beschleuniger, kein Backup-Inhalt, und muss bei jedem neuen // Backup fortgeschrieben werden. if writeError := writeFileAtomically(localRepository.catalogPath(), encodedCatalog, dataFilePermissions); writeError != nil { return fmt.Errorf("der katalog konnte nicht geschrieben werden: %w", writeError) } return nil } // Catalog liefert den Katalog des Repositorys. // // Ist der Katalog nicht lesbar, wird er stillschweigend neu aufgebaut: er ist // jederzeit aus den Manifesten herleitbar und deshalb kein Datenverlust. func (localRepository *LocalRepository) Catalog(catalogContext context.Context) (*Catalog, error) { localRepository.catalogMutex.Lock() defer localRepository.catalogMutex.Unlock() return localRepository.loadCatalogLocked(catalogContext) } // loadCatalogLocked liest den Katalog; der Aufrufer hält bereits die Sperre. func (localRepository *LocalRepository) loadCatalogLocked(catalogContext context.Context) (*Catalog, error) { rawCatalog, readError := os.ReadFile(localRepository.catalogPath()) if readError != nil { if !errors.Is(readError, os.ErrNotExist) { return nil, fmt.Errorf("der katalog konnte nicht gelesen werden: %w", readError) } localRepository.logger.Warn("der katalog fehlt und wird aus den manifesten neu aufgebaut") return localRepository.rebuildCatalogLocked(catalogContext) } catalog, decodeError := decodeCatalog(rawCatalog) if decodeError != nil { localRepository.logger.Warn("der katalog ist unlesbar und wird aus den manifesten neu aufgebaut", slog.String("error", decodeError.Error())) return localRepository.rebuildCatalogLocked(catalogContext) } // Ein Katalog eines fremden Repositorys enthält falsche Angaben; er wird // verworfen statt verwendet. if catalog.RepositoryID != localRepository.descriptor.RepositoryID { localRepository.logger.Warn("der katalog gehört zu einem anderen repository und wird neu aufgebaut", slog.String("catalog_repository_id", catalog.RepositoryID), slog.String("repository_id", localRepository.descriptor.RepositoryID)) return localRepository.rebuildCatalogLocked(catalogContext) } return catalog, nil } // appendToCatalog nimmt ein abgeschlossenes Backup in den Katalog auf. func (localRepository *LocalRepository) appendToCatalog(manifest *Manifest) error { localRepository.catalogMutex.Lock() defer localRepository.catalogMutex.Unlock() catalog, loadError := localRepository.loadCatalogLocked(context.Background()) if loadError != nil { return loadError } newEntry := catalogEntryFromManifest(manifest) // Ein bereits vorhandener Eintrag wird ersetzt statt verdoppelt. entryReplaced := false for entryIndex := range catalog.Entries { if catalog.Entries[entryIndex].BackupID == newEntry.BackupID { catalog.Entries[entryIndex] = newEntry entryReplaced = true break } } if !entryReplaced { catalog.Entries = append(catalog.Entries, newEntry) } sortCatalogEntries(catalog.Entries) catalog.GeneratedAt = localRepository.timeSource().UTC() catalog.RebuiltFromManifests = false return localRepository.writeCatalog(catalog) } // removeFromCatalog entfernt ein Backup aus dem Katalog. func (localRepository *LocalRepository) removeFromCatalog(backupID string) error { localRepository.catalogMutex.Lock() defer localRepository.catalogMutex.Unlock() catalog, loadError := localRepository.loadCatalogLocked(context.Background()) if loadError != nil { return loadError } remainingEntries := make([]CatalogEntry, 0, len(catalog.Entries)) for _, catalogEntry := range catalog.Entries { if catalogEntry.BackupID != backupID { remainingEntries = append(remainingEntries, catalogEntry) } } catalog.Entries = remainingEntries catalog.GeneratedAt = localRepository.timeSource().UTC() return localRepository.writeCatalog(catalog) } // RebuildCatalog baut den Katalog allein aus den Manifesten neu auf. // // Das ist der Kern der Repository-Wiederherstellung (PROMPT.md §46): Nach dem // Verlust des Control Servers genügt es, das Repository anzuhängen und diesen // Vorgang auszuführen. Es wird keinerlei Datenbank benötigt. func (localRepository *LocalRepository) RebuildCatalog(rebuildContext context.Context) (*Catalog, error) { localRepository.catalogMutex.Lock() defer localRepository.catalogMutex.Unlock() return localRepository.rebuildCatalogLocked(rebuildContext) } // rebuildCatalogLocked baut den Katalog neu auf; der Aufrufer hält die Sperre. func (localRepository *LocalRepository) rebuildCatalogLocked(rebuildContext context.Context) (*Catalog, error) { backupIDs, listError := localRepository.listManifestBackupIDs() if listError != nil { return nil, listError } rebuiltCatalog := &Catalog{ CatalogVersion: CatalogVersion, RepositoryID: localRepository.descriptor.RepositoryID, Entries: make([]CatalogEntry, 0, len(backupIDs)), GeneratedAt: localRepository.timeSource().UTC(), RebuiltFromManifests: true, } var skippedManifests int for _, backupID := range backupIDs { if contextError := rebuildContext.Err(); contextError != nil { return nil, contextError } rawManifest, readError := os.ReadFile(localRepository.manifestPath(backupID)) if readError != nil { localRepository.logger.Error("ein manifest konnte beim wiederaufbau nicht gelesen werden", slog.String("backup_id", backupID), slog.String("error", readError.Error())) skippedManifests++ continue } manifest, decodeError := decodeManifest(rawManifest) if decodeError != nil { localRepository.logger.Error("ein manifest ist beschädigt und wird nicht in den katalog aufgenommen", slog.String("backup_id", backupID), slog.String("error", decodeError.Error())) skippedManifests++ continue } // Ein unvollständiges oder verfälschtes Backup gehört nicht in den // Katalog: es würde als wiederherstellbar erscheinen, ohne es zu sein. if verifyError := VerifyManifest(manifest); verifyError != nil { localRepository.logger.Error("ein backup ist unvollständig und wird nicht in den katalog aufgenommen", slog.String("backup_id", backupID), slog.String("error", verifyError.Error())) skippedManifests++ continue } rebuiltCatalog.Entries = append(rebuiltCatalog.Entries, catalogEntryFromManifest(manifest)) } sortCatalogEntries(rebuiltCatalog.Entries) if writeError := localRepository.writeCatalog(rebuiltCatalog); writeError != nil { return nil, writeError } localRepository.logger.Info("katalog aus den manifesten neu aufgebaut", slog.Int("backups", len(rebuiltCatalog.Entries)), slog.Int("uebersprungene_manifeste", skippedManifests)) return rebuiltCatalog, nil } // ListBackups liefert alle abgeschlossenen Backups. func (localRepository *LocalRepository) ListBackups(listContext context.Context) ([]CatalogEntry, error) { catalog, catalogError := localRepository.Catalog(listContext) if catalogError != nil { return nil, catalogError } return catalog.Entries, nil } // sortCatalogEntries sortiert Katalogeinträge, jüngstes Backup zuerst. // // Die Reihenfolge entspricht der Erwartung in der Oberfläche: der letzte // Wiederherstellungspunkt ist der wichtigste. func sortCatalogEntries(catalogEntries []CatalogEntry) { sort.SliceStable(catalogEntries, func(firstIndex int, secondIndex int) bool { if !catalogEntries[firstIndex].CompletedAt.Equal(catalogEntries[secondIndex].CompletedAt) { return catalogEntries[firstIndex].CompletedAt.After(catalogEntries[secondIndex].CompletedAt) } // Bei gleichem Zeitstempel entscheidet die Kennung, damit die // Reihenfolge über Läufe hinweg stabil bleibt. return catalogEntries[firstIndex].BackupID < catalogEntries[secondIndex].BackupID }) }