package repository import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "time" ) // RetentionHold ist der Schutzvermerk eines Backups. // // Warum eine eigene Datei und nicht ein Feld im Manifest? Das Manifest ist mit // seinem ContentHash **versiegelt** — es ist der Abschlussvermerk des Backups. // Wer nachträglich ein Feld darin ändert, macht entweder den Hash ungültig oder // muss ihn neu bilden; im zweiten Fall wäre das Manifest nicht mehr das, was // beim Commit geprüft wurde. Beides untergräbt genau die Eigenschaft, auf der // die gesamte Integritätsprüfung beruht. // // Der Vermerk liegt deshalb daneben, wird selbst gegen Löschung geschützt und // führt seine eigene Historie mit: Ein Repository muss ohne Control Server // deutbar bleiben, also gehört die Frage „warum wird das noch gehalten?" ins // Repository und nicht nur in die Datenbank. type RetentionHold struct { // BackupID ist das geschützte Backup. BackupID string `json:"backup_id"` // ImmutableUntil ist das Ende der verlängerten Aufbewahrungsfrist in UTC. // // Nil bedeutet: keine Verlängerung über die Frist des Manifests hinaus. ImmutableUntil *time.Time `json:"immutable_until,omitempty"` // LegalHold meldet einen unbefristeten Schutz für Beweiszwecke. LegalHold bool `json:"legal_hold"` // LegalHoldReason begründet den unbefristeten Schutz. // // Ein Legal Hold ohne Begründung wäre nach einem Jahr nicht mehr auflösbar: // Niemand traute sich, ihn aufzuheben, weil niemand mehr wüsste, warum er // besteht. LegalHoldReason string `json:"legal_hold_reason,omitempty"` // History hält jede Änderung fest. History []RetentionChange `json:"history"` // UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC. UpdatedAt time.Time `json:"updated_at"` } // RetentionChange ist eine einzelne Änderung am Schutzvermerk. type RetentionChange struct { // Action benennt die Änderung. Action string `json:"action"` // Actor ist der Verursacher. Actor string `json:"actor"` // Reason begründet die Änderung. Reason string `json:"reason,omitempty"` // PreviousUntil ist die vorherige Frist in UTC. PreviousUntil *time.Time `json:"previous_until,omitempty"` // NewUntil ist die neue Frist in UTC. NewUntil *time.Time `json:"new_until,omitempty"` // OccurredAt ist der Zeitpunkt in UTC. OccurredAt time.Time `json:"occurred_at"` } // Aktionen am Schutzvermerk. const ( // retentionActionExtended meldet eine verlängerte Frist. retentionActionExtended = "retention_extended" // retentionActionLegalHoldPlaced meldet einen gesetzten Legal Hold. retentionActionLegalHoldPlaced = "legal_hold_placed" // retentionActionLegalHoldReleased meldet einen aufgehobenen Legal Hold. retentionActionLegalHoldReleased = "legal_hold_released" ) // ProtectionStatus ist die geltende Schutzlage eines Backups. // // Sie fasst Manifest und Schutzvermerk zusammen. Kein Aufrufer soll beide // Quellen selbst zusammenrechnen müssen — die Regel „es gilt die längere Frist" // gehört an genau eine Stelle. type ProtectionStatus struct { // BackupID ist das betrachtete Backup. BackupID string `json:"backup_id"` // ImmutableUntil ist das Ende der geltenden Frist in UTC. ImmutableUntil *time.Time `json:"immutable_until,omitempty"` // LegalHold meldet einen unbefristeten Schutz. LegalHold bool `json:"legal_hold"` // LegalHoldReason begründet den unbefristeten Schutz. LegalHoldReason string `json:"legal_hold_reason,omitempty"` // ExtendedBeyondManifest meldet eine über das Manifest hinaus verlängerte Frist. ExtendedBeyondManifest bool `json:"extended_beyond_manifest"` } // IsProtected meldet, ob eine Löschung derzeit unzulässig ist. func (status ProtectionStatus) IsProtected(referenceTime time.Time) bool { if status.LegalHold { return true } return status.ImmutableUntil != nil && referenceTime.Before(*status.ImmutableUntil) } // Describe erklärt die Schutzlage in einem Satz. func (status ProtectionStatus) Describe(referenceTime time.Time) string { if status.LegalHold { if status.LegalHoldReason != "" { return "Fuer Beweiszwecke gehalten: " + status.LegalHoldReason } return "Fuer Beweiszwecke gehalten." } if status.ImmutableUntil == nil { return "Kein Aufbewahrungsschutz." } if referenceTime.Before(*status.ImmutableUntil) { return fmt.Sprintf("Geschuetzt bis %s.", status.ImmutableUntil.Format(time.RFC3339)) } return fmt.Sprintf("Die Aufbewahrungsfrist endete am %s.", status.ImmutableUntil.Format(time.RFC3339)) } // ProtectionStatusOf liefert die geltende Schutzlage eines Backups. func (localRepository *LocalRepository) ProtectionStatusOf(statusContext context.Context, backupID string) (ProtectionStatus, error) { manifest, manifestError := localRepository.ReadManifest(statusContext, backupID) if manifestError != nil { return ProtectionStatus{}, manifestError } return localRepository.protectionStatusFromManifest(manifest) } // protectionStatusFromManifest bildet die Schutzlage aus Manifest und Vermerk. func (localRepository *LocalRepository) protectionStatusFromManifest(manifest *Manifest) (ProtectionStatus, error) { status := ProtectionStatus{ BackupID: manifest.BackupID, ImmutableUntil: manifest.ImmutableUntil, } retentionHold, holdError := localRepository.ReadRetentionHold(manifest.BackupID) if holdError != nil { return ProtectionStatus{}, holdError } if retentionHold == nil { return status, nil } status.LegalHold = retentionHold.LegalHold status.LegalHoldReason = retentionHold.LegalHoldReason // Es gilt immer die **längere** Frist. Der Vermerk kann verlängern, niemals // verkürzen — sonst wäre er der bequemste Weg, den Schutz auszuhebeln. if retentionHold.ImmutableUntil != nil { if status.ImmutableUntil == nil || retentionHold.ImmutableUntil.After(*status.ImmutableUntil) { status.ImmutableUntil = retentionHold.ImmutableUntil status.ExtendedBeyondManifest = true } } return status, nil } // ReadRetentionHold liest den Schutzvermerk eines Backups. // // Ein fehlender Vermerk ist kein Fehler: Die meisten Backups haben keinen. func (localRepository *LocalRepository) ReadRetentionHold(backupID string) (*RetentionHold, error) { if validationError := validateBackupIdentifier(backupID); validationError != nil { return nil, validationError } holdFilePath := localRepository.retentionHoldPath(backupID) holdContent, readError := os.ReadFile(holdFilePath) if errors.Is(readError, os.ErrNotExist) { return nil, nil } if readError != nil { return nil, fmt.Errorf("der schutzvermerk konnte nicht gelesen werden: %w", readError) } var retentionHold RetentionHold if decodeError := json.Unmarshal(holdContent, &retentionHold); decodeError != nil { // Ein unlesbarer Vermerk wird **nicht** als „kein Schutz" gedeutet. Das // wäre der gefährlichste Fehlerfall der ganzen Phase: Eine beschädigte // Datei gäbe ein geschütztes Backup zum Löschen frei. return nil, fmt.Errorf("der schutzvermerk von %s ist unlesbar; das backup gilt weiterhin als geschützt: %w", backupID, decodeError) } return &retentionHold, nil } // ExtendRetention verlängert die Aufbewahrungsfrist eines Backups. // // Verkürzen ist ausgeschlossen — auch für einen Administrator. Wäre es möglich, // bestünde der Aufbewahrungsschutz nur aus einer Zahl, die der Angreifer // zuallererst ändern würde. func (localRepository *LocalRepository) ExtendRetention(extendContext context.Context, backupID string, newImmutableUntil time.Time, actor string, reason string) (ProtectionStatus, error) { if localRepository.lockHandle == nil { return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern") } currentStatus, statusError := localRepository.ProtectionStatusOf(extendContext, backupID) if statusError != nil { return ProtectionStatus{}, statusError } if currentStatus.ImmutableUntil != nil && !newImmutableUntil.After(*currentStatus.ImmutableUntil) { return ProtectionStatus{}, fmt.Errorf("%w (geltend bis %s, verlangt bis %s)", ErrRetentionCannotBeShortened, currentStatus.ImmutableUntil.Format(time.RFC3339), newImmutableUntil.Format(time.RFC3339)) } retentionHold, loadError := localRepository.loadOrCreateHold(backupID) if loadError != nil { return ProtectionStatus{}, loadError } previousUntil := currentStatus.ImmutableUntil normalizedUntil := newImmutableUntil.UTC() retentionHold.ImmutableUntil = &normalizedUntil retentionHold.History = append(retentionHold.History, RetentionChange{ Action: retentionActionExtended, Actor: actor, Reason: reason, PreviousUntil: previousUntil, NewUntil: &normalizedUntil, OccurredAt: localRepository.timeSource().UTC(), }) if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil { return ProtectionStatus{}, writeError } localRepository.logger.Info("die aufbewahrungsfrist wurde verlängert", slog.String("backup_id", backupID), slog.String("bis", normalizedUntil.Format(time.RFC3339)), slog.String("veranlasst_von", actor)) return localRepository.ProtectionStatusOf(extendContext, backupID) } // PlaceLegalHold hält ein Backup unbefristet für Beweiszwecke. func (localRepository *LocalRepository) PlaceLegalHold(holdContext context.Context, backupID string, actor string, reason string) (ProtectionStatus, error) { if localRepository.lockHandle == nil { return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern") } if reason == "" { // Ohne Begründung liesse sich der Hold später nicht mehr auflösen: // Niemand traut sich, einen Schutz aufzuheben, dessen Anlass unbekannt ist. return ProtectionStatus{}, errors.New("ein legal hold verlangt eine begründung") } if _, manifestError := localRepository.ReadManifest(holdContext, backupID); manifestError != nil { return ProtectionStatus{}, manifestError } retentionHold, loadError := localRepository.loadOrCreateHold(backupID) if loadError != nil { return ProtectionStatus{}, loadError } retentionHold.LegalHold = true retentionHold.LegalHoldReason = reason retentionHold.History = append(retentionHold.History, RetentionChange{ Action: retentionActionLegalHoldPlaced, Actor: actor, Reason: reason, OccurredAt: localRepository.timeSource().UTC(), }) if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil { return ProtectionStatus{}, writeError } localRepository.logger.Warn("ein backup wird für beweiszwecke gehalten", slog.String("backup_id", backupID), slog.String("grund", reason), slog.String("veranlasst_von", actor)) return localRepository.ProtectionStatusOf(holdContext, backupID) } // ReleaseLegalHold hebt einen unbefristeten Schutz auf. // // Die Aufbewahrungsfrist bleibt davon unberührt: Ein aufgehobener Legal Hold // gibt ein Backup nicht zum Löschen frei, das noch unter Frist steht. func (localRepository *LocalRepository) ReleaseLegalHold(releaseContext context.Context, backupID string, actor string, reason string) (ProtectionStatus, error) { if localRepository.lockHandle == nil { return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern") } if reason == "" { return ProtectionStatus{}, errors.New("die aufhebung eines legal holds verlangt eine begründung") } retentionHold, loadError := localRepository.loadOrCreateHold(backupID) if loadError != nil { return ProtectionStatus{}, loadError } if !retentionHold.LegalHold { return ProtectionStatus{}, errors.New("für dieses backup besteht kein legal hold") } retentionHold.LegalHold = false retentionHold.LegalHoldReason = "" retentionHold.History = append(retentionHold.History, RetentionChange{ Action: retentionActionLegalHoldReleased, Actor: actor, Reason: reason, OccurredAt: localRepository.timeSource().UTC(), }) if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil { return ProtectionStatus{}, writeError } localRepository.logger.Warn("ein legal hold wurde aufgehoben", slog.String("backup_id", backupID), slog.String("grund", reason), slog.String("veranlasst_von", actor)) return localRepository.ProtectionStatusOf(releaseContext, backupID) } // loadOrCreateHold liest den Schutzvermerk oder legt einen neuen an. func (localRepository *LocalRepository) loadOrCreateHold(backupID string) (*RetentionHold, error) { existingHold, readError := localRepository.ReadRetentionHold(backupID) if readError != nil { return nil, readError } if existingHold != nil { return existingHold, nil } return &RetentionHold{BackupID: backupID, History: make([]RetentionChange, 0, 1)}, nil } // writeRetentionHold legt den Schutzvermerk ab. // // Der Vermerk wird selbst gegen Löschung geschützt: Wäre er ungeschützt, liesse // sich ein Legal Hold durch das Entfernen einer einzigen kleinen Datei // aufheben — und der Weg wäre bequemer als der über die Anwendung. func (localRepository *LocalRepository) writeRetentionHold(retentionHold *RetentionHold) error { retentionHold.UpdatedAt = localRepository.timeSource().UTC() encodedHold, encodeError := json.MarshalIndent(retentionHold, "", " ") if encodeError != nil { return fmt.Errorf("der schutzvermerk konnte nicht kodiert werden: %w", encodeError) } holdFilePath := localRepository.retentionHoldPath(retentionHold.BackupID) // Ein bestehender geschützter Vermerk lässt sich nicht per rename ersetzen. // Der Schutz wird deshalb kurz aufgehoben und danach neu gesetzt. if _, statError := os.Stat(holdFilePath); statError == nil { if releaseError := releaseManifestFile(holdFilePath); releaseError != nil { return fmt.Errorf("der bestehende schutzvermerk liess sich nicht zum schreiben freigeben: %w", releaseError) } } if writeError := writeFileAtomically(holdFilePath, encodedHold, dataFilePermissions); writeError != nil { return fmt.Errorf("der schutzvermerk konnte nicht abgelegt werden: %w", writeError) } if localRepository.descriptor.Immutable { if protectError := protectManifestFile(holdFilePath); protectError != nil { localRepository.logger.Warn("der schutzvermerk konnte nicht gegen löschung geschützt werden", slog.String("backup_id", retentionHold.BackupID), slog.String("grund", protectError.Error())) } } return nil } // removeRetentionHold entfernt den Schutzvermerk eines gelöschten Backups. func (localRepository *LocalRepository) removeRetentionHold(backupID string) error { holdFilePath := localRepository.retentionHoldPath(backupID) if _, statError := os.Stat(holdFilePath); errors.Is(statError, os.ErrNotExist) { return nil } if releaseError := releaseManifestFile(holdFilePath); releaseError != nil { return releaseError } if removeError := os.Remove(holdFilePath); removeError != nil && !errors.Is(removeError, os.ErrNotExist) { return fmt.Errorf("der schutzvermerk konnte nicht entfernt werden: %w", removeError) } return nil } // protectStoredChunk schützt einen frisch abgelegten Datenblock vor Löschung. // // Der Schritt stammt aus einem Angriffsversuch, der zunächst gelang: Ein // geschütztes Manifest überlebte `rm -rf`, **seine Chunks nicht**. Zurück blieb // ein Manifest, das auf nichts mehr zeigte — ein Backup, das sich für vollständig // ausgibt und leer ist. Genau das darf es nicht geben (PROMPT.md §137). // // In einem gehärteten Repository gibt es deshalb keine ungeschützten Daten: // Jeder Block trägt das Kennzeichen von dem Moment an, in dem er abgelegt wird. // Ob ein späteres Backup ihn wiederverwendet, spielt keine Rolle — der Schutz // gilt dem Inhalt, nicht dem Verweis. // // Fehler werden protokolliert, aber nicht geworfen: Ein Backup daran scheitern // zu lassen wäre der falsche Tausch. Die tatsächlich erreichte Stufe steht in // der Messung, nicht in einer Behauptung. func (localRepository *LocalRepository) protectStoredChunk(chunkFilePath string) { if !localRepository.descriptor.Immutable { return } if protectError := protectManifestFile(chunkFilePath); protectError != nil { localRepository.logger.Warn("ein datenblock konnte nicht gegen löschung geschützt werden", slog.String("pfad", chunkFilePath), slog.String("grund", protectError.Error())) } } // releaseStoredChunk gibt einen Datenblock zum Entfernen frei. // // Aufgerufen wird das ausschließlich von der Bereinigung, und dort nur für // Blöcke, die **kein** Manifest mehr referenziert. func (localRepository *LocalRepository) releaseStoredChunk(chunkFilePath string) error { if !localRepository.descriptor.Immutable { return nil } return releaseManifestFile(chunkFilePath) }