package backup import ( "archive/tar" "bytes" "compress/gzip" "crypto/hmac" "crypto/sha256" "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) } // --- REQ-127 / F7 backup symlink validation tests --- // TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute // symlink linkname is rejected. func TestRestoreRejectsAbsoluteSymlink(t *testing.T) { dir := t.TempDir() // Create a crafted tarball with an absolute symlink. tarPath := filepath.Join(dir, "evil.tar.gz") sigPath := tarPath + ".sig" if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil { t.Fatalf("create tarball: %v", err) } // Create a valid signature (the signature verifies, but the symlink // validation should still reject the restore). key := make([]byte, 32) for i := range key { key[i] = byte(i) } mac := hmac.New(sha256.New, key) data, _ := os.ReadFile(tarPath) mac.Write(data) if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil { t.Fatalf("write sig: %v", err) } target := filepath.Join(dir, "restore") os.MkdirAll(target, 0o755) err := Restore(RestoreOptions{ InputPath: tarPath, TargetDir: target, MasterKey: key, Force: true, }) if err == nil { t.Fatal("Restore should reject absolute symlink (REQ-127)") } if !strings.Contains(err.Error(), "absolute") { t.Errorf("error should mention absolute: %v", err) } } // TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink // that escapes the target is rejected. func TestRestoreRejectsTraversalSymlink(t *testing.T) { dir := t.TempDir() tarPath := filepath.Join(dir, "evil2.tar.gz") sigPath := tarPath + ".sig" if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil { t.Fatalf("create tarball: %v", err) } key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } mac := hmac.New(sha256.New, key) data, _ := os.ReadFile(tarPath) mac.Write(data) if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil { t.Fatalf("write sig: %v", err) } target := filepath.Join(dir, "restore2") os.MkdirAll(target, 0o755) err := Restore(RestoreOptions{ InputPath: tarPath, TargetDir: target, MasterKey: key, Force: true, }) if err == nil { t.Fatal("Restore should reject traversal symlink (REQ-127)") } } // createCraftedTarball creates a tar.gz containing a single symlink // entry with the given linkname. Used to test symlink validation. func createCraftedTarball(path, name, linkname string) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() gz := gzip.NewWriter(f) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() hdr := &tar.Header{ Name: name, Typeflag: tar.TypeSymlink, Linkname: linkname, Mode: 0o644, } return tw.WriteHeader(hdr) }