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

664 lines
22 KiB
Go

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
}