syncova-backup/packages/repository/layout.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

196 lines
7.6 KiB
Go

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)
}