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>
512 lines
19 KiB
Go
512 lines
19 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/packages/backupengine"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/platform/ratelimit"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
)
|
|
|
|
// BackupRunOptions steuern einen Sicherungslauf des Agents.
|
|
type BackupRunOptions struct {
|
|
// BackupID ist die Kennung des zu erzeugenden Backups.
|
|
BackupID string
|
|
// SourcePath ist das zu sichernde Verzeichnis.
|
|
SourcePath string
|
|
// SourceName ist die sprechende Bezeichnung der Quelle.
|
|
SourceName string
|
|
// DiscoveryOptions steuern die Erfassung.
|
|
DiscoveryOptions DiscoveryOptions
|
|
// CompressionLevel ist die gewünschte Kompressionsstufe.
|
|
CompressionLevel backupengine.CompressionLevel
|
|
// EncryptionEnabled schaltet die Verschlüsselung ein.
|
|
EncryptionEnabled bool
|
|
// ChainID verbindet das Backup mit seiner Kette.
|
|
ChainID string
|
|
// ParentBackupID benennt das Elternbackup einer Zusatzsicherung.
|
|
ParentBackupID string
|
|
// ProgressCallback meldet den Fortschritt.
|
|
ProgressCallback backupengine.ProgressCallback
|
|
// CreatedByVersion ist die erzeugende Programmversion.
|
|
CreatedByVersion string
|
|
// BandwidthLimiter begrenzt den Lesedurchsatz von der Quelle.
|
|
//
|
|
// Er wird durchgereicht statt hier gebaut: So teilen sich mehrere Läufe
|
|
// desselben Auftrags — ein Auftrag mit mehreren Quellen erzeugt je Quelle
|
|
// eine eigene Sicherung — denselben Begrenzer. Je Quelle einen neuen zu
|
|
// bauen ergäbe ein Vielfaches der vereinbarten Rate.
|
|
BandwidthLimiter *ratelimit.Limiter
|
|
// Incremental sichert nur, was sich seit dem Elternbackup geändert hat.
|
|
//
|
|
// Unveränderte Objekte werden nicht gelesen; ihre Blockverweise stammen
|
|
// aus dem Elternmanifest. Das entstehende Manifest beschreibt trotzdem den
|
|
// vollständigen Bestand.
|
|
Incremental bool
|
|
// AbortOnProblems bricht bei Erfassungsproblemen ab, statt sie zu vermerken.
|
|
//
|
|
// Standardmäßig läuft die Sicherung weiter: eine einzelne gesperrte Datei
|
|
// darf nicht verhindern, dass die übrigen gesichert werden. Das Ergebnis
|
|
// weist die Probleme dann aber aus.
|
|
AbortOnProblems bool
|
|
}
|
|
|
|
// BackupRunResult beschreibt einen abgeschlossenen Sicherungslauf.
|
|
type BackupRunResult struct {
|
|
// BackupID ist die Kennung des erzeugten Backups.
|
|
BackupID string `json:"backup_id"`
|
|
// FilesBackedUp ist die Zahl gesicherter Dateien.
|
|
FilesBackedUp int `json:"files_backed_up"`
|
|
// DirectoriesRecorded ist die Zahl vermerkter Verzeichnisse.
|
|
DirectoriesRecorded int `json:"directories_recorded"`
|
|
// SymlinksRecorded ist die Zahl vermerkter symbolischer Verweise.
|
|
SymlinksRecorded int `json:"symlinks_recorded"`
|
|
// Progress sind die Kennzahlen der Pipeline.
|
|
Progress backupengine.Progress `json:"progress"`
|
|
// Problems sind die beim Erfassen aufgetretenen Probleme.
|
|
//
|
|
// Ist die Liste nicht leer, ist das Backup unvollständig — auch wenn es
|
|
// technisch abgeschlossen wurde (PROMPT.md §140).
|
|
Problems []DiscoveryProblem `json:"problems"`
|
|
// Duration ist die Gesamtdauer.
|
|
Duration time.Duration `json:"duration"`
|
|
// BackupType ist die Art des erzeugten Backups.
|
|
BackupType repository.BackupType `json:"backup_type"`
|
|
// Changes ist das Ergebnis der Änderungserkennung.
|
|
//
|
|
// Bei einer vollständigen Sicherung bleibt es nil.
|
|
Changes *ChangeSet `json:"changes,omitempty"`
|
|
// ExtensionDistribution zählt die Dateiendungen der erfassten Objekte.
|
|
//
|
|
// Eines der sechs Signale der Ransomware-Erkennung (Phase 16). Sie entsteht
|
|
// hier, weil die Einträge ohnehin durchlaufen werden — eine nachträgliche
|
|
// Auswertung müsste das Manifest erneut lesen.
|
|
ExtensionDistribution map[string]int64 `json:"extension_distribution,omitempty"`
|
|
}
|
|
|
|
// IsPartialFailure meldet, ob Daten aus dem Backup fehlen.
|
|
//
|
|
// Ein solcher Lauf ist ein Teilfehler und darf niemals als voller Erfolg
|
|
// dargestellt werden (PROMPT.md §140).
|
|
//
|
|
// Gezählt werden ausschliesslich Probleme, die **Daten fernhalten** — nicht
|
|
// jeder Vermerk. Ein Socket oder eine benannte Pipe fehlt nicht im Backup, sie
|
|
// gehoert nicht hinein. Zaehlte man sie mit, waere jede Sicherung eines
|
|
// Linux-Systems ein Teilfehler: In /var/run und /tmp liegen staendig Sockets.
|
|
// Nach einer Woche klickt niemand mehr einen Teilfehler an — und dann faellt
|
|
// auch der echte nicht mehr auf.
|
|
func (backupRunResult *BackupRunResult) IsPartialFailure() bool {
|
|
return backupRunResult.DataLossProblemCount() > 0
|
|
}
|
|
|
|
// DataLossProblemCount zaehlt die Probleme, die Daten fernhalten.
|
|
func (backupRunResult *BackupRunResult) DataLossProblemCount() int {
|
|
dataLossCount := 0
|
|
|
|
for _, problem := range backupRunResult.Problems {
|
|
if problem.IsDataLoss() {
|
|
dataLossCount++
|
|
}
|
|
}
|
|
|
|
return dataLossCount
|
|
}
|
|
|
|
// SkippedObjectCount zaehlt die Objekte ohne sicherbaren Inhalt.
|
|
//
|
|
// Sie werden ausgewiesen, aber nicht als Fehler gewertet.
|
|
func (backupRunResult *BackupRunResult) SkippedObjectCount() int {
|
|
return len(backupRunResult.Problems) - backupRunResult.DataLossProblemCount()
|
|
}
|
|
|
|
// Summary fasst das Ergebnis in einem Satz zusammen.
|
|
func (backupRunResult *BackupRunResult) Summary() string {
|
|
// Objekte ohne sicherbaren Inhalt werden **immer** genannt, auch im
|
|
// Erfolgsfall. Sie machen den Lauf nicht zum Teilfehler — verschwiegen
|
|
// werden duerfen sie trotzdem nicht: Wer einen Socket im Quellverzeichnis
|
|
// hat, soll erfahren, dass er nicht im Backup ist.
|
|
skippedSuffix := ""
|
|
if skippedCount := backupRunResult.SkippedObjectCount(); skippedCount > 0 {
|
|
skippedSuffix = fmt.Sprintf(" %d Objekt(e) ohne sicherbaren Inhalt (Sockets, Pipes, "+
|
|
"Gerätedateien) wurden übergangen; das ist kein Fehler.", skippedCount)
|
|
}
|
|
|
|
if backupRunResult.IsPartialFailure() {
|
|
return fmt.Sprintf("TEILWEISE FEHLGESCHLAGEN: %d Dateien gesichert, %d Objekte konnten nicht gelesen werden.",
|
|
backupRunResult.FilesBackedUp, backupRunResult.DataLossProblemCount()) + skippedSuffix
|
|
}
|
|
|
|
if backupRunResult.Changes != nil {
|
|
return fmt.Sprintf("Erfolgreich: %d Dateien gesichert, davon %d neu gelesen und %d aus %s übernommen.",
|
|
backupRunResult.FilesBackedUp,
|
|
backupRunResult.Changes.ChangedFileCount(),
|
|
backupRunResult.Changes.ReusedFileCount(),
|
|
backupRunResult.Changes.ParentBackupID) + skippedSuffix
|
|
}
|
|
|
|
return fmt.Sprintf("Erfolgreich: %d Dateien, %d Verzeichnisse gesichert.",
|
|
backupRunResult.FilesBackedUp, backupRunResult.DirectoriesRecorded) + skippedSuffix
|
|
}
|
|
|
|
// ErrBackupIncompleteDueToProblems meldet einen wegen Erfassungsproblemen
|
|
// abgebrochenen Lauf.
|
|
var ErrBackupIncompleteDueToProblems = errors.New("die sicherung wurde abgebrochen, weil objekte nicht gelesen werden konnten")
|
|
|
|
// BackupRunner führt Sicherungsläufe aus.
|
|
type BackupRunner struct {
|
|
// engine ist die Backup Engine.
|
|
engine *backupengine.Engine
|
|
// logger protokolliert den Verlauf.
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewBackupRunner erzeugt den Sicherungslauf.
|
|
func NewBackupRunner(engine *backupengine.Engine, baseLogger *slog.Logger) *BackupRunner {
|
|
return &BackupRunner{
|
|
engine: engine,
|
|
logger: logging.WithComponent(baseLogger, "agent-backup"),
|
|
}
|
|
}
|
|
|
|
// resolveParentManifest ermittelt das Elternbackup einer Zusatzsicherung.
|
|
//
|
|
// Ist eine Kennung angegeben, gilt sie; sonst wird das jüngste abgeschlossene
|
|
// Backup derselben Quelle gesucht. Ein ausdrücklich benanntes Elternbackup
|
|
// wird trotzdem geprüft: eine Zusatzsicherung auf ein unvollständiges Backup
|
|
// aufzubauen ergäbe ein Backup, das vollständig aussieht und es nicht ist.
|
|
func (backupRunner *BackupRunner) resolveParentManifest(resolveContext context.Context, runOptions BackupRunOptions) (*repository.Manifest, error) {
|
|
sourceRepository := backupRunner.engine.Repository()
|
|
|
|
if runOptions.ParentBackupID == "" {
|
|
return FindParentBackup(resolveContext, sourceRepository, runOptions.SourcePath)
|
|
}
|
|
|
|
parentManifest, readError := sourceRepository.ReadManifest(resolveContext, runOptions.ParentBackupID)
|
|
if readError != nil {
|
|
return nil, fmt.Errorf("das angegebene elternbackup %s konnte nicht gelesen werden: %w",
|
|
runOptions.ParentBackupID, readError)
|
|
}
|
|
|
|
if !parentManifest.Complete {
|
|
return nil, fmt.Errorf("%w: das angegebene backup %s ist unvollständig",
|
|
ErrNoParentBackup, runOptions.ParentBackupID)
|
|
}
|
|
|
|
return parentManifest, nil
|
|
}
|
|
|
|
// RunBackup sichert einen Verzeichnisbaum.
|
|
//
|
|
// Der Ablauf verbindet die Erfassung mit der Pipeline: erst wird ermittelt, was
|
|
// gesichert werden soll, dann wandert jede Datei als eigener Datenstrom durch
|
|
// Chunking, Deduplizierung, Kompression und Verschlüsselung.
|
|
//
|
|
// Verzeichnisse und symbolische Verweise tragen keine Daten, gehören aber ins
|
|
// Manifest: ohne ihre Rechte und Ziele wäre eine Wiederherstellung unvollständig.
|
|
func (backupRunner *BackupRunner) RunBackup(backupContext context.Context, runOptions BackupRunOptions) (*BackupRunResult, error) {
|
|
startTime := time.Now()
|
|
|
|
// Verzeichnisse werden immer mit erfasst: ihre Rechte gehen sonst verloren.
|
|
discoveryOptions := runOptions.DiscoveryOptions
|
|
discoveryOptions.IncludeDirectories = true
|
|
|
|
discoveryResult, discoveryError := Discover(runOptions.SourcePath, discoveryOptions)
|
|
if discoveryError != nil {
|
|
return nil, discoveryError
|
|
}
|
|
|
|
// Auch der strenge Modus bricht nur bei echtem Datenverlust ab: Ein Socket
|
|
// im Quellverzeichnis darf keine Sicherung verhindern.
|
|
if runOptions.AbortOnProblems && discoveryResult.DataLossProblemCount() > 0 {
|
|
return nil, fmt.Errorf("%w (%d objekte betroffen)",
|
|
ErrBackupIncompleteDueToProblems, discoveryResult.DataLossProblemCount())
|
|
}
|
|
|
|
if len(discoveryResult.Entries) == 0 {
|
|
// Ein leeres Backup wäre ein Gebilde, das sich als erfolgreiche
|
|
// Sicherung ausgibt, ohne etwas zu enthalten.
|
|
return nil, errors.New("die quelle enthält keine sicherbaren objekte")
|
|
}
|
|
|
|
// Bei einer Zusatzsicherung entscheidet der Vergleich mit dem
|
|
// Elternmanifest, was überhaupt gelesen werden muss.
|
|
backupType := repository.BackupTypeFull
|
|
chainIdentifier := runOptions.ChainID
|
|
parentBackupIdentifier := runOptions.ParentBackupID
|
|
|
|
var changeSet *ChangeSet
|
|
var decisionsByPath map[string]ChangeDecision
|
|
|
|
if runOptions.Incremental {
|
|
parentManifest, parentError := backupRunner.resolveParentManifest(backupContext, runOptions)
|
|
if parentError != nil {
|
|
return nil, parentError
|
|
}
|
|
|
|
changeSet = DetectChanges(discoveryResult.Entries, parentManifest)
|
|
|
|
decisionsByPath = make(map[string]ChangeDecision, len(changeSet.Decisions))
|
|
for _, changeDecision := range changeSet.Decisions {
|
|
decisionsByPath[changeDecision.Entry.RelativePath] = changeDecision
|
|
}
|
|
|
|
backupType = repository.BackupTypeIncremental
|
|
parentBackupIdentifier = parentManifest.BackupID
|
|
|
|
// Die Kette wird vom Elternbackup übernommen. Eine eigene Kette je
|
|
// Zusatzsicherung machte die Zusammengehörigkeit unauffindbar.
|
|
chainIdentifier = parentManifest.ChainID
|
|
if chainIdentifier == "" {
|
|
chainIdentifier = parentManifest.BackupID
|
|
}
|
|
}
|
|
|
|
backupRunner.logger.Info("sicherung gestartet",
|
|
slog.String("backup_id", runOptions.BackupID),
|
|
slog.String("quelle", runOptions.SourcePath),
|
|
slog.String("art", string(backupType)),
|
|
slog.Int("objekte", len(discoveryResult.Entries)),
|
|
slog.Int64("bytes", discoveryResult.TotalBytes))
|
|
|
|
if changeSet != nil {
|
|
backupRunner.logger.Info("änderungserkennung abgeschlossen",
|
|
slog.String("elternbackup", changeSet.ParentBackupID),
|
|
slog.Int("geaenderte_dateien", changeSet.ChangedFileCount()),
|
|
slog.Int("uebernommene_dateien", changeSet.ReusedFileCount()),
|
|
slog.Int("geloeschte_objekte", len(changeSet.DeletedPaths)),
|
|
slog.Int64("zu_lesende_bytes", changeSet.BytesToRead()))
|
|
}
|
|
|
|
// Die geöffneten Dateien werden nach dem Lauf geschlossen. Sie müssen bis
|
|
// zum Ende offen bleiben, weil die Pipeline sie als Datenströme liest.
|
|
openedFiles := make([]*os.File, 0, discoveryResult.FileCount())
|
|
defer func() {
|
|
for _, openedFile := range openedFiles {
|
|
_ = openedFile.Close()
|
|
}
|
|
}()
|
|
|
|
backupSources := make([]backupengine.BackupSource, 0, len(discoveryResult.Entries))
|
|
runResult := &BackupRunResult{
|
|
BackupID: runOptions.BackupID,
|
|
Problems: discoveryResult.Problems,
|
|
// Die Endungsverteilung entsteht hier, weil die Einträge ohnehin
|
|
// vorliegen — eine nachträgliche Auswertung müsste das Manifest erneut
|
|
// lesen (Phase 16).
|
|
ExtensionDistribution: countFileExtensions(discoveryResult.Entries),
|
|
}
|
|
|
|
for _, discoveredEntry := range discoveryResult.Entries {
|
|
switch discoveredEntry.EntryType {
|
|
case EntryTypeFile:
|
|
// Ein unverändertes Objekt wird nicht einmal geöffnet — darin
|
|
// besteht der Gewinn der Zusatzsicherung.
|
|
if changeDecision, hasDecision := decisionsByPath[discoveredEntry.RelativePath]; hasDecision && changeDecision.ParentEntry != nil {
|
|
backupSources = append(backupSources, backupengine.BackupSource{
|
|
Path: discoveredEntry.RelativePath,
|
|
EntryType: string(EntryTypeFile),
|
|
SizeBytes: discoveredEntry.SizeBytes,
|
|
ModifiedAt: discoveredEntry.ModifiedAt,
|
|
Mode: discoveredEntry.Mode,
|
|
ReusedChunks: changeDecision.ParentEntry.Chunks,
|
|
ContentHash: changeDecision.ParentEntry.ContentHash,
|
|
})
|
|
|
|
runResult.FilesBackedUp++
|
|
|
|
continue
|
|
}
|
|
|
|
sourceFile, openError := os.Open(discoveredEntry.AbsolutePath)
|
|
if openError != nil {
|
|
// Eine zwischen Erfassung und Sicherung verschwundene oder
|
|
// gesperrte Datei wird vermerkt, nicht verschwiegen.
|
|
runResult.Problems = append(runResult.Problems, DiscoveryProblem{
|
|
Path: discoveredEntry.AbsolutePath,
|
|
Reason: "Die Datei konnte beim Sichern nicht geöffnet werden: " + openError.Error(),
|
|
IsPermissionDenied: errors.Is(openError, os.ErrPermission),
|
|
})
|
|
|
|
continue
|
|
}
|
|
|
|
openedFiles = append(openedFiles, sourceFile)
|
|
|
|
backupSources = append(backupSources, backupengine.BackupSource{
|
|
Path: discoveredEntry.RelativePath,
|
|
EntryType: string(EntryTypeFile),
|
|
SizeBytes: discoveredEntry.SizeBytes,
|
|
ModifiedAt: discoveredEntry.ModifiedAt,
|
|
Reader: sourceFile,
|
|
Mode: discoveredEntry.Mode,
|
|
})
|
|
|
|
runResult.FilesBackedUp++
|
|
|
|
case EntryTypeDirectory:
|
|
// Ein Verzeichnis trägt keine Daten, aber seine Rechte.
|
|
backupSources = append(backupSources, backupengine.BackupSource{
|
|
Path: discoveredEntry.RelativePath,
|
|
EntryType: string(EntryTypeDirectory),
|
|
ModifiedAt: discoveredEntry.ModifiedAt,
|
|
Mode: discoveredEntry.Mode,
|
|
})
|
|
|
|
runResult.DirectoriesRecorded++
|
|
|
|
case EntryTypeSymlink:
|
|
backupSources = append(backupSources, backupengine.BackupSource{
|
|
Path: discoveredEntry.RelativePath,
|
|
EntryType: string(EntryTypeSymlink),
|
|
ModifiedAt: discoveredEntry.ModifiedAt,
|
|
Mode: discoveredEntry.Mode,
|
|
LinkTarget: discoveredEntry.LinkTarget,
|
|
})
|
|
|
|
runResult.SymlinksRecorded++
|
|
}
|
|
}
|
|
|
|
backupResult, backupError := backupRunner.engine.Backup(backupContext, backupengine.BackupOptions{
|
|
BackupID: runOptions.BackupID,
|
|
ChainID: chainIdentifier,
|
|
ParentBackupID: parentBackupIdentifier,
|
|
BackupType: backupType,
|
|
Source: repository.SourceInformation{
|
|
SourceType: "filesystem",
|
|
SourceID: runOptions.SourcePath,
|
|
SourceName: runOptions.SourceName,
|
|
Hostname: CollectSystemInformation().Hostname,
|
|
OperatingSystem: CollectSystemInformation().Platform,
|
|
},
|
|
CompressionLevel: runOptions.CompressionLevel,
|
|
EncryptionEnabled: runOptions.EncryptionEnabled,
|
|
ProgressCallback: runOptions.ProgressCallback,
|
|
CreatedByVersion: runOptions.CreatedByVersion,
|
|
BandwidthLimiter: runOptions.BandwidthLimiter,
|
|
}, backupSources)
|
|
|
|
if backupError != nil {
|
|
return nil, backupError
|
|
}
|
|
|
|
runResult.Progress = backupResult.Progress
|
|
runResult.Duration = time.Since(startTime)
|
|
runResult.BackupType = backupType
|
|
runResult.Changes = changeSet
|
|
|
|
// Der Ausgang wird ehrlich benannt: ein Lauf mit übergangenen Objekten ist
|
|
// ein Teilfehler, kein Erfolg.
|
|
if runResult.IsPartialFailure() {
|
|
backupRunner.logger.Warn("sicherung teilweise fehlgeschlagen",
|
|
slog.String("backup_id", runOptions.BackupID),
|
|
slog.Int("gesicherte_dateien", runResult.FilesBackedUp),
|
|
slog.Int("uebergangene_objekte", runResult.DataLossProblemCount()),
|
|
slog.Int("ohne_sicherbaren_inhalt", runResult.SkippedObjectCount()))
|
|
} else {
|
|
backupRunner.logger.Info("sicherung abgeschlossen",
|
|
slog.String("backup_id", runOptions.BackupID),
|
|
slog.Int("dateien", runResult.FilesBackedUp),
|
|
slog.Int64("bytes", runResult.Progress.BytesProcessed),
|
|
slog.String("dauer", runResult.Duration.String()))
|
|
}
|
|
|
|
return runResult, nil
|
|
}
|
|
|
|
// maximumTrackedExtensions begrenzt die Zahl gemeldeter Dateiendungen.
|
|
//
|
|
// Eine Quelle mit tausend verschiedenen Endungen ergäbe eine Verteilung, die
|
|
// niemand liest und die in jeder Zeile der Datenbank Platz kostet. Für die
|
|
// Erkennung zählt ohnehin nur, was häufig ist — und eine plötzlich auftauchende
|
|
// neue Endung erscheint bei massenhafter Verschlüsselung sofort ganz oben.
|
|
const maximumTrackedExtensions = 20
|
|
|
|
// countFileExtensions zählt die Dateiendungen der erfassten Objekte.
|
|
//
|
|
// Eines der sechs Signale der Ransomware-Erkennung (Phase 16). Verzeichnisse
|
|
// und Verweise bleiben aussen vor: Sie tragen keine Endung, die etwas über den
|
|
// Inhalt sagt.
|
|
//
|
|
// Dateien ohne Endung werden unter „(ohne)" geführt statt weggelassen — ihr
|
|
// Anteil ist selbst eine Aussage.
|
|
func countFileExtensions(discoveredEntries []DiscoveredEntry) map[string]int64 {
|
|
extensionCounts := make(map[string]int64, 16)
|
|
|
|
for _, discoveredEntry := range discoveredEntries {
|
|
if discoveredEntry.EntryType != EntryTypeFile {
|
|
continue
|
|
}
|
|
|
|
fileExtension := strings.ToLower(filepath.Ext(discoveredEntry.RelativePath))
|
|
if fileExtension == "" {
|
|
fileExtension = "(ohne)"
|
|
}
|
|
|
|
extensionCounts[fileExtension]++
|
|
}
|
|
|
|
if len(extensionCounts) <= maximumTrackedExtensions {
|
|
return extensionCounts
|
|
}
|
|
|
|
return keepMostFrequentExtensions(extensionCounts)
|
|
}
|
|
|
|
// keepMostFrequentExtensions behält die häufigsten Endungen.
|
|
//
|
|
// Der Rest wird nicht verworfen, sondern unter „(weitere)" zusammengefasst: Eine
|
|
// Verteilung, deren Summe nicht mehr der Zahl der Dateien entspricht, wäre in
|
|
// jeder Auswertung irreführend.
|
|
func keepMostFrequentExtensions(extensionCounts map[string]int64) map[string]int64 {
|
|
type extensionCount struct {
|
|
extension string
|
|
count int64
|
|
}
|
|
|
|
sortedCounts := make([]extensionCount, 0, len(extensionCounts))
|
|
|
|
for extension, count := range extensionCounts {
|
|
sortedCounts = append(sortedCounts, extensionCount{extension: extension, count: count})
|
|
}
|
|
|
|
sort.Slice(sortedCounts, func(firstIndex int, secondIndex int) bool {
|
|
if sortedCounts[firstIndex].count != sortedCounts[secondIndex].count {
|
|
return sortedCounts[firstIndex].count > sortedCounts[secondIndex].count
|
|
}
|
|
|
|
// Bei Gleichstand alphabetisch, damit dieselbe Quelle stets dieselbe
|
|
// Verteilung ergibt — sonst schwankte der Vergleich zweier Läufe
|
|
// zufällig.
|
|
return sortedCounts[firstIndex].extension < sortedCounts[secondIndex].extension
|
|
})
|
|
|
|
reducedCounts := make(map[string]int64, maximumTrackedExtensions+1)
|
|
var remainingCount int64
|
|
|
|
for countIndex, entry := range sortedCounts {
|
|
if countIndex < maximumTrackedExtensions {
|
|
reducedCounts[entry.extension] = entry.count
|
|
|
|
continue
|
|
}
|
|
|
|
remainingCount += entry.count
|
|
}
|
|
|
|
if remainingCount > 0 {
|
|
reducedCounts["(weitere)"] = remainingCount
|
|
}
|
|
|
|
return reducedCounts
|
|
}
|