diff --git a/internal/restic/list_snapshots.go b/internal/restic/list_snapshots.go new file mode 100644 index 0000000000000000000000000000000000000000..355e5f876ea707c78e95697162d703fc85f11157 --- /dev/null +++ b/internal/restic/list_snapshots.go @@ -0,0 +1,147 @@ +package restic + +import ( + "bytes" + "fmt" + "maps" + "os/exec" + "sort" + + "git.secluded.site/keld/internal/config" +) + +// globalFlags is the set of restic global flags that should be forwarded +// when running `restic snapshots` to list available snapshots. These are +// the connection, auth, cache, and TLS flags shared across all restic +// commands — not command-specific flags like --target or --exclude. +// +// Sourced from `restic snapshots --help` "Global Flags" section. +var globalFlags = map[string]bool{ + "--cacert": true, + "--cache-dir": true, + "--cleanup-cache": true, + "--compression": true, + "--http-user-agent": true, + "--insecure-no-password": true, + "--insecure-tls": true, + "--key-hint": true, + "--limit-download": true, + "--limit-upload": true, + "--no-cache": true, + "--no-extra-verify": true, + "--no-lock": true, + "-o": true, + "--pack-size": true, + "--password-command": true, + "--password-file": true, + "-p": true, + "--repo": true, + "-r": true, + "--repository-file": true, + "--retry-lock": true, + "--stuck-request-timeout": true, + "--tls-client-cert": true, +} + +// hasRepoSource reports whether the resolved config provides a repository +// location, either via a flag (--repo, -r, --repository-file) or via +// the RESTIC_REPOSITORY / RESTIC_REPOSITORY_FILE environment variables. +func hasRepoSource(cfg *config.ResolvedConfig) bool { + for _, f := range cfg.Flags { + switch f.Name { + case "--repo", "-r", "--repository-file": + return true + } + } + if cfg.Environ != nil { + if _, ok := cfg.Environ["RESTIC_REPOSITORY"]; ok { + return true + } + if _, ok := cfg.Environ["RESTIC_REPOSITORY_FILE"]; ok { + return true + } + } + return false +} + +// buildSnapshotCmd constructs the argument vector for running +// `restic snapshots --json`, extracting only global flags from the +// given resolved config. Returns an error if no repository source +// is available. +func buildSnapshotCmd(cfg *config.ResolvedConfig) ([]string, error) { + if !hasRepoSource(cfg) { + return nil, fmt.Errorf("no repository configured (need --repo, --repository-file, or RESTIC_REPOSITORY)") + } + + argv := []string{executable(), "snapshots", "--json"} + + for _, f := range cfg.Flags { + if !globalFlags[f.Name] { + continue + } + argv = append(argv, f.Name) + if f.Value != "" { + argv = append(argv, f.Value) + } + } + + return argv, nil +} + +// copyEnviron returns a shallow copy of the environ map so that +// resolveEnvironCommands can mutate it without affecting the caller's +// config. +func copyEnviron(environ map[string]string) map[string]string { + if environ == nil { + return nil + } + copied := make(map[string]string, len(environ)) + maps.Copy(copied, environ) + return copied +} + +// ListSnapshots runs `restic snapshots --json` using the connection +// details from the given resolved config and returns the parsed +// snapshots sorted newest-first. +// +// The config's environ map is copied before resolving _COMMAND entries +// so the caller's config is not mutated. +func ListSnapshots(cfg *config.ResolvedConfig) ([]Snapshot, error) { + argv, err := buildSnapshotCmd(cfg) + if err != nil { + return nil, err + } + + env := copyEnviron(cfg.Environ) + if err := resolveEnvironCommands(env); err != nil { + return nil, fmt.Errorf("resolving environ for snapshot listing: %w", err) + } + + cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec + cmd.Env = buildEnv(env) + + if cfg.Workdir != "" { + cmd.Dir = cfg.Workdir + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("running restic snapshots: %w\n%s", + err, bytes.TrimSpace(stderr.Bytes())) + } + + snapshots, err := ParseSnapshots(stdout.Bytes()) + if err != nil { + return nil, err + } + + // Sort newest-first for display. Don't assume restic's JSON order. + sort.Slice(snapshots, func(i, j int) bool { + return snapshots[i].Time.After(snapshots[j].Time) + }) + + return snapshots, nil +} diff --git a/internal/restic/list_snapshots_test.go b/internal/restic/list_snapshots_test.go new file mode 100644 index 0000000000000000000000000000000000000000..21ca04fd4008d3ece00b297477f15f3e11f63e47 --- /dev/null +++ b/internal/restic/list_snapshots_test.go @@ -0,0 +1,196 @@ +package restic + +import ( + "testing" + + "git.secluded.site/keld/internal/config" +) + +func TestBuildSnapshotCmd(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *config.ResolvedConfig + wantArgv []string + wantErr bool + }{ + { + name: "basic repo flag", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repo", Value: "/srv/backup"}, + {Name: "--target", Value: "/tmp/restore"}, + }, + }, + wantArgv: []string{"restic", "snapshots", "--json", "--repo", "/srv/backup"}, + }, + { + name: "repo with password-file", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repo", Value: "rclone:remote:/backup"}, + {Name: "--password-file", Value: "/etc/restic/pw"}, + {Name: "--target", Value: "/tmp/restore"}, + {Name: "--overwrite", Value: "if-changed"}, + }, + }, + wantArgv: []string{ + "restic", "snapshots", "--json", + "--repo", "rclone:remote:/backup", + "--password-file", "/etc/restic/pw", + }, + }, + { + name: "repo with cache-dir and no-lock", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repo", Value: "/srv/backup"}, + {Name: "--cache-dir", Value: "/tmp/cache"}, + {Name: "--no-lock"}, + {Name: "--target", Value: "/tmp/restore"}, + }, + }, + wantArgv: []string{ + "restic", "snapshots", "--json", + "--repo", "/srv/backup", + "--cache-dir", "/tmp/cache", + "--no-lock", + }, + }, + { + name: "no repo flag", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--target", Value: "/tmp/restore"}, + }, + }, + wantErr: true, + }, + { + name: "repo via repository-file", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repository-file", Value: "/etc/restic/repo"}, + {Name: "--target", Value: "/tmp/restore"}, + }, + }, + wantArgv: []string{ + "restic", "snapshots", "--json", + "--repository-file", "/etc/restic/repo", + }, + }, + { + name: "multiple connection flags preserved", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repo", Value: "s3:https://bucket/backup"}, + {Name: "--cacert", Value: "/etc/ssl/custom.pem"}, + {Name: "--insecure-tls"}, + {Name: "--limit-download", Value: "1024"}, + {Name: "--tls-client-cert", Value: "/etc/ssl/client.pem"}, + {Name: "--key-hint", Value: "abc123"}, + {Name: "--target", Value: "/tmp/restore"}, + {Name: "--exclude", Value: "*.log"}, + }, + }, + wantArgv: []string{ + "restic", "snapshots", "--json", + "--repo", "s3:https://bucket/backup", + "--cacert", "/etc/ssl/custom.pem", + "--insecure-tls", + "--limit-download", "1024", + "--tls-client-cert", "/etc/ssl/client.pem", + "--key-hint", "abc123", + }, + }, + { + name: "repo from environ only", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--target", Value: "/tmp/restore"}, + }, + Environ: map[string]string{ + "RESTIC_REPOSITORY": "/srv/backup", + }, + }, + // No --repo flag needed; restic reads the env var directly. + wantArgv: []string{"restic", "snapshots", "--json"}, + }, + { + name: "password-command flag preserved", + cfg: &config.ResolvedConfig{ + Command: "restore", + Flags: []config.Flag{ + {Name: "--repo", Value: "/srv/backup"}, + {Name: "--password-command", Value: "op read 'op://Vault/Backup/password'"}, + {Name: "--target", Value: "/tmp/restore"}, + }, + }, + wantArgv: []string{ + "restic", "snapshots", "--json", + "--repo", "/srv/backup", + "--password-command", "op read 'op://Vault/Backup/password'", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + argv, err := buildSnapshotCmd(tt.cfg) + if (err != nil) != tt.wantErr { + t.Fatalf("buildSnapshotCmd(): err=%v, wantErr=%v", err, tt.wantErr) + } + if err != nil { + return + } + + if len(argv) != len(tt.wantArgv) { + t.Fatalf("argv length: got %d %v, want %d %v", + len(argv), argv, len(tt.wantArgv), tt.wantArgv) + } + for i := range argv { + if argv[i] != tt.wantArgv[i] { + t.Errorf("argv[%d]: got %q, want %q", i, argv[i], tt.wantArgv[i]) + } + } + }) + } +} + +func TestCopyEnviron(t *testing.T) { + t.Parallel() + + original := map[string]string{ + "RESTIC_REPOSITORY": "/srv/backup", + "RESTIC_PASSWORD_COMMAND": "op read 'op://Vault/pw'", + "AWS_ACCESS_KEY_ID_COMMAND": "op read 'op://Vault/aws-key'", + } + + copied := copyEnviron(original) + + // Mutation of the copy must not affect the original. + copied["RESTIC_REPOSITORY"] = "changed" + delete(copied, "RESTIC_PASSWORD_COMMAND") + copied["NEW_KEY"] = "new_value" + + if original["RESTIC_REPOSITORY"] != "/srv/backup" { + t.Error("original RESTIC_REPOSITORY was mutated") + } + if _, ok := original["RESTIC_PASSWORD_COMMAND"]; !ok { + t.Error("original RESTIC_PASSWORD_COMMAND was deleted") + } + if _, ok := original["NEW_KEY"]; ok { + t.Error("original gained NEW_KEY from copy") + } +}