Files
orca/internal/backup/backup_test.go
T
Jon Chery f61ef2aa9e feat(P04): backup/restore — signed tarball (HMAC-SHA256)
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---
2026-08-07 04:55:27 +00:00

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