config.go

  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}