1use std::io::{self, IsTerminal};
2
3pub(crate) fn apply_utf8_locale(cmd: &mut std::process::Command) {
7 let has_utf8 = std::env::var("LC_ALL")
8 .or_else(|_| std::env::var("LC_CTYPE"))
9 .or_else(|_| std::env::var("LANG"))
10 .is_ok_and(|v| v.to_ascii_lowercase().contains("utf"));
11
12 if !has_utf8 {
13 cmd.env("LC_CTYPE", "C.UTF-8");
14 }
15}
16
17pub(crate) fn apply_profile_free_env(cmd: &mut std::process::Command) {
25 cmd.env("BASH_ENV", "").env("ENV", "");
26}
27
28pub fn decode_output(bytes: &[u8]) -> String {
29 match String::from_utf8(bytes.to_vec()) {
30 Ok(s) => s,
31 Err(_) => {
32 #[cfg(windows)]
33 {
34 decode_windows_output(bytes)
35 }
36 #[cfg(not(windows))]
37 {
38 String::from_utf8_lossy(bytes).into_owned()
39 }
40 }
41 }
42}
43
44#[cfg(windows)]
45fn decode_windows_output(bytes: &[u8]) -> String {
46 use std::os::windows::ffi::OsStringExt;
47
48 let lossy = String::from_utf8_lossy(bytes);
49 let replacement_count = lossy.chars().filter(|&c| c == '\u{FFFD}').count();
50 if replacement_count == 0 {
51 return lossy.into_owned();
52 }
53
54 unsafe extern "system" {
57 fn GetACP() -> u32;
58 fn MultiByteToWideChar(
59 cp: u32,
60 flags: u32,
61 src: *const u8,
62 srclen: i32,
63 dst: *mut u16,
64 dstlen: i32,
65 ) -> i32;
66 }
67
68 let codepage = unsafe { GetACP() };
71 let wide_len = unsafe {
75 MultiByteToWideChar(
76 codepage,
77 0,
78 bytes.as_ptr(),
79 bytes.len() as i32,
80 std::ptr::null_mut(),
81 0,
82 )
83 };
84 if wide_len <= 0 {
85 return lossy.into_owned();
86 }
87 let mut wide: Vec<u16> = vec![0u16; wide_len as usize];
88 unsafe {
92 MultiByteToWideChar(
93 codepage,
94 0,
95 bytes.as_ptr(),
96 bytes.len() as i32,
97 wide.as_mut_ptr(),
98 wide_len,
99 );
100 }
101 std::ffi::OsString::from_wide(&wide)
102 .to_string_lossy()
103 .into_owned()
104}
105
106#[cfg(windows)]
107pub(super) fn set_console_utf8() {
108 unsafe extern "system" {
111 fn SetConsoleOutputCP(id: u32) -> i32;
112 }
113 unsafe {
116 SetConsoleOutputCP(65001);
117 }
118}
119
120pub fn is_container() -> bool {
122 #[cfg(unix)]
123 {
124 if std::path::Path::new("/.dockerenv").exists() {
125 return true;
126 }
127 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
128 && (cgroup.contains("/docker/") || cgroup.contains("/lxc/"))
129 {
130 return true;
131 }
132 if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo")
133 && mounts.contains("/docker/containers/")
134 {
135 return true;
136 }
137 false
138 }
139 #[cfg(not(unix))]
140 {
141 false
142 }
143}
144
145pub fn is_non_interactive() -> bool {
147 !io::stdin().is_terminal()
148}
149
150pub(crate) fn is_powershell(shell_path: &str) -> bool {
152 let name = std::path::Path::new(shell_path)
153 .file_name()
154 .and_then(|n| n.to_str())
155 .unwrap_or("")
156 .to_ascii_lowercase();
157 name.contains("powershell") || name.contains("pwsh")
158}
159
160pub(crate) fn powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
169 const PROFILE_FILE: &str = "Microsoft.PowerShell_profile.ps1";
170 if cfg!(windows) {
171 home.join("Documents").join("PowerShell").join(PROFILE_FILE)
172 } else {
173 home.join(".config").join("powershell").join(PROFILE_FILE)
174 }
175}
176
177fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
180 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
181 "-Command"
182 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
183 "/C"
184 } else {
185 "-c"
186 }
187}
188
189pub fn shell_and_flag() -> (String, String) {
190 let shell = detect_shell();
191 let flag = if cfg!(windows) {
192 let name = std::path::Path::new(&shell)
193 .file_name()
194 .and_then(|n| n.to_str())
195 .unwrap_or("")
196 .to_ascii_lowercase();
197 windows_shell_flag_for_exe_basename(&name).to_string()
198 } else {
199 "-c".to_string()
200 };
201 (shell, flag)
202}
203
204pub fn shell_name() -> String {
206 let shell = detect_shell();
207 let basename = std::path::Path::new(&shell)
208 .file_name()
209 .and_then(|n| n.to_str())
210 .unwrap_or("sh")
211 .to_ascii_lowercase();
212 basename
213 .strip_suffix(".exe")
214 .unwrap_or(&basename)
215 .to_string()
216}
217
218pub(super) fn detect_shell() -> String {
219 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
220 return shell;
221 }
222
223 if let Ok(shell) = std::env::var("SHELL") {
224 let bin = std::path::Path::new(&shell)
225 .file_name()
226 .and_then(|n| n.to_str())
227 .unwrap_or("sh");
228
229 if bin == "lean-ctx" {
230 return find_real_shell();
231 }
232 if shell_acceptable_for_exec(&shell) {
239 return shell;
240 }
241 return find_real_shell();
242 }
243
244 find_real_shell()
245}
246
247#[cfg(unix)]
254fn shell_acceptable_for_exec(shell: &str) -> bool {
255 is_posix_compatible_shell(shell)
256}
257
258#[cfg(windows)]
259fn shell_acceptable_for_exec(_shell: &str) -> bool {
260 true
261}
262
263#[cfg(unix)]
268fn is_posix_compatible_shell(shell: &str) -> bool {
269 let name = std::path::Path::new(shell)
270 .file_name()
271 .and_then(|n| n.to_str())
272 .unwrap_or("")
273 .to_ascii_lowercase();
274 let name = name.strip_suffix(".exe").unwrap_or(&name);
275 matches!(
276 name,
277 "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
278 )
279}
280
281#[cfg(unix)]
282fn find_real_shell() -> String {
283 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
284 if std::path::Path::new(shell).exists() {
285 return shell.to_string();
286 }
287 }
288 "/bin/sh".to_string()
289}
290
291#[cfg(windows)]
292fn find_real_shell() -> String {
293 if is_running_in_msys_or_gitbash() {
294 for candidate in &["bash.exe", "sh.exe"] {
295 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
296 if output.status.success() {
297 if let Ok(path) = String::from_utf8(output.stdout) {
298 if let Some(first_line) = path.lines().next() {
299 let trimmed = first_line.trim();
300 if !trimmed.is_empty() {
301 return trimmed.to_string();
302 }
303 }
304 }
305 }
306 }
307 }
308 }
309 if let Ok(pwsh) = which_powershell() {
310 return pwsh;
311 }
312 if let Ok(comspec) = std::env::var("COMSPEC") {
313 return comspec;
314 }
315 "cmd.exe".to_string()
316}
317
318#[cfg(windows)]
319fn is_running_in_msys_or_gitbash() -> bool {
320 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
321}
322
323#[cfg(windows)]
324fn which_powershell() -> Result<String, ()> {
325 for candidate in &["pwsh.exe", "powershell.exe"] {
326 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
327 if output.status.success() {
328 if let Ok(path) = String::from_utf8(output.stdout) {
329 if let Some(first_line) = path.lines().next() {
330 let trimmed = first_line.trim();
331 if !trimmed.is_empty() {
332 return Ok(trimmed.to_string());
333 }
334 }
335 }
336 }
337 }
338 }
339 Err(())
340}
341
342pub fn join_command(args: &[String]) -> String {
349 let (_, flag) = shell_and_flag();
350 join_command_for(args, &flag)
351}
352
353pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
354 match shell_flag {
355 "-Command" => join_powershell(args),
356 "/C" => join_cmd(args),
357 _ => join_posix(args),
358 }
359}
360
361fn join_posix(args: &[String]) -> String {
362 args.iter()
363 .map(|a| quote_posix(a))
364 .collect::<Vec<_>>()
365 .join(" ")
366}
367
368fn join_powershell(args: &[String]) -> String {
369 if args.len() == 1 && args[0].contains(' ') {
370 return args[0].clone();
371 }
372 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
373 format!("& {}", quoted.join(" "))
374}
375
376fn join_cmd(args: &[String]) -> String {
377 args.iter()
378 .map(|a| quote_cmd(a))
379 .collect::<Vec<_>>()
380 .join(" ")
381}
382
383fn quote_posix(s: &str) -> String {
384 if s.is_empty() {
385 return "''".to_string();
386 }
387 if s.bytes()
388 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
389 {
390 return s.to_string();
391 }
392 format!("'{}'", s.replace('\'', "'\\''"))
393}
394
395fn quote_powershell(s: &str) -> String {
396 if s.is_empty() {
397 return "''".to_string();
398 }
399 if s.bytes()
400 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
401 {
402 return s.to_string();
403 }
404 format!("'{}'", s.replace('\'', "''"))
405}
406
407fn quote_cmd(s: &str) -> String {
408 if s.is_empty() {
409 return "\"\"".to_string();
410 }
411 if s.bytes()
412 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
413 {
414 return s.to_string();
415 }
416 format!("\"{}\"", s.replace('"', "\\\""))
417}
418
419#[cfg(test)]
420mod join_command_tests {
421 use super::*;
422
423 #[test]
424 fn posix_simple_args() {
425 let args: Vec<String> = vec!["git".into(), "status".into()];
426 assert_eq!(join_command_for(&args, "-c"), "git status");
427 }
428
429 #[test]
430 fn posix_path_with_spaces() {
431 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
432 assert_eq!(
433 join_command_for(&args, "-c"),
434 "'/usr/local/my app/bin' --help"
435 );
436 }
437
438 #[test]
439 fn posix_single_quotes_escaped() {
440 let args: Vec<String> = vec!["echo".into(), "it's".into()];
441 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
442 }
443
444 #[test]
445 fn posix_empty_arg() {
446 let args: Vec<String> = vec!["cmd".into(), String::new()];
447 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
448 }
449
450 #[test]
451 fn powershell_simple_args() {
452 let args: Vec<String> = vec!["npm".into(), "install".into()];
453 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
454 }
455
456 #[test]
457 fn powershell_path_with_spaces() {
458 let args: Vec<String> = vec![
459 "C:\\Program Files\\nodejs\\npm.cmd".into(),
460 "install".into(),
461 ];
462 assert_eq!(
463 join_command_for(&args, "-Command"),
464 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
465 );
466 }
467
468 #[test]
469 fn powershell_single_quotes_escaped() {
470 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
471 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
472 }
473
474 #[test]
475 fn cmd_simple_args() {
476 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
477 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
478 }
479
480 #[test]
481 fn cmd_path_with_spaces() {
482 let args: Vec<String> = vec![
483 "C:\\Program Files\\nodejs\\npm.cmd".into(),
484 "install".into(),
485 ];
486 assert_eq!(
487 join_command_for(&args, "/C"),
488 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
489 );
490 }
491
492 #[test]
493 fn cmd_double_quotes_escaped() {
494 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
495 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
496 }
497
498 #[test]
499 fn unknown_flag_uses_posix() {
500 let args: Vec<String> = vec!["ls".into(), "-la".into()];
501 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
502 }
503
504 #[test]
505 fn powershell_single_full_command_not_quoted() {
506 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
507 let result = join_command_for(&args, "-Command");
508 assert_eq!(result, "git commit -m \"feat: add feature\"");
509 assert!(
510 !result.starts_with("& '"),
511 "must not wrap full command in & '...'"
512 );
513 }
514
515 #[test]
516 fn powershell_single_no_spaces_still_uses_call_operator() {
517 let args: Vec<String> = vec!["git".into()];
518 assert_eq!(join_command_for(&args, "-Command"), "& git");
519 }
520}
521
522#[cfg(test)]
523mod is_powershell_tests {
524 use super::is_powershell;
525
526 #[test]
527 fn detects_pwsh_exe() {
528 assert!(is_powershell("pwsh.exe"));
529 }
530
531 #[test]
532 fn detects_powershell_exe() {
533 assert!(is_powershell("powershell.exe"));
534 }
535
536 #[test]
537 fn rejects_cmd() {
538 assert!(!is_powershell("cmd.exe"));
539 }
540
541 #[test]
542 fn rejects_bash() {
543 assert!(!is_powershell("/usr/bin/bash"));
544 }
545
546 #[test]
547 fn case_insensitive() {
548 assert!(is_powershell("PWSH.EXE"));
549 assert!(is_powershell("PowerShell.exe"));
550 }
551
552 #[test]
553 fn full_path_with_pwsh() {
554 assert!(is_powershell(
555 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
556 ));
557 assert!(is_powershell("/usr/local/bin/pwsh"));
558 }
559}
560
561#[cfg(test)]
562mod powershell_profile_tests {
563 use super::powershell_profile_path;
564 use std::path::Path;
565
566 #[test]
567 fn always_ends_with_profile_file() {
568 let p = powershell_profile_path(Path::new("/home/u"));
569 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
570 }
571
572 #[cfg(not(windows))]
573 #[test]
574 fn non_windows_uses_config_powershell_never_documents() {
575 let p = powershell_profile_path(Path::new("/Users/jane"));
578 assert_eq!(
579 p,
580 Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
581 );
582 assert!(
583 !p.to_string_lossy().contains("Documents"),
584 "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
585 );
586 }
587
588 #[cfg(windows)]
589 #[test]
590 fn windows_uses_documents_powershell() {
591 let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
592 assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
593 }
594}
595
596#[cfg(test)]
597mod windows_shell_flag_tests {
598 use super::windows_shell_flag_for_exe_basename;
599
600 #[test]
601 fn cmd_uses_slash_c() {
602 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
603 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
604 }
605
606 #[test]
607 fn powershell_uses_command() {
608 assert_eq!(
609 windows_shell_flag_for_exe_basename("powershell.exe"),
610 "-Command"
611 );
612 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
613 }
614
615 #[test]
616 fn posix_shells_use_dash_c() {
617 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
618 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
619 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
620 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
621 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
622 }
623}
624
625#[cfg(test)]
626mod platform_tests {
627 #[test]
628 fn is_container_returns_bool() {
629 let _ = super::is_container();
630 }
631
632 #[test]
633 fn is_non_interactive_returns_bool() {
634 let _ = super::is_non_interactive();
635 }
636
637 #[test]
638 fn join_command_preserves_structure() {
639 let args = vec![
640 "git".to_string(),
641 "commit".to_string(),
642 "-m".to_string(),
643 "my message".to_string(),
644 ];
645 let joined = super::join_command(&args);
646 assert!(joined.contains("git"));
647 assert!(joined.contains("commit"));
648 assert!(joined.contains("my message") || joined.contains("'my message'"));
649 }
650
651 #[test]
652 fn quote_posix_handles_em_dash() {
653 let result = super::quote_posix("closing — see #407");
654 assert!(
655 result.starts_with('\''),
656 "em-dash args must be single-quoted: {result}"
657 );
658 }
659
660 #[test]
661 fn quote_posix_handles_nested_single_quotes() {
662 let result = super::quote_posix("it's a test");
663 assert!(
664 result.contains("\\'"),
665 "single quotes must be escaped: {result}"
666 );
667 }
668
669 #[test]
670 fn quote_posix_safe_chars_unquoted() {
671 let result = super::quote_posix("simple_word");
672 assert_eq!(result, "simple_word");
673 }
674
675 #[test]
676 fn quote_posix_empty_string() {
677 let result = super::quote_posix("");
678 assert_eq!(result, "''");
679 }
680
681 #[test]
682 fn quote_posix_dollar_expansion_protected() {
683 let result = super::quote_posix("$HOME/test");
684 assert!(
685 result.starts_with('\''),
686 "dollar signs must be single-quoted: {result}"
687 );
688 }
689
690 #[test]
691 fn quote_posix_backtick_protected() {
692 let result = super::quote_posix("echo `date`");
693 assert!(
694 result.starts_with('\''),
695 "backticks must be single-quoted: {result}"
696 );
697 }
698
699 #[test]
700 fn quote_posix_double_quotes_protected() {
701 let result = super::quote_posix(r#"he said "hello""#);
702 assert!(
703 result.starts_with('\''),
704 "double quotes must be single-quoted: {result}"
705 );
706 }
707
708 #[cfg(unix)]
712 #[test]
713 fn profile_free_env_blocks_bash_env_contamination() {
714 let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
715 .into_iter()
716 .find(|p| std::path::Path::new(p).exists())
717 else {
718 return; };
720
721 let startup = std::env::temp_dir().join(format!(
722 "lean_ctx_bashenv_{}_{}.sh",
723 std::process::id(),
724 "guard"
725 ));
726 std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
727
728 let mut cmd = std::process::Command::new(bash);
729 cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
730 super::apply_profile_free_env(&mut cmd);
731 let out = cmd.output().expect("run bash");
732 let stdout = String::from_utf8_lossy(&out.stdout);
733
734 let _ = std::fs::remove_file(&startup);
735
736 assert!(stdout.contains("clean"), "command output missing: {stdout}");
737 assert!(
738 !stdout.contains("CONTAMINATED"),
739 "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
740 );
741 }
742}
743
744#[cfg(all(test, unix))]
745mod posix_shell_gate_tests {
746 use super::{detect_shell, is_posix_compatible_shell};
747
748 #[test]
749 fn accepts_posix_shells() {
750 for s in [
751 "/bin/bash",
752 "/bin/zsh",
753 "/bin/sh",
754 "/usr/bin/dash",
755 "/bin/ash",
756 "/usr/bin/ksh",
757 "/usr/bin/mksh",
758 "bash",
759 "zsh",
760 ] {
761 assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
762 }
763 }
764
765 #[test]
766 fn rejects_interactive_and_nonposix_shells() {
767 for s in [
769 "/usr/bin/nu",
770 "/opt/homebrew/bin/nu",
771 "/usr/bin/fish",
772 "/usr/local/bin/elvish",
773 "/usr/bin/xonsh",
774 "/usr/bin/pwsh",
775 "powershell.exe",
776 "cmd.exe",
777 ] {
778 assert!(
779 !is_posix_compatible_shell(s),
780 "{s} must be rejected by the POSIX gate"
781 );
782 }
783 }
784
785 #[test]
786 #[cfg_attr(miri, ignore)]
787 fn detect_shell_falls_back_when_shell_is_nushell() {
788 let _lock = crate::core::data_dir::test_env_lock();
790 let saved_shell = std::env::var_os("SHELL");
791 let saved_override = std::env::var_os("LEAN_CTX_SHELL");
792
793 crate::test_env::remove_var("LEAN_CTX_SHELL");
794 crate::test_env::set_var("SHELL", "/usr/bin/nu");
795 let resolved = detect_shell();
796 assert!(
797 is_posix_compatible_shell(&resolved),
798 "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
799 );
800 assert!(
801 !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
802 "must not run agent commands in Nushell, got {resolved}"
803 );
804
805 crate::test_env::set_var("SHELL", "/bin/sh");
807 assert_eq!(detect_shell(), "/bin/sh");
808
809 crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
811 assert_eq!(detect_shell(), "/usr/bin/nu");
812
813 match saved_shell {
814 Some(v) => crate::test_env::set_var("SHELL", v),
815 None => crate::test_env::remove_var("SHELL"),
816 }
817 match saved_override {
818 Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
819 None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
820 }
821 }
822}