| 1 | package environment |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/secrets" |
| 15 | ) |
| 16 | |
| 17 | func TestFormatSectionSortsAndRedacts(t *testing.T) { |
| 18 | home, err := os.UserHomeDir() |
| 19 | if err != nil { |
| 20 | t.Fatalf("home: %v", err) |
| 21 | } |
| 22 | section := FormatSection([]ProbeResult{ |
| 23 | {Binary: "python3", Found: true, Output: "Python 3.12.0"}, |
| 24 | {Binary: "go", Found: true, Output: "go version go1.24 darwin/arm64"}, |
| 25 | {Binary: "docker", Error: "not found"}, |
| 26 | }, "darwin/arm64", filepath.Join(home, "bin", "bash"), map[string]string{ |
| 27 | "python3": filepath.Join(home, ".pyenv", "shims", "python3"), |
| 28 | "go": "/opt/homebrew/bin/go", |
| 29 | }) |
| 30 | |
| 31 | for _, want := range []string{ |
| 32 | "## Environment", |
| 33 | "- OS: darwin/arm64", |
| 34 | "- Shell: ~/bin/bash", |
| 35 | "Configured tools:\n- go: /opt/homebrew/bin/go\n- python3: ~/.pyenv/shims/python3", |
| 36 | "Detected tools:\n- go: go version go1.24 darwin/arm64\n- python3: Python 3.12.0", |
| 37 | "Not found or unavailable:\n- docker: not found", |
| 38 | } { |
| 39 | if !strings.Contains(section, want) { |
| 40 | t.Fatalf("section missing %q:\n%s", want, section) |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestRunProbesReportsMissingCommand(t *testing.T) { |
| 46 | results := RunProbes(context.Background(), []string{"__reasonix_missing_probe__ --version"}) |
| 47 | if len(results) != 1 { |
| 48 | t.Fatalf("results len = %d, want 1", len(results)) |
| 49 | } |
| 50 | if results[0].Found { |
| 51 | t.Fatalf("missing command marked found: %+v", results[0]) |
| 52 | } |
| 53 | if results[0].Error != "not found" { |
| 54 | t.Fatalf("Error = %q, want not found", results[0].Error) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func TestRunProbesUsesOverridePathAndFirstLine(t *testing.T) { |
| 59 | dir := t.TempDir() |
| 60 | toolPath := filepath.Join(dir, "mytool") |
| 61 | toolPath = writeProbeTool(t, toolPath, "custom version\nignored") |
| 62 | |
| 63 | results := RunProbesWithOverrides(context.Background(), []string{"mytool --version"}, map[string]string{"mytool": toolPath}) |
| 64 | if len(results) != 1 { |
| 65 | t.Fatalf("results len = %d, want 1", len(results)) |
| 66 | } |
| 67 | if !results[0].Found { |
| 68 | t.Fatalf("override command not found: %+v", results[0]) |
| 69 | } |
| 70 | if results[0].Output != "custom version" { |
| 71 | t.Fatalf("Output = %q, want first line", results[0].Output) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestRunProbesParsesQuotedStaticArgs(t *testing.T) { |
| 76 | resetProbeCacheForTest(t, time.Unix(25, 0)) |
| 77 | dir := t.TempDir() |
| 78 | toolPath := filepath.Join(dir, "quotedtool") |
| 79 | toolPath = writeProbeTool(t, toolPath, "quoted version") |
| 80 | |
| 81 | results := RunProbesWithOverrides(context.Background(), []string{`quotedtool "--version with spaces"`}, map[string]string{"quotedtool": toolPath}) |
| 82 | if len(results) != 1 { |
| 83 | t.Fatalf("results len = %d, want 1", len(results)) |
| 84 | } |
| 85 | if !results[0].Found || results[0].Output != "quoted version" { |
| 86 | t.Fatalf("quoted probe result = %+v", results[0]) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestRunProbesAllowsStaticEnvAssignment(t *testing.T) { |
| 91 | resetProbeCacheForTest(t, time.Unix(35, 0)) |
| 92 | dir := t.TempDir() |
| 93 | toolPath := filepath.Join(dir, "envtool") |
| 94 | toolPath = writeEnvProbeTool(t, toolPath) |
| 95 | |
| 96 | results := RunProbesWithOverrides(context.Background(), []string{`REASONIX_PROBE_ENV=ok envtool --version`}, map[string]string{"envtool": toolPath}) |
| 97 | if len(results) != 1 { |
| 98 | t.Fatalf("results len = %d, want 1", len(results)) |
| 99 | } |
| 100 | if !results[0].Found || results[0].Output != "ok" { |
| 101 | t.Fatalf("env probe result = %+v", results[0]) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestRunProbesAllowsStaticStderrMerge(t *testing.T) { |
| 106 | resetProbeCacheForTest(t, time.Unix(40, 0)) |
| 107 | dir := t.TempDir() |
| 108 | toolPath := filepath.Join(dir, "stderrtool") |
| 109 | toolPath = writeStderrProbeTool(t, toolPath, "stderr version") |
| 110 | |
| 111 | results := RunProbesWithOverrides(context.Background(), []string{`stderrtool --version 2>&1`}, map[string]string{"stderrtool": toolPath}) |
| 112 | if len(results) != 1 { |
| 113 | t.Fatalf("results len = %d, want 1", len(results)) |
| 114 | } |
| 115 | if !results[0].Found || results[0].Output != "stderr version" { |
| 116 | t.Fatalf("stderr merge probe result = %+v", results[0]) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestRunProbesRejectsDeniedOverridePath(t *testing.T) { |
| 121 | resetProbeCacheForTest(t, time.Unix(50, 0)) |
| 122 | dir := t.TempDir() |
| 123 | toolPath := filepath.Join(dir, "deniedtool") |
| 124 | toolPath = writeProbeTool(t, toolPath, "should not run") |
| 125 | |
| 126 | results := RunProbesWithOptions(context.Background(), []string{"deniedtool --version"}, ProbeOptions{ |
| 127 | Overrides: map[string]string{"deniedtool": toolPath}, |
| 128 | DenyRoots: []string{dir}, |
| 129 | }) |
| 130 | if len(results) != 1 { |
| 131 | t.Fatalf("results len = %d, want 1", len(results)) |
| 132 | } |
| 133 | if results[0].Found || results[0].Error != "not trusted" { |
| 134 | t.Fatalf("denied override result = %+v, want not trusted", results[0]) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestRunProbesRejectsDeniedPathHit(t *testing.T) { |
| 139 | resetProbeCacheForTest(t, time.Unix(75, 0)) |
| 140 | dir := t.TempDir() |
| 141 | writeProbeTool(t, filepath.Join(dir, "pathtool"), "should not run") |
| 142 | t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) |
| 143 | |
| 144 | results := RunProbesWithOptions(context.Background(), []string{"pathtool --version"}, ProbeOptions{ |
| 145 | DenyRoots: []string{dir}, |
| 146 | }) |
| 147 | if len(results) != 1 { |
| 148 | t.Fatalf("results len = %d, want 1", len(results)) |
| 149 | } |
| 150 | if results[0].Found || results[0].Error != "not trusted" { |
| 151 | t.Fatalf("denied PATH result = %+v, want not trusted", results[0]) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func TestRunProbesReportsTimeout(t *testing.T) { |
| 156 | setProbeTimeoutForTest(t, 200*time.Millisecond) |
| 157 | dir := t.TempDir() |
| 158 | toolPath := filepath.Join(dir, "slowtool") |
| 159 | body := "#!/bin/sh\nsleep 3\n" |
| 160 | if runtime.GOOS == "windows" { |
| 161 | toolPath += ".bat" |
| 162 | body = "@ping 127.0.0.1 -n 4 > nul\r\n" |
| 163 | } |
| 164 | if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil { |
| 165 | t.Fatalf("write tool: %v", err) |
| 166 | } |
| 167 | |
| 168 | results := RunProbesWithOverrides(context.Background(), []string{"slowtool --version"}, map[string]string{"slowtool": toolPath}) |
| 169 | if len(results) != 1 { |
| 170 | t.Fatalf("results len = %d, want 1", len(results)) |
| 171 | } |
| 172 | if results[0].Found { |
| 173 | t.Fatalf("timeout command marked found: %+v", results[0]) |
| 174 | } |
| 175 | if results[0].Error != "timeout" { |
| 176 | t.Fatalf("Error = %q, want timeout", results[0].Error) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func TestPrepareProbeCommandSetsCancellationBudget(t *testing.T) { |
| 181 | cmd := exec.Command("reasonix-test-probe") |
| 182 | prepareProbeCommand(cmd) |
| 183 | if cmd.Cancel == nil { |
| 184 | t.Fatal("probe command must install a cancellation hook") |
| 185 | } |
| 186 | if cmd.WaitDelay != probeWaitDelay { |
| 187 | t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, probeWaitDelay) |
| 188 | } |
| 189 | if runtime.GOOS == "windows" && cmd.SysProcAttr == nil { |
| 190 | t.Fatal("probe command must hide console windows on Windows") |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestRunProbesCachesByFingerprint(t *testing.T) { |
| 195 | resetProbeCacheForTest(t, time.Unix(100, 0)) |
| 196 | dir := t.TempDir() |
| 197 | toolPath := filepath.Join(dir, "cachedtool") |
| 198 | toolPath = writeProbeTool(t, toolPath, "version one") |
| 199 | |
| 200 | results := RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath}) |
| 201 | if got := results[0].Output; got != "version one" { |
| 202 | t.Fatalf("first Output = %q, want version one", got) |
| 203 | } |
| 204 | results[0].Output = "mutated" |
| 205 | toolPath = writeProbeTool(t, toolPath, "version two") |
| 206 | |
| 207 | results = RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath}) |
| 208 | if got := results[0].Output; got != "version one" { |
| 209 | t.Fatalf("cached Output = %q, want version one", got) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestRunProbesCacheExpires(t *testing.T) { |
| 214 | now := time.Unix(200, 0) |
| 215 | resetProbeCacheForTest(t, now) |
| 216 | dir := t.TempDir() |
| 217 | toolPath := filepath.Join(dir, "expiringtool") |
| 218 | toolPath = writeProbeTool(t, toolPath, "version one") |
| 219 | |
| 220 | results := RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath}) |
| 221 | if got := results[0].Output; got != "version one" { |
| 222 | t.Fatalf("first Output = %q, want version one", got) |
| 223 | } |
| 224 | toolPath = writeProbeTool(t, toolPath, "version two") |
| 225 | setProbeNowForTest(now.Add(probeCacheTTL + time.Second)) |
| 226 | |
| 227 | results = RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath}) |
| 228 | if got := results[0].Output; got != "version two" { |
| 229 | t.Fatalf("expired Output = %q, want version two", got) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestRunProbesCacheSeparatesOverrides(t *testing.T) { |
| 234 | resetProbeCacheForTest(t, time.Unix(300, 0)) |
| 235 | dir := t.TempDir() |
| 236 | toolOne := filepath.Join(dir, "override-one") |
| 237 | toolTwo := filepath.Join(dir, "override-two") |
| 238 | toolOne = writeProbeTool(t, toolOne, "version one") |
| 239 | toolTwo = writeProbeTool(t, toolTwo, "version two") |
| 240 | |
| 241 | results := RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolOne}) |
| 242 | if got := results[0].Output; got != "version one" { |
| 243 | t.Fatalf("first override Output = %q, want version one", got) |
| 244 | } |
| 245 | results = RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolTwo}) |
| 246 | if got := results[0].Output; got != "version two" { |
| 247 | t.Fatalf("second override Output = %q, want version two", got) |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | func TestFormatSectionLimitsToolOutput(t *testing.T) { |
| 252 | overrides := map[string]string{} |
| 253 | var results []ProbeResult |
| 254 | for i := 0; i < maxRenderedTools+2; i++ { |
| 255 | name := fmt.Sprintf("tool%02d", i) |
| 256 | overrides[name] = "/bin/" + name |
| 257 | results = append(results, ProbeResult{Binary: name, Found: true, Output: "ok"}) |
| 258 | results = append(results, ProbeResult{Binary: "missing" + name, Error: "not found"}) |
| 259 | } |
| 260 | |
| 261 | section := FormatSection(results, "test/os", "", overrides) |
| 262 | for _, want := range []string{ |
| 263 | "- ... 2 more configured tools omitted", |
| 264 | "- ... 2 more detected tools omitted", |
| 265 | "- ... 2 more unavailable tools omitted", |
| 266 | } { |
| 267 | if !strings.Contains(section, want) { |
| 268 | t.Fatalf("section missing %q:\n%s", want, section) |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | func writeProbeTool(t *testing.T, path, output string) string { |
| 274 | t.Helper() |
| 275 | setProbeTimeoutForTest(t, 10*time.Second) |
| 276 | body := "#!/bin/sh\nprintf '%s\\n'\n" |
| 277 | body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''")) |
| 278 | if runtime.GOOS == "windows" { |
| 279 | if !strings.HasSuffix(path, ".bat") { |
| 280 | path += ".bat" |
| 281 | } |
| 282 | body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + "\r\n" |
| 283 | } |
| 284 | if err := os.WriteFile(path, []byte(body), 0o755); err != nil { |
| 285 | t.Fatalf("write tool: %v", err) |
| 286 | } |
| 287 | return path |
| 288 | } |
| 289 | |
| 290 | func writeEnvProbeTool(t *testing.T, path string) string { |
| 291 | t.Helper() |
| 292 | setProbeTimeoutForTest(t, 10*time.Second) |
| 293 | body := "#!/bin/sh\nprintf '%s\\n' \"$REASONIX_PROBE_ENV\"\n" |
| 294 | if runtime.GOOS == "windows" { |
| 295 | if !strings.HasSuffix(path, ".bat") { |
| 296 | path += ".bat" |
| 297 | } |
| 298 | body = "@echo %REASONIX_PROBE_ENV%\r\n" |
| 299 | } |
| 300 | if err := os.WriteFile(path, []byte(body), 0o755); err != nil { |
| 301 | t.Fatalf("write env tool: %v", err) |
| 302 | } |
| 303 | return path |
| 304 | } |
| 305 | |
| 306 | func writeStderrProbeTool(t *testing.T, path, output string) string { |
| 307 | t.Helper() |
| 308 | setProbeTimeoutForTest(t, 10*time.Second) |
| 309 | body := "#!/bin/sh\nprintf '%s\\n' >&2\n" |
| 310 | body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''")) |
| 311 | if runtime.GOOS == "windows" { |
| 312 | if !strings.HasSuffix(path, ".bat") { |
| 313 | path += ".bat" |
| 314 | } |
| 315 | body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + " 1>&2\r\n" |
| 316 | } |
| 317 | if err := os.WriteFile(path, []byte(body), 0o755); err != nil { |
| 318 | t.Fatalf("write stderr tool: %v", err) |
| 319 | } |
| 320 | return path |
| 321 | } |
| 322 | |
| 323 | func resetProbeCacheForTest(t *testing.T, now time.Time) { |
| 324 | t.Helper() |
| 325 | setProbeNowForTest(now) |
| 326 | probeCacheMu.Lock() |
| 327 | probeCache = map[string]probeCacheEntry{} |
| 328 | probeInflightCalls = map[string]*probeInflight{} |
| 329 | probeCacheMu.Unlock() |
| 330 | t.Cleanup(func() { |
| 331 | probeCacheMu.Lock() |
| 332 | probeCache = map[string]probeCacheEntry{} |
| 333 | probeInflightCalls = map[string]*probeInflight{} |
| 334 | probeNow = time.Now |
| 335 | probeTimeout = ProbeTimeout |
| 336 | probeCacheMu.Unlock() |
| 337 | }) |
| 338 | } |
| 339 | |
| 340 | func setProbeNowForTest(now time.Time) { |
| 341 | probeCacheMu.Lock() |
| 342 | probeNow = func() time.Time { return now } |
| 343 | probeCacheMu.Unlock() |
| 344 | } |
| 345 | |
| 346 | func setProbeTimeoutForTest(t *testing.T, timeout time.Duration) { |
| 347 | t.Helper() |
| 348 | probeCacheMu.Lock() |
| 349 | probeTimeout = timeout |
| 350 | probeCacheMu.Unlock() |
| 351 | t.Cleanup(func() { |
| 352 | probeCacheMu.Lock() |
| 353 | probeTimeout = ProbeTimeout |
| 354 | probeCacheMu.Unlock() |
| 355 | }) |
| 356 | } |
| 357 | |
| 358 | func TestRunProbesFilterSubprocessEnv(t *testing.T) { |
| 359 | if runtime.GOOS == "windows" { |
| 360 | t.Skip("POSIX shell probe tool") |
| 361 | } |
| 362 | resetProbeCacheForTest(t, time.Unix(90, 0)) |
| 363 | setProbeTimeoutForTest(t, 10*time.Second) |
| 364 | dir := t.TempDir() |
| 365 | toolPath := filepath.Join(dir, "envtool") |
| 366 | body := "#!/bin/sh\nprintf 'tok=%s' \"${REASONIX_TEST_SECRET_TOKEN:-none}\"\n" |
| 367 | if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil { |
| 368 | t.Fatal(err) |
| 369 | } |
| 370 | t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz") |
| 371 | secrets.SetFilterSubprocessEnv(true) |
| 372 | t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) }) |
| 373 | |
| 374 | results := RunProbesWithOverrides(context.Background(), []string{"envtool --version"}, map[string]string{"envtool": toolPath}) |
| 375 | if len(results) != 1 || !results[0].Found { |
| 376 | t.Fatalf("probe result = %+v", results) |
| 377 | } |
| 378 | // Probes declaring no extra env of their own must still get the filtered |
| 379 | // environment, not inherit the full one. |
| 380 | if results[0].Output != "tok=none" { |
| 381 | t.Fatalf("probe leaked filtered env: output = %q", results[0].Output) |
| 382 | } |
| 383 | } |
| 384 |