1package restic
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "sort"
9 "strings"
10 "syscall"
11
12 "git.secluded.site/keld/internal/config"
13)
14
15// commandSuffix is the suffix keld recognises in .environ keys to indicate
16// that the value is a shell command whose stdout should be captured and used
17// as the actual environment variable value (with the suffix stripped).
18const commandSuffix = "_COMMAND"
19
20// resticNativeCommands lists .environ keys that restic handles natively.
21// Keld passes these through as-is rather than executing them.
22var resticNativeCommands = map[string]bool{
23 "RESTIC_PASSWORD_COMMAND": true,
24 "RESTIC_FROM_PASSWORD_COMMAND": true,
25}
26
27// DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is
28// unset.
29const DefaultExecutable = "restic"
30
31// Run replaces the current process with restic, configured according to cfg.
32func Run(cfg *config.ResolvedConfig) error {
33 exe := executable()
34
35 path, err := exec.LookPath(exe)
36 if err != nil {
37 return fmt.Errorf("finding %s: %w", exe, err)
38 }
39
40 if cfg.Workdir != "" {
41 if err := os.Chdir(cfg.Workdir); err != nil {
42 return fmt.Errorf("chdir %s: %w", cfg.Workdir, err)
43 }
44 }
45
46 if err := resolveEnvironCommands(cfg.Environ); err != nil {
47 return err
48 }
49
50 argv := buildArgv(exe, cfg)
51 env := buildEnv(cfg.Environ)
52
53 return syscall.Exec(path, argv, env)
54}
55
56// DryRun formats a human-readable summary of what Run would execute.
57func DryRun(cfg *config.ResolvedConfig) string {
58 var b strings.Builder
59
60 if len(cfg.SectionsRead) > 0 {
61 fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → "))
62 }
63
64 if cfg.Workdir != "" {
65 fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
66 }
67
68 if len(cfg.Environ) > 0 {
69 fmt.Fprintln(&b, "environ:")
70
71 // Sort keys for deterministic output.
72 keys := make([]string, 0, len(cfg.Environ))
73 for k := range cfg.Environ {
74 keys = append(keys, k)
75 }
76 sort.Strings(keys)
77
78 for _, k := range keys {
79 fmt.Fprintf(&b, " %s=%s\n", k, cfg.Environ[k])
80 }
81 }
82
83 argv := buildArgv(executable(), cfg)
84 fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))
85
86 return b.String()
87}
88
89// executable returns the restic binary name, respecting KELD_EXECUTABLE.
90func executable() string {
91 if e := os.Getenv("KELD_EXECUTABLE"); e != "" {
92 return config.ExpandPath(e)
93 }
94 return DefaultExecutable
95}
96
97// buildArgv assembles the full argument vector for the restic process.
98func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
99 argv := []string{exe}
100
101 if cfg.Command != "" {
102 argv = append(argv, cfg.Command)
103 }
104
105 for _, f := range cfg.Flags {
106 argv = append(argv, f.Name)
107 if f.Value != "" {
108 argv = append(argv, f.Value)
109 }
110 }
111
112 argv = append(argv, cfg.Arguments...)
113 return argv
114}
115
116// resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
117// executes their values as shell commands, and replaces them with the base key
118// set to the command's stdout (trailing newline stripped). Keys that restic
119// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched.
120//
121// If both FOO_COMMAND and FOO are present, the command result takes precedence
122// and FOO_COMMAND is removed from the map.
123func resolveEnvironCommands(environ map[string]string) error {
124 for key, cmd := range environ {
125 if !strings.HasSuffix(key, commandSuffix) {
126 continue
127 }
128 if resticNativeCommands[key] {
129 continue
130 }
131
132 baseKey := strings.TrimSuffix(key, commandSuffix)
133 if baseKey == "" {
134 continue
135 }
136
137 var stdout, stderr bytes.Buffer
138 proc := exec.Command("sh", "-c", cmd)
139 proc.Stdout = &stdout
140 proc.Stderr = &stderr
141
142 if err := proc.Run(); err != nil {
143 return fmt.Errorf(
144 "environ %s: running %q: %w\nstderr: %s",
145 key, cmd, err, strings.TrimSpace(stderr.String()),
146 )
147 }
148
149 environ[baseKey] = strings.TrimRight(stdout.String(), "\n")
150 delete(environ, key)
151 }
152 return nil
153}
154
155// buildEnv merges extra environment variables into the current process env.
156func buildEnv(extra map[string]string) []string {
157 env := os.Environ()
158 for k, v := range extra {
159 env = append(env, k+"="+v)
160 }
161 return env
162}
163
164// quotedJoin formats an argv slice by wrapping every element in double
165// quotes. This makes the dry-run output unambiguous — each argument
166// boundary is visible even when values contain spaces.
167func quotedJoin(argv []string) string {
168 var b strings.Builder
169 for i, arg := range argv {
170 if i > 0 {
171 b.WriteByte(' ')
172 }
173 b.WriteByte('"')
174 b.WriteString(arg)
175 b.WriteByte('"')
176 }
177 return b.String()
178}