Add snapshot listing via restic subprocess

Amolith created

Change summary

internal/restic/list_snapshots.go      | 147 +++++++++++++++++++++
internal/restic/list_snapshots_test.go | 196 ++++++++++++++++++++++++++++
2 files changed, 343 insertions(+)

Detailed changes

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
+}

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