package repository import ( "fmt" "path/filepath" "strings" ) // Verzeichnisse eines Repositorys (SYNCOVA_ARCHITECTURE.md §9). // // Die Aufteilung erlaubt es, beim Wiederaufbau gezielt nur die Manifeste zu // lesen, ohne die weitaus größere Chunk-Ablage anzufassen. const ( // directoryFormat enthält den Descriptor und die Formatangaben. directoryFormat = "format" // directoryManifests enthält die abgeschlossenen Backup-Manifeste. directoryManifests = "manifests" // directoryChunks enthält die eigentlichen Datenblöcke. directoryChunks = "chunks" // directoryIndexes enthält den Katalog und weitere Beschleuniger. directoryIndexes = "indexes" // directoryJournals enthält die Journale laufender Schreibsessions. directoryJournals = "journals" // directoryVerification enthält die Ergebnisse von Integritätsläufen. directoryVerification = "verification" // directoryMetadata enthält ergänzende Repository-Angaben. directoryMetadata = "metadata" // directoryStaging nimmt unfertige Daten auf, bis ein Commit sie sichtbar macht. directoryStaging = "staging" ) // Dateinamen innerhalb eines Repositorys. const ( // fileDescriptor beschreibt das Repository und liegt in format/. fileDescriptor = "repository.json" // fileCatalog ist der Katalog aller Backups und liegt in indexes/. fileCatalog = "catalog.json" // fileLock verhindert gleichzeitige Schreibzugriffe und liegt im Wurzelverzeichnis. fileLock = "repository.lock" // manifestExtension ist die Endung einer Manifestdatei. manifestExtension = ".manifest.json" // journalExtension ist die Endung einer Journaldatei. journalExtension = ".journal.json" // retentionHoldExtension ist die Endung eines Schutzvermerks. // // Der Vermerk liegt neben dem Manifest, damit ein Wiederaufbau ohne // Datenbank beides in einem Durchgang findet. retentionHoldExtension = ".hold.json" ) // allRepositoryDirectories listet die beim Anlegen zu erstellenden Verzeichnisse. var allRepositoryDirectories = []string{ directoryFormat, directoryManifests, directoryChunks, directoryIndexes, directoryJournals, directoryVerification, directoryMetadata, directoryStaging, } // chunkFanoutDepth ist die Zahl der Unterverzeichnisebenen der Chunk-Ablage. // // Zwei Ebenen à zwei Hex-Zeichen ergeben 65 536 Verzeichnisse. Ohne diese // Aufteilung lägen Millionen Dateien in einem einzigen Verzeichnis, was auf // gängigen Dateisystemen jede Suche unbrauchbar langsam macht. const chunkFanoutDepth = 2 // chunkFanoutCharacters ist die Zahl der Hex-Zeichen je Verzeichnisebene. const chunkFanoutCharacters = 2 // descriptorPath liefert den Pfad des Descriptors. func (localRepository *LocalRepository) descriptorPath() string { return filepath.Join(localRepository.rootPath, directoryFormat, fileDescriptor) } // catalogPath liefert den Pfad des Katalogs. func (localRepository *LocalRepository) catalogPath() string { return filepath.Join(localRepository.rootPath, directoryIndexes, fileCatalog) } // lockPath liefert den Pfad der Sperrdatei. func (localRepository *LocalRepository) lockPath() string { return filepath.Join(localRepository.rootPath, fileLock) } // manifestPath liefert den Pfad des Manifests eines Backups. func (localRepository *LocalRepository) manifestPath(backupID string) string { return filepath.Join(localRepository.rootPath, directoryManifests, backupID+manifestExtension) } // journalPath liefert den Pfad des Journals einer Schreibsession. func (localRepository *LocalRepository) journalPath(sessionID string) string { return filepath.Join(localRepository.rootPath, directoryJournals, sessionID+journalExtension) } // stagingPath liefert das Arbeitsverzeichnis einer Schreibsession. func (localRepository *LocalRepository) stagingPath(sessionID string) string { return filepath.Join(localRepository.rootPath, directoryStaging, sessionID) } // chunkPath liefert den Ablagepfad eines Chunks anhand seiner Kennung. // // Der Pfad ergibt sich allein aus dem Inhaltshash. Damit ist die Ablage // inhaltsadressiert: derselbe Inhalt landet immer am selben Ort, was die // Deduplizierung ohne zusätzlichen Index ermöglicht (PROMPT.md §10). func (localRepository *LocalRepository) chunkPath(chunkIdentifier string) (string, error) { // Die Kennung wird geprüft, bevor sie zu einem Pfad wird: ein manipulierter // Wert wie "../../etc/passwd" darf niemals aus dem Repository herausführen. if validationError := validateChunkIdentifier(chunkIdentifier); validationError != nil { return "", validationError } pathElements := make([]string, 0, chunkFanoutDepth+2) pathElements = append(pathElements, localRepository.rootPath, directoryChunks) for fanoutLevel := 0; fanoutLevel < chunkFanoutDepth; fanoutLevel++ { startIndex := fanoutLevel * chunkFanoutCharacters pathElements = append(pathElements, chunkIdentifier[startIndex:startIndex+chunkFanoutCharacters]) } pathElements = append(pathElements, chunkIdentifier) return filepath.Join(pathElements...), nil } // chunkIdentifierLength ist die Länge einer Chunk-Kennung in Hex-Zeichen. // // SHA-256 liefert 32 Byte, also 64 Hex-Zeichen. const chunkIdentifierLength = 64 // validateChunkIdentifier prüft eine Chunk-Kennung auf Wohlgeformtheit. // // Die Prüfung ist eine Sicherheitsmaßnahme: Kennungen stammen aus Manifesten, // die auch aus einem fremden Repository stammen können. Ohne Prüfung liesse // sich über einen manipulierten Wert auf beliebige Pfade zugreifen // (Path Traversal, PROMPT.md §97). func validateChunkIdentifier(chunkIdentifier string) error { if len(chunkIdentifier) != chunkIdentifierLength { return fmt.Errorf("%w: die kennung hat %d zeichen, erwartet werden %d", ErrInvalidChunkIdentifier, len(chunkIdentifier), chunkIdentifierLength) } // Nur kleingeschriebene Hex-Zeichen sind zulässig. Damit sind Pfadtrenner, // Punkte und alle anderen Sonderzeichen ausgeschlossen. for _, identifierRune := range chunkIdentifier { isHexDigit := (identifierRune >= '0' && identifierRune <= '9') || (identifierRune >= 'a' && identifierRune <= 'f') if !isHexDigit { return fmt.Errorf("%w: die kennung enthält ein unzulässiges zeichen", ErrInvalidChunkIdentifier) } } return nil } // validateBackupIdentifier prüft eine Backup-Kennung auf Wohlgeformtheit. // // Auch sie wird zu einem Dateipfad und muss deshalb dieselbe Sorgfalt erfahren // wie eine Chunk-Kennung. func validateBackupIdentifier(backupIdentifier string) error { if backupIdentifier == "" { return fmt.Errorf("%w: die kennung ist leer", ErrInvalidBackupIdentifier) } // Ein Pfadtrenner oder eine Punktfolge würde aus dem Repository herausführen. if strings.ContainsAny(backupIdentifier, `/\`) || strings.Contains(backupIdentifier, "..") { return fmt.Errorf("%w: die kennung enthält unzulässige zeichen", ErrInvalidBackupIdentifier) } // UUIDs sind 36 Zeichen lang; etwas Spielraum lässt spätere Formate zu. if len(backupIdentifier) > 64 { return fmt.Errorf("%w: die kennung ist zu lang", ErrInvalidBackupIdentifier) } for _, identifierRune := range backupIdentifier { isAllowed := (identifierRune >= '0' && identifierRune <= '9') || (identifierRune >= 'a' && identifierRune <= 'z') || (identifierRune >= 'A' && identifierRune <= 'Z') || identifierRune == '-' || identifierRune == '_' if !isAllowed { return fmt.Errorf("%w: die kennung enthält unzulässige zeichen", ErrInvalidBackupIdentifier) } } return nil } // retentionHoldPath liefert den Pfad des Schutzvermerks eines Backups. func (localRepository *LocalRepository) retentionHoldPath(backupID string) string { return filepath.Join(localRepository.rootPath, directoryManifests, backupID+retentionHoldExtension) }