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