| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/sandbox" |
| 13 | "reasonix/internal/secrets" |
| 14 | ) |
| 15 | |
| 16 | func TestBashMergesLoginShellPath(t *testing.T) { |
| 17 | if runtime.GOOS == "windows" { |
| 18 | t.Skip("login shell PATH probing is POSIX-only") |
| 19 | } |
| 20 | |
| 21 | dir := t.TempDir() |
| 22 | bin := filepath.Join(dir, "bin") |
| 23 | if err := os.Mkdir(bin, 0o755); err != nil { |
| 24 | t.Fatalf("mkdir bin: %v", err) |
| 25 | } |
| 26 | probe := filepath.Join(bin, "reasonix-path-probe") |
| 27 | if err := os.WriteFile(probe, []byte("#!/bin/sh\nprintf 'shell-path-ok\\n'\n"), 0o755); err != nil { |
| 28 | t.Fatalf("write probe: %v", err) |
| 29 | } |
| 30 | |
| 31 | // Inject a deterministic login-shell PATH instead of spawning a real login |
| 32 | // shell. The real probe (defaultBashShellPATH) runs up to three |
| 33 | // interactive-login shells with a 2s timeout each; under the CPU load of |
| 34 | // `go test ./...` it times out and returns an empty PATH, so this test failed |
| 35 | // with command-not-found only in the full suite, never in isolation. This |
| 36 | // test covers merging the probed PATH into the exec environment; the probe's |
| 37 | // own parsing/merging is covered by TestParseShellPATH and TestMergePathLists. |
| 38 | prev := bashShellPATH |
| 39 | bashShellPATH = func(context.Context) string { return bin + ":/usr/bin:/bin" } |
| 40 | t.Cleanup(func() { bashShellPATH = prev }) |
| 41 | |
| 42 | t.Setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") |
| 43 | |
| 44 | b := bash{shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "/bin/sh"}} |
| 45 | args, _ := json.Marshal(map[string]string{"command": "reasonix-path-probe"}) |
| 46 | |
| 47 | out, err := b.Execute(context.Background(), args) |
| 48 | if err != nil { |
| 49 | t.Fatalf("command should resolve through merged login-shell PATH: %v (out=%q)", err, out) |
| 50 | } |
| 51 | if !strings.Contains(out, "shell-path-ok") { |
| 52 | t.Fatalf("output = %q, want shell-path-ok", out) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestBashCommandEnvFiltersSensitiveKeysWhenEnabled(t *testing.T) { |
| 57 | secrets.SetFilterSubprocessEnv(true) |
| 58 | t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) }) |
| 59 | t.Setenv("DEEPSEEK_API_KEY", "sk-real-secret-value-123456") |
| 60 | t.Setenv("GH_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz") |
| 61 | t.Setenv("REASONIX_TEST_VISIBLE", "ok") |
| 62 | // PWD is the POSIX working-directory variable, not a password: the name |
| 63 | // filter must never strip it or every subprocess loses its cwd context. |
| 64 | t.Setenv("PWD", "/tmp/somewhere") |
| 65 | |
| 66 | env := strings.Join(bashCommandEnv(context.Background()), "\n") |
| 67 | if strings.Contains(env, "DEEPSEEK_API_KEY") || strings.Contains(env, "GH_TOKEN") { |
| 68 | t.Fatalf("bash env leaked sensitive keys:\n%s", env) |
| 69 | } |
| 70 | if !strings.Contains(env, "REASONIX_TEST_VISIBLE=ok") { |
| 71 | t.Fatalf("bash env dropped non-sensitive key:\n%s", env) |
| 72 | } |
| 73 | if !strings.Contains(env, "PWD=/tmp/somewhere") { |
| 74 | t.Fatalf("bash env dropped PWD:\n%s", env) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestBashCommandEnvKeepsTokensByDefault(t *testing.T) { |
| 79 | t.Setenv("GH_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz") |
| 80 | |
| 81 | env := strings.Join(bashCommandEnv(context.Background()), "\n") |
| 82 | if !strings.Contains(env, "GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz") { |
| 83 | t.Fatalf("bash env must inherit tokens while filter_subprocess_env is off (default):\n%s", env) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestParseShellPATH(t *testing.T) { |
| 88 | const marker = "__REASONIX_BASH_PATH__=" |
| 89 | cases := []struct { |
| 90 | name string |
| 91 | out string |
| 92 | want string |
| 93 | }{ |
| 94 | {"simple", marker + "/usr/local/bin:/usr/bin\n", "/usr/local/bin:/usr/bin"}, |
| 95 | {"crlf", "noise\r\n" + marker + "/opt/bin:/bin\r\n", "/opt/bin:/bin"}, |
| 96 | {"last marker wins", marker + "/early\n" + marker + "/late\n", "/late"}, |
| 97 | {"ignores surrounding output", "login banner\n" + marker + "/p\ntrailing\n", "/p"}, |
| 98 | {"absent", "no marker here\n", ""}, |
| 99 | {"empty", "", ""}, |
| 100 | } |
| 101 | for _, c := range cases { |
| 102 | t.Run(c.name, func(t *testing.T) { |
| 103 | if got := parseShellPATH([]byte(c.out), marker); got != c.want { |
| 104 | t.Fatalf("parseShellPATH(%q) = %q, want %q", c.out, got, c.want) |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestMergePathLists(t *testing.T) { |
| 111 | sep := string(os.PathListSeparator) |
| 112 | cases := []struct { |
| 113 | name string |
| 114 | primary string |
| 115 | secondary string |
| 116 | want string |
| 117 | }{ |
| 118 | {"dedupes, primary first", "/a" + sep + "/b", "/b" + sep + "/c", "/a" + sep + "/b" + sep + "/c"}, |
| 119 | {"empty secondary", "/a" + sep + "/b", "", "/a" + sep + "/b"}, |
| 120 | {"empty primary", "", "/x" + sep + "/y", "/x" + sep + "/y"}, |
| 121 | {"skips blank entries", "/a" + sep + sep + "/b", "", "/a" + sep + "/b"}, |
| 122 | } |
| 123 | for _, c := range cases { |
| 124 | t.Run(c.name, func(t *testing.T) { |
| 125 | if got := mergePathLists(c.primary, c.secondary); got != c.want { |
| 126 | t.Fatalf("mergePathLists(%q, %q) = %q, want %q", c.primary, c.secondary, got, c.want) |
| 127 | } |
| 128 | }) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestRunShellPATHCommandFiltersEnvWhenEnabled(t *testing.T) { |
| 133 | if runtime.GOOS == "windows" { |
| 134 | t.Skip("POSIX shell probe") |
| 135 | } |
| 136 | secrets.SetFilterSubprocessEnv(true) |
| 137 | t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) }) |
| 138 | t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz") |
| 139 | |
| 140 | out := runShellPATHCommand(context.Background(), "/bin/sh", []string{"-c", `printf 'tok=%s' "${REASONIX_TEST_SECRET_TOKEN:-none}"`}) |
| 141 | if !strings.Contains(string(out), "tok=none") { |
| 142 | t.Fatalf("login-shell PATH probe leaked filtered env: %q", out) |
| 143 | } |
| 144 | } |
| 145 |