1package config
2
3import (
4 "fmt"
5 "os"
6 "regexp"
7 "strings"
8
9 "github.com/BurntSushi/toml"
10)
11
12// Special keys recognised during option assembly.
13const (
14 keyArguments = "_arguments"
15 keyWorkdir = "_workdir"
16 keyCommand = "_command"
17 keyPreHooks = "_pre_hooks"
18 keyPostHooks = "_post_hooks"
19)
20
21// environSuffix marks a section as containing environment variables.
22const environSuffix = ".environ"
23
24// interpolateRe matches ${section.key} references for cross-section interpolation.
25var interpolateRe = regexp.MustCompile(`\$\{([^.}]+)\.([^}]+)\}`)
26
27// ResolvedConfig holds the fully-merged result of config resolution, ready for
28// the restic exec layer to consume.
29type ResolvedConfig struct {
30 // Command is the restic subcommand to run (may be aliased via _command).
31 Command string
32
33 // Flags maps flag names to their values. Multi-value flags have multiple
34 // entries. Boolean flags (true) are represented as a nil slice.
35 Flags []Flag
36
37 // Arguments are positional args passed after the flags.
38 Arguments []string
39
40 // Workdir is the directory to chdir into before exec, or "" for cwd.
41 Workdir string
42
43 // Environ holds additional environment variables for the restic process.
44 Environ map[string]string
45
46 // PreHooks are shell commands to run before restic backup.
47 PreHooks []string
48
49 // PostHooks are shell commands to run after restic backup is attempted.
50 PostHooks []string
51
52 // SectionsRead lists which config sections contributed to this resolution.
53 SectionsRead []string
54}
55
56// Flag is a single CLI flag to pass to restic.
57type Flag struct {
58 Name string
59 Value string // empty for boolean switches
60}
61
62// HasFlag reports whether the resolved config contains a flag with the given
63// name (without leading dashes, e.g. "target" not "--target").
64func (rc *ResolvedConfig) HasFlag(name string) bool {
65 dashed := flagName(name)
66 for _, f := range rc.Flags {
67 if f.Name == dashed {
68 return true
69 }
70 }
71 return false
72}
73
74// CommandSuffix is the suffix keld recognises in .environ keys to indicate
75// that the value is a shell command whose stdout should be captured and
76// used as the actual variable value (with the suffix stripped).
77//
78// For example, RESTIC_REPOSITORY_COMMAND = "op read ..." resolves to
79// RESTIC_REPOSITORY at execution time.
80const CommandSuffix = "_COMMAND"
81
82// HasEnvSource reports whether the given environment variable will have
83// a value when the restic process runs. It checks three sources in
84// order:
85//
86// 1. Direct key in [ResolvedConfig.Environ] (e.g. RESTIC_REPOSITORY)
87// 2. _COMMAND variant in Environ (e.g. RESTIC_REPOSITORY_COMMAND),
88// which keld resolves before exec
89// 3. Process environment via [os.Getenv]
90//
91// This centralises the _COMMAND convention so callers never need to
92// check for it themselves.
93func (rc *ResolvedConfig) HasEnvSource(key string) bool {
94 if rc.Environ != nil {
95 if v, ok := rc.Environ[key]; ok && v != "" {
96 return true
97 }
98 if v, ok := rc.Environ[key+CommandSuffix]; ok && v != "" {
99 return true
100 }
101 }
102 return os.Getenv(key) != ""
103}
104
105// rawConfig is the entire parsed TOML file as nested string-keyed maps.
106type rawConfig map[string]any
107
108// Resolve loads all discovered config files, merges sections according to
109// preset/command rules, and returns a ResolvedConfig.
110//
111// cliOverrides are applied last and should use flag names without leading
112// dashes (e.g. "exclude" not "--exclude"). Each key maps to one or more values;
113// boolean switches use a nil/empty slice.
114func Resolve(preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
115 files := DiscoverFiles()
116 return resolveFrom(files, preset, command, cliOverrides)
117}
118
119func resolveFrom(files []string, preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) {
120 raw, err := loadFiles(files)
121 if err != nil {
122 return nil, err
123 }
124
125 sections := buildSectionOrder(preset, command)
126 envSections := make([]string, len(sections))
127 for i, s := range sections {
128 envSections[i] = s + environSuffix
129 }
130
131 // Merge option sections in order.
132 merged := make(map[string]any)
133 var sectionsRead []string
134 for _, sect := range sections {
135 tbl, ok := lookupSection(raw, sect)
136 if !ok {
137 continue
138 }
139 // Skip sub-tables (nested commands / environ) — only merge leaf keys.
140 for k, v := range tbl {
141 if _, isMap := v.(map[string]any); isMap {
142 continue
143 }
144 merged[k] = v
145 }
146 sectionsRead = append(sectionsRead, sect)
147 }
148
149 // Merge environ sections.
150 environ := make(map[string]string)
151 for _, sect := range envSections {
152 tbl, ok := lookupSection(raw, sect)
153 if !ok {
154 continue
155 }
156 for k, v := range tbl {
157 environ[k] = ExpandPath(fmt.Sprint(v))
158 }
159 }
160
161 // Perform cross-section interpolation on merged values.
162 interpolate(merged, raw)
163
164 // Apply CLI overrides last.
165 for k, vals := range cliOverrides {
166 if len(vals) == 0 {
167 merged[k] = true
168 } else if len(vals) == 1 {
169 merged[k] = vals[0]
170 } else {
171 iface := make([]any, len(vals))
172 for i, v := range vals {
173 iface[i] = v
174 }
175 merged[k] = iface
176 }
177 }
178
179 return assemble(merged, command, environ, sectionsRead), nil
180}
181
182// loadFiles reads and merges all TOML config files. Later files override
183// earlier ones at the top-level section granularity.
184func loadFiles(files []string) (rawConfig, error) {
185 combined := make(rawConfig)
186 for _, f := range files {
187 data, err := os.ReadFile(f)
188 if err != nil {
189 if os.IsNotExist(err) {
190 continue
191 }
192 return nil, fmt.Errorf("reading config %s: %w", f, err)
193 }
194 var parsed rawConfig
195 if err := toml.Unmarshal(data, &parsed); err != nil {
196 return nil, fmt.Errorf("parsing config %s: %w", f, err)
197 }
198 // Merge top-level keys; later files win.
199 for k, v := range parsed {
200 if existing, ok := combined[k]; ok {
201 if eMap, eOk := existing.(map[string]any); eOk {
202 if vMap, vOk := v.(map[string]any); vOk {
203 for mk, mv := range vMap {
204 eMap[mk] = mv
205 }
206 continue
207 }
208 }
209 }
210 combined[k] = v
211 }
212 }
213 return combined, nil
214}
215
216// buildSectionOrder returns the list of config sections to read, in ascending
217// priority order, for the given preset and command.
218//
219// For a plain preset "foo" with command "backup":
220//
221// [global] -> [global.backup] -> [foo] -> [foo.backup]
222//
223// For a split preset "home@nas" with command "backup":
224//
225// [global] -> [global.backup] -> [@nas] -> [@nas.backup] ->
226// [home@] -> [home@.backup] -> [home@nas] -> [home@nas.backup]
227func buildSectionOrder(preset, command string) []string {
228 sections := []string{"global"}
229 if command != "" {
230 sections = append(sections, "global."+command)
231 }
232
233 if preset == "" {
234 return sections
235 }
236
237 if idx := strings.Index(preset, "@"); idx >= 0 {
238 // Split preset: "prefix@suffix"
239 // Parts in reverse order of the split, then the full preset.
240 parts := splitPreset(preset)
241 for _, part := range parts {
242 sections = append(sections, part)
243 if command != "" {
244 sections = append(sections, part+"."+command)
245 }
246 }
247 // Full preset (only if not already the sole part).
248 if len(parts) != 1 || parts[0] != preset {
249 sections = append(sections, preset)
250 if command != "" {
251 sections = append(sections, preset+"."+command)
252 }
253 }
254 } else {
255 sections = append(sections, preset)
256 if command != "" {
257 sections = append(sections, preset+"."+command)
258 }
259 }
260
261 return sections
262}
263
264// splitPreset splits "prefix@suffix" into ["@suffix", "prefix@"] — the two
265// halves that get their own section lookups before the full preset.
266func splitPreset(preset string) []string {
267 idx := strings.Index(preset, "@")
268 if idx < 0 {
269 return []string{preset}
270 }
271 suffix := preset[idx:] // "@nas"
272 prefix := preset[:idx+1] // "home@"
273
274 return []string{suffix, prefix}
275}
276
277// lookupSection finds a dotted section name in the raw config tree.
278// "global.backup" looks up raw["global"]["backup"].
279func lookupSection(raw rawConfig, section string) (map[string]any, bool) {
280 parts := strings.Split(section, ".")
281 var current any = (map[string]any)(raw)
282 for _, p := range parts {
283 m, ok := current.(map[string]any)
284 if !ok {
285 return nil, false
286 }
287 current, ok = m[p]
288 if !ok {
289 return nil, false
290 }
291 }
292 m, ok := current.(map[string]any)
293 if !ok {
294 return nil, false
295 }
296 return m, true
297}
298
299// interpolate resolves ${section.key} references in merged values.
300func interpolate(merged map[string]any, raw rawConfig) {
301 for k, v := range merged {
302 s, ok := v.(string)
303 if !ok {
304 continue
305 }
306 merged[k] = interpolateRe.ReplaceAllStringFunc(s, func(match string) string {
307 sub := interpolateRe.FindStringSubmatch(match)
308 if len(sub) != 3 {
309 return match
310 }
311 sect, key := sub[1], sub[2]
312 tbl, ok := lookupSection(raw, sect)
313 if !ok {
314 return match
315 }
316 val, ok := tbl[key]
317 if !ok {
318 return match
319 }
320 return fmt.Sprint(val)
321 })
322 }
323}
324
325// assemble converts the merged key-value map into a ResolvedConfig.
326func assemble(merged map[string]any, command string, environ map[string]string, sectionsRead []string) *ResolvedConfig {
327 rc := &ResolvedConfig{
328 Command: command,
329 Environ: environ,
330 SectionsRead: sectionsRead,
331 }
332
333 // Extract special keys.
334 if args, ok := merged[keyArguments]; ok {
335 rc.Arguments = toStringSlice(args)
336 delete(merged, keyArguments)
337 }
338 if wd, ok := merged[keyWorkdir]; ok {
339 rc.Workdir = ExpandPath(fmt.Sprint(wd))
340 delete(merged, keyWorkdir)
341 }
342 if cmd, ok := merged[keyCommand]; ok {
343 rc.Command = fmt.Sprint(cmd)
344 delete(merged, keyCommand)
345 }
346 if command == "backup" {
347 if hooks, ok := merged[keyPreHooks]; ok {
348 rc.PreHooks = toStringSlice(hooks)
349 }
350 if hooks, ok := merged[keyPostHooks]; ok {
351 rc.PostHooks = toStringSlice(hooks)
352 }
353 }
354 delete(merged, keyPreHooks)
355 delete(merged, keyPostHooks)
356
357 // Build flags.
358 for k, v := range merged {
359 switch val := v.(type) {
360 case bool:
361 if val {
362 rc.Flags = append(rc.Flags, Flag{Name: flagName(k)})
363 }
364 case []any:
365 for _, elem := range val {
366 rc.Flags = append(rc.Flags, Flag{
367 Name: flagName(k),
368 Value: fmt.Sprint(elem),
369 })
370 }
371 default:
372 s := fmt.Sprint(val)
373 // Multi-line string values (newline-separated) become repeated flags.
374 lines := strings.Split(s, "\n")
375 for _, line := range lines {
376 rc.Flags = append(rc.Flags, Flag{
377 Name: flagName(k),
378 Value: ExpandPath(line),
379 })
380 }
381 }
382 }
383
384 // Expand path references in arguments.
385 for i, a := range rc.Arguments {
386 rc.Arguments[i] = ExpandPath(a)
387 }
388
389 return rc
390}
391
392// keyAliases maps TOML config key names that don't match restic's actual CLI
393// flag names. For example, "repository" is a natural config key but restic's
394// flag is "--repo".
395var keyAliases = map[string]string{
396 "repository": "repo",
397}
398
399// flagName returns the CLI flag form of a key: single-char keys get "-k",
400// longer keys get "--key". Known aliases are resolved first.
401func flagName(key string) string {
402 if alias, ok := keyAliases[key]; ok {
403 key = alias
404 }
405 if len(key) == 1 {
406 return "-" + key
407 }
408 return "--" + key
409}
410
411// toStringSlice coerces a value (string or []any) into []string.
412func toStringSlice(v any) []string {
413 switch val := v.(type) {
414 case string:
415 return strings.Fields(val)
416 case []any:
417 out := make([]string, 0, len(val))
418 for _, elem := range val {
419 out = append(out, fmt.Sprint(elem))
420 }
421 return out
422 default:
423 return []string{fmt.Sprint(v)}
424 }
425}
426
427// IsDryRun reports whether the KELD_DRYRUN environment variable is set.
428func IsDryRun() bool {
429 return os.Getenv("KELD_DRYRUN") != ""
430}