| 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 | |