package repository import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "os" "github.com/syncova/syncova/packages/backupformat" ) // ExportBackup schreibt ein Backup als portablen Container. // // Der Container ist in sich geschlossen: er enthält Manifest, Chunk-Verzeichnis // und sämtliche Datenblöcke. Ein Empfänger benötigt weder dieses Repository // noch eine Datenbank, um ihn zu lesen (PROMPT.md §17: Backup Copy). func (localRepository *LocalRepository) ExportBackup(exportContext context.Context, backupID string, outputWriter io.Writer, createdByVersion string) (exportedBytes int64, exportError error) { manifest, manifestError := localRepository.ReadManifest(exportContext, backupID) if manifestError != nil { return 0, manifestError } // Die Chunks werden in einer festen Reihenfolge abgelegt, damit ihre // Positionen im Verzeichnis stimmen. Die Reihenfolge des Manifests ist die // natürliche: sie entspricht dem Lesefluss einer Wiederherstellung. orderedChunkIdentifiers := collectOrderedChunkIdentifiers(manifest) // Vor dem Schreiben wird geprüft, ob alle Chunks vorliegen. Ein Container, // dem mitten im Schreiben ein Block fehlt, wäre nur noch Ausschuss. chunkIndex, indexError := localRepository.buildChunkIndex(exportContext, orderedChunkIdentifiers) if indexError != nil { return 0, indexError } containerHeader := backupformat.ContainerHeader{ BackupID: manifest.BackupID, ChainID: manifest.ChainID, ParentBackupID: manifest.ParentBackupID, SourceID: manifest.Source.SourceID, SourceType: manifest.Source.SourceType, CreatedAt: manifest.CompletedAt, CreatedByVersion: createdByVersion, RepositoryID: localRepository.descriptor.RepositoryID, } containerWriter, writerError := backupformat.NewWriter(outputWriter, containerHeader) if writerError != nil { return 0, writerError } // Das Manifest ist der Kern des Containers und deshalb erforderlich. if writeError := containerWriter.WriteJSONSection(backupformat.SectionManifest, backupformat.FlagRequired, manifest); writeError != nil { return 0, writeError } if writeError := containerWriter.WriteJSONSection(backupformat.SectionSourceMetadata, backupformat.FlagRequired, manifest.Source); writeError != nil { return 0, writeError } if writeError := containerWriter.WriteJSONSection(backupformat.SectionChunkIndex, backupformat.FlagRequired, chunkIndex); writeError != nil { return 0, writeError } // Der Datenbereich entsteht als Datenstrom: er kann beliebig groß werden // und darf nicht im Arbeitsspeicher gehalten werden (PROMPT.md §80). streamWriter, streamError := containerWriter.BeginSectionStream(backupformat.SectionDataChunks, backupformat.FlagRequired, chunkIndex.TotalStoredBytes()) if streamError != nil { return 0, streamError } for _, indexEntry := range chunkIndex.Entries { if contextError := exportContext.Err(); contextError != nil { return 0, contextError } // ReadChunk prüft die Unversehrtheit; ein beschädigter Block gelangt // damit gar nicht erst in den Container. chunkData, readError := localRepository.ReadChunk(exportContext, indexEntry.Identifier) if readError != nil { return 0, fmt.Errorf("der export wurde abgebrochen: %w", readError) } if _, writeError := streamWriter.Write(chunkData); writeError != nil { return 0, writeError } } if finishError := streamWriter.Finish(); finishError != nil { return 0, finishError } if closeError := containerWriter.Close(); closeError != nil { return 0, closeError } localRepository.logger.Info("backup als container exportiert", slog.String("backup_id", backupID), slog.Int("chunks", len(chunkIndex.Entries)), slog.Int64("bytes", containerWriter.BytesWritten())) return containerWriter.BytesWritten(), nil } // collectOrderedChunkIdentifiers sammelt die Chunks eines Manifests ohne Wiederholung. // // Ein mehrfach verwendeter Chunk erscheint nur einmal im Container — genau das // ist der Zweck der Deduplizierung, und sie soll auch im Container erhalten bleiben. func collectOrderedChunkIdentifiers(manifest *Manifest) []string { seenIdentifiers := make(map[string]struct{}) orderedIdentifiers := make([]string, 0) for _, manifestEntry := range manifest.Entries { for _, chunkReference := range manifestEntry.Chunks { if _, alreadySeen := seenIdentifiers[chunkReference.Identifier]; alreadySeen { continue } seenIdentifiers[chunkReference.Identifier] = struct{}{} orderedIdentifiers = append(orderedIdentifiers, chunkReference.Identifier) } } return orderedIdentifiers } // buildChunkIndex ermittelt Größe und Position aller Chunks. func (localRepository *LocalRepository) buildChunkIndex(buildContext context.Context, chunkIdentifiers []string) (backupformat.ChunkIndex, error) { chunkIndex := backupformat.ChunkIndex{ Entries: make([]backupformat.ChunkIndexEntry, 0, len(chunkIdentifiers)), } var currentOffset int64 for _, chunkIdentifier := range chunkIdentifiers { if contextError := buildContext.Err(); contextError != nil { return backupformat.ChunkIndex{}, contextError } chunkFilePath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return backupformat.ChunkIndex{}, pathError } fileInformation, statError := os.Stat(chunkFilePath) if statError != nil { // Ein fehlender Chunk bedeutet: das Backup ist unvollständig. Der // Export wird abgebrochen, statt einen lückenhaften Container zu // erzeugen, der sich als vollständig ausgäbe (PROMPT.md §140). return backupformat.ChunkIndex{}, fmt.Errorf( "der export ist nicht möglich, das backup ist unvollständig: %w (kennung %s)", ErrChunkNotFound, chunkIdentifier) } chunkLength := fileInformation.Size() chunkIndex.Entries = append(chunkIndex.Entries, backupformat.ChunkIndexEntry{ Identifier: chunkIdentifier, ContainerOffset: currentOffset, StoredLength: chunkLength, LogicalLength: chunkLength, }) currentOffset += chunkLength } return chunkIndex, nil } // ImportResult beschreibt das Ergebnis eines Container-Imports. type ImportResult struct { // BackupID ist die Kennung des eingelesenen Backups. BackupID string `json:"backup_id"` // ChunksImported ist die Zahl neu übernommener Chunks. ChunksImported int `json:"chunks_imported"` // ChunksAlreadyPresent ist die Zahl bereits vorhandener Chunks. // // Beim Einlesen mehrerer Container derselben Quelle greift dieselbe // Deduplizierung wie beim Sichern. ChunksAlreadyPresent int `json:"chunks_already_present"` // BytesImported ist die Menge neu geschriebener Daten. BytesImported int64 `json:"bytes_imported"` // SourceRepositoryID benennt das Ursprungs-Repository. SourceRepositoryID string `json:"source_repository_id,omitempty"` } // ImportBackup liest einen Container in dieses Repository ein. // // Der Container wird vollständig geprüft, bevor das Manifest sichtbar wird: // Erst wenn alle Abschnitte, Prüfsummen und der Abschlussvermerk stimmen, // entsteht ein sichtbares Backup. Ein abgebrochener oder verfälschter Container // hinterlässt allenfalls Chunks, aber niemals ein scheinbar gültiges Backup. func (localRepository *LocalRepository) ImportBackup(importContext context.Context, inputReader io.Reader) (ImportResult, error) { if localRepository.lockHandle == nil { return ImportResult{}, fmt.Errorf("das repository wurde nur lesend geöffnet und kann nichts aufnehmen") } containerReader, readerError := backupformat.NewReader(inputReader) if readerError != nil { return ImportResult{}, readerError } containerHeader := containerReader.Header() if validationError := validateBackupIdentifier(containerHeader.BackupID); validationError != nil { return ImportResult{}, validationError } // Ein bereits vorhandenes Backup darf nicht überschrieben werden. if _, statError := os.Stat(localRepository.manifestPath(containerHeader.BackupID)); statError == nil { return ImportResult{}, fmt.Errorf("zu der kennung %s existiert bereits ein backup", containerHeader.BackupID) } importResult := ImportResult{ BackupID: containerHeader.BackupID, SourceRepositoryID: containerHeader.RepositoryID, } var importedManifest *Manifest var chunkIndex backupformat.ChunkIndex var hasChunkIndex bool for { if contextError := importContext.Err(); contextError != nil { return ImportResult{}, contextError } nextSection, sectionError := containerReader.NextSection() if errors.Is(sectionError, io.EOF) { break } if sectionError != nil { return ImportResult{}, sectionError } switch nextSection.Type { case backupformat.SectionManifest: parsedManifest, decodeError := decodeManifest(nextSection.Content) if decodeError != nil { return ImportResult{}, decodeError } // Das Manifest wird auf seinen eigenen Abschlussvermerk geprüft - // unabhängig davon, dass der Container einen trägt. if verifyError := VerifyManifest(parsedManifest); verifyError != nil { return ImportResult{}, fmt.Errorf("der container enthält kein gültiges backup: %w", verifyError) } importedManifest = parsedManifest case backupformat.SectionChunkIndex: if unmarshalError := json.Unmarshal(nextSection.Content, &chunkIndex); unmarshalError != nil { return ImportResult{}, fmt.Errorf("das chunk-verzeichnis des containers ist unlesbar: %w", unmarshalError) } hasChunkIndex = true case backupformat.SectionDataChunks: if !hasChunkIndex { // Ohne Verzeichnis liesse sich der Datenbereich nicht aufteilen. return ImportResult{}, fmt.Errorf("%w: das chunk-verzeichnis fehlt vor den datenblöcken", backupformat.ErrMissingSection) } if splitError := localRepository.storeChunksFromSection(importContext, nextSection.Content, chunkIndex, &importResult); splitError != nil { return ImportResult{}, splitError } } } // Der Abschlussvermerk des Containers entscheidet über die Gültigkeit. if !containerReader.IsComplete() { return ImportResult{}, backupformat.ErrIncompleteBackup } if importedManifest == nil { return ImportResult{}, fmt.Errorf("%w: %s", backupformat.ErrMissingSection, backupformat.SectionManifest) } // Erst nach allen Prüfungen wird das Manifest sichtbar. Damit gilt auch // hier: ein Backup erscheint nur vollständig oder gar nicht. encodedManifest, encodeError := encodeManifest(importedManifest) if encodeError != nil { return ImportResult{}, encodeError } manifestPermissions := dataFilePermissions if localRepository.descriptor.Immutable { manifestPermissions = immutableFilePermissions } if writeError := writeFileAtomically(localRepository.manifestPath(importedManifest.BackupID), encodedManifest, manifestPermissions); writeError != nil { return ImportResult{}, fmt.Errorf("das manifest konnte nicht abgelegt werden: %w", writeError) } if catalogError := localRepository.appendToCatalog(importedManifest); catalogError != nil { localRepository.logger.Error("der katalog konnte nach dem import nicht aktualisiert werden", slog.String("backup_id", importedManifest.BackupID), slog.String("error", catalogError.Error())) } localRepository.logger.Info("container eingelesen", slog.String("backup_id", importResult.BackupID), slog.Int("chunks_neu", importResult.ChunksImported), slog.Int("chunks_vorhanden", importResult.ChunksAlreadyPresent)) return importResult, nil } // storeChunksFromSection teilt den Datenbereich anhand des Verzeichnisses auf. func (localRepository *LocalRepository) storeChunksFromSection(storeContext context.Context, sectionContent []byte, chunkIndex backupformat.ChunkIndex, importResult *ImportResult) error { for _, indexEntry := range chunkIndex.Entries { if contextError := storeContext.Err(); contextError != nil { return contextError } endOffset := indexEntry.ContainerOffset + indexEntry.StoredLength // Eine Positionsangabe außerhalb des Datenbereichs deutet auf einen // verfälschten Container hin. if indexEntry.ContainerOffset < 0 || endOffset > int64(len(sectionContent)) { return fmt.Errorf("das chunk-verzeichnis passt nicht zum datenbereich (kennung %s)", indexEntry.Identifier) } chunkData := sectionContent[indexEntry.ContainerOffset:endOffset] // Der Inhalt muss zu seiner Kennung passen. Andernfalls wäre entweder // der Container verfälscht oder das Verzeichnis falsch - in beiden // Fällen darf nichts übernommen werden. if computeChunkIdentifier(chunkData) != indexEntry.Identifier { return fmt.Errorf("%w: ein chunk des containers passt nicht zu seiner kennung (%s)", ErrChunkCorrupted, indexEntry.Identifier) } _, wasNew, writeError := localRepository.writeChunk(storeContext, chunkData) if writeError != nil { return writeError } if wasNew { importResult.ChunksImported++ importResult.BytesImported += indexEntry.StoredLength } else { importResult.ChunksAlreadyPresent++ } } return nil }