| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/persistentshell" |
| 14 | "reasonix/internal/sandbox" |
| 15 | ) |
| 16 | |
| 17 | func TestPowerShellNeverUsesPersistentSession(t *testing.T) { |
| 18 | b := bash{persistent: persistentshell.New()} |
| 19 | sh := sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"} |
| 20 | if b.shouldUsePersistent(context.Background(), bashParams{}, sh) { |
| 21 | t.Fatal("ordinary PowerShell calls must use one-shot processes") |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | func persistentBash(t *testing.T, workDir string) bash { |
| 26 | t.Helper() |
| 27 | m := persistentshell.New() |
| 28 | m.Retain() |
| 29 | t.Cleanup(m.Release) |
| 30 | sh := sandbox.ResolveShell("", "", nil) |
| 31 | return bash{ |
| 32 | sb: sandbox.Spec{Mode: "off"}, |
| 33 | shell: sh, |
| 34 | workDir: workDir, |
| 35 | timeout: 8 * time.Second, |
| 36 | persistent: m, |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestBashPersistentKeepsWorkingDirectory(t *testing.T) { |
| 41 | if runtime.GOOS == "windows" { |
| 42 | t.Skip("POSIX persistent bash") |
| 43 | } |
| 44 | dir := t.TempDir() |
| 45 | sub := filepath.Join(dir, "sub") |
| 46 | if err := os.Mkdir(sub, 0o755); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | b := persistentBash(t, dir) |
| 50 | ctx := fullAccessBashTestContext(t.Context()) |
| 51 | if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil { |
| 52 | t.Fatalf("cd: %v", err) |
| 53 | } |
| 54 | out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "pwd"})) |
| 55 | if err != nil { |
| 56 | t.Fatalf("pwd: %v (%q)", err, out) |
| 57 | } |
| 58 | want, _ := filepath.EvalSymlinks(sub) |
| 59 | got := strings.TrimSpace(out) |
| 60 | if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil { |
| 61 | got = resolved |
| 62 | } |
| 63 | if got != want && !strings.Contains(out, "sub") { |
| 64 | t.Fatalf("pwd=%q want %q", out, want) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestBashWithoutPersistentDoesNotKeepCwd(t *testing.T) { |
| 69 | if runtime.GOOS == "windows" { |
| 70 | t.Skip("POSIX one-shot bash") |
| 71 | } |
| 72 | dir := t.TempDir() |
| 73 | sub := filepath.Join(dir, "sub") |
| 74 | if err := os.Mkdir(sub, 0o755); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | b := bash{ |
| 78 | sb: sandbox.Spec{Mode: "off"}, |
| 79 | shell: sandbox.ResolveShell("", "", nil), |
| 80 | workDir: dir, |
| 81 | timeout: 8 * time.Second, |
| 82 | } |
| 83 | ctx := fullAccessBashTestContext(t.Context()) |
| 84 | if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil { |
| 85 | t.Fatalf("cd: %v", err) |
| 86 | } |
| 87 | out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "pwd"})) |
| 88 | if err != nil { |
| 89 | t.Fatalf("pwd: %v (%q)", err, out) |
| 90 | } |
| 91 | got := strings.TrimSpace(out) |
| 92 | if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil { |
| 93 | got = resolved |
| 94 | } |
| 95 | want, _ := filepath.EvalSymlinks(dir) |
| 96 | if got != want { |
| 97 | t.Fatalf("one-shot pwd=%q want workspace %q", out, want) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestBashPersistentSchemaUnchanged(t *testing.T) { |
| 102 | plain := bash{}.Schema() |
| 103 | withPTY := bash{persistent: persistentshell.New()}.Schema() |
| 104 | if string(plain) != string(withPTY) { |
| 105 | t.Fatalf("persistent PTY must not change bash schema\nplain=%s\nwith=%s", plain, withPTY) |
| 106 | } |
| 107 | if (bash{}).Description() != (bash{persistent: persistentshell.New()}).Description() { |
| 108 | t.Fatal("persistent PTY must not change bash description") |
| 109 | } |
| 110 | var schema map[string]any |
| 111 | if err := json.Unmarshal(plain, &schema); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestBashPersistentUnicodeWithoutUTF8Locale(t *testing.T) { |
| 117 | if runtime.GOOS == "windows" { |
| 118 | t.Skip("POSIX persistent bash") |
| 119 | } |
| 120 | t.Setenv("LC_ALL", "C") |
| 121 | t.Setenv("INPUTRC", "/dev/null") |
| 122 | b := persistentBash(t, t.TempDir()) |
| 123 | b.shell = sandbox.ResolveShell("bash", "", nil) |
| 124 | ctx := fullAccessBashTestContext(t.Context()) |
| 125 | for _, command := range []string{ |
| 126 | "export RX_UNICODE='中文😀'; printf '%s' \"$RX_UNICODE\"", |
| 127 | "printf '%s' \"$RX_UNICODE\"", |
| 128 | } { |
| 129 | out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": command})) |
| 130 | if err != nil || strings.TrimSpace(out) != "中文😀" { |
| 131 | t.Fatalf("Unicode command: output=%q err=%v", out, err) |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | func TestBashPersistentSkipsBackgroundAndWriteEscalation(t *testing.T) { |
| 137 | if runtime.GOOS == "windows" { |
| 138 | t.Skip("POSIX persistent bash") |
| 139 | } |
| 140 | dir := t.TempDir() |
| 141 | sub := filepath.Join(dir, "sub") |
| 142 | extra := t.TempDir() |
| 143 | if err := os.Mkdir(sub, 0o755); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | b := persistentBash(t, dir) |
| 147 | ctx := fullAccessBashTestContext(t.Context()) |
| 148 | if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil { |
| 149 | t.Fatalf("cd: %v", err) |
| 150 | } |
| 151 | out, err := b.Execute(ctx, argsJSON(t, map[string]any{ |
| 152 | "command": "pwd", |
| 153 | "additional_write_dirs": []string{extra}, |
| 154 | "justification": "test one-shot fallback", |
| 155 | })) |
| 156 | if err != nil { |
| 157 | t.Fatalf("escalated pwd: %v (%q)", err, out) |
| 158 | } |
| 159 | got := strings.TrimSpace(out) |
| 160 | if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil { |
| 161 | got = resolved |
| 162 | } |
| 163 | wantSub, _ := filepath.EvalSymlinks(sub) |
| 164 | if got == wantSub { |
| 165 | t.Fatalf("additional_write_dirs must not reuse persistent cwd, got %q", out) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // A foreground command that backgrounds a child stays on the one-shot path: |
| 170 | // only there does #3702's process-group reap run, and only there can the |
| 171 | // child's later output not land inside the next command's result. |
| 172 | func TestBashPersistentSkipsBackgroundOperator(t *testing.T) { |
| 173 | if runtime.GOOS == "windows" { |
| 174 | t.Skip("POSIX persistent bash") |
| 175 | } |
| 176 | dir := t.TempDir() |
| 177 | sub := filepath.Join(dir, "sub") |
| 178 | if err := os.Mkdir(sub, 0o755); err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | b := persistentBash(t, dir) |
| 182 | ctx := fullAccessBashTestContext(t.Context()) |
| 183 | if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil { |
| 184 | t.Fatalf("cd: %v", err) |
| 185 | } |
| 186 | // A backgrounding command must not observe the persistent cwd. |
| 187 | out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "(sleep 0) & wait; pwd"})) |
| 188 | if err != nil { |
| 189 | t.Fatalf("background pwd: %v (%q)", err, out) |
| 190 | } |
| 191 | got := strings.TrimSpace(out) |
| 192 | if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil { |
| 193 | got = resolved |
| 194 | } |
| 195 | wantSub, _ := filepath.EvalSymlinks(sub) |
| 196 | if got == wantSub { |
| 197 | t.Fatalf("a backgrounding command must run one-shot, got %q", out) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestHasBackgroundStatement(t *testing.T) { |
| 202 | cases := map[string]bool{ |
| 203 | "sleep 1 &": true, |
| 204 | "npm run dev &": true, |
| 205 | "(while true; do :; done) &": true, |
| 206 | "sleep 1": false, |
| 207 | "echo 'a & b'": false, |
| 208 | "grep -n 'x && y' file": false, |
| 209 | "a && b": false, |
| 210 | } |
| 211 | for command, want := range cases { |
| 212 | if got := hasBackgroundStatement(command); got != want { |
| 213 | t.Fatalf("hasBackgroundStatement(%q)=%v want %v", command, got, want) |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // The persistent launch must carry the session-private temporary directory into |
| 219 | // the sandbox profile, not only into the child environment: the same spec sets |
| 220 | // TMPDIR/GOCACHE, so a profile without that directory denies every write |
| 221 | // through them. |
| 222 | func TestBashPersistentLaunchCarriesSessionTemp(t *testing.T) { |
| 223 | sessionTemp := t.TempDir() |
| 224 | spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{t.TempDir()}} |
| 225 | launch := sandbox.PrepareArgs(spec, persistentshell.InteractiveArgv(sandbox.ResolveShell("", "", nil)), sessionTemp) |
| 226 | if launch.SessionTemp != sessionTemp { |
| 227 | t.Fatalf("session temp %q not carried into the launch", launch.SessionTemp) |
| 228 | } |
| 229 | if len(launch.EnvOverrides) == 0 { |
| 230 | t.Fatal("session temp env overrides missing") |
| 231 | } |
| 232 | if !launch.Wrapped { |
| 233 | t.Skip("no OS sandbox backend on this host") |
| 234 | } |
| 235 | if !strings.Contains(strings.Join(launch.Argv, " "), sessionTemp) { |
| 236 | t.Fatalf("sandbox argv does not reference the session temp dir: %v", launch.Argv) |
| 237 | } |
| 238 | } |
| 239 |