exec.go

  1package restic
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"sort"
  9	"strings"
 10	"syscall"
 11
 12	"git.secluded.site/keld/internal/config"
 13)
 14
 15// commandSuffix is the suffix keld recognises in .environ keys to indicate
 16// that the value is a shell command whose stdout should be captured and used
 17// as the actual environment variable value (with the suffix stripped).
 18const commandSuffix = "_COMMAND"
 19
 20// resticNativeCommands lists .environ keys that restic handles natively.
 21// Keld passes these through as-is rather than executing them.
 22var resticNativeCommands = map[string]bool{
 23	"RESTIC_PASSWORD_COMMAND":      true,
 24	"RESTIC_FROM_PASSWORD_COMMAND": true,
 25}
 26
 27// DefaultExecutable is the restic binary name used when KELD_EXECUTABLE is
 28// unset.
 29const DefaultExecutable = "restic"
 30
 31// Run replaces the current process with restic, configured according to cfg.
 32func Run(cfg *config.ResolvedConfig) error {
 33	exe := executable()
 34
 35	path, err := exec.LookPath(exe)
 36	if err != nil {
 37		return fmt.Errorf("finding %s: %w", exe, err)
 38	}
 39
 40	if cfg.Workdir != "" {
 41		if err := os.Chdir(cfg.Workdir); err != nil {
 42			return fmt.Errorf("chdir %s: %w", cfg.Workdir, err)
 43		}
 44	}
 45
 46	if err := resolveEnvironCommands(cfg.Environ); err != nil {
 47		return err
 48	}
 49
 50	argv := buildArgv(exe, cfg)
 51	env := buildEnv(cfg.Environ)
 52
 53	return syscall.Exec(path, argv, env)
 54}
 55
 56// DryRun formats a human-readable summary of what Run would execute.
 57func DryRun(cfg *config.ResolvedConfig) string {
 58	var b strings.Builder
 59
 60	if len(cfg.SectionsRead) > 0 {
 61		fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → "))
 62	}
 63
 64	if cfg.Workdir != "" {
 65		fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir)
 66	}
 67
 68	if len(cfg.Environ) > 0 {
 69		fmt.Fprintln(&b, "environ:")
 70
 71		// Sort keys for deterministic output.
 72		keys := make([]string, 0, len(cfg.Environ))
 73		for k := range cfg.Environ {
 74			keys = append(keys, k)
 75		}
 76		sort.Strings(keys)
 77
 78		for _, k := range keys {
 79			fmt.Fprintf(&b, "  %s=%s\n", k, cfg.Environ[k])
 80		}
 81	}
 82
 83	argv := buildArgv(executable(), cfg)
 84	fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))
 85
 86	return b.String()
 87}
 88
 89// executable returns the restic binary name, respecting KELD_EXECUTABLE.
 90func executable() string {
 91	if e := os.Getenv("KELD_EXECUTABLE"); e != "" {
 92		return config.ExpandPath(e)
 93	}
 94	return DefaultExecutable
 95}
 96
 97// buildArgv assembles the full argument vector for the restic process.
 98func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
 99	argv := []string{exe}
100
101	if cfg.Command != "" {
102		argv = append(argv, cfg.Command)
103	}
104
105	for _, f := range cfg.Flags {
106		argv = append(argv, f.Name)
107		if f.Value != "" {
108			argv = append(argv, f.Value)
109		}
110	}
111
112	argv = append(argv, cfg.Arguments...)
113	return argv
114}
115
116// resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
117// executes their values as shell commands, and replaces them with the base key
118// set to the command's stdout (trailing newline stripped). Keys that restic
119// handles natively (like RESTIC_PASSWORD_COMMAND) are left untouched.
120//
121// If both FOO_COMMAND and FOO are present, the command result takes precedence
122// and FOO_COMMAND is removed from the map.
123func resolveEnvironCommands(environ map[string]string) error {
124	for key, cmd := range environ {
125		if !strings.HasSuffix(key, commandSuffix) {
126			continue
127		}
128		if resticNativeCommands[key] {
129			continue
130		}
131
132		baseKey := strings.TrimSuffix(key, commandSuffix)
133		if baseKey == "" {
134			continue
135		}
136
137		var stdout, stderr bytes.Buffer
138		proc := exec.Command("sh", "-c", cmd)
139		proc.Stdout = &stdout
140		proc.Stderr = &stderr
141
142		if err := proc.Run(); err != nil {
143			return fmt.Errorf(
144				"environ %s: running %q: %w\nstderr: %s",
145				key, cmd, err, strings.TrimSpace(stderr.String()),
146			)
147		}
148
149		environ[baseKey] = strings.TrimRight(stdout.String(), "\n")
150		delete(environ, key)
151	}
152	return nil
153}
154
155// buildEnv merges extra environment variables into the current process env.
156func buildEnv(extra map[string]string) []string {
157	env := os.Environ()
158	for k, v := range extra {
159		env = append(env, k+"="+v)
160	}
161	return env
162}
163
164// quotedJoin formats an argv slice by wrapping every element in double
165// quotes. This makes the dry-run output unambiguous — each argument
166// boundary is visible even when values contain spaces.
167func quotedJoin(argv []string) string {
168	var b strings.Builder
169	for i, arg := range argv {
170		if i > 0 {
171			b.WriteByte(' ')
172		}
173		b.WriteByte('"')
174		b.WriteString(arg)
175		b.WriteByte('"')
176	}
177	return b.String()
178}