command.go

v0.6.0
Doc Versions Source
1
package transport
2
3
import (
4
	"sort"
5
	"strings"
6
7
	"go.bigb.es/auxilia/culpa"
8
	"go.bigb.es/auxilia/legatus"
9
)
10
11
// command.go is the pure, network-free core of remote execution: it turns a
12
// structured legatus.Cmd into the exact single string the SSH exec channel
13
// carries. It is the heart of IV2 — every argument and env value is individually
14
// single-quoted so the remote login shell, which DOES parse the exec string,
15
// can never re-split or re-evaluate caller data. The only unquoted tokens it
16
// emits are fixed literals chosen here: "sudo", "--", "env", and the "=" that
17
// joins KEY=value.
18
19
// buildRemote renders the remote exec string for cmd, with env exported in
20
// front and sudo-wrapping applied when cmd.Sudo is set (IV6).
21
//
22
// Layout:
23
//   - root, no env:   'path' 'arg'...
24
//   - root, env:      KEY='val' ... 'path' 'arg'...        (shell assignment)
25
//   - sudo, no env:   sudo -- 'path' 'arg'...              (-- stops sudo flags)
26
//   - sudo, env:      sudo -- env KEY='val' ... 'path' ... (env(1) re-exports)
27
//
28
// sudo strips the environment, so a leading shell `KEY=val` prefix would not
29
// survive the privilege transition; for the sudo+env case env(1) applies the
30
// assignments to the command as root.
31
func buildRemote(cmd legatus.Cmd) (string, error) {
32
	var parts []string
33
34
	switch {
35
	case cmd.Sudo && len(cmd.Env) > 0:
36
		assigns, err := envAssignments(cmd.Env)
37
		if err != nil {
38
			return "", err
39
		}
40
		parts = append(parts, "sudo", "--", "env")
41
		parts = append(parts, assigns...)
42
	case cmd.Sudo:
43
		parts = append(parts, "sudo", "--")
44
	case len(cmd.Env) > 0:
45
		assigns, err := envAssignments(cmd.Env)
46
		if err != nil {
47
			return "", err
48
		}
49
		parts = append(parts, assigns...)
50
	}
51
52
	parts = append(parts, shQuote(cmd.Path))
53
	for _, a := range cmd.Args {
54
		parts = append(parts, shQuote(a))
55
	}
56
57
	// A non-empty Dir prefixes a `cd <quoted> && ` so the command runs there. The
58
	// prefix sits OUTSIDE any sudo/env wrapping already in parts, so `cd` runs as
59
	// the connecting user (IV4); the directory is shQuote'd like every arg (IV2);
60
	// a missing dir makes `cd` exit non-zero and `&&` short-circuits, so the
61
	// command never runs in the wrong place (IV5).
62
	joined := strings.Join(parts, " ")
63
	if cmd.Dir != "" {
64
		joined = "cd " + shQuote(cmd.Dir) + " && " + joined
65
	}
66
	return joined, nil
67
}
68
69
// envAssignments renders env as sorted `KEY='value'` tokens. The value is
70
// single-quoted so spaces/metachars never re-split (IV2). The key is emitted
71
// bare (unquoted) before the `=`, so it MUST be a real shell identifier — a key
72
// with a space or metachar would itself re-split and break IV2; such keys are
73
// rejected rather than silently mangled. Sorting makes the output deterministic.
74
func envAssignments(env map[string]string) ([]string, error) {
75
	keys := make([]string, 0, len(env))
76
	for k := range env {
77
		keys = append(keys, k)
78
	}
79
	sort.Strings(keys)
80
	out := make([]string, 0, len(keys))
81
	for _, k := range keys {
82
		if !validEnvKey(k) {
83
			return nil, culpa.WithCode(
84
				culpa.WithHint(
85
					culpa.Errorf("transport: invalid env key %q", k),
86
					"env keys must match [A-Za-z_][A-Za-z0-9_]*"),
87
				"SSH_INVALID_ENV_KEY")
88
		}
89
		out = append(out, k+"="+shQuote(env[k]))
90
	}
91
	return out, nil
92
}
93
94
// validEnvKey reports whether k is a POSIX shell name: a leading letter or
95
// underscore followed by letters, digits, or underscores.
96
func validEnvKey(k string) bool {
97
	if k == "" {
98
		return false
99
	}
100
	for i, r := range k {
101
		switch {
102
		case r == '_':
103
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
104
		case r >= '0' && r <= '9' && i > 0:
105
		default:
106
			return false
107
		}
108
	}
109
	return true
110
}
111
112
// shQuote single-quotes s for POSIX sh/bash, escaping embedded single quotes via
113
// the '"'"' idiom. Inside single quotes nothing is special, so ANY byte sequence
114
// — spaces, $, `, ;, <, >, glob chars, newlines — is preserved verbatim and can
115
// never be re-split or re-evaluated by the remote shell (IV2).
116
func shQuote(s string) string {
117
	return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
118
}
119

Source Files