返回 DeepSeek-Reasonix
bash_env_test.go
根目录 / internal / tool / builtin / bash_env_test.go
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 ctx := sandbox.WithPermissionPreset(context.Background(), "danger-full-access")
48 out, err := b.Execute(ctx, args)
49 if err != nil {
50 t.Fatalf("command should resolve through merged login-shell PATH: %v (out=%q)", err, out)
51 }
52 if !strings.Contains(out, "shell-path-ok") {
53 t.Fatalf("output = %q, want shell-path-ok", out)
54 }
55 }
56
57 func TestBashCommandEnvFiltersSensitiveKeysWhenEnabled(t *testing.T) {
58 secrets.SetFilterSubprocessEnv(true)
59 t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) })
60 t.Setenv("DEEPSEEK_API_KEY", "sk-real-secret-value-123456")
61 t.Setenv("GH_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
62 t.Setenv("REASONIX_TEST_VISIBLE", "ok")
63 // PWD is the POSIX working-directory variable, not a password: the name
64 // filter must never strip it or every subprocess loses its cwd context.
65 t.Setenv("PWD", "/tmp/somewhere")
66
67 env := strings.Join(bashCommandEnv(context.Background()), "\n")
68 if strings.Contains(env, "DEEPSEEK_API_KEY") || strings.Contains(env, "GH_TOKEN") {
69 t.Fatalf("bash env leaked sensitive keys:\n%s", env)
70 }
71 if !strings.Contains(env, "REASONIX_TEST_VISIBLE=ok") {
72 t.Fatalf("bash env dropped non-sensitive key:\n%s", env)
73 }
74 if !strings.Contains(env, "PWD=/tmp/somewhere") {
75 t.Fatalf("bash env dropped PWD:\n%s", env)
76 }
77 }
78
79 func TestBashCommandEnvKeepsTokensByDefault(t *testing.T) {
80 t.Setenv("GH_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
81
82 env := strings.Join(bashCommandEnv(context.Background()), "\n")
83 if !strings.Contains(env, "GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz") {
84 t.Fatalf("bash env must inherit tokens while filter_subprocess_env is off (default):\n%s", env)
85 }
86 }
87
88 func TestParseShellPATH(t *testing.T) {
89 const marker = "__REASONIX_BASH_PATH__="
90 cases := []struct {
91 name string
92 out string
93 want string
94 }{
95 {"simple", marker + "/usr/local/bin:/usr/bin\n", "/usr/local/bin:/usr/bin"},
96 {"crlf", "noise\r\n" + marker + "/opt/bin:/bin\r\n", "/opt/bin:/bin"},
97 {"last marker wins", marker + "/early\n" + marker + "/late\n", "/late"},
98 {"ignores surrounding output", "login banner\n" + marker + "/p\ntrailing\n", "/p"},
99 {"absent", "no marker here\n", ""},
100 {"empty", "", ""},
101 }
102 for _, c := range cases {
103 t.Run(c.name, func(t *testing.T) {
104 if got := parseShellPATH([]byte(c.out), marker); got != c.want {
105 t.Fatalf("parseShellPATH(%q) = %q, want %q", c.out, got, c.want)
106 }
107 })
108 }
109 }
110
111 func TestMergePathLists(t *testing.T) {
112 sep := string(os.PathListSeparator)
113 cases := []struct {
114 name string
115 primary string
116 secondary string
117 want string
118 }{
119 {"dedupes, primary first", "/a" + sep + "/b", "/b" + sep + "/c", "/a" + sep + "/b" + sep + "/c"},
120 {"empty secondary", "/a" + sep + "/b", "", "/a" + sep + "/b"},
121 {"empty primary", "", "/x" + sep + "/y", "/x" + sep + "/y"},
122 {"skips blank entries", "/a" + sep + sep + "/b", "", "/a" + sep + "/b"},
123 }
124 for _, c := range cases {
125 t.Run(c.name, func(t *testing.T) {
126 if got := mergePathLists(c.primary, c.secondary); got != c.want {
127 t.Fatalf("mergePathLists(%q, %q) = %q, want %q", c.primary, c.secondary, got, c.want)
128 }
129 })
130 }
131 }
132
133 func TestRunShellPATHCommandFiltersEnvWhenEnabled(t *testing.T) {
134 if runtime.GOOS == "windows" {
135 t.Skip("POSIX shell probe")
136 }
137 secrets.SetFilterSubprocessEnv(true)
138 t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) })
139 t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
140
141 out := runShellPATHCommand(context.Background(), "/bin/sh", []string{"-c", `printf 'tok=%s' "${REASONIX_TEST_SECRET_TOKEN:-none}"`})
142 if !strings.Contains(string(out), "tok=none") {
143 t.Fatalf("login-shell PATH probe leaked filtered env: %q", out)
144 }
145 }
146
146 lines GO