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}