list_snapshots.go

  1package restic
  2
  3import (
  4	"bytes"
  5	"errors"
  6	"fmt"
  7	"maps"
  8	"os/exec"
  9	"sort"
 10
 11	"git.secluded.site/keld/internal/config"
 12)
 13
 14// ErrNoRepo is returned by ListSnapshots when no repository is configured
 15// via flags, config environ, or process environment.
 16var ErrNoRepo = errors.New("no repository configured (need --repo, --repository-file, or RESTIC_REPOSITORY)")
 17
 18// globalFlags is the set of restic global flags that should be forwarded
 19// when running `restic snapshots` to list available snapshots. These are
 20// the connection, auth, cache, and TLS flags shared across all restic
 21// commands — not command-specific flags like --target or --exclude.
 22//
 23// Sourced from `restic snapshots --help` "Global Flags" section.
 24var globalFlags = map[string]bool{
 25	"--cacert":                true,
 26	"--cache-dir":             true,
 27	"--cleanup-cache":         true,
 28	"--compression":           true,
 29	"--http-user-agent":       true,
 30	"--insecure-no-password":  true,
 31	"--insecure-tls":          true,
 32	"--key-hint":              true,
 33	"--limit-download":        true,
 34	"--limit-upload":          true,
 35	"--no-cache":              true,
 36	"--no-extra-verify":       true,
 37	"--no-lock":               true,
 38	"-o":                      true,
 39	"--pack-size":             true,
 40	"--password-command":      true,
 41	"--password-file":         true,
 42	"-p":                      true,
 43	"--repo":                  true,
 44	"-r":                      true,
 45	"--repository-file":       true,
 46	"--retry-lock":            true,
 47	"--stuck-request-timeout": true,
 48	"--tls-client-cert":       true,
 49}
 50
 51// hasRepoSource reports whether a repository location is available from
 52// any source: CLI flags in the resolved config, the config's environ
 53// section (including _COMMAND variants), or the process environment.
 54func hasRepoSource(cfg *config.ResolvedConfig) bool {
 55	for _, f := range cfg.Flags {
 56		switch f.Name {
 57		case "--repo", "-r", "--repository-file":
 58			return true
 59		}
 60	}
 61	return cfg.HasEnvSource("RESTIC_REPOSITORY") ||
 62		cfg.HasEnvSource("RESTIC_REPOSITORY_FILE")
 63}
 64
 65// buildSnapshotCmd constructs the argument vector for running
 66// `restic snapshots --json`, extracting only global flags from the
 67// given resolved config. Returns an error if no repository source
 68// is available.
 69func buildSnapshotCmd(cfg *config.ResolvedConfig) ([]string, error) {
 70	if !hasRepoSource(cfg) {
 71		return nil, ErrNoRepo
 72	}
 73
 74	argv := []string{executable(), "snapshots", "--json"}
 75
 76	for _, f := range cfg.Flags {
 77		if !globalFlags[f.Name] {
 78			continue
 79		}
 80		argv = append(argv, f.Name)
 81		if f.Value != "" {
 82			argv = append(argv, f.Value)
 83		}
 84	}
 85
 86	return argv, nil
 87}
 88
 89// copyEnviron returns a shallow copy of the environ map so that
 90// resolveEnvironCommands can mutate it without affecting the caller's
 91// config.
 92func copyEnviron(environ map[string]string) map[string]string {
 93	if environ == nil {
 94		return nil
 95	}
 96	copied := make(map[string]string, len(environ))
 97	maps.Copy(copied, environ)
 98	return copied
 99}
100
101// ListSnapshots runs `restic snapshots --json` using the connection
102// details from the given resolved config and returns the parsed
103// snapshots sorted newest-first.
104//
105// The config's environ map is copied before resolving _COMMAND entries
106// so the caller's config is not mutated.
107func ListSnapshots(cfg *config.ResolvedConfig) ([]Snapshot, error) {
108	argv, err := buildSnapshotCmd(cfg)
109	if err != nil {
110		return nil, err
111	}
112
113	env := copyEnviron(cfg.Environ)
114	if err := resolveEnvironCommands(env, cfg.Workdir); err != nil {
115		return nil, fmt.Errorf("resolving environ for snapshot listing: %w", err)
116	}
117
118	cmd := exec.Command(argv[0], argv[1:]...) //nolint:gosec
119	cmd.Env = buildEnv(env)
120
121	if cfg.Workdir != "" {
122		cmd.Dir = cfg.Workdir
123	}
124
125	var stdout, stderr bytes.Buffer
126	cmd.Stdout = &stdout
127	cmd.Stderr = &stderr
128
129	if err := cmd.Run(); err != nil {
130		return nil, fmt.Errorf("running restic snapshots: %w\n%s",
131			err, bytes.TrimSpace(stderr.Bytes()))
132	}
133
134	snapshots, err := ParseSnapshots(stdout.Bytes())
135	if err != nil {
136		return nil, err
137	}
138
139	// Sort newest-first for display. Don't assume restic's JSON order.
140	sort.Slice(snapshots, func(i, j int) bool {
141		return snapshots[i].Time.After(snapshots[j].Time)
142	})
143
144	return snapshots, nil
145}