返回 CodeWhale
shell_dispatcher.rs
根目录 / crates / tui / src / shell_dispatcher.rs
1 //! Shell abstraction layer for Codewhale.
2 //!
3 //! Detects the user's shell at startup and provides a single entry point for
4 //! all command execution. Codewhale never calls `Command::new("cmd")` (or
5 //! `"sh"`, `"pwsh"`, ...) directly — it asks the [`ShellDispatcher`] to build
6 //! a correctly configured [`std::process::Command`].
7 //!
8 //! ## Responsibilities
9 //!
10 //! 1. **Shell detection** — find the user's actual shell (PowerShell, pwsh,
11 //! bash via WSL / Git Bash, cmd.exe fallback on Windows, /bin/sh on Unix).
12 //! On Windows, prefer PowerShell 7 (`pwsh`) over Windows PowerShell 5.1.
13 //! 2. **Quoting correctness** — each shell's argument-passing convention is
14 //! respected so quoted strings survive the spawn boundary intact.
15 //! 3. **PowerShell safety** — non-interactive flags, temporary `.ps1` files
16 //! for multiline scripts, explicit native `$LASTEXITCODE` capture, and a
17 //! process-scoped execution-policy bypass so a machine whose local policy
18 //! is `Restricted`/`AllSigned` does not refuse the tool's own temp script
19 //! (issue #6745).
20 //!
21 //! ## Known limitations
22 //!
23 //! - A Group Policy execution policy (`Get-ExecutionPolicy -List` rows
24 //! `MachinePolicy`/`UserPolicy`) outranks the process scope, so on such a
25 //! machine multiline commands (the temp `-File` form) are still refused and
26 //! PowerShell's own refusal is returned as the command's error. This is
27 //! deliberate: the dispatcher does not re-send the script through
28 //! `-EncodedCommand` to get around an administrator-enforced policy.
29 //! - The process scope covers the whole child: scripts that a command itself
30 //! invokes run under the same bypass. The shell tool's approval and sandbox
31 //! policy, not the execution policy, is what gates what may run.
32 //! 4. **Terminal state** — foreground shell execution saves and restores
33 //! crossterm raw-mode so the TUI input pipeline is not broken after a
34 //! child process exits (issue #1690).
35
36 use std::fs::OpenOptions;
37 use std::io::Write;
38 #[cfg(windows)]
39 use std::os::windows::process::CommandExt;
40 use std::path::Path;
41 use std::process::Command;
42 use std::sync::Mutex;
43
44 static LOG_MUTEX: Mutex<()> = Mutex::new(());
45
46 #[cfg(test)]
47 #[allow(dead_code)] // Direct integration-harness inclusion only needs the read barrier.
48 #[path = "test_env_lock.rs"]
49 pub(crate) mod test_env_lock;
50
51 // ---------------------------------------------------------------------------
52 // Shell kind
53 // ---------------------------------------------------------------------------
54
55 /// The concrete shell that the dispatcher will use.
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub enum ShellKind {
58 // Which variants are live is exactly a platform split: `detect` builds the
59 // four Windows shells under `cfg(windows)` and `Sh`/`Custom` under
60 // `cfg(not(windows))`. So each `expect(dead_code)` has to name the platform
61 // it is dead on, or it fires as unfulfilled on the other one — which is how
62 // the Windows build broke while unix stayed green.
63 /// PowerShell 7+ (`pwsh.exe`).
64 #[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
65 Pwsh,
66 /// Windows PowerShell 5.1 (`powershell.exe`).
67 #[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
68 WindowsPowerShell,
69 /// Command Prompt (`cmd.exe`).
70 #[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
71 Cmd,
72 /// Unix `/bin/sh` fallback.
73 #[cfg_attr(all(not(test), windows), expect(dead_code))]
74 Sh,
75 /// Bash — detected via `$SHELL` on WSL/Git Bash, or constructed explicitly.
76 #[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
77 Bash,
78 /// The exact shell executable selected by Unix `$SHELL`.
79 #[cfg_attr(all(not(test), windows), expect(dead_code))]
80 Custom { binary: String, flag: String },
81 }
82
83 impl ShellKind {
84 /// Binary name for the shell. Appends `.exe` on Windows where needed.
85 pub fn binary(&self) -> &str {
86 match self {
87 #[cfg(windows)]
88 ShellKind::Pwsh => "pwsh.exe",
89 #[cfg(not(windows))]
90 ShellKind::Pwsh => "pwsh",
91
92 #[cfg(windows)]
93 ShellKind::WindowsPowerShell => "powershell.exe",
94 #[cfg(not(windows))]
95 ShellKind::WindowsPowerShell => "powershell",
96
97 #[cfg(windows)]
98 ShellKind::Cmd => "cmd.exe",
99 #[cfg(not(windows))]
100 ShellKind::Cmd => "cmd",
101
102 #[cfg(windows)]
103 ShellKind::Sh => "sh",
104 #[cfg(not(windows))]
105 ShellKind::Sh => "/bin/sh",
106 ShellKind::Bash => "bash",
107 ShellKind::Custom { binary, .. } => binary,
108 }
109 }
110
111 /// Flag that tells the shell to execute the following argument as a
112 /// command string.
113 pub fn command_flag(&self) -> &str {
114 match self {
115 ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile",
116 ShellKind::Cmd => "/C",
117 ShellKind::Sh | ShellKind::Bash => "-c",
118 ShellKind::Custom { flag, .. } => flag,
119 }
120 }
121
122 /// Whether this shell needs an extra `-Command` flag after the profile
123 /// flag (PowerShell-specific). Only exercised by shell-flag unit tests.
124 #[cfg(test)]
125 pub fn needs_command_flag(&self) -> bool {
126 matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
127 }
128
129 /// Returns true when this is a PowerShell-family shell.
130 pub fn is_powershell(&self) -> bool {
131 match self {
132 ShellKind::Pwsh | ShellKind::WindowsPowerShell => true,
133 ShellKind::Custom { binary, .. } => Path::new(binary)
134 .file_name()
135 .and_then(|name| name.to_str())
136 .is_some_and(|name| {
137 let name = name.to_ascii_lowercase();
138 name.contains("pwsh") || name.contains("powershell")
139 }),
140 ShellKind::Cmd | ShellKind::Sh | ShellKind::Bash => false,
141 }
142 }
143 }
144
145 /// Multiline, nested-quote, or non-ASCII PowerShell scripts are safer as a
146 /// temporary `-File` script than as a single `-Command` string.
147 fn powershell_prefers_script_file(shell_command: &str) -> bool {
148 shell_command.contains('\n')
149 || shell_command.contains('\r')
150 || !shell_command.is_ascii()
151 || shell_command.matches('"').count() >= 4
152 || shell_command.contains("'''")
153 || shell_command.contains("@'")
154 || shell_command.contains("@\"")
155 }
156
157 /// Flags shared by every PowerShell invocation this dispatcher builds.
158 ///
159 /// `-ExecutionPolicy Bypass` matters for the temp `.ps1` form: script files are
160 /// subject to the execution policy (the Windows client default is
161 /// `Restricted`), so without it a stock or hardened machine refuses a script
162 /// this tool wrote itself before a single statement runs. The parameter only
163 /// sets the *process* scope — no administrator rights, no persisted change, the
164 /// user's own shells are untouched — and it is applied to both forms so a
165 /// command behaves the same whether it travels as `-Command` or `-File`.
166 /// Group Policy scopes still take precedence; see the module's known
167 /// limitations.
168 ///
169 /// `CODEWHALE_POWERSHELL_EXECUTION_POLICY=inherit` omits the flag so the
170 /// machine/user policy applies instead (#6745). Unset, `bypass`, or any other
171 /// value keeps the default.
172 fn powershell_base_args() -> Vec<String> {
173 powershell_base_args_for_policy(
174 std::env::var(POWERSHELL_EXECUTION_POLICY_ENV)
175 .ok()
176 .as_deref(),
177 )
178 }
179
180 /// Environment variable that opts out of the process-scope policy bypass.
181 const POWERSHELL_EXECUTION_POLICY_ENV: &str = "CODEWHALE_POWERSHELL_EXECUTION_POLICY";
182
183 /// [`powershell_base_args`] with the opt-out value passed in, so the decision
184 /// is testable without touching the process environment.
185 fn powershell_base_args_for_policy(setting: Option<&str>) -> Vec<String> {
186 let mut args = vec![
187 "-NoLogo".to_string(),
188 "-NoProfile".to_string(),
189 "-NonInteractive".to_string(),
190 ];
191 if !setting.is_some_and(|value| value.trim().eq_ignore_ascii_case("inherit")) {
192 args.push("-ExecutionPolicy".to_string());
193 args.push("Bypass".to_string());
194 }
195 args
196 }
197
198 /// Wrap a model/user PowerShell command so native program failures surface
199 /// through `$LASTEXITCODE` without using `Invoke-Expression`.
200 fn powershell_exit_aware_command(shell_command: &str) -> String {
201 // Keep simple expressions as-is; only wrap when the payload looks like it
202 // may invoke a native executable (contains a path or known separators).
203 if shell_command.trim().is_empty() {
204 return shell_command.to_string();
205 }
206 // The exit-code check goes on its own line: a trailing unquoted `#`
207 // comment in the payload would otherwise swallow a `;`-joined check to
208 // end-of-line and silently report success for failing native commands.
209 // `-Command` accepts embedded newlines inside one argv string.
210 format!(
211 "$ErrorActionPreference = 'Continue'; {shell_command}\nif ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}"
212 )
213 }
214
215 /// Tail appended to every temp `-File` script: capture the native exit code,
216 /// remove the script itself (PowerShell reads the whole file before running,
217 /// so self-deletion is safe), then propagate the exit code.
218 const TEMP_PS1_TAIL: &str = concat!(
219 "$__codewhaleExit = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 }\n",
220 "Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force ",
221 "-ErrorAction SilentlyContinue\n",
222 "if ($__codewhaleExit -ne 0) { exit $__codewhaleExit }\n",
223 );
224
225 fn write_temp_ps1(shell_command: &str) -> std::io::Result<String> {
226 let dir = std::env::temp_dir();
227 sweep_stale_temp_ps1(&dir);
228 // Unguessable name: another user of the shared temporary directory cannot
229 // predict it, and `write_temp_ps1_at` still refuses anything already there.
230 let name = format!(
231 "codewhale-shell-{}-{}.ps1",
232 std::process::id(),
233 uuid::Uuid::new_v4().simple()
234 );
235 write_temp_ps1_at(&dir.join(name), shell_command)
236 }
237
238 /// Create `path` exclusively, owner-only on Unix, and write the script into it.
239 fn write_temp_ps1_at(path: &std::path::Path, shell_command: &str) -> std::io::Result<String> {
240 use std::io::Write;
241 // Create-new: never write the script through a file or link that someone
242 // else placed at this name in the shared temporary directory.
243 let mut options = std::fs::OpenOptions::new();
244 options.write(true).create_new(true);
245 #[cfg(unix)]
246 {
247 use std::os::unix::fs::OpenOptionsExt;
248 options.mode(0o600);
249 }
250 let mut file = options.open(path)?;
251 // UTF-8 with BOM helps Windows PowerShell 5.1 decode non-ASCII scripts.
252 file.write_all(&[0xEF, 0xBB, 0xBF])?;
253 file.write_all(shell_command.as_bytes())?;
254 if !shell_command.ends_with('\n') {
255 file.write_all(b"\n")?;
256 }
257 // Native exit-code propagation plus self-cleanup for the script form.
258 file.write_all(TEMP_PS1_TAIL.as_bytes())?;
259 Ok(path.to_string_lossy().into_owned())
260 }
261
262 /// Best-effort removal of leftover `codewhale-shell-*.ps1` scripts (for
263 /// example after a killed process, which skips the in-script self-delete).
264 /// Only files older than one hour are touched so a concurrently starting
265 /// invocation is never raced.
266 fn sweep_stale_temp_ps1(dir: &std::path::Path) {
267 const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(60 * 60);
268 let Ok(entries) = std::fs::read_dir(dir) else {
269 return;
270 };
271 for entry in entries.flatten() {
272 let name = entry.file_name();
273 let Some(name) = name.to_str() else {
274 continue;
275 };
276 if !name.starts_with("codewhale-shell-") || !name.ends_with(".ps1") {
277 continue;
278 }
279 let stale = entry
280 .metadata()
281 .and_then(|meta| meta.modified())
282 .ok()
283 .and_then(|modified| modified.elapsed().ok())
284 .is_some_and(|age| age > STALE_AFTER);
285 if stale {
286 let _ = std::fs::remove_file(entry.path());
287 }
288 }
289 }
290
291 // ---------------------------------------------------------------------------
292 // Dispatcher
293 // ---------------------------------------------------------------------------
294
295 /// Central shell abstraction. Created once at startup via
296 /// [`ShellDispatcher::detect`] and then used everywhere a command needs to
297 /// be spawned.
298 #[derive(Debug, Clone)]
299 pub struct ShellDispatcher {
300 kind: ShellKind,
301 }
302
303 impl ShellDispatcher {
304 /// Detect the user's shell from the environment.
305 ///
306 /// ## Detection order (Windows)
307 ///
308 /// 1. `$env:SHELL` — WSL interop or Git Bash often set this.
309 /// 2. `pwsh.exe` found on `PATH` — PowerShell 7+.
310 /// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1.
311 /// 4. `cmd.exe` — always available, last resort.
312 ///
313 /// ## Detection order (Unix)
314 ///
315 /// 1. `$SHELL` — preserve its actual executable via `Custom`; bare names
316 /// are resolved against the current `PATH` once at detection time.
317 /// 2. `/bin/sh` fallback.
318 pub fn detect() -> Self {
319 let kind = Self::detect_shell();
320 Self::log_startup(&kind);
321 ShellDispatcher { kind }
322 }
323
324 /// Log a shell execution line when `SHELL_DISPATCHER_LOG` is set.
325 #[cfg_attr(test, allow(dead_code))]
326 pub fn log_exec(command: &str) {
327 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
328 let _ = Self::append_log_static(&path, command);
329 }
330 }
331
332 fn log_startup(kind: &ShellKind) {
333 let _lock = LOG_MUTEX.lock();
334 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
335 let init_line = format!(
336 "--- ShellDispatcher log started pid={} ---\n",
337 std::process::id()
338 );
339 let _ = Self::append_log(&path, &init_line);
340 let detect_line = format!("[{}] detect: {kind:?}\n", now_iso());
341 let _ = Self::append_log(&path, &detect_line);
342 }
343 }
344
345 fn append_log(path: &str, line: &str) -> std::io::Result<()> {
346 let mut file = OpenOptions::new()
347 .create(true)
348 .append(true)
349 .open(Path::new(path))?;
350 file.write_all(line.as_bytes())?;
351 file.flush()
352 }
353
354 #[cfg_attr(test, allow(dead_code))]
355 fn append_log_static(path: &str, command: &str) -> std::io::Result<()> {
356 // Resolve kind outside the lock — `global_dispatcher()` may trigger
357 // `detect()` which calls `log_startup()` which also acquires the mutex.
358 let kind = global_dispatcher().kind();
359 let _lock = LOG_MUTEX.lock();
360 let line = format!("[{}] exec via {kind:?}: {command}\n", now_iso());
361 Self::append_log(path, &line)
362 }
363
364 /// The detected shell kind.
365 pub fn kind(&self) -> &ShellKind {
366 &self.kind
367 }
368
369 // -- Public builders --------------------------------------------------
370
371 /// Build a `std::process::Command` for the given shell command string.
372 pub fn build_command(&self, shell_command: &str) -> Command {
373 let (program, args) = self.build_command_parts(shell_command);
374 let mut cmd = Command::new(program);
375 if matches!(self.kind, ShellKind::Cmd) {
376 #[cfg(windows)]
377 {
378 // Preserve quotes for `cmd /C <payload>` (issue #1691).
379 if args.len() == 2 && args[0].eq_ignore_ascii_case("/C") {
380 cmd.raw_arg(&args[0]);
381 cmd.raw_arg(&args[1]);
382 return cmd;
383 }
384 }
385 }
386 cmd.args(args);
387 cmd
388 }
389
390 /// Build the program + args tuple. Useful when the caller needs to
391 /// inspect or modify the args before passing them to `Command`.
392 pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec<String>) {
393 let program = self.kind.binary().to_string();
394 if self.kind.is_powershell() {
395 let mut args = powershell_base_args();
396 if powershell_prefers_script_file(shell_command) {
397 // Complex multiline / heavily quoted scripts: write a temp
398 // .ps1 and invoke with -File so quoting stays structured.
399 match write_temp_ps1(shell_command) {
400 Ok(path) => {
401 args.push("-File".to_string());
402 args.push(path);
403 return (program, args);
404 }
405 Err(_) => {
406 // Fall through to -Command if the temp file cannot be
407 // created; execution still proceeds.
408 }
409 }
410 }
411 args.push("-Command".to_string());
412 args.push(powershell_exit_aware_command(shell_command));
413 return (program, args);
414 }
415 let args = if matches!(self.kind, ShellKind::Cmd) {
416 vec!["/C".to_string(), shell_command.to_string()]
417 } else {
418 vec![
419 self.kind.command_flag().to_string(),
420 shell_command.to_string(),
421 ]
422 };
423 (program, args)
424 }
425
426 /// Build a `Command` from separate program + args (bypasses the shell).
427 /// Used when the caller already has a resolved executable and argument
428 /// vector — e.g. `ExecEnv` from the sandbox.
429 #[cfg(test)]
430 pub fn build_direct(&self, program: &str, args: &[String]) -> Command {
431 let mut cmd = Command::new(program);
432 cmd.args(args);
433 cmd
434 }
435
436 /// Execute a foreground command with raw-mode save/restore.
437 ///
438 /// A scope guard ensures raw mode is restored even if the command fails
439 /// to spawn or returns early (review feedback, issue #1690).
440 pub fn run_foreground(
441 &self,
442 shell_command: &str,
443 cwd: &std::path::Path,
444 ) -> Result<String, anyhow::Error> {
445 use anyhow::Context;
446
447 // Log the execution
448 {
449 let _lock = LOG_MUTEX.lock();
450 if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
451 let kind = self.kind();
452 let line = format!("[{}] exec via {kind:?}: {shell_command}\n", now_iso());
453 let _ = Self::append_log(&path, &line);
454 }
455 }
456
457 // Leave raw mode; the guard restores it only if it was already enabled.
458 let _raw_mode = crate::host_terminal::suspend_raw_mode();
459
460 let mut cmd = self.build_command(shell_command);
461 cmd.current_dir(cwd);
462
463 let output = cmd
464 .output()
465 .with_context(|| format!("failed to execute shell command: {shell_command}"))?;
466
467 if !output.status.success() {
468 let stderr = String::from_utf8_lossy(&output.stderr);
469 anyhow::bail!(
470 "shell command failed (status={}): {}",
471 output.status,
472 stderr.trim()
473 );
474 }
475
476 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
477 Ok(stdout)
478 }
479
480 // -- Detection --------------------------------------------------------
481
482 fn detect_shell() -> ShellKind {
483 #[cfg(test)]
484 {
485 // Non-blocking on purpose. This runs inside the `LazyLock`
486 // initializer in `global_dispatcher()`, and a test that holds the
487 // env barrier can reach `global_dispatcher()` while another thread
488 // is initializing it — blocking here inverts the two locks and
489 // wedges the whole test binary with no libtest timeout to end it.
490 // `$SHELL` is the only variable read, and the two tests that set it
491 // set it to a fixed value, so an unsynchronized read is safe.
492 test_env_lock::with_test_env_lock_if_uncontended(Self::detect_shell_unlocked)
493 }
494 #[cfg(not(test))]
495 {
496 Self::detect_shell_unlocked()
497 }
498 }
499
500 fn detect_shell_unlocked() -> ShellKind {
501 #[cfg(windows)]
502 {
503 // 1. $env:SHELL — WSL interop or Git Bash often set this.
504 if let Ok(shell) = std::env::var("SHELL") {
505 let lower = shell.to_lowercase();
506 if lower.contains("bash") {
507 return ShellKind::Bash;
508 }
509 if lower.contains("pwsh") {
510 return ShellKind::Pwsh;
511 }
512 if lower.contains("powershell") {
513 return ShellKind::WindowsPowerShell;
514 }
515 }
516
517 if Self::find_exe("pwsh.exe") {
518 return ShellKind::Pwsh;
519 }
520 if Self::find_exe("powershell.exe") {
521 return ShellKind::WindowsPowerShell;
522 }
523 ShellKind::Cmd
524 }
525
526 #[cfg(not(windows))]
527 {
528 if let Ok(shell) = std::env::var("SHELL")
529 && let Some(kind) = Self::unix_shell_kind(&shell)
530 {
531 return kind;
532 }
533
534 ShellKind::Sh
535 }
536 }
537
538 #[cfg(not(windows))]
539 fn unix_shell_kind(shell: &str) -> Option<ShellKind> {
540 let shell = shell.trim();
541 if shell.is_empty() {
542 return None;
543 }
544 let path = Path::new(shell);
545 let binary = if path.is_absolute() || path.components().count() > 1 {
546 shell.to_string()
547 } else {
548 std::env::var_os("PATH")
549 .and_then(|path| {
550 std::env::split_paths(&path)
551 .map(|dir| dir.join(shell))
552 .find(|candidate| candidate.is_file())
553 })
554 .map_or_else(
555 || shell.to_string(),
556 |path| path.to_string_lossy().into_owned(),
557 )
558 };
559 Some(ShellKind::Custom {
560 binary,
561 flag: "-c".to_string(),
562 })
563 }
564
565 /// Check PATH first, then fall back to well-known install directories.
566 #[cfg(windows)]
567 fn find_exe(name: &str) -> bool {
568 if Self::binary_on_path(name) {
569 return true;
570 }
571 // Well-known install locations (order by preference).
572 let known_dirs: &[&str] = &[
573 r"C:\Program Files\PowerShell\7",
574 r"C:\Windows\System32\WindowsPowerShell\v1.0",
575 ];
576 known_dirs
577 .iter()
578 .any(|dir| std::path::Path::new(dir).join(name).is_file())
579 }
580
581 #[cfg(windows)]
582 fn binary_on_path(name: &str) -> bool {
583 std::env::var_os("PATH")
584 .map(|path| {
585 std::env::split_paths(&path).any(|dir| {
586 let candidate = dir.join(name);
587 candidate.is_file()
588 })
589 })
590 .unwrap_or(false)
591 }
592 }
593
594 // -- Helpers ---------------------------------------------------------------
595
596 fn now_iso() -> String {
597 chrono::Utc::now()
598 .format("%Y-%m-%dT%H:%M:%S%.3f")
599 .to_string()
600 }
601
602 /// Global dispatcher instance, detected once at startup.
603 ///
604 /// Any code path that needs to spawn a shell command can use
605 /// `global_dispatcher()` instead of threading the dispatcher through
606 /// every function signature.
607 pub fn global_dispatcher() -> &'static ShellDispatcher {
608 use std::sync::LazyLock;
609 static DISPATCHER: LazyLock<ShellDispatcher> = LazyLock::new(ShellDispatcher::detect);
610 &DISPATCHER
611 }
612
613 // ---------------------------------------------------------------------------
614 // Tests
615 // ---------------------------------------------------------------------------
616
617 #[cfg(test)]
618 mod tests {
619 use super::*;
620
621 #[test]
622 fn shell_kind_binary_names() {
623 #[cfg(windows)]
624 {
625 assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe");
626 assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe");
627 assert_eq!(ShellKind::Cmd.binary(), "cmd.exe");
628 }
629 #[cfg(not(windows))]
630 {
631 assert_eq!(ShellKind::Pwsh.binary(), "pwsh");
632 assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell");
633 assert_eq!(ShellKind::Cmd.binary(), "cmd");
634 }
635 #[cfg(windows)]
636 assert_eq!(ShellKind::Sh.binary(), "sh");
637 #[cfg(not(windows))]
638 assert_eq!(ShellKind::Sh.binary(), "/bin/sh");
639 assert_eq!(ShellKind::Bash.binary(), "bash");
640 }
641
642 #[cfg(not(windows))]
643 #[test]
644 fn unix_shell_detection_preserves_absolute_executable_paths() {
645 let bash = ShellDispatcher::unix_shell_kind("/bin/bash").expect("bash shell");
646 assert_eq!(
647 bash,
648 ShellKind::Custom {
649 binary: "/bin/bash".to_string(),
650 flag: "-c".to_string(),
651 }
652 );
653
654 let pwsh =
655 ShellDispatcher::unix_shell_kind("/opt/homebrew/bin/pwsh").expect("PowerShell path");
656 assert!(pwsh.is_powershell());
657 assert_eq!(pwsh.binary(), "/opt/homebrew/bin/pwsh");
658
659 let dispatcher = ShellDispatcher {
660 kind: ShellDispatcher::unix_shell_kind("/bin/sh").expect("POSIX shell"),
661 };
662 let mut command = dispatcher.build_command("printf path-independent");
663 command.env_clear();
664 let output = command.output().expect("absolute shell must not need PATH");
665 assert!(output.status.success(), "{output:?}");
666 assert_eq!(output.stdout, b"path-independent");
667 }
668
669 #[test]
670 fn detect_returns_some_shell() {
671 let dispatcher = global_dispatcher();
672 let _kind = dispatcher.kind();
673 }
674
675 #[test]
676 fn powershell_build_command_includes_no_profile_and_command_flags() {
677 let dispatcher = ShellDispatcher {
678 kind: ShellKind::Pwsh,
679 };
680 let cmd = dispatcher.build_command("echo hello");
681 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
682 assert!(args.contains(&"-NoLogo"));
683 assert!(args.contains(&"-NoProfile"));
684 assert!(args.contains(&"-NonInteractive"));
685 assert!(args.contains(&"-Command"));
686 assert!(
687 args.iter().any(|a| a.contains("echo hello")),
688 "command payload missing: {args:?}"
689 );
690 assert!(
691 args.iter().any(|a| a.contains("$LASTEXITCODE")),
692 "native exit-code capture missing: {args:?}"
693 );
694 }
695
696 #[test]
697 fn powershell_multiline_uses_temp_file_invocation() {
698 let dispatcher = ShellDispatcher {
699 kind: ShellKind::Pwsh,
700 };
701 let script = "Write-Output 'line1'\nWrite-Output 'line2'";
702 let (program, args) = dispatcher.build_command_parts(script);
703 assert!(program.contains("pwsh"));
704 assert!(args.iter().any(|a| a == "-File"), "{args:?}");
705 let path = args
706 .iter()
707 .find(|a| a.ends_with(".ps1"))
708 .unwrap_or_else(|| panic!("expected temp .ps1 path: {args:?}"));
709 // The script must clean up after itself and still propagate the
710 // native exit code — self-delete before the exit line, so a nonzero
711 // exit cannot skip the removal.
712 let contents = std::fs::read_to_string(path).expect("read temp script");
713 let remove_at = contents
714 .find("Remove-Item -LiteralPath $MyInvocation.MyCommand.Path")
715 .expect("self-delete line present");
716 let exit_at = contents
717 .find("if ($__codewhaleExit -ne 0) { exit $__codewhaleExit }")
718 .expect("exit propagation present");
719 assert!(remove_at < exit_at, "self-delete must precede exit");
720 // Cleanup temp script created by the builder (the test never runs it).
721 let _ = std::fs::remove_file(path);
722 }
723
724 /// #6745: both PowerShell forms carry a process-scoped policy bypass, and
725 /// it precedes `-File`/`-Command` — PowerShell hands every argument after
726 /// `-File` to the script, so a later `-ExecutionPolicy` would be ignored.
727 #[test]
728 fn powershell_forms_bypass_execution_policy_at_process_scope() {
729 let dispatcher = ShellDispatcher {
730 kind: ShellKind::WindowsPowerShell,
731 };
732 for script in ["Write-Output 'one'", "Write-Output 'a'\nWrite-Output 'b'"] {
733 let (_, args) = dispatcher.build_command_parts(script);
734 let policy = args
735 .iter()
736 .position(|a| a == "-ExecutionPolicy")
737 .unwrap_or_else(|| panic!("process-scope policy missing: {args:?}"));
738 assert_eq!(args[policy + 1], "Bypass", "{args:?}");
739 let payload_flag = args
740 .iter()
741 .position(|a| a == "-File" || a == "-Command")
742 .expect("payload flag");
743 assert!(policy < payload_flag, "{args:?}");
744 if args[payload_flag] == "-File" {
745 let _ = std::fs::remove_file(&args[payload_flag + 1]);
746 }
747 }
748 }
749
750 /// #6745: `inherit` is the only value that drops the process-scope bypass;
751 /// unset, `bypass`, and anything unrecognised keep it. Tested on the pure
752 /// decision so no test mutates the process environment.
753 #[test]
754 fn powershell_execution_policy_opt_out_only_honours_inherit() {
755 let bypass = |args: &[String]| {
756 args.iter()
757 .position(|a| a == "-ExecutionPolicy")
758 .map(|index| args[index + 1].clone())
759 };
760 for setting in [
761 None,
762 Some("bypass"),
763 Some("Bypass"),
764 Some(""),
765 Some("restricted"),
766 ] {
767 let args = powershell_base_args_for_policy(setting);
768 assert_eq!(bypass(&args).as_deref(), Some("Bypass"), "{setting:?}");
769 assert!(args.contains(&"-NonInteractive".to_string()), "{setting:?}");
770 }
771 for setting in ["inherit", "INHERIT", " inherit "] {
772 let args = powershell_base_args_for_policy(Some(setting));
773 assert_eq!(bypass(&args), None, "{setting:?}: {args:?}");
774 assert_eq!(args, ["-NoLogo", "-NoProfile", "-NonInteractive"]);
775 }
776 }
777
778 #[test]
779 fn powershell_trailing_comment_cannot_swallow_exit_capture() {
780 // An unquoted `#` in a single-line payload comments to end-of-line;
781 // the appended $LASTEXITCODE check must live on its own line so a
782 // failing native command can never silently report success.
783 let dispatcher = ShellDispatcher {
784 kind: ShellKind::Pwsh,
785 };
786 let (_, args) = dispatcher.build_command_parts("git log --oneline -5 # recent");
787 let payload = args.last().expect("command payload");
788 assert!(payload.contains("# recent"), "{payload}");
789 assert!(
790 payload.contains("\nif ($null -ne $LASTEXITCODE"),
791 "exit-code capture must start on a fresh line: {payload}"
792 );
793 }
794
795 /// The temporary script is created exclusively: a file or link already at
796 /// the name is refused and left exactly as it was, and a fresh script is
797 /// owner-only.
798 #[test]
799 fn temp_ps1_script_is_created_exclusively_and_privately() {
800 let dir = tempfile::tempdir().expect("tempdir");
801
802 let taken = dir.path().join("codewhale-shell-taken.ps1");
803 std::fs::write(&taken, "original").expect("pre-existing file");
804 let error = write_temp_ps1_at(&taken, "Write-Output 'x'")
805 .expect_err("an existing file must be refused");
806 assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
807 assert_eq!(std::fs::read_to_string(&taken).unwrap(), "original");
808
809 #[cfg(unix)]
810 {
811 use std::os::unix::fs::{PermissionsExt, symlink};
812 let victim = dir.path().join("victim.txt");
813 let linked = dir.path().join("codewhale-shell-linked.ps1");
814 symlink(&victim, &linked).expect("plant dangling link");
815 assert!(write_temp_ps1_at(&linked, "Write-Output 'x'").is_err());
816 assert!(
817 !victim.exists(),
818 "a planted link must not be written through"
819 );
820
821 let fresh = dir.path().join("codewhale-shell-fresh.ps1");
822 write_temp_ps1_at(&fresh, "Write-Output 'x'").expect("fresh script");
823 let mode = std::fs::metadata(&fresh).unwrap().permissions().mode();
824 assert_eq!(mode & 0o777, 0o600);
825 }
826
827 let fresh = dir.path().join("codewhale-shell-content.ps1");
828 let written = write_temp_ps1_at(&fresh, "Write-Output 'x'").expect("script");
829 assert_eq!(written, fresh.to_string_lossy());
830 let bytes = std::fs::read(&fresh).unwrap();
831 assert_eq!(&bytes[..3], &[0xEF, 0xBB, 0xBF]);
832 let text = String::from_utf8(bytes[3..].to_vec()).unwrap();
833 assert!(text.starts_with("Write-Output 'x'\n"), "{text}");
834 assert!(text.ends_with(TEMP_PS1_TAIL), "{text}");
835
836 // Two scripts from the public entry point never collide.
837 let first = write_temp_ps1("1").expect("first");
838 let second = write_temp_ps1("2").expect("second");
839 assert_ne!(first, second);
840 let _ = std::fs::remove_file(first);
841 let _ = std::fs::remove_file(second);
842 }
843
844 #[test]
845 fn stale_temp_ps1_scripts_are_swept() {
846 let dir = std::env::temp_dir();
847 let stale = dir.join("codewhale-shell-0-stale-test.ps1");
848 std::fs::write(&stale, "Write-Output 'stale'\n").expect("write stale script");
849 // Backdate the file beyond the sweep horizon.
850 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 60 * 60);
851 let file = std::fs::File::options()
852 .append(true)
853 .open(&stale)
854 .expect("open stale script");
855 file.set_modified(old).expect("backdate stale script");
856 drop(file);
857
858 sweep_stale_temp_ps1(&dir);
859 assert!(!stale.exists(), "stale script should be removed");
860 }
861
862 #[test]
863 fn cmd_build_command_uses_c_flag() {
864 let dispatcher = ShellDispatcher {
865 kind: ShellKind::Cmd,
866 };
867 let cmd = dispatcher.build_command("echo hello");
868 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
869 assert!(args.contains(&"/C"));
870 assert!(args.contains(&"echo hello"));
871 }
872
873 #[test]
874 fn sh_build_command_uses_dash_c() {
875 let dispatcher = ShellDispatcher {
876 kind: ShellKind::Sh,
877 };
878 let cmd = dispatcher.build_command("echo hello");
879 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
880 assert!(args.contains(&"-c"));
881 assert!(args.contains(&"echo hello"));
882 }
883
884 #[cfg(test)]
885 #[test]
886 fn build_direct_preserves_args() {
887 let dispatcher = ShellDispatcher {
888 kind: ShellKind::Cmd,
889 };
890 let args = vec!["-m".to_string(), "commit message".to_string()];
891 let cmd = dispatcher.build_direct("git", &args);
892 let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
893 assert_eq!(cmd_args, vec!["-m", "commit message"]);
894 }
895
896 #[cfg(test)]
897 #[test]
898 fn powershell_flags_are_correct() {
899 assert!(ShellKind::Pwsh.needs_command_flag());
900 assert!(ShellKind::WindowsPowerShell.needs_command_flag());
901 assert!(!ShellKind::Cmd.needs_command_flag());
902 assert!(!ShellKind::Sh.needs_command_flag());
903 assert!(!ShellKind::Bash.needs_command_flag());
904 }
905
906 #[cfg(test)]
907 #[test]
908 fn is_powershell_detects_both_variants() {
909 assert!(ShellKind::Pwsh.is_powershell());
910 assert!(ShellKind::WindowsPowerShell.is_powershell());
911 assert!(!ShellKind::Cmd.is_powershell());
912 assert!(!ShellKind::Sh.is_powershell());
913 assert!(!ShellKind::Bash.is_powershell());
914 }
915
916 #[cfg(test)]
917 #[test]
918 fn build_command_quotes_spaces_for_cmd() {
919 let dispatcher = ShellDispatcher {
920 kind: ShellKind::Cmd,
921 };
922 let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
923 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
924 assert_eq!(args.len(), 2);
925 assert_eq!(args[0], "/C");
926 assert!(args[1].contains("msg with spaces"));
927 assert!(args[1].starts_with("git "));
928 }
929
930 #[cfg(test)]
931 #[test]
932 fn build_command_quotes_spaces_for_pwsh() {
933 let dispatcher = ShellDispatcher {
934 kind: ShellKind::Pwsh,
935 };
936 let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
937 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
938 assert!(args.contains(&"-NoLogo"));
939 assert!(args.contains(&"-NoProfile"));
940 assert!(args.contains(&"-NonInteractive"));
941 assert!(args.contains(&"-Command"));
942 assert!(
943 args.iter().any(|a| a.contains("msg with spaces")),
944 "quoted payload missing: {args:?}"
945 );
946 }
947
948 #[cfg(test)]
949 #[test]
950 fn build_direct_handles_empty_args() {
951 let dispatcher = ShellDispatcher {
952 kind: ShellKind::Sh,
953 };
954 let cmd = dispatcher.build_direct("echo", &[]);
955 let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
956 assert!(args.is_empty());
957 }
958
959 #[cfg(windows)]
960 #[test]
961 fn find_exe_finds_cmd_on_path() {
962 // cmd.exe is always on PATH on Windows.
963 assert!(ShellDispatcher::find_exe("cmd.exe"));
964 }
965
966 #[cfg(windows)]
967 #[test]
968 fn find_exe_rejects_nonexistent_binary() {
969 assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe"));
970 }
971
972 #[cfg(windows)]
973 #[test]
974 fn find_exe_falls_back_to_known_dirs() {
975 // Verify the known-dirs fallback path actually exists on this system.
976 let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
977 if std::path::Path::new(ps_path).is_file() {
978 // The fallback directory exists — find_exe should locate it.
979 assert!(ShellDispatcher::find_exe("powershell.exe"));
980 } else {
981 eprintln!("Skipping: {ps_path} not present on this system");
982 }
983 }
984
985 #[test]
986 fn custom_shell_uses_provided_binary_and_flag() {
987 let kind = ShellKind::Custom {
988 binary: "/bin/zsh".to_string(),
989 flag: "-c".to_string(),
990 };
991 assert_eq!(kind.binary(), "/bin/zsh");
992 assert_eq!(kind.command_flag(), "-c");
993 }
994 }
995
995 lines RUST