syncova-backup/packages/repository/transformed.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

168 lines
6.3 KiB
Go

package repository
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
)
// WriteTransformedChunk legt einen Block ab, dessen gespeicherte Form von
// seinem Klartext abweicht.
//
// Die Kennung bleibt der Hash des **Klartextes**. Nur so wirkt die
// Deduplizierung: verschlüsselte Daten sind bei jedem Durchgang verschieden,
// zwei gleiche Ursprungsblöcke ergäben also nie denselben Geheimtext. Die
// Zuordnung Klartext-Kennung → abgelegte Form ist der Kern jedes
// deduplizierenden und zugleich verschlüsselnden Backup-Systems.
//
// Die Unversehrtheit der abgelegten Form sichert nicht die Kennung, sondern
// die zurückgegebene Prüfsumme: sie gehört ins Manifest, damit ein
// Integritätslauf ohne Entschlüsselungsschlüssel prüfen kann.
func (localRepository *LocalRepository) WriteTransformedChunk(writeContext context.Context, plaintextIdentifier string, storedData []byte) (storedDigest string, wasNew bool, writeError error) {
if validationError := validateChunkIdentifier(plaintextIdentifier); validationError != nil {
return "", false, validationError
}
targetPath, pathError := localRepository.chunkPath(plaintextIdentifier)
if pathError != nil {
return "", false, pathError
}
digestOfStoredForm := sha256.Sum256(storedData)
storedDigest = hex.EncodeToString(digestOfStoredForm[:])
// Liegt der Block bereits vor, wird nichts erneut geschrieben. Die
// vorhandene Fassung bleibt maßgeblich: ein zweiter Durchgang erzeugte
// wegen der Verschlüsselung andere Bytes bei identischem Klartext.
if _, statError := os.Stat(targetPath); statError == nil {
existingDigest, digestError := localRepository.digestOfStoredChunk(plaintextIdentifier)
if digestError != nil {
return "", false, digestError
}
return existingDigest, false, nil
}
if directoryError := ensureDirectory(filepath.Dir(targetPath)); directoryError != nil {
return "", false, directoryError
}
if atomicWriteError := writeFileAtomically(targetPath, storedData, dataFilePermissions); atomicWriteError != nil {
return "", false, fmt.Errorf("der chunk konnte nicht geschrieben werden: %w", atomicWriteError)
}
localRepository.protectStoredChunk(targetPath)
return storedDigest, true, nil
}
// StoredDigestOfChunk liefert die Prüfsumme der abgelegten Form eines Blocks.
//
// Der Weg wird gebraucht, wenn ein Block bereits vorliegt: sein Eintrag im
// Manifest braucht die Prüfsumme dennoch, damit ein Integritätslauf ihn prüfen kann.
func (localRepository *LocalRepository) StoredDigestOfChunk(queryContext context.Context, chunkIdentifier string) (string, error) {
return localRepository.digestOfStoredChunk(chunkIdentifier)
}
// digestOfStoredChunk berechnet die Prüfsumme der abgelegten Form eines Blocks.
func (localRepository *LocalRepository) digestOfStoredChunk(chunkIdentifier string) (string, error) {
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return "", pathError
}
storedData, readError := os.ReadFile(targetPath)
if readError != nil {
if errors.Is(readError, os.ErrNotExist) {
return "", fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier)
}
return "", fmt.Errorf("der chunk konnte nicht gelesen werden: %w", readError)
}
digestOfStoredForm := sha256.Sum256(storedData)
return hex.EncodeToString(digestOfStoredForm[:]), nil
}
// ReadStoredChunk liest die abgelegte Form eines Blocks.
//
// Geprüft wird gegen die im Manifest vermerkte Prüfsumme der gespeicherten
// Form, nicht gegen die Klartext-Kennung: der Klartext liegt hier noch nicht
// vor. Die abschließende Prüfung gegen die Kennung erfolgt in der Backup
// Engine, nachdem der Block entschlüsselt und entpackt wurde.
//
// Ist expectedStoredDigest leer, unterbleibt die Prüfung an dieser Stelle —
// dann muss der Aufrufer sie zwingend selbst vornehmen.
func (localRepository *LocalRepository) ReadStoredChunk(readContext context.Context, chunkIdentifier string, expectedStoredDigest string) ([]byte, error) {
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return nil, pathError
}
storedData, 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)
}
if expectedStoredDigest == "" {
return storedData, nil
}
digestOfStoredForm := sha256.Sum256(storedData)
actualDigest := hex.EncodeToString(digestOfStoredForm[:])
if actualDigest != expectedStoredDigest {
localRepository.logger.Error("beschädigter chunk erkannt",
slog.String("chunk_id", chunkIdentifier),
slog.String("expected_stored_digest", expectedStoredDigest),
slog.String("actual_stored_digest", actualDigest))
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkIdentifier)
}
return storedData, nil
}
// VerifyStoredChunk prüft einen abgelegten Block gegen eine erwartete Prüfsumme.
//
// Der Weg erlaubt es dem Integritätslauf, auch verschlüsselte Blöcke zu prüfen,
// ohne einen Schlüssel zu benötigen (PROMPT.md §14).
func (localRepository *LocalRepository) VerifyStoredChunk(verifyContext context.Context, chunkIdentifier string, expectedStoredDigest string) error {
_, readError := localRepository.ReadStoredChunk(verifyContext, chunkIdentifier, expectedStoredDigest)
return readError
}
// WriteTransformedChunkForTest überschreibt einen abgelegten Block.
//
// Der Weg dient ausschließlich Tests, die eine Beschädigung auf dem Datenträger
// nachstellen. Im Betrieb darf ein abgelegter Block niemals überschrieben
// werden: seine Kennung beschreibt genau einen Inhalt.
func (localRepository *LocalRepository) WriteTransformedChunkForTest(chunkIdentifier string, storedData []byte) (string, bool, error) {
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
if pathError != nil {
return "", false, pathError
}
if chmodError := os.Chmod(targetPath, dataFilePermissions); chmodError != nil && !errors.Is(chmodError, os.ErrNotExist) {
return "", false, chmodError
}
if writeError := writeFileAtomically(targetPath, storedData, dataFilePermissions); writeError != nil {
return "", false, writeError
}
digestOfStoredForm := sha256.Sum256(storedData)
return hex.EncodeToString(digestOfStoredForm[:]), true, nil
}