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---
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// Package backup implements orca's signed tarball backup/restore
|
||||
// subsystem (P04, v0.11 milestone).
|
||||
//
|
||||
// The model is tar.gz + HMAC-SHA256 signature:
|
||||
//
|
||||
// - Backup walks the ORCA_HOME recursively, excludes ephemeral
|
||||
// paths (/run/orca/*), unix sockets (*.sock), and SQLite WAL/SHM
|
||||
// sidecars (*.db-wal, *.db-shm), packs the rest into a tar.gz, and
|
||||
// computes an HMAC-SHA256 of the tarball using the cluster master
|
||||
// key. The tarball is written to OutputPath; the hex-encoded
|
||||
// signature to OutputPath + ".sig".
|
||||
// - VerifySignature recomputes the HMAC and compares it (constant
|
||||
// time) against the recorded signature.
|
||||
// - Restore verifies the signature first (refuses on mismatch), then
|
||||
// extracts the tarball to TargetDir. With Force=false it refuses to
|
||||
// clobber a non-empty TargetDir; with Force=true it overwrites.
|
||||
//
|
||||
// The package never logs key material. slog calls carry only metadata.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrSignatureMismatch is returned when the recorded HMAC-SHA256
|
||||
// signature does not match the recomputed one (tampering or wrong key).
|
||||
var ErrSignatureMismatch = errors.New("backup: signature mismatch")
|
||||
|
||||
// ErrTargetNotEmpty is returned when Restore is called with Force=false
|
||||
// against a non-empty TargetDir.
|
||||
var ErrTargetNotEmpty = errors.New("backup: target directory not empty (use Force to overwrite)")
|
||||
|
||||
// BackupOptions configures Backup.
|
||||
type BackupOptions struct {
|
||||
SourceDir string // ORCA_HOME — the tree to back up
|
||||
OutputPath string // destination tarball path (.tar.gz)
|
||||
MasterKey []byte // HMAC-SHA256 key (cluster master.key)
|
||||
}
|
||||
|
||||
// RestoreOptions configures Restore.
|
||||
type RestoreOptions struct {
|
||||
InputPath string // source tarball path (.tar.gz)
|
||||
TargetDir string // destination ORCA_HOME
|
||||
MasterKey []byte // HMAC-SHA256 key (for verification)
|
||||
Force bool // overwrite non-empty target
|
||||
}
|
||||
|
||||
// excludeGlobSuffixes are the suffixes excluded from the backup. We
|
||||
// exclude SQLite WAL/SHM sidecars (the main db is backed up) and unix
|
||||
// sockets.
|
||||
var excludeGlobSuffixes = []string{".sock", ".db-wal", ".db-shm"}
|
||||
|
||||
// shouldExclude reports whether a path should be excluded from the
|
||||
// backup. It excludes /run/orca/* (ephemeral runtime), *.sock, *.db-wal,
|
||||
// and *.db-shm. The /run/orca match is done on the absolute path; the
|
||||
// suffix matches are done on the base name.
|
||||
func shouldExclude(absPath string) bool {
|
||||
clean := filepath.Clean(absPath)
|
||||
if strings.HasPrefix(clean, "/run/orca/") || clean == "/run/orca" {
|
||||
return true
|
||||
}
|
||||
if idx := strings.LastIndex(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca"+string(filepath.Separator)); idx >= 0 {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca") {
|
||||
return true
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
for _, suf := range excludeGlobSuffixes {
|
||||
if strings.HasSuffix(base, suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Backup creates a signed tar.gz of SourceDir. The tarball is written
|
||||
// to OutputPath and the hex-encoded HMAC-SHA256 signature to
|
||||
// OutputPath + ".sig". The write is atomic: the tarball is streamed to
|
||||
// a temp file in the same directory and renamed on success; the
|
||||
// signature is written after the rename so a crash never leaves a
|
||||
// tarball with a stale or missing signature.
|
||||
func Backup(opts BackupOptions) error {
|
||||
if opts.SourceDir == "" {
|
||||
return fmt.Errorf("backup: SourceDir is empty")
|
||||
}
|
||||
if opts.OutputPath == "" {
|
||||
return fmt.Errorf("backup: OutputPath is empty")
|
||||
}
|
||||
if len(opts.MasterKey) == 0 {
|
||||
return fmt.Errorf("backup: MasterKey is empty")
|
||||
}
|
||||
|
||||
src, err := filepath.Abs(opts.SourceDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: resolve SourceDir: %w", err)
|
||||
}
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: stat SourceDir: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("backup: SourceDir %q is not a directory", src)
|
||||
}
|
||||
|
||||
outDir := filepath.Dir(opts.OutputPath)
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
return fmt.Errorf("backup: mkdir output dir: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(outDir, ".orca-backup-*.tar.gz.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: create temp tarball: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
|
||||
gw := gzip.NewWriter(tmp)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
var walked int
|
||||
walkErr := filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shouldExclude(path) {
|
||||
if fi.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(src, path)
|
||||
if rerr != nil {
|
||||
return fmt.Errorf("rel path %s: %w", path, rerr)
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
hdr, herr := tar.FileInfoHeader(fi, "")
|
||||
if herr != nil {
|
||||
return fmt.Errorf("tar header for %s: %w", path, herr)
|
||||
}
|
||||
hdr.Name = filepath.ToSlash(rel)
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("write header %s: %w", rel, err)
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
f, oerr := os.Open(path)
|
||||
if oerr != nil {
|
||||
return fmt.Errorf("open %s: %w", path, oerr)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(tw, f); err != nil {
|
||||
return fmt.Errorf("copy %s: %w", rel, err)
|
||||
}
|
||||
walked++
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
return fmt.Errorf("backup: walk: %w", walkErr)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
gw.Close()
|
||||
return fmt.Errorf("backup: close tar writer: %w", err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return fmt.Errorf("backup: close gzip writer: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("backup: close temp tarball: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, opts.OutputPath); err != nil {
|
||||
return fmt.Errorf("backup: rename tarball: %w", err)
|
||||
}
|
||||
|
||||
sig, err := computeSignature(opts.OutputPath, opts.MasterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: compute signature: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(opts.OutputPath+".sig", []byte(hex.EncodeToString(sig)), 0o644); err != nil {
|
||||
return fmt.Errorf("backup: write signature: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("backup complete", "path", opts.OutputPath, "files", walked, "sig", opts.OutputPath+".sig")
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeSignature reads the file at path and returns its HMAC-SHA256
|
||||
// MAC under key.
|
||||
func computeSignature(path string, key []byte) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
mac := hmac.New(sha256.New, key)
|
||||
if _, err := io.Copy(mac, f); err != nil {
|
||||
return nil, fmt.Errorf("hash %s: %w", path, err)
|
||||
}
|
||||
return mac.Sum(nil), nil
|
||||
}
|
||||
|
||||
// VerifySignature recomputes the HMAC-SHA256 of the tarball at
|
||||
// tarballPath and compares it (constant time) against the hex-encoded
|
||||
// signature at sigPath. Returns ErrSignatureMismatch on a mismatch.
|
||||
func VerifySignature(tarballPath, sigPath string, masterKey []byte) error {
|
||||
if len(masterKey) == 0 {
|
||||
return fmt.Errorf("backup: MasterKey is empty")
|
||||
}
|
||||
got, err := computeSignature(tarballPath, masterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compute signature: %w", err)
|
||||
}
|
||||
wantHex, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signature: %w", err)
|
||||
}
|
||||
want, err := hex.DecodeString(strings.TrimSpace(string(wantHex)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
if !hmac.Equal(got, want) {
|
||||
return ErrSignatureMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore verifies the signature on (InputPath, InputPath+".sig") using
|
||||
// MasterKey, then extracts the tarball to TargetDir. With Force=false
|
||||
// a non-empty TargetDir is refused (ErrTargetNotEmpty); with Force=true
|
||||
// existing files are overwritten.
|
||||
func Restore(opts RestoreOptions) error {
|
||||
if opts.InputPath == "" {
|
||||
return fmt.Errorf("restore: InputPath is empty")
|
||||
}
|
||||
if opts.TargetDir == "" {
|
||||
return fmt.Errorf("restore: TargetDir is empty")
|
||||
}
|
||||
if len(opts.MasterKey) == 0 {
|
||||
return fmt.Errorf("restore: MasterKey is empty")
|
||||
}
|
||||
|
||||
sigPath := opts.InputPath + ".sig"
|
||||
if err := VerifySignature(opts.InputPath, sigPath, opts.MasterKey); err != nil {
|
||||
return fmt.Errorf("restore: verify signature: %w", err)
|
||||
}
|
||||
|
||||
target := filepath.Clean(opts.TargetDir)
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return fmt.Errorf("restore: mkdir target: %w", err)
|
||||
}
|
||||
if !opts.Force {
|
||||
empty, err := dirIsEmpty(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: check target: %w", err)
|
||||
}
|
||||
if !empty {
|
||||
return ErrTargetNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Open(opts.InputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: open tarball: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
var extracted int
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: read tar entry: %w", err)
|
||||
}
|
||||
name := filepath.FromSlash(hdr.Name)
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "..") {
|
||||
return fmt.Errorf("restore: unsafe path %q", hdr.Name)
|
||||
}
|
||||
dest := filepath.Join(target, name)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(dest, os.FileMode(hdr.Mode)); err != nil {
|
||||
return fmt.Errorf("restore: mkdir %s: %w", name, err)
|
||||
}
|
||||
continue
|
||||
case tar.TypeSymlink:
|
||||
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
||||
}
|
||||
if err := os.Symlink(hdr.Linkname, dest); err != nil {
|
||||
return fmt.Errorf("restore: symlink %s: %w", name, err)
|
||||
}
|
||||
continue
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fmt.Errorf("restore: mkdir parent %s: %w", name, err)
|
||||
}
|
||||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("restore: create %s: %w", name, err)
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return fmt.Errorf("restore: write %s: %w", name, err)
|
||||
}
|
||||
out.Close()
|
||||
extracted++
|
||||
default:
|
||||
slog.Warn("restore: skipping non-regular entry", "name", name, "type", hdr.Typeflag)
|
||||
}
|
||||
}
|
||||
slog.Info("restore complete", "path", target, "files", extracted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dirIsEmpty reports whether dir contains no entries.
|
||||
func dirIsEmpty(dir string) (bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(entries) == 0, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user