Skip to main content

lean_ctx/shell/
platform.rs

1use std::io::{self, IsTerminal};
2
3/// Sets `LC_CTYPE=C.UTF-8` when no UTF-8 locale is inherited from the parent
4/// process. Without this, commands treat bytes >127 as non-printable (C locale),
5/// mangling Cyrillic, CJK, emoji, etc.
6pub(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
17/// Neutralizes inherited startup-file hooks so a non-login/non-interactive
18/// `sh -c` / `bash -c` cannot be hijacked into sourcing a profile or rc file
19/// (#451). lean-ctx runs the system POSIX shell profile-free and deterministic;
20/// a stray inherited `BASH_ENV` (read by bash even for `bash -c`) or `ENV`
21/// pointing at e.g. an `exec nu` snippet would otherwise silently replace the
22/// shell. Cleared to empty so the expansion names no file. No-op in effect for
23/// PowerShell/cmd, which ignore both variables.
24pub(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    // SAFETY: declares Win32 API symbols that exist in kernel32; signatures
55    // match the documented ABI.
56    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    // SAFETY: `GetACP` takes no arguments and only returns the active code
69    // page; it cannot fail or cause undefined behaviour.
70    let codepage = unsafe { GetACP() };
71    // SAFETY: called with a null destination and length 0 to measure the
72    // required buffer size; `bytes` is a live slice and every pointer/length
73    // argument is valid.
74    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    // SAFETY: `wide` is sized to the previously measured length and `bytes` is
89    // a live slice; the source and destination pointers/lengths are valid and
90    // do not overlap.
91    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    // SAFETY: declares a Win32 API symbol that exists in kernel32; the
109    // signature matches the documented ABI.
110    unsafe extern "system" {
111        fn SetConsoleOutputCP(id: u32) -> i32;
112    }
113    // SAFETY: `SetConsoleOutputCP` takes a code-page id (65001 = UTF-8) by
114    // value; it cannot cause undefined behaviour.
115    unsafe {
116        SetConsoleOutputCP(65001);
117    }
118}
119
120/// Detects if the current process runs inside a Docker/container environment.
121pub 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
145/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
146pub fn is_non_interactive() -> bool {
147    !io::stdin().is_terminal()
148}
149
150/// Returns `true` when `shell_path` points to a PowerShell executable.
151pub(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
160/// Path to the current-user PowerShell profile (`$PROFILE.CurrentUserCurrentHost`).
161///
162/// Windows PowerShell stores it under `Documents\PowerShell\…`, but **PowerShell
163/// (pwsh) on macOS/Linux reads `~/.config/powershell/…` instead** — and stat-ing
164/// anything inside `~/Documents` on macOS pops a TCC privacy prompt ("lean-ctx
165/// would like to access files in your Documents folder", #356). Resolving the
166/// profile per-OS keeps pwsh support everywhere while never touching `~/Documents`
167/// on non-Windows hosts.
168pub(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
177/// Windows only: argument that passes one command string to the shell binary.
178/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
179fn 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
204/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
205pub 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        return shell;
233    }
234
235    find_real_shell()
236}
237
238#[cfg(unix)]
239fn find_real_shell() -> String {
240    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
241        if std::path::Path::new(shell).exists() {
242            return shell.to_string();
243        }
244    }
245    "/bin/sh".to_string()
246}
247
248#[cfg(windows)]
249fn find_real_shell() -> String {
250    if is_running_in_msys_or_gitbash() {
251        for candidate in &["bash.exe", "sh.exe"] {
252            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
253                if output.status.success() {
254                    if let Ok(path) = String::from_utf8(output.stdout) {
255                        if let Some(first_line) = path.lines().next() {
256                            let trimmed = first_line.trim();
257                            if !trimmed.is_empty() {
258                                return trimmed.to_string();
259                            }
260                        }
261                    }
262                }
263            }
264        }
265    }
266    if let Ok(pwsh) = which_powershell() {
267        return pwsh;
268    }
269    if let Ok(comspec) = std::env::var("COMSPEC") {
270        return comspec;
271    }
272    "cmd.exe".to_string()
273}
274
275#[cfg(windows)]
276fn is_running_in_msys_or_gitbash() -> bool {
277    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
278}
279
280#[cfg(windows)]
281fn which_powershell() -> Result<String, ()> {
282    for candidate in &["pwsh.exe", "powershell.exe"] {
283        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
284            if output.status.success() {
285                if let Ok(path) = String::from_utf8(output.stdout) {
286                    if let Some(first_line) = path.lines().next() {
287                        let trimmed = first_line.trim();
288                        if !trimmed.is_empty() {
289                            return Ok(trimmed.to_string());
290                        }
291                    }
292                }
293            }
294        }
295    }
296    Err(())
297}
298
299/// Join multiple CLI arguments into a single command string, using quoting
300/// conventions appropriate for the detected shell.
301///
302/// On Unix, this always produces POSIX-compatible quoting.
303/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
304/// or Git Bash / MSYS).
305pub fn join_command(args: &[String]) -> String {
306    let (_, flag) = shell_and_flag();
307    join_command_for(args, &flag)
308}
309
310pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
311    match shell_flag {
312        "-Command" => join_powershell(args),
313        "/C" => join_cmd(args),
314        _ => join_posix(args),
315    }
316}
317
318fn join_posix(args: &[String]) -> String {
319    args.iter()
320        .map(|a| quote_posix(a))
321        .collect::<Vec<_>>()
322        .join(" ")
323}
324
325fn join_powershell(args: &[String]) -> String {
326    if args.len() == 1 && args[0].contains(' ') {
327        return args[0].clone();
328    }
329    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
330    format!("& {}", quoted.join(" "))
331}
332
333fn join_cmd(args: &[String]) -> String {
334    args.iter()
335        .map(|a| quote_cmd(a))
336        .collect::<Vec<_>>()
337        .join(" ")
338}
339
340fn quote_posix(s: &str) -> String {
341    if s.is_empty() {
342        return "''".to_string();
343    }
344    if s.bytes()
345        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
346    {
347        return s.to_string();
348    }
349    format!("'{}'", s.replace('\'', "'\\''"))
350}
351
352fn quote_powershell(s: &str) -> String {
353    if s.is_empty() {
354        return "''".to_string();
355    }
356    if s.bytes()
357        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
358    {
359        return s.to_string();
360    }
361    format!("'{}'", s.replace('\'', "''"))
362}
363
364fn quote_cmd(s: &str) -> String {
365    if s.is_empty() {
366        return "\"\"".to_string();
367    }
368    if s.bytes()
369        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
370    {
371        return s.to_string();
372    }
373    format!("\"{}\"", s.replace('"', "\\\""))
374}
375
376#[cfg(test)]
377mod join_command_tests {
378    use super::*;
379
380    #[test]
381    fn posix_simple_args() {
382        let args: Vec<String> = vec!["git".into(), "status".into()];
383        assert_eq!(join_command_for(&args, "-c"), "git status");
384    }
385
386    #[test]
387    fn posix_path_with_spaces() {
388        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
389        assert_eq!(
390            join_command_for(&args, "-c"),
391            "'/usr/local/my app/bin' --help"
392        );
393    }
394
395    #[test]
396    fn posix_single_quotes_escaped() {
397        let args: Vec<String> = vec!["echo".into(), "it's".into()];
398        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
399    }
400
401    #[test]
402    fn posix_empty_arg() {
403        let args: Vec<String> = vec!["cmd".into(), String::new()];
404        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
405    }
406
407    #[test]
408    fn powershell_simple_args() {
409        let args: Vec<String> = vec!["npm".into(), "install".into()];
410        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
411    }
412
413    #[test]
414    fn powershell_path_with_spaces() {
415        let args: Vec<String> = vec![
416            "C:\\Program Files\\nodejs\\npm.cmd".into(),
417            "install".into(),
418        ];
419        assert_eq!(
420            join_command_for(&args, "-Command"),
421            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
422        );
423    }
424
425    #[test]
426    fn powershell_single_quotes_escaped() {
427        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
428        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
429    }
430
431    #[test]
432    fn cmd_simple_args() {
433        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
434        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
435    }
436
437    #[test]
438    fn cmd_path_with_spaces() {
439        let args: Vec<String> = vec![
440            "C:\\Program Files\\nodejs\\npm.cmd".into(),
441            "install".into(),
442        ];
443        assert_eq!(
444            join_command_for(&args, "/C"),
445            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
446        );
447    }
448
449    #[test]
450    fn cmd_double_quotes_escaped() {
451        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
452        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
453    }
454
455    #[test]
456    fn unknown_flag_uses_posix() {
457        let args: Vec<String> = vec!["ls".into(), "-la".into()];
458        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
459    }
460
461    #[test]
462    fn powershell_single_full_command_not_quoted() {
463        let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
464        let result = join_command_for(&args, "-Command");
465        assert_eq!(result, "git commit -m \"feat: add feature\"");
466        assert!(
467            !result.starts_with("& '"),
468            "must not wrap full command in & '...'"
469        );
470    }
471
472    #[test]
473    fn powershell_single_no_spaces_still_uses_call_operator() {
474        let args: Vec<String> = vec!["git".into()];
475        assert_eq!(join_command_for(&args, "-Command"), "& git");
476    }
477}
478
479#[cfg(test)]
480mod is_powershell_tests {
481    use super::is_powershell;
482
483    #[test]
484    fn detects_pwsh_exe() {
485        assert!(is_powershell("pwsh.exe"));
486    }
487
488    #[test]
489    fn detects_powershell_exe() {
490        assert!(is_powershell("powershell.exe"));
491    }
492
493    #[test]
494    fn rejects_cmd() {
495        assert!(!is_powershell("cmd.exe"));
496    }
497
498    #[test]
499    fn rejects_bash() {
500        assert!(!is_powershell("/usr/bin/bash"));
501    }
502
503    #[test]
504    fn case_insensitive() {
505        assert!(is_powershell("PWSH.EXE"));
506        assert!(is_powershell("PowerShell.exe"));
507    }
508
509    #[test]
510    fn full_path_with_pwsh() {
511        assert!(is_powershell(
512            "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
513        ));
514        assert!(is_powershell("/usr/local/bin/pwsh"));
515    }
516}
517
518#[cfg(test)]
519mod powershell_profile_tests {
520    use super::powershell_profile_path;
521    use std::path::Path;
522
523    #[test]
524    fn always_ends_with_profile_file() {
525        let p = powershell_profile_path(Path::new("/home/u"));
526        assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
527    }
528
529    #[cfg(not(windows))]
530    #[test]
531    fn non_windows_uses_config_powershell_never_documents() {
532        // #356: stat-ing anything under ~/Documents pops a macOS TCC prompt, so the
533        // non-Windows profile path must live under ~/.config/powershell instead.
534        let p = powershell_profile_path(Path::new("/Users/jane"));
535        assert_eq!(
536            p,
537            Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
538        );
539        assert!(
540            !p.to_string_lossy().contains("Documents"),
541            "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
542        );
543    }
544
545    #[cfg(windows)]
546    #[test]
547    fn windows_uses_documents_powershell() {
548        let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
549        assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
550    }
551}
552
553#[cfg(test)]
554mod windows_shell_flag_tests {
555    use super::windows_shell_flag_for_exe_basename;
556
557    #[test]
558    fn cmd_uses_slash_c() {
559        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
560        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
561    }
562
563    #[test]
564    fn powershell_uses_command() {
565        assert_eq!(
566            windows_shell_flag_for_exe_basename("powershell.exe"),
567            "-Command"
568        );
569        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
570    }
571
572    #[test]
573    fn posix_shells_use_dash_c() {
574        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
575        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
576        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
577        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
578        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
579    }
580}
581
582#[cfg(test)]
583mod platform_tests {
584    #[test]
585    fn is_container_returns_bool() {
586        let _ = super::is_container();
587    }
588
589    #[test]
590    fn is_non_interactive_returns_bool() {
591        let _ = super::is_non_interactive();
592    }
593
594    #[test]
595    fn join_command_preserves_structure() {
596        let args = vec![
597            "git".to_string(),
598            "commit".to_string(),
599            "-m".to_string(),
600            "my message".to_string(),
601        ];
602        let joined = super::join_command(&args);
603        assert!(joined.contains("git"));
604        assert!(joined.contains("commit"));
605        assert!(joined.contains("my message") || joined.contains("'my message'"));
606    }
607
608    #[test]
609    fn quote_posix_handles_em_dash() {
610        let result = super::quote_posix("closing — see #407");
611        assert!(
612            result.starts_with('\''),
613            "em-dash args must be single-quoted: {result}"
614        );
615    }
616
617    #[test]
618    fn quote_posix_handles_nested_single_quotes() {
619        let result = super::quote_posix("it's a test");
620        assert!(
621            result.contains("\\'"),
622            "single quotes must be escaped: {result}"
623        );
624    }
625
626    #[test]
627    fn quote_posix_safe_chars_unquoted() {
628        let result = super::quote_posix("simple_word");
629        assert_eq!(result, "simple_word");
630    }
631
632    #[test]
633    fn quote_posix_empty_string() {
634        let result = super::quote_posix("");
635        assert_eq!(result, "''");
636    }
637
638    #[test]
639    fn quote_posix_dollar_expansion_protected() {
640        let result = super::quote_posix("$HOME/test");
641        assert!(
642            result.starts_with('\''),
643            "dollar signs must be single-quoted: {result}"
644        );
645    }
646
647    #[test]
648    fn quote_posix_backtick_protected() {
649        let result = super::quote_posix("echo `date`");
650        assert!(
651            result.starts_with('\''),
652            "backticks must be single-quoted: {result}"
653        );
654    }
655
656    #[test]
657    fn quote_posix_double_quotes_protected() {
658        let result = super::quote_posix(r#"he said "hello""#);
659        assert!(
660            result.starts_with('\''),
661            "double quotes must be single-quoted: {result}"
662        );
663    }
664
665    // #451: a non-interactive `bash -c` sources $BASH_ENV. lean-ctx must run
666    // profile-free, so `apply_profile_free_env` has to neutralize an inherited
667    // BASH_ENV before it can pull in a contaminating startup file.
668    #[cfg(unix)]
669    #[test]
670    fn profile_free_env_blocks_bash_env_contamination() {
671        let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
672            .into_iter()
673            .find(|p| std::path::Path::new(p).exists())
674        else {
675            return; // no bash on this host → nothing to guard against
676        };
677
678        let startup = std::env::temp_dir().join(format!(
679            "lean_ctx_bashenv_{}_{}.sh",
680            std::process::id(),
681            "guard"
682        ));
683        std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
684
685        let mut cmd = std::process::Command::new(bash);
686        cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
687        super::apply_profile_free_env(&mut cmd);
688        let out = cmd.output().expect("run bash");
689        let stdout = String::from_utf8_lossy(&out.stdout);
690
691        let _ = std::fs::remove_file(&startup);
692
693        assert!(stdout.contains("clean"), "command output missing: {stdout}");
694        assert!(
695            !stdout.contains("CONTAMINATED"),
696            "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
697        );
698    }
699}