package verification import ( "fmt" "sort" "time" ) // Classification ist die objektive Einstufung eines Backups. // // Sie ist das Exit-Kriterium der Phase (SYNCOVA_IMPLEMENTATION_PLAN.md §12) und // eine Leiter, keine Menge: Jede Stufe setzt die darunter voraus. Der // entscheidende Punkt liegt zwischen „erfolgreich" und „wiederherstellbar" — // dass ein Sicherungslauf durchlief, sagt **nichts** darueber, ob sich die // Daten zurueckholen lassen. Genau diese Verwechslung soll die Einstufung // unmoeglich machen. type Classification string const ( // ClassificationFailed meldet einen nicht abgeschlossenen Sicherungslauf. ClassificationFailed Classification = "failed" // ClassificationCorrupted meldet nachgewiesene Beschaedigung. // // Sie steht ausserhalb der Leiter: Ein beschaedigtes Backup ist nicht // „weniger gut" als ein erfolgreiches, sondern etwas anderes — es sieht // vollstaendig aus und ist es nicht. ClassificationCorrupted Classification = "corrupted" // ClassificationSuccessful meldet einen abgeschlossenen Lauf ohne Pruefung. // // Die schwaechste positive Aussage: Es wurde gesichert. Ob die Daten noch // da sind, weiss niemand. ClassificationSuccessful Classification = "successful" // ClassificationVerified meldet ein geprueftes Backup. // // Bloecke vorhanden und unbeschaedigt. Ein starkes Indiz — kein Nachweis: // Ob Entschluesselung, Dekompression und Zusammensetzung ein brauchbares // Ergebnis liefern, ist damit nicht gesagt. ClassificationVerified Classification = "verified" // ClassificationRecoverable meldet ein nachweislich wiederherstellbares Backup. // // Es wurde tatsaechlich zurueckgeschrieben und gegen das Original // verglichen. Nur diese Stufe ist ein Nachweis. ClassificationRecoverable Classification = "recoverable" ) // classificationRanks ordnen die Stufen fuer Vergleiche. var classificationRanks = map[Classification]int{ ClassificationCorrupted: -1, ClassificationFailed: 0, ClassificationSuccessful: 1, ClassificationVerified: 2, ClassificationRecoverable: 3, } // Rank liefert den Zahlenwert einer Einstufung. func (classification Classification) Rank() int { return classificationRanks[classification] } // Describe beschreibt eine Einstufung in einem Satz. func (classification Classification) Describe() string { switch classification { case ClassificationFailed: return "Der Sicherungslauf wurde nicht abgeschlossen. Es gibt kein verwendbares Backup." case ClassificationCorrupted: return "Eine Pruefung hat Beschaedigungen gefunden. Dieses Backup ist nicht vertrauenswuerdig." case ClassificationSuccessful: return "Der Sicherungslauf ist abgeschlossen. Ob sich die Daten zurueckholen lassen, wurde nicht geprueft." case ClassificationVerified: return "Die Bloecke sind vorhanden und unbeschaedigt. Eine Wiederherstellung wurde nicht erprobt." case ClassificationRecoverable: return "Das Backup wurde zurueckgeschrieben und gegen das Original verglichen. Es ist nachweislich wiederherstellbar." default: return "Der Zustand dieses Backups ist unbekannt." } } // BackupFacts sind die bekannten Tatsachen ueber ein Backup. // // Alles Unbekannte ist ausdruecklich als solches gefuehrt — ein Zeitpunkt, der // fehlt, ist ein nil-Zeiger und kein Nullwert. Der Unterschied entscheidet // ueber die Bewertung: „nie geprueft" darf nicht wie „vor einer Sekunde // geprueft" aussehen. type BackupFacts struct { // RunSucceeded meldet einen vollstaendig gelungenen Sicherungslauf. RunSucceeded bool // RunPartial meldet einen Lauf mit uebergangenen Objekten. RunPartial bool // CompletedAt ist der Abschluss des Backups in UTC; nil bei unvollstaendig. CompletedAt *time.Time // LastVerifiedAt ist die letzte erfolgreiche Integritaetspruefung; nil wenn nie. LastVerifiedAt *time.Time // VerificationFoundCorruption meldet eine Pruefung mit Beschaedigungsbefund. VerificationFoundCorruption bool // VerificationFoundMissing meldet eine Pruefung mit fehlenden Bloecken. VerificationFoundMissing bool // LastRestoreTestAt ist der letzte bestandene Wiederherstellungstest; nil wenn nie. LastRestoreTestAt *time.Time // RestoreTestFailed meldet einen nicht bestandenen Wiederherstellungstest. RestoreTestFailed bool // IsEncrypted meldet ein verschluesseltes Backup. IsEncrypted bool // IsImmutable meldet eine Aufbewahrungspflicht. IsImmutable bool // HasOffsiteCopy meldet eine Kopie an einem zweiten Ort. HasOffsiteCopy bool // RepositoryHealthy meldet ein einwandfreies Repository. RepositoryHealthy bool // RecoveryPointObjective ist der geforderte Wiederherstellungspunkt; 0 wenn keiner. RecoveryPointObjective time.Duration // RecoveryTimeObjective ist die geforderte Wiederherstellungsdauer; 0 wenn keine. RecoveryTimeObjective time.Duration // MeasuredRestoreDuration ist die gemessene Dauer eines Tests; 0 wenn keine. MeasuredRestoreDuration time.Duration // AnomalyDetected meldet eine erkannte Auffaelligkeit. AnomalyDetected bool } // Classify stuft ein Backup anhand der bekannten Tatsachen ein. // // Die Reihenfolge der Pruefungen ist festgelegt und nicht beliebig: Eine // nachgewiesene Beschaedigung schlaegt jede andere Aussage. Ein Backup, das // erfolgreich gesichert **und** beschaedigt ist, ist beschaedigt. func Classify(facts BackupFacts) Classification { if facts.VerificationFoundCorruption || facts.RestoreTestFailed { return ClassificationCorrupted } if !facts.RunSucceeded { return ClassificationFailed } // Fehlende Bloecke sind keine Beschaedigung, machen das Backup aber // unbrauchbar: Was fehlt, laesst sich nicht zurueckholen. if facts.VerificationFoundMissing { return ClassificationCorrupted } if facts.LastRestoreTestAt != nil { return ClassificationRecoverable } if facts.LastVerifiedAt != nil { return ClassificationVerified } return ClassificationSuccessful } // ScoreInput ist eine einzelne Eingangsgroesse der Bewertung. type ScoreInput struct { // Name benennt die Groesse. Name string `json:"name"` // Weight ist ihr Gewicht in Punkten. Weight int `json:"weight"` // EarnedPoints sind die erreichten Punkte. EarnedPoints int `json:"earned_points"` // IsKnown meldet, ob eine Messung vorliegt. // // Der wichtigste Wert der ganzen Bewertung: Unbekannt zaehlt niemals als // gut. Eine Bewertung, die ohne Messung hoch ausfaellt, waere das // schlimmste Fake-Feature der Anlage — sie beruhigte genau dort, wo man // hinsehen muesste. IsKnown bool `json:"is_known"` // Explanation erklaert die Punktzahl verstaendlich. Explanation string `json:"explanation"` } // AssuranceScore ist die Bewertung der Wiederherstellungssicherheit. type AssuranceScore struct { // TotalPoints sind die erreichten Punkte. TotalPoints int `json:"total_points"` // MaximumPoints sind die erreichbaren Punkte. MaximumPoints int `json:"maximum_points"` // Percentage ist der Anteil in Prozent. Percentage int `json:"percentage"` // Classification ist die Einstufung des Backups. Classification Classification `json:"classification"` // Inputs sind die einzelnen Eingangsgroessen. Inputs []ScoreInput `json:"inputs"` // UnknownInputCount ist die Zahl ungemessener Groessen. // // Sie steht neben der Prozentzahl, damit niemand eine Bewertung fuer // belastbar haelt, die auf lauter Unbekanntem beruht. UnknownInputCount int `json:"unknown_input_count"` // CalculatedAt ist der Zeitpunkt der Berechnung in UTC. CalculatedAt time.Time `json:"calculated_at"` } // IsTrustworthy meldet ein Backup, auf das man sich verlassen kann. // // Zwei Gruende schliessen das aus. Der erste: Mehr als drei unbekannte Groessen // machen die Prozentzahl zur Vermutung. Der zweite wiegt schwerer — ein Backup // mit Befund oder ein gescheiterter Lauf ist unbrauchbar, gleich wie viele // andere Groessen gut aussehen. Eine hohe Prozentzahl daneben liesse genau die // Beschoenigung zu, die PROMPT.md §138 verbietet. func (score *AssuranceScore) IsTrustworthy() bool { if score.Classification == ClassificationCorrupted || score.Classification == ClassificationFailed { return false } return score.UnknownInputCount <= 3 } // Summary fasst die Bewertung in einem Satz zusammen. func (score *AssuranceScore) Summary() string { if score.Classification == ClassificationCorrupted { return "0 % — eine Pruefung hat Beschaedigungen gefunden. Dieses Backup ist unbrauchbar, " + "bis der Befund ausgeraeumt ist." } if score.Classification == ClassificationFailed { return "0 % — der Sicherungslauf ist gescheitert. Es gibt nichts, worauf man sich verlassen koennte." } if !score.IsTrustworthy() { return fmt.Sprintf("%d %% — aber %d von %d Groessen wurden nie gemessen. Die Zahl ist eine "+ "Vermutung, keine Aussage.", score.Percentage, score.UnknownInputCount, len(score.Inputs)) } return fmt.Sprintf("%d %% (%s)", score.Percentage, score.Classification) } // MissingMeasurements liefert die ungemessenen Groessen. // // Sie sind die eigentliche Handlungsanweisung: Was hier steht, sollte als // Naechstes geprueft werden. func (score *AssuranceScore) MissingMeasurements() []string { missingNames := make([]string, 0) for _, scoreInput := range score.Inputs { if !scoreInput.IsKnown { missingNames = append(missingNames, scoreInput.Name) } } sort.Strings(missingNames) return missingNames } // freshnessThresholds bestimmen die Bewertung des Backupalters. var freshnessThresholds = []struct { // maximumAge ist die Obergrenze dieser Stufe. maximumAge time.Duration // pointFraction ist der Anteil der erreichbaren Punkte. pointFraction float64 }{ {24 * time.Hour, 1.0}, {72 * time.Hour, 0.6}, {7 * 24 * time.Hour, 0.3}, } // verificationFreshness bestimmt, wie lange eine Pruefung ihre Aussage behaelt. // // Eine Pruefung vom letzten Jahr sagt wenig ueber heute: Datentraeger // verfaelschen Daten still, und ein Repository kann in der Zwischenzeit // aufgeraeumt worden sein. const verificationFreshness = 30 * 24 * time.Hour // restoreTestFreshness bestimmt, wie lange ein Wiederherstellungstest zaehlt. const restoreTestFreshness = 90 * 24 * time.Hour // CalculateScore berechnet die Bewertung der Wiederherstellungssicherheit. // // Zehn Eingangsgroessen nach SYNCOVA_IMPLEMENTATION_PLAN.md §12. Die Gewichte // folgen einer Ueberzeugung: Der Wiederherstellungstest wiegt am schwersten, // weil er als einziger etwas nachweist. Verschluesselung und Unveraenderlichkeit // schuetzen die Daten, sagen aber nichts darueber, ob sie noch da sind. func CalculateScore(facts BackupFacts, referenceTime time.Time) *AssuranceScore { score := &AssuranceScore{ Classification: Classify(facts), Inputs: make([]ScoreInput, 0, 10), CalculatedAt: referenceTime.UTC(), } score.Inputs = append(score.Inputs, scoreFreshness(facts, referenceTime), scoreVerification(facts, referenceTime), scoreRestoreTest(facts, referenceTime), scoreRecoveryPoint(facts, referenceTime), scoreRecoveryTime(facts), scoreImmutability(facts), scoreOffsite(facts), scoreEncryption(facts), scoreRepositoryHealth(facts), scoreAnomalies(facts), ) for _, scoreInput := range score.Inputs { score.TotalPoints += scoreInput.EarnedPoints score.MaximumPoints += scoreInput.Weight if !scoreInput.IsKnown { score.UnknownInputCount++ } } if score.MaximumPoints > 0 { score.Percentage = score.TotalPoints * 100 / score.MaximumPoints } // Ein beschaedigtes oder gescheitertes Backup bekommt null Prozent — und // zwar unabhaengig davon, wie gut die uebrigen Groessen aussehen. // // Der Grund ist eine Beobachtung aus dem Nachweis dieser Phase: Ein Backup // mit einem beschaedigten Block kam auf 70 %, weil Aktualitaet, // Verschluesselung und ein frueherer Wiederherstellungstest weiterhin // zaehlten. Diese Zahl liest sich wie „weitgehend in Ordnung". Das ist sie // nicht: Ein Backup, aus dem sich ein Block nicht mehr lesen laesst, ist // nicht zu 70 % wiederherstellbar, sondern nicht wiederherstellbar. if score.Classification == ClassificationCorrupted || score.Classification == ClassificationFailed { score.TotalPoints = 0 score.Percentage = 0 } return score } // scoreFreshness bewertet das Alter des Backups. func scoreFreshness(facts BackupFacts, referenceTime time.Time) ScoreInput { const weight = 15 if facts.CompletedAt == nil { return ScoreInput{ Name: "Aktualitaet", Weight: weight, IsKnown: false, Explanation: "Das Backup wurde nie abgeschlossen.", } } backupAge := referenceTime.Sub(*facts.CompletedAt) for _, threshold := range freshnessThresholds { if backupAge <= threshold.maximumAge { return ScoreInput{ Name: "Aktualitaet", Weight: weight, EarnedPoints: int(float64(weight) * threshold.pointFraction), IsKnown: true, Explanation: fmt.Sprintf("Das Backup ist %s alt.", formatAge(backupAge)), } } } return ScoreInput{ Name: "Aktualitaet", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: fmt.Sprintf("Das Backup ist %s alt und damit veraltet.", formatAge(backupAge)), } } // scoreVerification bewertet die Integritaetspruefung. func scoreVerification(facts BackupFacts, referenceTime time.Time) ScoreInput { const weight = 20 if facts.VerificationFoundCorruption || facts.VerificationFoundMissing { return ScoreInput{ Name: "Integritaetspruefung", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Die Pruefung fand Beschaedigungen oder fehlende Bloecke.", } } if facts.LastVerifiedAt == nil { // Nie geprueft: null Punkte, und ausdruecklich als ungemessen gefuehrt. return ScoreInput{ Name: "Integritaetspruefung", Weight: weight, IsKnown: false, Explanation: "Dieses Backup wurde nie auf Integritaet geprueft.", } } verificationAge := referenceTime.Sub(*facts.LastVerifiedAt) if verificationAge > verificationFreshness { return ScoreInput{ Name: "Integritaetspruefung", Weight: weight, EarnedPoints: weight / 3, IsKnown: true, Explanation: fmt.Sprintf("Die letzte Pruefung liegt %s zurueck und sagt wenig ueber heute.", formatAge(verificationAge)), } } return ScoreInput{ Name: "Integritaetspruefung", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: fmt.Sprintf("Vor %s geprueft, ohne Beanstandung.", formatAge(verificationAge)), } } // scoreRestoreTest bewertet den Wiederherstellungstest. // // Das hoechste Gewicht der Bewertung: Er ist die einzige Groesse, die etwas // nachweist statt es nahezulegen. func scoreRestoreTest(facts BackupFacts, referenceTime time.Time) ScoreInput { const weight = 25 if facts.RestoreTestFailed { return ScoreInput{ Name: "Wiederherstellungstest", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Ein Wiederherstellungstest ist fehlgeschlagen.", } } // Ein Befund entwertet jeden frueheren Test: Er bezieht sich auf einen // Zustand des Repositorys, den es nicht mehr gibt. if facts.VerificationFoundCorruption || facts.VerificationFoundMissing { return ScoreInput{ Name: "Wiederherstellungstest", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Ein frueherer Test zaehlt nicht mehr: Seither wurden Beschaedigungen " + "gefunden. Der Test muss wiederholt werden.", } } if facts.LastRestoreTestAt == nil { return ScoreInput{ Name: "Wiederherstellungstest", Weight: weight, IsKnown: false, Explanation: "Es wurde nie erprobt, ob sich dieses Backup zurueckholen laesst. " + "Alles Uebrige sind Indizien.", } } testAge := referenceTime.Sub(*facts.LastRestoreTestAt) if testAge > restoreTestFreshness { return ScoreInput{ Name: "Wiederherstellungstest", Weight: weight, EarnedPoints: weight / 3, IsKnown: true, Explanation: fmt.Sprintf("Der letzte Test liegt %s zurueck.", formatAge(testAge)), } } return ScoreInput{ Name: "Wiederherstellungstest", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: fmt.Sprintf("Vor %s erfolgreich zurueckgeschrieben und verglichen.", formatAge(testAge)), } } // scoreRecoveryPoint bewertet die Einhaltung des Wiederherstellungspunkts. func scoreRecoveryPoint(facts BackupFacts, referenceTime time.Time) ScoreInput { const weight = 10 if facts.RecoveryPointObjective <= 0 { return ScoreInput{ Name: "Wiederherstellungspunkt", Weight: weight, IsKnown: false, Explanation: "Fuer diesen Auftrag ist kein Wiederherstellungspunkt festgelegt.", } } if facts.CompletedAt == nil { return ScoreInput{ Name: "Wiederherstellungspunkt", Weight: weight, IsKnown: false, Explanation: "Ohne abgeschlossenes Backup laesst sich der Wiederherstellungspunkt nicht beurteilen.", } } backupAge := referenceTime.Sub(*facts.CompletedAt) if backupAge <= facts.RecoveryPointObjective { return ScoreInput{ Name: "Wiederherstellungspunkt", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: fmt.Sprintf("Eingehalten: %s alt bei einer Vorgabe von %s.", formatAge(backupAge), formatAge(facts.RecoveryPointObjective)), } } return ScoreInput{ Name: "Wiederherstellungspunkt", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: fmt.Sprintf("VERLETZT: %s alt bei einer Vorgabe von %s.", formatAge(backupAge), formatAge(facts.RecoveryPointObjective)), } } // scoreRecoveryTime bewertet die Einhaltung der Wiederherstellungsdauer. func scoreRecoveryTime(facts BackupFacts) ScoreInput { const weight = 10 if facts.RecoveryTimeObjective <= 0 { return ScoreInput{ Name: "Wiederherstellungsdauer", Weight: weight, IsKnown: false, Explanation: "Fuer diesen Auftrag ist keine Wiederherstellungsdauer festgelegt.", } } // Ohne gemessene Dauer bleibt die Vorgabe eine Absichtserklaerung. Sie als // eingehalten zu werten waere die gefaehrlichste Annahme der Bewertung. if facts.MeasuredRestoreDuration <= 0 { return ScoreInput{ Name: "Wiederherstellungsdauer", Weight: weight, IsKnown: false, Explanation: "Es wurde nie gemessen, wie lange eine Wiederherstellung dauert. Die Vorgabe " + "ist eine Absichtserklaerung.", } } if facts.MeasuredRestoreDuration <= facts.RecoveryTimeObjective { return ScoreInput{ Name: "Wiederherstellungsdauer", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: fmt.Sprintf("Gemessen %s bei einer Vorgabe von %s.", formatAge(facts.MeasuredRestoreDuration), formatAge(facts.RecoveryTimeObjective)), } } return ScoreInput{ Name: "Wiederherstellungsdauer", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: fmt.Sprintf("VERLETZT: gemessen %s bei einer Vorgabe von %s.", formatAge(facts.MeasuredRestoreDuration), formatAge(facts.RecoveryTimeObjective)), } } // scoreImmutability bewertet die Unveraenderlichkeit. func scoreImmutability(facts BackupFacts) ScoreInput { const weight = 5 if !facts.IsImmutable { return ScoreInput{ Name: "Unveraenderlichkeit", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Dieses Backup ist nicht gegen Loeschung geschuetzt.", } } return ScoreInput{ Name: "Unveraenderlichkeit", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: "Das Backup unterliegt einer Aufbewahrungspflicht.", } } // scoreOffsite bewertet die Kopie an einem zweiten Ort. func scoreOffsite(facts BackupFacts) ScoreInput { const weight = 5 if !facts.HasOffsiteCopy { return ScoreInput{ Name: "Zweiter Standort", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Es gibt keine Kopie an einem zweiten Ort. Ein Brand vernichtet beides.", } } return ScoreInput{ Name: "Zweiter Standort", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: "Eine Kopie liegt an einem zweiten Ort.", } } // scoreEncryption bewertet die Verschluesselung. func scoreEncryption(facts BackupFacts) ScoreInput { const weight = 5 if !facts.IsEncrypted { return ScoreInput{ Name: "Verschluesselung", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Dieses Backup ist unverschluesselt abgelegt.", } } return ScoreInput{ Name: "Verschluesselung", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: "Das Backup ist verschluesselt.", } } // scoreRepositoryHealth bewertet den Zustand des Repositorys. func scoreRepositoryHealth(facts BackupFacts) ScoreInput { const weight = 3 if !facts.RepositoryHealthy { return ScoreInput{ Name: "Repository-Zustand", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Das Repository meldet einen auffaelligen Zustand.", } } return ScoreInput{ Name: "Repository-Zustand", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: "Das Repository ist einwandfrei.", } } // scoreAnomalies bewertet erkannte Auffaelligkeiten. func scoreAnomalies(facts BackupFacts) ScoreInput { const weight = 2 if facts.AnomalyDetected { return ScoreInput{ Name: "Auffaelligkeiten", Weight: weight, EarnedPoints: 0, IsKnown: true, Explanation: "Es wurde eine Auffaelligkeit erkannt.", } } return ScoreInput{ Name: "Auffaelligkeiten", Weight: weight, EarnedPoints: weight, IsKnown: true, Explanation: "Keine Auffaelligkeiten.", } } // formatAge gibt eine Dauer lesbar aus. func formatAge(duration time.Duration) string { switch { case duration >= 24*time.Hour: return fmt.Sprintf("%.0f Tage", duration.Hours()/24) case duration >= time.Hour: return fmt.Sprintf("%.0f Stunden", duration.Hours()) case duration >= time.Minute: return fmt.Sprintf("%.0f Minuten", duration.Minutes()) default: return fmt.Sprintf("%.0f Sekunden", duration.Seconds()) } }