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>
482 lines
18 KiB
Go
482 lines
18 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/syncova/syncova/packages/providers"
|
|
)
|
|
|
|
// TestLocalTransportReadsArchive prueft den Dateizugriff.
|
|
func TestLocalTransportReadsArchive(testInstance *testing.T) {
|
|
storageRoot := testInstance.TempDir()
|
|
|
|
dumpDirectory := filepath.Join(storageRoot, "dump")
|
|
if makeError := os.MkdirAll(dumpDirectory, 0o700); makeError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
|
|
}
|
|
|
|
archiveContent := []byte("VZDUMP-ARCHIVINHALT")
|
|
archivePath := filepath.Join(dumpDirectory, "vzdump-qemu-100-2026_08_13-02_00_00.vma")
|
|
|
|
if writeError := os.WriteFile(archivePath, archiveContent, 0o600); writeError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", writeError)
|
|
}
|
|
|
|
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
|
|
|
|
archiveReader, openError := transport.OpenArchive(context.Background(), "pve-01",
|
|
"local:dump/vzdump-qemu-100-2026_08_13-02_00_00.vma")
|
|
if openError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht öffnen: %v", openError)
|
|
}
|
|
|
|
defer func() { _ = archiveReader.Close() }()
|
|
|
|
readContent, readError := io.ReadAll(archiveReader)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
if string(readContent) != string(archiveContent) {
|
|
testInstance.Errorf("der Inhalt weicht ab: %q", string(readContent))
|
|
}
|
|
}
|
|
|
|
// TestLocalTransportRejectsPathEscape ist der Sicherheitstest des Transports.
|
|
//
|
|
// Die Volumenkennung kommt vom Proxmox-Knoten. Vertrauenswuerdig ist sie nur so
|
|
// weit, wie man dem Knoten vertraut — und ein Pfad wie
|
|
// "dump/../../../etc/shadow" laese eine beliebige Datei des Syncova-Servers ins
|
|
// Backup. Dieselbe Ueberlegung wie bei validateManifestPath (Phase 5).
|
|
func TestLocalTransportRejectsPathEscape(testInstance *testing.T) {
|
|
storageRoot := testInstance.TempDir()
|
|
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
|
|
|
|
escapeAttempts := []string{
|
|
"local:../../../etc/passwd",
|
|
"local:dump/../../../../etc/shadow",
|
|
"local:..",
|
|
}
|
|
|
|
for _, volumeIdentifier := range escapeAttempts {
|
|
archiveReader, openError := transport.OpenArchive(context.Background(), "pve-01", volumeIdentifier)
|
|
if openError == nil {
|
|
_ = archiveReader.Close()
|
|
testInstance.Errorf("der Ausbruchsversuch %q wurde zugelassen", volumeIdentifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLocalTransportNamesMissingMapping prueft die Fehlermeldung.
|
|
//
|
|
// Ein Speicher ohne hinterlegten Pfad ist der haeufigste Einrichtungsfehler.
|
|
// Die Meldung muss sagen, was zu tun ist — sonst sucht der Betreiber bei
|
|
// Proxmox statt in seiner eigenen Konfiguration.
|
|
func TestLocalTransportNamesMissingMapping(testInstance *testing.T) {
|
|
transport := NewLocalArchiveTransport(map[string]string{"local": testInstance.TempDir()})
|
|
|
|
_, openError := transport.OpenArchive(context.Background(), "pve-01", "nfs-backup:dump/x.vma")
|
|
if openError == nil {
|
|
testInstance.Fatal("ein unbekannter Speicher wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(openError.Error(), "nfs-backup") {
|
|
testInstance.Errorf("die Meldung nennt den Speicher nicht: %v", openError)
|
|
}
|
|
|
|
if !strings.Contains(openError.Error(), "ssh") {
|
|
testInstance.Errorf("die Meldung nennt den Ausweg nicht: %v", openError)
|
|
}
|
|
}
|
|
|
|
// TestSplitVolumeIdentifier prueft die Zerlegung der Volumenkennung.
|
|
func TestSplitVolumeIdentifier(testInstance *testing.T) {
|
|
storageIdentifier, relativePath, parseError := SplitVolumeIdentifier(
|
|
"local:dump/vzdump-qemu-100-2026_08_13-02_00_00.vma.zst")
|
|
if parseError != nil {
|
|
testInstance.Fatalf("die Kennung ließ sich nicht zerlegen: %v", parseError)
|
|
}
|
|
|
|
if storageIdentifier != "local" {
|
|
testInstance.Errorf("der Speicher ist %q", storageIdentifier)
|
|
}
|
|
|
|
if relativePath != "dump/vzdump-qemu-100-2026_08_13-02_00_00.vma.zst" {
|
|
testInstance.Errorf("der Pfad ist %q", relativePath)
|
|
}
|
|
|
|
// Fehlerhafte Formen werden abgelehnt, nicht geraten.
|
|
for _, malformedIdentifier := range []string{"", "local", "local:", ":dump/x", " "} {
|
|
if _, _, splitError := SplitVolumeIdentifier(malformedIdentifier); splitError == nil {
|
|
testInstance.Errorf("die fehlerhafte Kennung %q wurde angenommen", malformedIdentifier)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSSHTransportRequiresHostKeyFingerprints ist der Sicherheitstest des SSH-Wegs.
|
|
//
|
|
// Ein Transport, der jeden Wirtsschluessel annimmt, macht aus einem
|
|
// Zwischenangriff eine Einladung: Der Angreifer liefert dann das Archiv, das
|
|
// Syncova fuer ein Backup haelt.
|
|
func TestSSHTransportRequiresHostKeyFingerprints(testInstance *testing.T) {
|
|
_, buildError := NewSSHArchiveTransport(SSHTransportOptions{
|
|
Username: "syncova",
|
|
PrivateKeyPEM: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), // secretscan:erlaubt: Platzhalter ohne Schluesselmaterial
|
|
}, stubStoragePathResolver{})
|
|
|
|
if buildError == nil {
|
|
testInstance.Fatal("ein SSH-Transport ohne Fingerabdrücke wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(buildError.Error(), "zwischenangriff") {
|
|
testInstance.Errorf("die Meldung nennt den Grund nicht: %v", buildError)
|
|
}
|
|
}
|
|
|
|
// TestSSHTransportRequiresCredentials prueft die uebrigen Pflichtangaben.
|
|
func TestSSHTransportRequiresCredentials(testInstance *testing.T) {
|
|
fingerprints := map[string]string{"pve-01": "SHA256:abc"}
|
|
|
|
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
|
|
PrivateKeyPEM: []byte("key"),
|
|
HostKeyFingerprints: fingerprints,
|
|
}, stubStoragePathResolver{}); buildError == nil {
|
|
testInstance.Error("ein SSH-Transport ohne Anmeldekonto wurde angenommen")
|
|
}
|
|
|
|
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
|
|
Username: "syncova",
|
|
HostKeyFingerprints: fingerprints,
|
|
}, stubStoragePathResolver{}); buildError == nil {
|
|
testInstance.Error("ein SSH-Transport ohne Schlüssel wurde angenommen")
|
|
}
|
|
|
|
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
|
|
Username: "syncova",
|
|
PrivateKeyPEM: []byte("key"),
|
|
HostKeyFingerprints: fingerprints,
|
|
}, nil); buildError == nil {
|
|
testInstance.Error("ein SSH-Transport ohne Pfadauflösung wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// TestSSHFingerprintNormalizationKeepsCase ist der Regressionstest zum Fund.
|
|
//
|
|
// Der bestehende normalizeFingerprint fuer TLS-Zertifikate macht
|
|
// Kleinbuchstaben — bei einem Hex-Fingerabdruck richtig, bei einem Base64-Wert
|
|
// **falsch**. Base64 unterscheidet Gross- und Kleinschreibung; ein
|
|
// kleingeschriebener SSH-Fingerabdruck passte auf keinen Schluessel mehr, und
|
|
// die Verbindung schluege mit einer Meldung fehl, die nach einem Angriff
|
|
// aussieht.
|
|
func TestSSHFingerprintNormalizationKeepsCase(testInstance *testing.T) {
|
|
const mixedCaseFingerprint = "SHA256:aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/xyz"
|
|
|
|
normalizedValue := normalizeSSHFingerprint(mixedCaseFingerprint)
|
|
|
|
if normalizedValue != "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/xyz" {
|
|
testInstance.Errorf("die Groß-/Kleinschreibung ging verloren: %q", normalizedValue)
|
|
}
|
|
|
|
// Mit und ohne Praefix muessen gleich behandelt werden: Wer den Wert aus
|
|
// `ssh-keygen -l` kopiert, hat das Praefix dabei — aus einer Konfiguration
|
|
// womoeglich nicht.
|
|
if normalizeSSHFingerprint("SHA256:abc") != normalizeSSHFingerprint("abc") {
|
|
testInstance.Error("das Präfix SHA256: wird nicht abgeschnitten")
|
|
}
|
|
|
|
// Die Base64-Auffuellung laesst ssh-keygen weg.
|
|
if normalizeSSHFingerprint("abc==") != normalizeSSHFingerprint("abc") {
|
|
testInstance.Error("die Base64-Auffüllung wird nicht abgeschnitten")
|
|
}
|
|
}
|
|
|
|
// TestQuoteShellArgumentIsSafe prueft die Maskierung des Dateipfads.
|
|
//
|
|
// Der Pfad geht als Teil einer Befehlszeile an die Gegenseite. Enthaelt ein
|
|
// Dateiname ein einfaches Anfuehrungszeichen — bei einem vzdump-Archiv
|
|
// unwahrscheinlich, bei einem selbst gewaehlten Speicherpfad nicht —, dann
|
|
// entscheidet allein diese Funktion darueber, ob daraus ein zweiter Befehl
|
|
// wird. Geprueft wird deshalb gegen eine **echte Shell**, nicht gegen ein
|
|
// erwartetes Zeichenmuster: Eine handgeschriebene Erwartung prueft nur, ob die
|
|
// Funktion tut, was ich mir gedacht habe.
|
|
func TestQuoteShellArgumentIsSafe(testInstance *testing.T) {
|
|
dangerousArguments := []string{
|
|
"/var/lib/vz/dump/x'; rm -rf /; echo '",
|
|
"/var/lib/vz/dump/$(whoami).vma",
|
|
"/var/lib/vz/dump/`id`.vma",
|
|
"/var/lib/vz/dump/a b\tc.vma",
|
|
"/var/lib/vz/dump/x\"y.vma",
|
|
"/var/lib/vz/dump/*",
|
|
"",
|
|
}
|
|
|
|
for _, dangerousArgument := range dangerousArguments {
|
|
// printf %s gibt genau ein Argument aus. Kommt der Pfad unveraendert
|
|
// zurueck, ist er weder zerlegt noch ausgewertet worden.
|
|
shellCommand := "printf %s " + quoteShellArgument(dangerousArgument)
|
|
|
|
shellOutput, runError := exec.Command("/bin/sh", "-c", shellCommand).Output()
|
|
if runError != nil {
|
|
testInstance.Fatalf("die shell scheiterte an %q: %v", dangerousArgument, runError)
|
|
}
|
|
|
|
if string(shellOutput) != dangerousArgument {
|
|
testInstance.Errorf("das argument %q kam als %q zurueck", dangerousArgument, string(shellOutput))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestQuoteShellArgumentPassesSingleArgument stellt sicher, dass kein zweites entsteht.
|
|
func TestQuoteShellArgumentPassesSingleArgument(testInstance *testing.T) {
|
|
// Zwei Zaehlungen: Ohne Maskierung ergaebe dieser Pfad drei Argumente.
|
|
shellCommand := "set -- " + quoteShellArgument("/var/lib/vz/dump/a b c.vma") + "; printf %s $#"
|
|
|
|
shellOutput, runError := exec.Command("/bin/sh", "-c", shellCommand).Output()
|
|
if runError != nil {
|
|
testInstance.Fatalf("die shell scheiterte: %v", runError)
|
|
}
|
|
|
|
if string(shellOutput) != "1" {
|
|
testInstance.Errorf("es entstanden %s argumente statt einem", string(shellOutput))
|
|
}
|
|
}
|
|
|
|
// stubStoragePathResolver ersetzt die Pfadauflösung im Test.
|
|
type stubStoragePathResolver struct{}
|
|
|
|
// StoragePath liefert einen festen Pfad.
|
|
func (stubStoragePathResolver) StoragePath(context.Context, string, string) (string, error) {
|
|
return "/var/lib/vz", nil
|
|
}
|
|
|
|
// TestOpenDiskReadsArchiveAndCleansUp prueft den gesamten Weg.
|
|
//
|
|
// Das ist der Test, der die Luecke der Phase 7 schliesst: vzdump anstossen,
|
|
// das entstandene Archiv finden, es **als Datenstrom lesen** und es danach vom
|
|
// Knoten entfernen. Bis hierher konnte OpenDisk kein einziges Byte liefern —
|
|
// das ArchiveTransport-Interface hatte keine Umsetzung.
|
|
func TestOpenDiskReadsArchiveAndCleansUp(testInstance *testing.T) {
|
|
fakeServer := newFakeProxmoxServer(testInstance)
|
|
testProvider := fakeServer.newConnectedProvider(testInstance)
|
|
|
|
// Das Archiv, das der Nachbau nach dem vzdump ausweist, wird als echte
|
|
// Datei angelegt: local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma
|
|
storageRoot := testInstance.TempDir()
|
|
|
|
if makeError := os.MkdirAll(filepath.Join(storageRoot, "backup"), 0o700); makeError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
|
|
}
|
|
|
|
archiveContent := []byte("VMA\x00 echter archivinhalt aus dem dateisystem")
|
|
archivePath := filepath.Join(storageRoot, "backup", "vzdump-qemu-100-2026_08_11-02_00_00.vma")
|
|
|
|
if writeError := os.WriteFile(archivePath, archiveContent, 0o600); writeError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", writeError)
|
|
}
|
|
|
|
testProvider.options.ArchiveTransport = NewLocalArchiveTransport(map[string]string{"local": storageRoot})
|
|
|
|
diskStream, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
|
|
GuestID: "qemu/100",
|
|
})
|
|
if openError != nil {
|
|
testInstance.Fatalf("die Sicherung ließ sich nicht öffnen: %v", openError)
|
|
}
|
|
|
|
readContent, readError := io.ReadAll(diskStream)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
if string(readContent) != string(archiveContent) {
|
|
testInstance.Errorf("der gelesene Inhalt weicht ab: %q", string(readContent))
|
|
}
|
|
|
|
// Vor dem Schliessen darf nichts geloescht sein — sonst zoege das Aufraeumen
|
|
// dem Leser die Datei unter den Fuessen weg.
|
|
if len(fakeServer.deletedArchiveList()) != 0 {
|
|
testInstance.Error("das Archiv wurde entfernt, bevor es gelesen war")
|
|
}
|
|
|
|
if closeError := diskStream.Close(); closeError != nil {
|
|
testInstance.Fatalf("der Datenstrom ließ sich nicht schließen: %v", closeError)
|
|
}
|
|
|
|
deletedArchives := fakeServer.deletedArchiveList()
|
|
if len(deletedArchives) != 1 {
|
|
testInstance.Fatalf("es wurden %d Archive entfernt, erwartet war eines", len(deletedArchives))
|
|
}
|
|
|
|
if !strings.Contains(deletedArchives[0], "vzdump-qemu-100") {
|
|
testInstance.Errorf("es wurde das falsche Archiv entfernt: %s", deletedArchives[0])
|
|
}
|
|
}
|
|
|
|
// TestOpenDiskKeepsArchiveWhenRequested prueft den ausdruecklichen Ausweg.
|
|
//
|
|
// Fuehrt Proxmox auf diesem Speicher selbst eine Aufbewahrung, waere das
|
|
// Loeschen ein Eingriff in fremde Verwaltung.
|
|
func TestOpenDiskKeepsArchiveWhenRequested(testInstance *testing.T) {
|
|
fakeServer := newFakeProxmoxServer(testInstance)
|
|
testProvider := fakeServer.newConnectedProvider(testInstance)
|
|
|
|
testProvider.options.ArchiveTransport = &stubArchiveTransport{}
|
|
testProvider.options.KeepArchiveOnNode = true
|
|
|
|
diskStream, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
|
|
GuestID: "qemu/100",
|
|
})
|
|
if openError != nil {
|
|
testInstance.Fatalf("die Sicherung ließ sich nicht öffnen: %v", openError)
|
|
}
|
|
|
|
if closeError := diskStream.Close(); closeError != nil {
|
|
testInstance.Fatalf("der Datenstrom ließ sich nicht schließen: %v", closeError)
|
|
}
|
|
|
|
if len(fakeServer.deletedArchiveList()) != 0 {
|
|
testInstance.Error("das Archiv wurde trotz KeepArchiveOnNode entfernt")
|
|
}
|
|
}
|
|
|
|
// TestOpenDiskCleansUpWhenTransportFails prueft den Fehlerweg.
|
|
//
|
|
// Scheitert der Zugriff auf das Archiv, bleibt es sonst auf dem Knoten liegen —
|
|
// bei jedem naechtlichen Versuch eines. Ein voller Proxmox-Speicher legt danach
|
|
// auch die Sicherungen still, die bisher liefen.
|
|
func TestOpenDiskCleansUpWhenTransportFails(testInstance *testing.T) {
|
|
fakeServer := newFakeProxmoxServer(testInstance)
|
|
testProvider := fakeServer.newConnectedProvider(testInstance)
|
|
|
|
// Ein Transport ohne hinterlegte Zuordnung scheitert beim Oeffnen.
|
|
testProvider.options.ArchiveTransport = NewLocalArchiveTransport(map[string]string{})
|
|
|
|
if _, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
|
|
GuestID: "qemu/100",
|
|
}); openError == nil {
|
|
testInstance.Fatal("ein nicht erreichbares Archiv muss fehlschlagen")
|
|
}
|
|
|
|
if len(fakeServer.deletedArchiveList()) != 1 {
|
|
testInstance.Errorf("das Archiv blieb nach dem Fehlschlag auf dem Knoten liegen")
|
|
}
|
|
}
|
|
|
|
// TestStoragePathNamesUnsupportedStorage prueft die Pfadauflösung.
|
|
//
|
|
// ZFS, LVM und Ceph legen Archive nicht als Datei ab. Einen Pfad zu raten
|
|
// ergaebe eine Fehlermeldung ueber eine fehlende Datei — und der Betreiber
|
|
// suchte an der falschen Stelle.
|
|
func TestStoragePathNamesUnsupportedStorage(testInstance *testing.T) {
|
|
fakeServer := newFakeProxmoxServer(testInstance)
|
|
testProvider := fakeServer.newConnectedProvider(testInstance)
|
|
|
|
resolvedPath, resolveError := testProvider.StoragePath(context.Background(), "pve-01", "local")
|
|
if resolveError != nil {
|
|
testInstance.Fatalf("der Speicherpfad ließ sich nicht auflösen: %v", resolveError)
|
|
}
|
|
|
|
if resolvedPath != "/var/lib/vz" {
|
|
testInstance.Errorf("der Pfad ist %q", resolvedPath)
|
|
}
|
|
|
|
_, poolError := testProvider.StoragePath(context.Background(), "pve-01", "zfs-pool")
|
|
if poolError == nil {
|
|
testInstance.Fatal("ein Speicher ohne Pfad wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(poolError.Error(), "zfs-pool") {
|
|
testInstance.Errorf("die Meldung nennt den Speicher nicht: %v", poolError)
|
|
}
|
|
}
|
|
|
|
// TestLocalTransportWritesArchiveAtomically prueft die Gegenrichtung.
|
|
//
|
|
// Das Archiv wird erst mit dem Close() unter seinem endgueltigen Namen
|
|
// sichtbar. Ohne diesen Schritt faende Proxmox waehrend der Uebertragung eine
|
|
// Datei, die es fuer ein vollstaendiges Archiv haelt.
|
|
func TestLocalTransportWritesArchiveAtomically(testInstance *testing.T) {
|
|
storageRoot := testInstance.TempDir()
|
|
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
|
|
|
|
archiveWriter, volumeIdentifier, createError := transport.CreateArchive(context.Background(),
|
|
"pve-01", "local", "vzdump-qemu-100-syncova.vma")
|
|
if createError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
if volumeIdentifier != "local:dump/vzdump-qemu-100-syncova.vma" {
|
|
testInstance.Errorf("die Volumenkennung ist %q", volumeIdentifier)
|
|
}
|
|
|
|
archiveContent := []byte("zurueckgeschriebenes archiv")
|
|
|
|
if _, writeError := archiveWriter.Write(archiveContent); writeError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht schreiben: %v", writeError)
|
|
}
|
|
|
|
finalPath := filepath.Join(storageRoot, "dump", "vzdump-qemu-100-syncova.vma")
|
|
|
|
// Vor dem Close() darf die Datei unter ihrem endgültigen Namen nicht da sein.
|
|
if _, statError := os.Stat(finalPath); statError == nil {
|
|
testInstance.Error("das unvollständige Archiv trug bereits seinen endgültigen Namen")
|
|
}
|
|
|
|
if closeError := archiveWriter.Close(); closeError != nil {
|
|
testInstance.Fatalf("das Archiv ließ sich nicht abschließen: %v", closeError)
|
|
}
|
|
|
|
writtenContent, readError := os.ReadFile(finalPath)
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Archiv liegt nicht an seinem Platz: %v", readError)
|
|
}
|
|
|
|
if string(writtenContent) != string(archiveContent) {
|
|
testInstance.Errorf("der Inhalt weicht ab: %q", string(writtenContent))
|
|
}
|
|
|
|
// Es darf keine Zwischendatei liegen bleiben.
|
|
directoryEntries, listError := os.ReadDir(filepath.Join(storageRoot, "dump"))
|
|
if listError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht lesen: %v", listError)
|
|
}
|
|
|
|
if len(directoryEntries) != 1 {
|
|
testInstance.Errorf("im Verzeichnis liegen %d Dateien statt einer", len(directoryEntries))
|
|
}
|
|
}
|
|
|
|
// TestArchiveFileNameIsValidated ist der Sicherheitstest der Gegenrichtung.
|
|
//
|
|
// Der Dateiname entsteht aus einer Backup-Kennung und damit mittelbar aus einer
|
|
// Benutzereingabe. Ein Name mit Pfadanteil schriebe die Datei irgendwohin — auf
|
|
// dem Proxmox-Knoten, mit den Rechten des Anmeldekontos.
|
|
func TestArchiveFileNameIsValidated(testInstance *testing.T) {
|
|
storageRoot := testInstance.TempDir()
|
|
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
|
|
|
|
forbiddenNames := []string{
|
|
"../../etc/cron.d/lauf",
|
|
"unterordner/archiv.vma",
|
|
"..",
|
|
".",
|
|
"",
|
|
" ",
|
|
}
|
|
|
|
for _, forbiddenName := range forbiddenNames {
|
|
archiveWriter, _, createError := transport.CreateArchive(context.Background(),
|
|
"pve-01", "local", forbiddenName)
|
|
if createError == nil {
|
|
_ = archiveWriter.Close()
|
|
testInstance.Errorf("der Archivname %q wurde angenommen", forbiddenName)
|
|
}
|
|
}
|
|
}
|