@@ -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
+}
@@ -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")
+ }
+}