package repository import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "sort" "sync" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/platform/logging" ) // LocalRepository ist ein Repository im lokalen Dateisystem. // // Es ist zugleich die Grundlage des gehärteten Repositorys: der Unterschied // liegt allein in den Schutzmaßnahmen beim Commit und beim Löschen, nicht im // Format (PROMPT.md §15). type LocalRepository struct { // rootPath ist das Wurzelverzeichnis des Repositorys. rootPath string // descriptor beschreibt das Repository. descriptor Descriptor // logger protokolliert Vorgänge des Repositorys. logger *slog.Logger // lockHandle hält die Sperrdatei offen, solange geschrieben werden darf. lockHandle *os.File // catalogMutex schützt den Katalog vor gleichzeitiger Änderung im Prozess. catalogMutex sync.Mutex // timeSource liefert die aktuelle Zeit und ist in Tests ersetzbar. timeSource func() time.Time } // CreateOptions beschreiben ein neu anzulegendes Repository. type CreateOptions struct { // Name ist die sprechende Bezeichnung. Name string // Kind ist die Betriebsart. Kind RepositoryKind // Immutable aktiviert den Aufbewahrungsschutz. Immutable bool // Retention ist die Aufbewahrungsfrist neuer Backups. // // Null bedeutet die Standardfrist von 30 Tagen. Retention time.Duration // MinimumRetention ist die kürzeste je zulässige Frist. // // Sie lässt sich später nicht mehr verringern. Ohne diese Sperre wäre der // Aufbewahrungsschutz eine Einstellung — und eine Einstellung, die der // Angreifer ändern kann, ist kein Schutz. MinimumRetention time.Duration // EncryptionRequired verlangt verschlüsselte Ablage. EncryptionRequired bool // CreatedByVersion ist die anlegende Programmversion. CreatedByVersion string } // Create legt ein neues Repository an. // // Der Descriptor wird zuletzt geschrieben: bricht der Vorgang vorher ab, bleibt // ein unvollständiges Verzeichnis zurück, das beim Öffnen nicht als gültiges // Repository durchgeht. func Create(createContext context.Context, rootPath string, createOptions CreateOptions, baseLogger *slog.Logger) (*LocalRepository, error) { absoluteRootPath, pathError := filepath.Abs(rootPath) if pathError != nil { return nil, fmt.Errorf("der repository-pfad konnte nicht aufgelöst werden: %w", pathError) } // Ein bestehendes Repository darf niemals überschrieben werden. descriptorPath := filepath.Join(absoluteRootPath, directoryFormat, fileDescriptor) if _, statError := os.Stat(descriptorPath); statError == nil { return nil, fmt.Errorf("%w: %s", ErrRepositoryExists, absoluteRootPath) } // Das Wurzelverzeichnis bekommt dieselben Rechte wie alles darunter. // // MkdirAll legt Elternverzeichnisse mit der umask des Aufrufers an — die // Wurzel eines frisch erzeugten Repositorys war dadurch weltlesbar, // waehrend jedes Unterverzeichnis 0700 trug. Ein Fremder kam zwar in // keines hinein, sah aber, dass hier ein Repository liegt und wie viele // Backups es fuehrt. In Phase 19 aufgefallen. if directoryError := ensureDirectory(absoluteRootPath); directoryError != nil { return nil, directoryError } if permissionError := os.Chmod(absoluteRootPath, directoryPermissions); permissionError != nil { return nil, fmt.Errorf("die rechte des repositorys konnten nicht gesetzt werden: %w", permissionError) } for _, repositoryDirectory := range allRepositoryDirectories { if directoryError := ensureDirectory(filepath.Join(absoluteRootPath, repositoryDirectory)); directoryError != nil { return nil, directoryError } } repositoryKind := createOptions.Kind if repositoryKind == "" { repositoryKind = KindLocal } descriptor := Descriptor{ Identifier: FormatIdentifier, FormatVersion: FormatVersion, RepositoryID: uuid.NewString(), Name: createOptions.Name, Kind: repositoryKind, HashAlgorithm: HashAlgorithmSHA256, ChunkFanoutDepth: chunkFanoutDepth, // Ein gehärtetes Repository ist immer unveränderlich; die Betriebsart // wäre sonst wirkungslos. Immutable: createOptions.Immutable || repositoryKind == KindHardenedLinux, RetentionSeconds: int64(createOptions.Retention.Seconds()), MinimumRetentionSeconds: int64(createOptions.MinimumRetention.Seconds()), EncryptionRequired: createOptions.EncryptionRequired, CreatedAt: time.Now().UTC(), CreatedByVersion: createOptions.CreatedByVersion, } encodedDescriptor, encodeError := encodeDescriptor(descriptor) if encodeError != nil { return nil, encodeError } if writeError := writeFileAtomically(descriptorPath, encodedDescriptor, dataFilePermissions); writeError != nil { return nil, fmt.Errorf("der repository-descriptor konnte nicht geschrieben werden: %w", writeError) } // Der Descriptor ist der Einstiegspunkt jedes Wiederaufbaus. Fehlt er, sind // Manifeste und Blöcke zwar noch da, aber nicht mehr als Repository zu // erkennen — im Angriffsversuch überlebten die Daten und der Descriptor // nicht, womit das Repository trotzdem verloren war. if descriptor.Immutable { if protectError := protectManifestFile(descriptorPath); protectError != nil { repositoryLoggerForProtection := logging.WithComponent(baseLogger, "repository") repositoryLoggerForProtection.Warn("der descriptor konnte nicht gegen löschung geschützt werden", slog.String("grund", protectError.Error())) } } repositoryLogger := logging.WithComponent(baseLogger, "repository") repositoryLogger.Info("repository angelegt", slog.String("path", absoluteRootPath), slog.String("repository_id", descriptor.RepositoryID), slog.String("kind", string(descriptor.Kind)), slog.Bool("immutable", descriptor.Immutable), slog.Duration("aufbewahrungsfrist", descriptor.RetentionPeriod()), slog.Duration("mindestfrist", descriptor.MinimumRetentionPeriod()), ) localRepository := &LocalRepository{ rootPath: absoluteRootPath, descriptor: descriptor, logger: repositoryLogger, timeSource: time.Now, } // Das neu angelegte Repository wird beschreibbar zurückgegeben: wer es // erzeugt, will es unmittelbar befüllen. Ohne Sperre müsste der Aufrufer es // sofort wieder schließen und erneut öffnen. if lockError := localRepository.acquireLock(); lockError != nil { return nil, lockError } // Ein leerer Katalog macht das Repository von Anfang an vollständig. emptyCatalog := &Catalog{ CatalogVersion: CatalogVersion, RepositoryID: descriptor.RepositoryID, Entries: []CatalogEntry{}, GeneratedAt: time.Now().UTC(), } if catalogError := localRepository.writeCatalog(emptyCatalog); catalogError != nil { return nil, catalogError } return localRepository, nil } // OpenOptions steuern das Öffnen eines Repositorys. type OpenOptions struct { // ReadOnly öffnet das Repository ohne Schreibsperre. // // Damit können mehrere Vorgänge gleichzeitig lesen, etwa für eine // Wiederherstellung während ein Backup läuft. ReadOnly bool } // Open öffnet ein bestehendes Repository. // // Das Format wird geprüft, bevor irgendetwas geschrieben wird: ein Repository // unbekannter Version darf nicht angetastet werden. func Open(openContext context.Context, rootPath string, openOptions OpenOptions, baseLogger *slog.Logger) (*LocalRepository, error) { absoluteRootPath, pathError := filepath.Abs(rootPath) if pathError != nil { return nil, fmt.Errorf("der repository-pfad konnte nicht aufgelöst werden: %w", pathError) } descriptorPath := filepath.Join(absoluteRootPath, directoryFormat, fileDescriptor) rawDescriptor, readError := os.ReadFile(descriptorPath) if readError != nil { if errors.Is(readError, os.ErrNotExist) { return nil, fmt.Errorf("%w: %s", ErrNotARepository, absoluteRootPath) } return nil, fmt.Errorf("der repository-descriptor konnte nicht gelesen werden: %w", readError) } descriptor, decodeError := decodeDescriptor(rawDescriptor) if decodeError != nil { return nil, decodeError } if validationError := descriptor.Validate(); validationError != nil { return nil, validationError } localRepository := &LocalRepository{ rootPath: absoluteRootPath, descriptor: descriptor, logger: logging.WithComponent(baseLogger, "repository"), timeSource: time.Now, } // Verzeichnisse, die eine ältere Formatversion noch nicht kannte, werden // beim Öffnen ergänzt. if !openOptions.ReadOnly { for _, repositoryDirectory := range allRepositoryDirectories { if directoryError := ensureDirectory(filepath.Join(absoluteRootPath, repositoryDirectory)); directoryError != nil { return nil, directoryError } } if lockError := localRepository.acquireLock(); lockError != nil { return nil, lockError } } localRepository.logger.Info("repository geöffnet", slog.String("path", absoluteRootPath), slog.String("repository_id", descriptor.RepositoryID), slog.Bool("read_only", openOptions.ReadOnly), ) return localRepository, nil } // Descriptor beschreibt das Repository. func (localRepository *LocalRepository) Descriptor() Descriptor { return localRepository.descriptor } // RootPath liefert das Wurzelverzeichnis des Repositorys. func (localRepository *LocalRepository) RootPath() string { return localRepository.rootPath } // Close gibt die Schreibsperre frei. func (localRepository *LocalRepository) Close() error { return localRepository.releaseLock() } // --------------------------------------------------------------------------- // Chunk-Ablage // --------------------------------------------------------------------------- // computeChunkIdentifier bildet die Kennung eines Datenblocks. // // Die Kennung ist der Inhaltshash. Daraus folgt unmittelbar die Deduplizierung: // gleicher Inhalt ergibt dieselbe Kennung und damit denselben Ablageort. func computeChunkIdentifier(chunkData []byte) string { chunkDigest := sha256.Sum256(chunkData) return hex.EncodeToString(chunkDigest[:]) } // HasChunk meldet, ob ein Chunk bereits vorliegt. func (localRepository *LocalRepository) HasChunk(queryContext context.Context, chunkIdentifier string) (bool, error) { targetPath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return false, pathError } _, statError := os.Stat(targetPath) if statError == nil { return true, nil } if errors.Is(statError, os.ErrNotExist) { return false, nil } return false, fmt.Errorf("der chunk konnte nicht geprüft werden: %w", statError) } // writeChunk legt einen Datenblock ab. // // Der zweite Rückgabewert meldet, ob der Chunk neu war. Ein bereits vorhandener // Chunk wird nicht erneut geschrieben — Inhaltsadressierung macht das überflüssig. func (localRepository *LocalRepository) writeChunk(writeContext context.Context, chunkData []byte) (string, bool, error) { chunkIdentifier := computeChunkIdentifier(chunkData) targetPath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return "", false, pathError } // Liegt der Inhalt bereits vor, ist nichts zu tun. if _, statError := os.Stat(targetPath); statError == nil { return chunkIdentifier, false, nil } if directoryError := ensureDirectory(filepath.Dir(targetPath)); directoryError != nil { return "", false, directoryError } if writeError := writeFileAtomically(targetPath, chunkData, dataFilePermissions); writeError != nil { return "", false, fmt.Errorf("der chunk konnte nicht geschrieben werden: %w", writeError) } localRepository.protectStoredChunk(targetPath) return chunkIdentifier, true, nil } // ReadChunk liest einen Chunk und prüft dabei seine Unversehrtheit. // // Die Prüfung erfolgt bei jedem Lesen, nicht nur beim Integritätslauf: ein // beschädigter Chunk darf niemals unbemerkt in eine Wiederherstellung fließen // (PROMPT.md §14). func (localRepository *LocalRepository) ReadChunk(readContext context.Context, chunkIdentifier string) ([]byte, error) { targetPath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return nil, pathError } chunkData, readError := os.ReadFile(targetPath) if readError != nil { if errors.Is(readError, os.ErrNotExist) { return nil, fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier) } return nil, fmt.Errorf("der chunk konnte nicht gelesen werden: %w", readError) } // Der neu berechnete Hash muss der Kennung entsprechen. actualIdentifier := computeChunkIdentifier(chunkData) if actualIdentifier != chunkIdentifier { localRepository.logger.Error("beschädigter chunk erkannt", slog.String("expected_id", chunkIdentifier), slog.String("actual_id", actualIdentifier), slog.String("path", targetPath), ) return nil, fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkIdentifier) } return chunkData, nil } // OpenChunk öffnet einen Chunk als Datenstrom. // // Anders als ReadChunk prüft dieser Weg die Prüfsumme nicht vorab — das würde // den gesamten Inhalt in den Speicher zwingen und damit den Zweck verfehlen // (PROMPT.md §80). Der Aufrufer muss beim Lesen selbst mitprüfen. func (localRepository *LocalRepository) OpenChunk(readContext context.Context, chunkIdentifier string) (io.ReadCloser, error) { targetPath, pathError := localRepository.chunkPath(chunkIdentifier) if pathError != nil { return nil, pathError } chunkFile, openError := os.Open(targetPath) if openError != nil { if errors.Is(openError, os.ErrNotExist) { return nil, fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier) } return nil, fmt.Errorf("der chunk konnte nicht geöffnet werden: %w", openError) } return &verifyingChunkReader{ underlyingFile: chunkFile, runningDigest: sha256.New(), expectedIdentifier: chunkIdentifier, }, nil } // verifyingChunkReader liest einen Chunk und prüft die Prüfsumme beim Schließen. // // So bleibt die Integritätsprüfung erhalten, ohne den Inhalt vollständig im // Arbeitsspeicher halten zu müssen. type verifyingChunkReader struct { // underlyingFile ist die geöffnete Chunkdatei. underlyingFile *os.File // runningDigest berechnet die Prüfsumme während des Lesens mit. runningDigest interface { io.Writer Sum([]byte) []byte } // expectedIdentifier ist die erwartete Kennung. expectedIdentifier string // reachedEndOfFile meldet, ob der Strom vollständig gelesen wurde. reachedEndOfFile bool } // Read liest Daten und aktualisiert dabei die laufende Prüfsumme. func (chunkReader *verifyingChunkReader) Read(targetBuffer []byte) (int, error) { bytesRead, readError := chunkReader.underlyingFile.Read(targetBuffer) if bytesRead > 0 { // Der Fehler von Write kann bei einem Hash nicht auftreten. _, _ = chunkReader.runningDigest.Write(targetBuffer[:bytesRead]) } if errors.Is(readError, io.EOF) { chunkReader.reachedEndOfFile = true // Am Ende des Stroms steht die Prüfsumme fest und wird sofort geprüft. if verifyError := chunkReader.verifyDigest(); verifyError != nil { return bytesRead, verifyError } } return bytesRead, readError } // Close schließt die Datei. func (chunkReader *verifyingChunkReader) Close() error { return chunkReader.underlyingFile.Close() } // verifyDigest vergleicht die berechnete Prüfsumme mit der erwarteten Kennung. func (chunkReader *verifyingChunkReader) verifyDigest() error { actualIdentifier := hex.EncodeToString(chunkReader.runningDigest.Sum(nil)) if actualIdentifier != chunkReader.expectedIdentifier { return fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkReader.expectedIdentifier) } return nil } // --------------------------------------------------------------------------- // Manifeste // --------------------------------------------------------------------------- // ReadManifest liest das Manifest eines Backups und prüft seine Unversehrtheit. func (localRepository *LocalRepository) ReadManifest(readContext context.Context, backupID string) (*Manifest, error) { if validationError := validateBackupIdentifier(backupID); validationError != nil { return nil, validationError } rawManifest, readError := os.ReadFile(localRepository.manifestPath(backupID)) if readError != nil { if errors.Is(readError, os.ErrNotExist) { return nil, fmt.Errorf("%w (kennung %s)", ErrBackupNotFound, backupID) } return nil, fmt.Errorf("das manifest konnte nicht gelesen werden: %w", readError) } manifest, decodeError := decodeManifest(rawManifest) if decodeError != nil { return nil, decodeError } // Ein Manifest ohne gültigen Abschluss beschreibt kein verwendbares Backup. if verifyError := VerifyManifest(manifest); verifyError != nil { return nil, verifyError } return manifest, nil } // listManifestBackupIDs liest die Kennungen aller vorhandenen Manifeste. func (localRepository *LocalRepository) listManifestBackupIDs() ([]string, error) { manifestDirectory := filepath.Join(localRepository.rootPath, directoryManifests) directoryEntries, readError := os.ReadDir(manifestDirectory) if readError != nil { if errors.Is(readError, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("das manifestverzeichnis konnte nicht gelesen werden: %w", readError) } backupIDs := make([]string, 0, len(directoryEntries)) for _, directoryEntry := range directoryEntries { if directoryEntry.IsDir() { continue } entryName := directoryEntry.Name() // Temporäre Dateien eines abgebrochenen Schreibvorgangs werden übergangen. if len(entryName) <= len(manifestExtension) || filepath.Ext(entryName) != ".json" { continue } if !hasSuffix(entryName, manifestExtension) { continue } backupIDs = append(backupIDs, entryName[:len(entryName)-len(manifestExtension)]) } sort.Strings(backupIDs) return backupIDs, nil } // hasSuffix meldet, ob eine Zeichenkette auf eine bestimmte Endung endet. func hasSuffix(fullText string, suffix string) bool { return len(fullText) >= len(suffix) && fullText[len(fullText)-len(suffix):] == suffix } // --------------------------------------------------------------------------- // Sperre // --------------------------------------------------------------------------- // lockInformation beschreibt den Inhaber einer Repository-Sperre. type lockInformation struct { // ProcessID ist die Kennung des sperrenden Prozesses. ProcessID int `json:"process_id"` // Hostname ist der Rechner, auf dem der Prozess läuft. Hostname string `json:"hostname"` // AcquiredAt ist der Zeitpunkt der Sperre in UTC. AcquiredAt time.Time `json:"acquired_at"` } // acquireLock belegt die Schreibsperre des Repositorys (PROMPT.md §83). // // Die Sperre verhindert, dass zwei Vorgänge gleichzeitig schreiben und dabei // den Katalog oder ein Manifest beschädigen. func (localRepository *LocalRepository) acquireLock() error { // O_EXCL schlägt fehl, wenn die Datei bereits existiert. Das Anlegen ist // damit unteilbar und eignet sich als Sperre. lockFile, createError := os.OpenFile(localRepository.lockPath(), os.O_CREATE|os.O_EXCL|os.O_WRONLY, dataFilePermissions) if createError != nil { if errors.Is(createError, os.ErrExist) { return localRepository.describeExistingLock() } return fmt.Errorf("die repository-sperre konnte nicht gesetzt werden: %w", createError) } hostname, _ := os.Hostname() lockDetails := lockInformation{ ProcessID: os.Getpid(), Hostname: hostname, AcquiredAt: time.Now().UTC(), } // Die Angaben helfen einem Administrator, eine hängengebliebene Sperre // einzuordnen, statt vor einer nichtssagenden Datei zu stehen. encodedLock, marshalError := jsonMarshalIndent(lockDetails) if marshalError == nil { _, _ = lockFile.Write(encodedLock) } if syncError := lockFile.Sync(); syncError != nil { _ = lockFile.Close() return fmt.Errorf("die repository-sperre konnte nicht gesichert werden: %w", syncError) } localRepository.lockHandle = lockFile return nil } // describeExistingLock erklärt eine vorgefundene Sperre. func (localRepository *LocalRepository) describeExistingLock() error { rawLock, readError := os.ReadFile(localRepository.lockPath()) if readError != nil { return ErrRepositoryLocked } var lockDetails lockInformation if unmarshalError := jsonUnmarshal(rawLock, &lockDetails); unmarshalError != nil { return ErrRepositoryLocked } return fmt.Errorf("%w (Prozess %d auf %s, seit %s)", ErrRepositoryLocked, lockDetails.ProcessID, lockDetails.Hostname, lockDetails.AcquiredAt.Format(time.RFC3339)) } // releaseLock gibt die Schreibsperre frei. func (localRepository *LocalRepository) releaseLock() error { if localRepository.lockHandle == nil { return nil } if closeError := localRepository.lockHandle.Close(); closeError != nil { return fmt.Errorf("die repository-sperre konnte nicht geschlossen werden: %w", closeError) } localRepository.lockHandle = nil if removeError := os.Remove(localRepository.lockPath()); removeError != nil && !errors.Is(removeError, os.ErrNotExist) { return fmt.Errorf("die repository-sperre konnte nicht entfernt werden: %w", removeError) } return nil } // BreakLock entfernt eine hängengebliebene Sperre. // // Der Eingriff ist ausdrücklich manuell: bricht man die Sperre eines noch // laufenden Vorgangs, können zwei Schreiber gleichzeitig arbeiten und das // Repository beschädigen (PROMPT.md §141). func BreakLock(rootPath string) error { lockFilePath := filepath.Join(rootPath, fileLock) if removeError := os.Remove(lockFilePath); removeError != nil { if errors.Is(removeError, os.ErrNotExist) { return nil } return fmt.Errorf("die repository-sperre konnte nicht entfernt werden: %w", removeError) } return nil } // ReadLockHolder beschreibt eine vorgefundene Sperre. // // Der Rückgabewert ist leer, wenn keine Sperre liegt. Er nennt Prozess, Rechner // und Zeitpunkt — die drei Angaben, die man braucht, bevor man eine Sperre // aufbricht: Ein Prozess auf einem anderen Rechner läuft womöglich noch, und // eine Sperre von vor fünf Minuten ist etwas anderes als eine von vorgestern. func ReadLockHolder(rootPath string) (string, error) { rawLock, readError := os.ReadFile(filepath.Join(rootPath, fileLock)) if errors.Is(readError, os.ErrNotExist) { return "", nil } if readError != nil { return "", fmt.Errorf("die repository-sperre konnte nicht gelesen werden: %w", readError) } var lockDetails lockInformation if unmarshalError := jsonUnmarshal(rawLock, &lockDetails); unmarshalError != nil { // Eine unlesbare Sperrdatei ist trotzdem eine Sperre. Sie als "keine" // zu melden wäre der gefährlichste Ausgang: Der Aufrufer bräche sie // dann ohne Rückfrage. return "unlesbare Sperrdatei", nil } return fmt.Sprintf("Prozess %d auf %s, seit %s", lockDetails.ProcessID, lockDetails.Hostname, lockDetails.AcquiredAt.Format(time.RFC3339)), nil }