Skip to main content

harn_vm/
shells.rs

1use std::cell::RefCell;
2#[cfg(not(windows))]
3use std::collections::BTreeSet;
4use std::path::{Path, PathBuf};
5
6use crate::value::{VmError, VmValue};
7
8thread_local! {
9    static SELECTED_DEFAULT_SHELL_ID: RefCell<Option<String>> = const { RefCell::new(None) };
10}
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ShellDescriptor {
14    pub id: String,
15    pub label: String,
16    pub path: String,
17    pub platform: String,
18    pub available: bool,
19    pub supports_login: bool,
20    pub supports_interactive: bool,
21    pub default_args: Vec<String>,
22    pub login_args: Vec<String>,
23    pub source: String,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct ShellCatalog {
28    pub shells: Vec<ShellDescriptor>,
29    pub default_shell_id: Option<String>,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct ShellInvocation {
34    pub program: String,
35    pub args: Vec<String>,
36    pub command_arg_index: usize,
37    pub shell: ShellDescriptor,
38}
39
40pub fn clear_selected_default_shell_for_test() {
41    SELECTED_DEFAULT_SHELL_ID.with(|selected| *selected.borrow_mut() = None);
42}
43
44pub fn discover_shells() -> ShellCatalog {
45    let shells = platform_shells();
46    let selected = SELECTED_DEFAULT_SHELL_ID.with(|selected| selected.borrow().clone());
47    let default_shell_id = selected
48        .filter(|id| {
49            shells
50                .iter()
51                .any(|shell| shell.id == *id && shell.available)
52        })
53        .or_else(|| {
54            shells
55                .iter()
56                .find(|shell| shell.available)
57                .map(|shell| shell.id.clone())
58        })
59        .or_else(|| shells.first().map(|shell| shell.id.clone()));
60    ShellCatalog {
61        shells,
62        default_shell_id,
63    }
64}
65
66pub fn get_default_shell() -> Option<ShellDescriptor> {
67    let catalog = discover_shells();
68    catalog
69        .default_shell_id
70        .as_deref()
71        .and_then(|id| catalog.shells.iter().find(|shell| shell.id == id))
72        .cloned()
73        .or_else(|| catalog.shells.first().cloned())
74}
75
76pub fn set_default_shell(shell_id: &str) -> Result<ShellDescriptor, String> {
77    let catalog = discover_shells();
78    let Some(shell) = catalog
79        .shells
80        .iter()
81        .find(|shell| shell.id == shell_id && shell.available)
82        .cloned()
83    else {
84        return Err(format!("unknown or unavailable shell id {shell_id:?}"));
85    };
86    SELECTED_DEFAULT_SHELL_ID.with(|selected| *selected.borrow_mut() = Some(shell.id.clone()));
87    Ok(shell)
88}
89
90pub fn list_shells_vm_value() -> VmValue {
91    shell_catalog_to_vm_value(&discover_shells())
92}
93
94pub fn default_shell_vm_value() -> VmValue {
95    get_default_shell()
96        .map(|shell| shell_descriptor_to_vm_value(&shell))
97        .unwrap_or(VmValue::Nil)
98}
99
100pub fn set_default_shell_vm_value(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
101    let shell_id = params
102        .get("shell_id")
103        .or_else(|| params.get("id"))
104        .and_then(vm_string)
105        .ok_or_else(|| {
106            VmError::Runtime("process.set_default_shell missing shell_id".to_string())
107        })?;
108    set_default_shell(shell_id)
109        .map(|shell| shell_descriptor_to_vm_value(&shell))
110        .map_err(|err| VmError::Runtime(format!("process.set_default_shell: {err}")))
111}
112
113pub fn shell_invocation_vm_value(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
114    resolve_invocation_from_vm_params(params)
115        .map(|invocation| shell_invocation_to_vm_value(&invocation))
116        .map_err(|err| VmError::Runtime(format!("process.shell_invocation: {err}")))
117}
118
119pub fn default_shell_invocation(command: &str) -> Result<ShellInvocation, String> {
120    let shell = get_default_shell().ok_or_else(|| "no shell candidates available".to_string())?;
121    Ok(invocation_for_shell(
122        shell,
123        command.to_string(),
124        false,
125        false,
126    ))
127}
128
129pub fn resolve_invocation_from_vm_params(
130    params: &crate::value::DictMap,
131) -> Result<ShellInvocation, String> {
132    // "{command}" is a downstream template placeholder, not a format string.
133    #[allow(clippy::literal_string_with_formatting_args)]
134    let command = params
135        .get("command")
136        .and_then(vm_string)
137        .unwrap_or("{command}")
138        .to_string();
139    let login = optional_bool(params, "login").unwrap_or(false);
140    let interactive = optional_bool(params, "interactive").unwrap_or(false);
141    let shell = resolve_shell_from_vm_params(params)?;
142    Ok(invocation_for_shell(shell, command, login, interactive))
143}
144
145pub fn resolve_shell_from_vm_params(
146    params: &crate::value::DictMap,
147) -> Result<ShellDescriptor, String> {
148    if let Some(value) = params.get("shell") {
149        if let Some(shell) = value.as_dict() {
150            return shell_descriptor_from_vm_dict(shell);
151        }
152        if !matches!(value, VmValue::Nil) {
153            return Err(format!("shell must be a dict, got {}", value.type_name()));
154        }
155    }
156    if let Some(value) = params.get("shell_id") {
157        if let Some(shell_id) = vm_string(value) {
158            return shell_by_id(shell_id);
159        }
160        if !matches!(value, VmValue::Nil) {
161            return Err(format!(
162                "shell_id must be a string, got {}",
163                value.type_name()
164            ));
165        }
166    }
167    get_default_shell().ok_or_else(|| "no default shell available".to_string())
168}
169
170pub fn shell_descriptor_to_vm_value(shell: &ShellDescriptor) -> VmValue {
171    let mut map = crate::value::DictMap::new();
172    map.insert(crate::value::intern_key("id"), string(&shell.id));
173    map.insert(crate::value::intern_key("label"), string(&shell.label));
174    map.insert(crate::value::intern_key("path"), string(&shell.path));
175    map.insert(
176        crate::value::intern_key("platform"),
177        string(&shell.platform),
178    );
179    map.insert(
180        crate::value::intern_key("available"),
181        VmValue::Bool(shell.available),
182    );
183    map.insert(
184        crate::value::intern_key("supports_login"),
185        VmValue::Bool(shell.supports_login),
186    );
187    map.insert(
188        crate::value::intern_key("supports_interactive"),
189        VmValue::Bool(shell.supports_interactive),
190    );
191    map.insert(
192        crate::value::intern_key("default_args"),
193        string_list(&shell.default_args),
194    );
195    map.insert(
196        crate::value::intern_key("login_args"),
197        string_list(&shell.login_args),
198    );
199    map.insert(crate::value::intern_key("source"), string(&shell.source));
200    VmValue::dict(map)
201}
202
203pub fn shell_invocation_to_vm_value(invocation: &ShellInvocation) -> VmValue {
204    let mut map = crate::value::DictMap::new();
205    map.insert(
206        crate::value::intern_key("program"),
207        string(&invocation.program),
208    );
209    map.insert(
210        crate::value::intern_key("args"),
211        string_list(&invocation.args),
212    );
213    map.insert(
214        crate::value::intern_key("command_arg_index"),
215        VmValue::Int(invocation.command_arg_index as i64),
216    );
217    map.insert(
218        crate::value::intern_key("shell"),
219        shell_descriptor_to_vm_value(&invocation.shell),
220    );
221    VmValue::dict(map)
222}
223
224fn shell_catalog_to_vm_value(catalog: &ShellCatalog) -> VmValue {
225    let mut map = crate::value::DictMap::new();
226    map.insert(
227        crate::value::intern_key("shells"),
228        VmValue::List(std::sync::Arc::new(
229            catalog
230                .shells
231                .iter()
232                .map(shell_descriptor_to_vm_value)
233                .collect(),
234        )),
235    );
236    map.insert(
237        crate::value::intern_key("default_shell_id"),
238        catalog
239            .default_shell_id
240            .as_ref()
241            .map(|id| string(id))
242            .unwrap_or(VmValue::Nil),
243    );
244    VmValue::dict(map)
245}
246
247fn shell_descriptor_from_vm_dict(dict: &crate::value::DictMap) -> Result<ShellDescriptor, String> {
248    if let Some(path) = dict.get("path").and_then(vm_string) {
249        let id = dict
250            .get("id")
251            .and_then(vm_string)
252            .map(ToString::to_string)
253            .unwrap_or_else(|| shell_id_from_path(path));
254        let platform = dict
255            .get("platform")
256            .and_then(vm_string)
257            .unwrap_or(platform_name())
258            .to_string();
259        let label = dict
260            .get("label")
261            .and_then(vm_string)
262            .map(ToString::to_string)
263            .unwrap_or_else(|| id.clone());
264        let default_args = dict
265            .get("default_args")
266            .and_then(vm_string_list)
267            .unwrap_or_else(|| default_args_for_id(&id));
268        let login_args = dict
269            .get("login_args")
270            .and_then(vm_string_list)
271            .unwrap_or_else(|| login_args_for_id(&id));
272        let available = dict
273            .get("available")
274            .and_then(|value| match value {
275                VmValue::Bool(value) => Some(*value),
276                _ => None,
277            })
278            .unwrap_or_else(|| executable_available(path));
279        let supports_login = dict
280            .get("supports_login")
281            .and_then(|value| match value {
282                VmValue::Bool(value) => Some(*value),
283                _ => None,
284            })
285            .unwrap_or_else(|| supports_login_for_id(&id));
286        let supports_interactive = dict
287            .get("supports_interactive")
288            .and_then(|value| match value {
289                VmValue::Bool(value) => Some(*value),
290                _ => None,
291            })
292            .unwrap_or_else(|| supports_interactive_for_id(&id));
293        return Ok(ShellDescriptor {
294            id,
295            label,
296            path: path.to_string(),
297            platform,
298            available,
299            supports_login,
300            supports_interactive,
301            default_args,
302            login_args,
303            source: dict
304                .get("source")
305                .and_then(vm_string)
306                .unwrap_or("host")
307                .to_string(),
308        });
309    }
310    if let Some(id) = dict.get("id").and_then(vm_string) {
311        return shell_by_id(id);
312    }
313    Err("shell object requires `path` or `id`".to_string())
314}
315
316fn shell_by_id(shell_id: &str) -> Result<ShellDescriptor, String> {
317    discover_shells()
318        .shells
319        .into_iter()
320        .find(|shell| shell.id == shell_id)
321        .ok_or_else(|| format!("unknown shell id {shell_id:?}"))
322}
323
324fn invocation_for_shell(
325    shell: ShellDescriptor,
326    command: String,
327    login: bool,
328    interactive: bool,
329) -> ShellInvocation {
330    let mut args = if login && shell.supports_login && !shell.login_args.is_empty() {
331        shell.login_args.clone()
332    } else {
333        shell.default_args.clone()
334    };
335    if interactive && shell.supports_interactive && !args.iter().any(|arg| arg == "-i") {
336        args.insert(0, "-i".to_string());
337    }
338    let command_arg_index = args.len();
339    args.push(command);
340    ShellInvocation {
341        program: shell.path.clone(),
342        args,
343        command_arg_index,
344        shell,
345    }
346}
347
348#[cfg(windows)]
349fn platform_shells() -> Vec<ShellDescriptor> {
350    let mut shells = Vec::new();
351    if let Ok(value) = std::env::var("HARN_DEFAULT_SHELL") {
352        push_shell(&mut shells, descriptor_for_path(&value, "configured"));
353    }
354    if let Ok(value) = std::env::var("COMSPEC") {
355        push_shell(&mut shells, descriptor_for_path(&value, "env"));
356    }
357    for (id, label, executable) in [
358        ("pwsh", "PowerShell 7", "pwsh.exe"),
359        ("powershell", "Windows PowerShell", "powershell.exe"),
360        ("cmd", "cmd", "cmd.exe"),
361    ] {
362        let path = find_on_path(executable).unwrap_or_else(|| executable.to_string());
363        let mut shell = descriptor_for_path(&path, "fallback");
364        shell.id = id.to_string();
365        shell.label = label.to_string();
366        push_shell(&mut shells, shell);
367    }
368    shells
369}
370
371#[cfg(not(windows))]
372fn platform_shells() -> Vec<ShellDescriptor> {
373    let mut shells = Vec::new();
374    if let Ok(value) = std::env::var("HARN_DEFAULT_SHELL") {
375        push_shell(&mut shells, descriptor_for_path(&value, "configured"));
376    }
377    if let Ok(value) = std::env::var("SHELL") {
378        push_shell(&mut shells, descriptor_for_path(&value, "env"));
379    }
380    if let Some(value) = login_shell_from_passwd() {
381        push_shell(&mut shells, descriptor_for_path(&value, "login"));
382    }
383    for value in shells_from_etc_shells() {
384        push_shell(&mut shells, descriptor_for_path(&value, "etc_shells"));
385    }
386    for value in [
387        "/bin/zsh",
388        "/bin/bash",
389        "/bin/sh",
390        "/usr/bin/zsh",
391        "/usr/bin/bash",
392        "/usr/bin/sh",
393    ] {
394        push_shell(&mut shells, descriptor_for_path(value, "fallback"));
395    }
396    shells
397}
398
399fn push_shell(shells: &mut Vec<ShellDescriptor>, shell: ShellDescriptor) {
400    if shells.iter().any(|existing| existing.id == shell.id) {
401        return;
402    }
403    shells.push(shell);
404}
405
406fn descriptor_for_path(path: &str, source: &str) -> ShellDescriptor {
407    let id = shell_id_from_path(path);
408    ShellDescriptor {
409        id: id.clone(),
410        label: label_for_id(&id),
411        path: path.to_string(),
412        platform: platform_name().to_string(),
413        available: executable_available(path),
414        supports_login: supports_login_for_id(&id),
415        supports_interactive: supports_interactive_for_id(&id),
416        default_args: default_args_for_id(&id),
417        login_args: login_args_for_id(&id),
418        source: source.to_string(),
419    }
420}
421
422fn shell_id_from_path(path: &str) -> String {
423    let raw = Path::new(path)
424        .file_name()
425        .and_then(|value| value.to_str())
426        .unwrap_or(path)
427        .to_ascii_lowercase();
428    let file_name = raw.strip_suffix(".exe").unwrap_or(&raw);
429    match file_name {
430        "powershell" | "windowspowershell" => "powershell".to_string(),
431        "pwsh" => "pwsh".to_string(),
432        "cmd" => "cmd".to_string(),
433        "bash" => "bash".to_string(),
434        "zsh" => "zsh".to_string(),
435        "fish" => "fish".to_string(),
436        _ if file_name.is_empty() => "shell".to_string(),
437        _ => file_name.to_string(),
438    }
439}
440
441fn label_for_id(id: &str) -> String {
442    match id {
443        "pwsh" => "PowerShell 7",
444        "powershell" => "Windows PowerShell",
445        "cmd" => "cmd",
446        "bash" => "bash",
447        "zsh" => "zsh",
448        "fish" => "fish",
449        "sh" => "sh",
450        other => other,
451    }
452    .to_string()
453}
454
455fn default_args_for_id(id: &str) -> Vec<String> {
456    match id {
457        "cmd" => vec!["/C".to_string()],
458        "pwsh" | "powershell" => vec!["-NoProfile".to_string(), "-Command".to_string()],
459        _ => vec!["-c".to_string()],
460    }
461}
462
463fn login_args_for_id(id: &str) -> Vec<String> {
464    match id {
465        "cmd" | "pwsh" | "powershell" => default_args_for_id(id),
466        _ => vec!["-l".to_string(), "-c".to_string()],
467    }
468}
469
470fn supports_login_for_id(id: &str) -> bool {
471    !matches!(id, "cmd" | "pwsh" | "powershell")
472}
473
474fn supports_interactive_for_id(id: &str) -> bool {
475    !matches!(id, "cmd" | "pwsh" | "powershell")
476}
477
478fn platform_name() -> &'static str {
479    if cfg!(target_os = "macos") {
480        "darwin"
481    } else if cfg!(target_os = "windows") {
482        "windows"
483    } else if cfg!(target_os = "linux") {
484        "linux"
485    } else {
486        std::env::consts::OS
487    }
488}
489
490fn executable_available(path: &str) -> bool {
491    let path_obj = Path::new(path);
492    if path_obj.components().count() > 1 || path_obj.is_absolute() {
493        return path_obj.is_file();
494    }
495    find_on_path(path).is_some()
496}
497
498fn find_on_path(program: &str) -> Option<String> {
499    let path = std::env::var_os("PATH")?;
500    let candidates = path_candidates(program);
501    for dir in std::env::split_paths(&path) {
502        for candidate in &candidates {
503            let full = dir.join(candidate);
504            if full.is_file() {
505                return Some(full.display().to_string());
506            }
507        }
508    }
509    None
510}
511
512#[cfg(windows)]
513fn path_candidates(program: &str) -> Vec<PathBuf> {
514    let mut candidates = vec![PathBuf::from(program)];
515    if Path::new(program).extension().is_none() {
516        for ext in [".exe", ".cmd", ".bat"] {
517            candidates.push(PathBuf::from(format!("{program}{ext}")));
518        }
519    }
520    candidates
521}
522
523#[cfg(not(windows))]
524fn path_candidates(program: &str) -> Vec<PathBuf> {
525    vec![PathBuf::from(program)]
526}
527
528#[cfg(not(windows))]
529fn login_shell_from_passwd() -> Option<String> {
530    let username = std::env::var("USER")
531        .or_else(|_| std::env::var("LOGNAME"))
532        .ok()?;
533    let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
534    passwd.lines().find_map(|line| {
535        let mut parts = line.split(':');
536        let name = parts.next()?;
537        if name != username {
538            return None;
539        }
540        parts
541            .nth(5)
542            .map(str::trim)
543            .filter(|shell| {
544                !shell.is_empty() && !shell.ends_with("/false") && !shell.ends_with("/nologin")
545            })
546            .map(ToString::to_string)
547    })
548}
549
550#[cfg(not(windows))]
551fn shells_from_etc_shells() -> Vec<String> {
552    let Ok(content) = std::fs::read_to_string("/etc/shells") else {
553        return Vec::new();
554    };
555    let mut seen = BTreeSet::new();
556    content
557        .lines()
558        .map(str::trim)
559        .filter(|line| !line.is_empty() && !line.starts_with('#') && line.starts_with('/'))
560        .filter(|line| seen.insert((*line).to_string()))
561        .map(ToString::to_string)
562        .collect()
563}
564
565fn optional_bool(params: &crate::value::DictMap, key: &str) -> Option<bool> {
566    match params.get(key) {
567        Some(VmValue::Bool(value)) => Some(*value),
568        _ => None,
569    }
570}
571
572fn vm_string(value: &VmValue) -> Option<&str> {
573    match value {
574        VmValue::String(value) => Some(value.as_ref()),
575        _ => None,
576    }
577}
578
579fn vm_string_list(value: &VmValue) -> Option<Vec<String>> {
580    let VmValue::List(values) = value else {
581        return None;
582    };
583    values
584        .iter()
585        .map(|value| vm_string(value).map(ToString::to_string))
586        .collect()
587}
588
589fn string(value: &str) -> VmValue {
590    VmValue::String(arcstr::ArcStr::from(value.to_string()))
591}
592
593fn string_list(values: &[String]) -> VmValue {
594    VmValue::List(std::sync::Arc::new(
595        values.iter().map(|value| string(value)).collect(),
596    ))
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    #[test]
604    fn unix_shell_descriptor_uses_split_login_args() {
605        let shell = descriptor_for_path("/bin/zsh", "fallback");
606        assert_eq!(shell.id, "zsh");
607        assert_eq!(shell.default_args, vec!["-c"]);
608        assert_eq!(shell.login_args, vec!["-l", "-c"]);
609        assert!(shell.supports_login);
610        assert!(shell.supports_interactive);
611    }
612
613    #[test]
614    fn windows_shell_descriptor_distinguishes_cmd_and_pwsh() {
615        let cmd = descriptor_for_path("cmd.exe", "fallback");
616        assert_eq!(cmd.id, "cmd");
617        assert_eq!(cmd.default_args, vec!["/C"]);
618        assert!(!cmd.supports_login);
619
620        let pwsh = descriptor_for_path("pwsh.exe", "fallback");
621        assert_eq!(pwsh.id, "pwsh");
622        assert_eq!(pwsh.default_args, vec!["-NoProfile", "-Command"]);
623    }
624
625    #[test]
626    fn invocation_appends_command_after_shell_args() {
627        let shell = ShellDescriptor {
628            id: "zsh".to_string(),
629            label: "zsh".to_string(),
630            path: "/bin/zsh".to_string(),
631            platform: "darwin".to_string(),
632            available: true,
633            supports_login: true,
634            supports_interactive: true,
635            default_args: vec!["-c".to_string()],
636            login_args: vec!["-l".to_string(), "-c".to_string()],
637            source: "test".to_string(),
638        };
639        let invocation = invocation_for_shell(shell, "echo ok".to_string(), true, true);
640        assert_eq!(invocation.program, "/bin/zsh");
641        assert_eq!(invocation.args, vec!["-i", "-l", "-c", "echo ok"]);
642        assert_eq!(invocation.command_arg_index, 3);
643    }
644
645    #[test]
646    fn invocation_without_explicit_shell_uses_default_shell() {
647        clear_selected_default_shell_for_test();
648        let default_shell = get_default_shell().expect("test host should expose a default shell");
649
650        let mut params = crate::value::DictMap::new();
651        params.insert(
652            crate::value::intern_key("command"),
653            string("echo default-shell"),
654        );
655
656        let invocation = resolve_invocation_from_vm_params(&params).unwrap();
657        assert_eq!(invocation.shell, default_shell);
658        assert_eq!(invocation.program, default_shell.path);
659        assert_eq!(
660            invocation.args[invocation.command_arg_index],
661            "echo default-shell"
662        );
663    }
664
665    #[test]
666    fn malformed_explicit_shell_fields_do_not_fall_back_to_default() {
667        let mut params = crate::value::DictMap::new();
668        params.insert(crate::value::intern_key("shell"), VmValue::Int(1));
669        assert_eq!(
670            resolve_shell_from_vm_params(&params).unwrap_err(),
671            "shell must be a dict, got int"
672        );
673
674        let mut params = crate::value::DictMap::new();
675        params.insert(crate::value::intern_key("shell_id"), VmValue::Int(1));
676        assert_eq!(
677            resolve_shell_from_vm_params(&params).unwrap_err(),
678            "shell_id must be a string, got int"
679        );
680    }
681}