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) } } }