1package restic
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "sort"
10 "strings"
11
12 "git.secluded.site/keld/internal/config"
13)
14
15// resticNativeCommands lists .environ keys that restic handles natively.
16// Keld passes these through as-is rather than executing them.
17var resticNativeCommands = map[string]bool{
18 "RESTIC_PASSWORD_COMMAND": true,
19 "RESTIC_FROM_PASSWORD_COMMAND": true,
20}
21
22// DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is
23// unset.
24const DefaultExecutable = "restic"
25
26// ExitError reports that restic exited unsuccessfully while preserving its
27// numeric exit code for callers that need to mirror restic's process status.
28type ExitError struct {
29 Code int
30 Err error
31}
32
33func (e *ExitError) Error() string {
34 if e.Err == nil {
35 return fmt.Sprintf("restic exited with code %d", e.Code)
36 }
37 return fmt.Sprintf("restic exited with code %d: %v", e.Code, e.Err)
38}
39
40func (e *ExitError) Unwrap() error { return e.Err }
41
42func (e *ExitError) ExitCode() int { return e.Code }
43
44// Run supervises restic, configured according to cfg.
45func Run(cfg *config.ResolvedConfig) error {
46 exe := executable()
47
48 path, err := exec.LookPath(exe)
49 if err != nil {
50 return fmt.Errorf("finding %s: %w", exe, err)
51 }
52
53 if err := resolveEnvironCommands(cfg.Environ, cfg.Workdir); err != nil {
54 return err
55 }
56
57 argv := buildArgv(exe, cfg)
58 env := buildEnv(cfg.Environ)
59
60 for _, hook := range cfg.PreHooks {
61 if err := runHook(hook, cfg.Workdir, env); err != nil {
62 return fmt.Errorf("pre-hook %q: %w", hook, err)
63 }
64 }
65
66 cmd := exec.Command(path, argv[1:]...) //nolint:gosec
67 cmd.Env = env
68 cmd.Stdin = os.Stdin
69 cmd.Stdout = os.Stdout
70 cmd.Stderr = os.Stderr
71 if cfg.Workdir != "" {
72 cmd.Dir = cfg.Workdir
73 }
74
75 startErr := cmd.Start()
76 var restErr error
77 restCode := 0
78 restStarted := startErr == nil
79 if startErr == nil {
80 stopSignals := watchResticSignals(cmd.Process)
81 restErr = cmd.Wait()
82 stopSignals()
83 if restErr != nil {
84 var exitErr *exec.ExitError
85 if errors.As(restErr, &exitErr) {
86 restCode = resticExitCode(exitErr)
87 } else {
88 restErr = fmt.Errorf("running restic: %w", restErr)
89 }
90 }
91 } else {
92 restErr = fmt.Errorf("starting restic: %w", startErr)
93 }
94
95 postEnv := append([]string(nil), env...)
96 if restStarted {
97 postEnv = append(
98 postEnv,
99 fmt.Sprintf("KELD_RESTIC_EXIT_CODE=%d", restCode),
100 "KELD_RESTIC_STATUS="+ResticStatus(restCode),
101 )
102 } else {
103 postEnv = append(postEnv, "KELD_RESTIC_STATUS=start_failed")
104 }
105 var postErr error
106 for _, hook := range cfg.PostHooks {
107 if err := runHook(hook, cfg.Workdir, postEnv); err != nil {
108 if postErr == nil {
109 postErr = err
110 }
111 fmt.Fprintf(os.Stderr, "post-hook %q failed: %v\n", hook, err)
112 }
113 }
114
115 if restErr != nil {
116 if !restStarted {
117 return restErr
118 }
119 return &ExitError{Code: restCode, Err: restErr}
120 }
121 if postErr != nil {
122 return fmt.Errorf("post-hook: %w", postErr)
123 }
124
125 return nil
126}
127
128// DryRun formats a human-readable summary of what Run would execute.
129func DryRun(cfg *config.ResolvedConfig) string {
130 var b strings.Builder
131
132 if len(cfg.SectionsRead) > 0 {
133 fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → "))
134 }
135
136 if cfg.Workdir != "" {
137 fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
138 }
139
140 if len(cfg.Environ) > 0 {
141 fmt.Fprintln(&b, "environ:")
142
143 // Sort keys for deterministic output.
144 keys := make([]string, 0, len(cfg.Environ))
145 for k := range cfg.Environ {
146 keys = append(keys, k)
147 }
148 sort.Strings(keys)
149
150 for _, k := range keys {
151 fmt.Fprintf(&b, " %s=%s\n", k, cfg.Environ[k])
152 }
153 }
154
155 if len(cfg.PreHooks) > 0 {
156 fmt.Fprintln(&b, "pre-hooks:")
157 for _, hook := range cfg.PreHooks {
158 fmt.Fprintf(&b, " %s\n", hook)
159 }
160 }
161 if len(cfg.PostHooks) > 0 {
162 fmt.Fprintln(&b, "post-hooks:")
163 for _, hook := range cfg.PostHooks {
164 fmt.Fprintf(&b, " %s\n", hook)
165 }
166 }
167
168 argv := buildArgv(executable(), cfg)
169 fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))
170
171 return b.String()
172}
173
174// executable returns the restic binary name, respecting KELD_EXECUTABLE.
175func executable() string {
176 if e := os.Getenv("KELD_EXECUTABLE"); e != "" {
177 return config.ExpandPath(e)
178 }
179 return DefaultExecutable
180}
181
182// buildArgv assembles the full argument vector for the restic process.
183func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
184 argv := []string{exe}
185
186 if cfg.Command != "" {
187 argv = append(argv, cfg.Command)
188 }
189
190 for _, f := range cfg.Flags {
191 argv = append(argv, f.Name)
192 if f.Value != "" {
193 argv = append(argv, f.Value)
194 }
195 }
196
197 argv = append(argv, cfg.Arguments...)
198 return argv
199}
200
201func runHook(hook, workdir string, env []string) error {
202 cmd := exec.Command("sh", "-c", hook) //nolint:gosec
203 cmd.Env = env
204 cmd.Stdin = os.Stdin
205 cmd.Stdout = os.Stdout
206 cmd.Stderr = os.Stderr
207 if workdir != "" {
208 cmd.Dir = workdir
209 }
210 return cmd.Run()
211}
212
213// ResticStatus maps documented restic backup exit codes to script-friendly
214// status labels. Unknown non-zero exit codes are failures by restic's
215// documented scripting contract.
216func ResticStatus(code int) string {
217 switch code {
218 case 0:
219 return "success"
220 case 1:
221 return "fatal"
222 case 2:
223 return "runtime_error"
224 case 3:
225 return "partial"
226 case 10:
227 return "repository_missing"
228 case 11:
229 return "locked"
230 case 12:
231 return "wrong_password"
232 case 130:
233 return "interrupted"
234 case 143:
235 return "terminated"
236 default:
237 return "unknown_failure"
238 }
239}
240
241// resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
242// executes their values as shell commands, and replaces them with the base key
243// set to the command's stdout (trailing newline stripped). Keys that restic
244// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched.
245//
246// If both FOO_COMMAND and FOO are present, the command result takes precedence
247// and FOO_COMMAND is removed from the map.
248func resolveEnvironCommands(environ map[string]string, workdir string) error {
249 for key, cmd := range environ {
250 if !strings.HasSuffix(key, config.CommandSuffix) {
251 continue
252 }
253 if resticNativeCommands[key] {
254 continue
255 }
256
257 baseKey := strings.TrimSuffix(key, config.CommandSuffix)
258 if baseKey == "" {
259 continue
260 }
261
262 var stdout, stderr bytes.Buffer
263 proc := exec.Command("sh", "-c", cmd)
264 proc.Stdout = &stdout
265 proc.Stderr = &stderr
266 if workdir != "" {
267 proc.Dir = workdir
268 }
269
270 if err := proc.Run(); err != nil {
271 return fmt.Errorf(
272 "environ %s: running %q: %w\nstderr: %s",
273 key, cmd, err, strings.TrimSpace(stderr.String()),
274 )
275 }
276
277 environ[baseKey] = strings.TrimRight(stdout.String(), "\n")
278 delete(environ, key)
279 }
280 return nil
281}
282
283// buildEnv merges extra environment variables into the current process env.
284func buildEnv(extra map[string]string) []string {
285 env := os.Environ()
286 for k, v := range extra {
287 env = append(env, k+"="+v)
288 }
289 return env
290}
291
292// quotedJoin formats an argv slice by wrapping every element in double
293// quotes. This makes the dry-run output unambiguous — each argument
294// boundary is visible even when values contain spaces.
295func quotedJoin(argv []string) string {
296 var b strings.Builder
297 for i, arg := range argv {
298 if i > 0 {
299 b.WriteByte(' ')
300 }
301 b.WriteByte('"')
302 b.WriteString(arg)
303 b.WriteByte('"')
304 }
305 return b.String()
306}