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