f61ef2aa9e
internal/backup/backup.go: Backup (tar.gz + HMAC-SHA256 signature, excludes /run/orca + sockets + WAL/SHM), VerifySignature, Restore (signature verify + extract + Force flag). internal/cli/backup.go: orca backup --out + orca restore --in --force. Tests: round-trip, signature mismatch, exclusion, force-refuse, force-overwrite. ---ci--- project: orca phase: 04 milestone: v0.11 status: execute ---/ci---
317 lines
8.4 KiB
Go
317 lines
8.4 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func keyA() []byte { return []byte("0123456789abcdef0123456789abcdef") }
|
|
func keyB() []byte { return []byte("abcdef0123456789abcdef0123456789") }
|
|
|
|
func writeFiles(t *testing.T, root string, files map[string]string) {
|
|
t.Helper()
|
|
for name, body := range files {
|
|
p := filepath.Join(root, name)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runBackup(t *testing.T, src, out string, key []byte) {
|
|
t.Helper()
|
|
if err := Backup(BackupOptions{
|
|
SourceDir: src,
|
|
OutputPath: out,
|
|
MasterKey: key,
|
|
}); err != nil {
|
|
t.Fatalf("Backup: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackupRestoreRoundTrip(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
writeFiles(t, src, map[string]string{
|
|
"cluster/master.key": "KEYMATERIAL",
|
|
"_defaults/db/orca.db": "SQLITE",
|
|
"_defaults/.env": "FOO=bar",
|
|
"_defaults/jobs/job1.md": "job body",
|
|
"cluster/peers/host1/peer.json": "{}",
|
|
})
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if _, err := os.Stat(out + ".sig"); err != nil {
|
|
t.Fatalf("sig file missing: %v", err)
|
|
}
|
|
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
|
|
for name, body := range map[string]string{
|
|
"cluster/master.key": "KEYMATERIAL",
|
|
"_defaults/db/orca.db": "SQLITE",
|
|
"_defaults/.env": "FOO=bar",
|
|
"_defaults/jobs/job1.md": "job body",
|
|
"cluster/peers/host1/peer.json": "{}",
|
|
} {
|
|
got, err := os.ReadFile(filepath.Join(target, name))
|
|
if err != nil {
|
|
t.Errorf("restored file %s missing: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("restored %s = %q, want %q", name, string(got), body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureSameKey(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
if err := VerifySignature(out, out+".sig", keyA()); err != nil {
|
|
t.Fatalf("verify same key: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureWrongKey(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
err := VerifySignature(out, out+".sig", keyB())
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("verify wrong key: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureTampered(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
body, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatalf("read tarball: %v", err)
|
|
}
|
|
body[0] ^= 0xff
|
|
if err := os.WriteFile(out, body, 0o644); err != nil {
|
|
t.Fatalf("rewrite tampered tarball: %v", err)
|
|
}
|
|
err = VerifySignature(out, out+".sig", keyA())
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("verify tampered: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestExclusionSocketsAndRunOrca(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
writeFiles(t, src, map[string]string{
|
|
"keep.txt": "keep me",
|
|
"normal.db": "main db",
|
|
"sock-excluded.sock": "sock",
|
|
"side.db-wal": "wal",
|
|
"side.db-shm": "shm",
|
|
})
|
|
|
|
runOrcaDir := filepath.Join(src, "run", "orca")
|
|
if err := os.MkdirAll(runOrcaDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir run/orca: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(runOrcaDir, "ephemeral.txt"), []byte("eph"), 0o644); err != nil {
|
|
t.Fatalf("write ephemeral: %v", err)
|
|
}
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
|
|
for _, excluded := range []string{
|
|
"sock-excluded.sock",
|
|
"side.db-wal",
|
|
"side.db-shm",
|
|
"run/orca/ephemeral.txt",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(target, excluded)); !os.IsNotExist(err) {
|
|
t.Errorf("excluded file %s should not be in restore (err=%v)", excluded, err)
|
|
}
|
|
}
|
|
for _, kept := range []string{"keep.txt", "normal.db"} {
|
|
if _, err := os.Stat(filepath.Join(target, kept)); err != nil {
|
|
t.Errorf("kept file %s missing from restore: %v", kept, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreForceFalseRefusesNonEmpty(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(target, "existing.txt"), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("seed target: %v", err)
|
|
}
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
Force: false,
|
|
})
|
|
if !errors.Is(err, ErrTargetNotEmpty) {
|
|
t.Fatalf("restore to non-empty: got %v, want ErrTargetNotEmpty", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreForceTrueOverwritesNonEmpty(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "new"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(target, "stale.txt"), []byte("old"), 0o644); err != nil {
|
|
t.Fatalf("seed target: %v", err)
|
|
}
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
Force: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("restore force: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(target, "a.txt"))
|
|
if err != nil {
|
|
t.Fatalf("restored a.txt missing: %v", err)
|
|
}
|
|
if string(got) != "new" {
|
|
t.Errorf("restored a.txt = %q, want %q", string(got), "new")
|
|
}
|
|
}
|
|
|
|
func TestEmptyBackup(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := VerifySignature(out, out+".sig", keyA()); err != nil {
|
|
t.Fatalf("verify empty backup: %v", err)
|
|
}
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("restore empty backup: %v", err)
|
|
}
|
|
entries, err := os.ReadDir(target)
|
|
if err != nil {
|
|
t.Fatalf("read target: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("empty backup restored %d entries, want 0", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestRestoreSignatureMismatchFailsBeforeExtract(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyB(),
|
|
})
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("restore wrong key: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
entries, _ := os.ReadDir(target)
|
|
if len(entries) != 0 {
|
|
t.Errorf("target should be empty after failed verify, got %d entries", len(entries))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreBadSignatureContent(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := os.WriteFile(out+".sig", []byte("not-hex!!"), 0o644); err != nil {
|
|
t.Fatalf("write bad sig: %v", err)
|
|
}
|
|
err := VerifySignature(out, out+".sig", keyA())
|
|
if err == nil {
|
|
t.Fatal("verify bad sig content: expected error, got nil")
|
|
}
|
|
if strings.Contains(err.Error(), "decode signature") {
|
|
return
|
|
}
|
|
if errors.Is(err, ErrSignatureMismatch) {
|
|
return
|
|
}
|
|
t.Errorf("verify bad sig content: got unexpected err %v", err)
|
|
}
|
|
|
|
func TestBackupSignatureFileContent(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
sig, err := os.ReadFile(out + ".sig")
|
|
if err != nil {
|
|
t.Fatalf("read sig: %v", err)
|
|
}
|
|
if dec, err := hexDecode(string(bytes.TrimSpace(sig))); err != nil {
|
|
t.Fatalf("sig not hex: %v", err)
|
|
} else if len(dec) != 32 {
|
|
t.Errorf("sig len = %d, want 32", len(dec))
|
|
}
|
|
}
|
|
|
|
func hexDecode(s string) ([]byte, error) {
|
|
return hex.DecodeString(s)
|
|
}
|