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}