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