package restic import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" "git.casaderoll.de/michael/urbm/internal/model" ) type fakeSecrets map[string]string func (s fakeSecrets) Get(id string) (string, error) { return s[id], nil } func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") passwordPath := filepath.Join(dir, "password") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.5,\"total_files\":12,\"files_done\":6,\"total_bytes\":1024,\"bytes_done\":512,\"seconds_remaining\":4,\"current_files\":[\"/source path/file.txt\"]}'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"} job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}} var progress Progress summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}, func(value Progress) { progress = value }) if err != nil { t.Fatal(err) } if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 { t.Fatalf("unexpected summary: %+v", summary) } if progress.PercentDone != 0.5 || progress.FilesDone != 6 || progress.BytesDone != 512 || len(progress.CurrentFiles) != 1 { t.Fatalf("unexpected progress: %+v", progress) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } password, _ := os.ReadFile(passwordPath) if string(password) != "top-secret" { t.Fatal("password file was not provided") } } func TestChangePasswordUsesCurrentAndNewPasswordFiles(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") currentPath := filepath.Join(dir, "current") newPath := filepath.Join(dir, "new") script := filepath.Join(dir, "restic") body := fmt.Sprintf(`#!/bin/sh printf '%%s\n' "$@" > '%s' cat "$RESTIC_PASSWORD_FILE" > '%s' previous='' for value in "$@"; do if [ "$previous" = "--new-password-file" ]; then cat "$value" > '%s' fi previous="$value" done `, argsPath, currentPath, newPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "stored-old"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} if err := runner.ChangePassword(context.Background(), repo, "current-secret", "new-secret"); err != nil { t.Fatal(err) } args, _ := os.ReadFile(argsPath) if !strings.Contains(string(args), "key\npasswd\n--new-password-file") { t.Fatalf("unexpected arguments: %s", args) } current, _ := os.ReadFile(currentPath) newPassword, _ := os.ReadFile(newPath) if string(current) != "current-secret" || string(newPassword) != "new-secret" { t.Fatalf("password files current=%q new=%q", current, newPassword) } } func TestBackupLimitsCPUForThisJob(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, "gomaxprocs") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$GOMAXPROCS\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"cpu123\"}'\n", envPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} job := model.Job{ID: "job", Compression: "auto", CPUCores: 2} if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil { t.Fatal(err) } value, err := os.ReadFile(envPath) if err != nil { t.Fatal(err) } if string(value) != "2" { t.Fatalf("GOMAXPROCS = %q", value) } } func TestBackupSetsReadConcurrencyForThisJob(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"read123\"}'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} job := model.Job{ID: "job", Compression: "auto", ReadConcurrency: 8} if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil { t.Fatal(err) } args, err := os.ReadFile(argsPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(args), "--read-concurrency\n8\n") { t.Fatalf("read concurrency missing from arguments: %s", args) } } func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") imagePath := filepath.Join(dir, "image") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"image123\",\"data_added\":4,\"total_bytes_processed\":4,\"total_files_processed\":1}'\n", argsPath, imagePath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} job := model.Job{ID: "flash", Compression: "auto"} summary, err := runner.BackupImage(context.Background(), repo, job, strings.NewReader("disk"), nil) if err != nil { t.Fatal(err) } if summary.SnapshotID != "image123" || summary.TotalBytesProcessed != 4 { t.Fatalf("unexpected summary: %+v", summary) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"--stdin", "--stdin-filename", "urbm-usb-flash.img", "usb-image"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } image, _ := os.ReadFile(imagePath) if string(image) != "disk" { t.Fatalf("streamed image = %q", image) } } func TestRestoreReportsProgress(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.25,\"total_files\":8,\"files_done\":2,\"total_bytes\":4096,\"bytes_done\":1024,\"seconds_remaining\":30,\"current_files\":[\"/restore/file.txt\"]}'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} task := model.RestoreTask{SnapshotID: "snap123", Target: "/target", Includes: []string{"/source/file.txt"}} var progress Progress if err := runner.Restore(context.Background(), repo, task, func(value Progress) { progress = value }); err != nil { t.Fatal(err) } if progress.PercentDone != 0.25 || progress.FilesDone != 2 || progress.BytesDone != 1024 || progress.SecondsRemaining != 30 || len(progress.CurrentFiles) != 1 { t.Fatalf("unexpected progress: %+v", progress) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"restore", "snap123", "--target", "/target", "--json", "--include", "/source/file.txt"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } } func TestDiffReportsLastChangedPaths(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf(`#!/bin/sh printf '%%s\n' "$@" > '%s' printf '%%s\n' 'comparing snapshot before to after:' printf '%%s\n' '+ /mnt/user/new.txt' printf '%%s\n' 'M /mnt/user/modified-a.txt' printf '%%s\n' '- /mnt/user/removed.txt' printf '%%s\n' 'M /mnt/user/modified-b.txt' printf '%%s\n' 'Files: 1 new, 1 removed, 2 changed' `, argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} diff, err := runner.Diff(context.Background(), repo, "before", "after", 3) if err != nil { t.Fatal(err) } if diff.Total != 4 || len(diff.Entries) != 3 { t.Fatalf("unexpected diff result: %+v", diff) } expected := []DiffEntry{ {Kind: "modified", Path: "/mnt/user/modified-a.txt"}, {Kind: "removed", Path: "/mnt/user/removed.txt"}, {Kind: "modified", Path: "/mnt/user/modified-b.txt"}, } for index, entry := range expected { if diff.Entries[index] != entry { t.Fatalf("entry %d = %+v, want %+v", index, diff.Entries[index], entry) } } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"diff", "--metadata", "before", "after"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } } func TestListStreamsSnapshotNodes(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf(`#!/bin/sh printf '%%s\n' "$@" > '%s' printf '%%s\n' '{"struct_type":"snapshot","id":"snap"}' printf '%%s\n' '{"struct_type":"node","path":"/mnt/user","type":"dir"}' printf '%%s\n' '{"struct_type":"node","path":"/mnt/user/file.txt","type":"file","size":12}' `, argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} items, err := runner.List(context.Background(), repo, "snap", "/mnt/user") if err != nil { t.Fatal(err) } if len(items) != 2 || items[0]["path"] != "/mnt/user" || items[1]["path"] != "/mnt/user/file.txt" { t.Fatalf("unexpected items: %+v", items) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"ls", "snap", "--json", "/mnt/user"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } } func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"total_size\":4096,\"total_file_count\":12,\"total_blob_count\":8}'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} stats, err := runner.Stats(context.Background(), repo, "raw-data") if err != nil { t.Fatal(err) } if stats.TotalSize != 4096 || stats.TotalFileCount != 12 || stats.TotalBlobCount != 8 { t.Fatalf("unexpected stats: %+v", stats) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"stats", "--json", "--mode", "raw-data"} { if !strings.Contains(string(args), expected) { t.Fatalf("arguments missing %q: %s", expected, args) } } } func TestStatsIgnoresProgressBeforePrettyPrintedJSON(t *testing.T) { dir := t.TempDir() script := filepath.Join(dir, "restic") body := `#!/bin/sh printf '%s\n' '[0:00] 100.00% 12 / 12 index files loaded' printf '%s\n' '{' printf '%s\n' ' "total_size": 8192,' printf '%s\n' ' "total_file_count": 24,' printf '%s\n' ' "total_blob_count": 16' printf '%s\n' '}' ` if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} stats, err := runner.Stats(context.Background(), repo, "raw-data") if err != nil { t.Fatal(err) } if stats.TotalSize != 8192 || stats.TotalFileCount != 24 || stats.TotalBlobCount != 16 { t.Fatalf("unexpected stats: %+v", stats) } } func TestStatsUnlocksStaleRepositoryLockAndRetries(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "commands") countPath := filepath.Join(dir, "stats-count") script := filepath.Join(dir, "restic") body := fmt.Sprintf(`#!/bin/sh printf '%%s\n' "$*" >> '%s' case " $* " in *" stats "*) count=0 [ -f '%s' ] && count=$(cat '%s') count=$((count + 1)) printf '%%s' "$count" > '%s' if [ "$count" -eq 1 ]; then printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2 exit 11 fi printf '%%s\n' '{"total_size":4096,"total_file_count":12}' ;; esac `, logPath, countPath, countPath, countPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} stats, err := runner.Stats(context.Background(), repo, "raw-data") if err != nil { t.Fatal(err) } if stats.TotalSize != 4096 { t.Fatalf("unexpected stats: %+v", stats) } commands, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(commands)), "\n") if len(lines) != 3 || !strings.Contains(lines[0], "stats") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "stats") { t.Fatalf("expected stats, unlock, stats; got %q", lines) } } func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30, Weekly: 4, Monthly: 12}} if err := runner.Forget(context.Background(), repo, job); err != nil { t.Fatal(err) } args, _ := os.ReadFile(argsPath) for _, expected := range []string{"--keep-within", "30d", "--keep-weekly", "4", "--keep-monthly", "12"} { if !strings.Contains(string(args), expected) { t.Fatalf("retention arguments missing %q: %s", expected, args) } } if strings.Contains(string(args), "--keep-daily") { t.Fatalf("disabled daily retention was included: %s", args) } } func TestForgetUnlocksStaleRepositoryLockAndRetries(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "commands") countPath := filepath.Join(dir, "forget-count") script := filepath.Join(dir, "restic") body := fmt.Sprintf(`#!/bin/sh printf '%%s\n' "$*" >> '%s' case " $* " in *" forget "*) count=0 [ -f '%s' ] && count=$(cat '%s') count=$((count + 1)) printf '%%s' "$count" > '%s' if [ "$count" -eq 1 ]; then printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2 exit 11 fi ;; esac `, logPath, countPath, countPath, countPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} job := model.Job{ID: "job", Retention: model.Retention{KeepWithinDays: 30}} if err := runner.Forget(context.Background(), repo, job); err != nil { t.Fatal(err) } commands, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(commands)), "\n") if len(lines) != 3 || !strings.Contains(lines[0], "forget") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "forget") { t.Fatalf("expected forget, unlock, forget; got %q", lines) } } func TestUnlockUsesSafeResticCommand(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} if err := runner.Unlock(context.Background(), repo); err != nil { t.Fatal(err) } args, _ := os.ReadFile(argsPath) if !strings.Contains(string(args), "unlock") { t.Fatalf("unlock command missing: %s", args) } if strings.Contains(string(args), "--remove-all") { t.Fatalf("unsafe remove-all option used: %s", args) } } func TestForceUnlockRemovesAllRepositoryLocks(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} if err := runner.ForceUnlock(context.Background(), repo); err != nil { t.Fatal(err) } args, _ := os.ReadFile(argsPath) if !strings.Contains(string(args), "unlock\n--remove-all") { t.Fatalf("force unlock did not use remove-all: %s", args) } } func TestResticErrorExplainsExistingRepository(t *testing.T) { err := resticError("Fatal: create repository failed: config file already exists", fmt.Errorf("exit status 1")) if !strings.Contains(err.Error(), "already initialized") || !strings.Contains(err.Error(), "select Test") { t.Fatalf("unexpected error: %v", err) } } func TestResticErrorExplainsCiphertextVerification(t *testing.T) { err := resticError(`{"message_type":"exit_error","message":"Fatal: config or key abc is damaged: ciphertext verification failed"}`, fmt.Errorf("exit status 1")) if !strings.Contains(err.Error(), "password does not match") || strings.Contains(err.Error(), "abc") { t.Fatalf("unexpected error: %v", err) } } func TestResticErrorExplainsMissingExistingRepositoryPath(t *testing.T) { err := resticError("Fatal: repository does not exist", fmt.Errorf("exit status 10")) if !strings.Contains(err.Error(), "exakten Repository-Ordner") || !strings.Contains(err.Error(), "wirklich neues") { t.Fatalf("unexpected error: %v", err) } } func TestRunnerPausesResumesAndCancelsProcessGroup(t *testing.T) { dir := t.TempDir() ticksPath := filepath.Join(dir, "ticks") script := filepath.Join(dir, "restic") body := fmt.Sprintf("#!/bin/sh\nwhile :; do printf x >> '%s'; sleep 0.05; done\n", ticksPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} ctx, cancel := context.WithCancel(WithRunID(context.Background(), "run")) done := make(chan error, 1) go func() { done <- runner.run(ctx, repo, []string{"backup"}, nil, nil) }() waitForSize := func(minimum int64) int64 { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if info, err := os.Stat(ticksPath); err == nil && info.Size() >= minimum { return info.Size() } time.Sleep(10 * time.Millisecond) } t.Fatalf("tick file did not reach %d bytes", minimum) return 0 } waitForSize(2) if err := runner.Pause("run"); err != nil { t.Fatal(err) } time.Sleep(100 * time.Millisecond) pausedSize := waitForSize(1) time.Sleep(150 * time.Millisecond) info, err := os.Stat(ticksPath) if err != nil { t.Fatal(err) } if info.Size() != pausedSize { t.Fatalf("process continued while paused: size=%d, expected=%d", info.Size(), pausedSize) } if err := runner.Resume("run"); err != nil { t.Fatal(err) } waitForSize(pausedSize + 2) if err := runner.Pause("run"); err != nil { t.Fatal(err) } cancel() select { case err := <-done: if err == nil || !strings.Contains(err.Error(), "cancelled") { t.Fatalf("cancel result = %v", err) } case <-time.After(2 * time.Second): t.Fatal("paused process group was not cancelled") } }