command.go

v0.5.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
	return strings.Join(parts, " "), nil
57
}
58
59
// envAssignments renders env as sorted `KEY='value'` tokens. The value is
60
// single-quoted so spaces/metachars never re-split (IV2). The key is emitted
61
// bare (unquoted) before the `=`, so it MUST be a real shell identifier — a key
62
// with a space or metachar would itself re-split and break IV2; such keys are
63
// rejected rather than silently mangled. Sorting makes the output deterministic.
64
func envAssignments(env map[string]string) ([]string, error) {
65
	keys := make([]string, 0, len(env))
66
	for k := range env {
67
		keys = append(keys, k)
68
	}
69
	sort.Strings(keys)
70
	out := make([]string, 0, len(keys))
71
	for _, k := range keys {
72
		if !validEnvKey(k) {
73
			return nil, culpa.WithCode(
74
				culpa.WithHint(
75
					culpa.Errorf("transport: invalid env key %q", k),
76
					"env keys must match [A-Za-z_][A-Za-z0-9_]*"),
77
				"SSH_INVALID_ENV_KEY")
78
		}
79
		out = append(out, k+"="+shQuote(env[k]))
80
	}
81
	return out, nil
82
}
83
84
// validEnvKey reports whether k is a POSIX shell name: a leading letter or
85
// underscore followed by letters, digits, or underscores.
86
func validEnvKey(k string) bool {
87
	if k == "" {
88
		return false
89
	}
90
	for i, r := range k {
91
		switch {
92
		case r == '_':
93
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
94
		case r >= '0' && r <= '9' && i > 0:
95
		default:
96
			return false
97
		}
98
	}
99
	return true
100
}
101
102
// shQuote single-quotes s for POSIX sh/bash, escaping embedded single quotes via
103
// the '"'"' idiom. Inside single quotes nothing is special, so ANY byte sequence
104
// — spaces, $, `, ;, <, >, glob chars, newlines — is preserved verbatim and can
105
// never be re-split or re-evaluated by the remote shell (IV2).
106
func shQuote(s string) string {
107
	return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
108
}
109

Source Files