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 #[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("id".to_string(), string(&shell.id));
173 map.insert("label".to_string(), string(&shell.label));
174 map.insert("path".to_string(), string(&shell.path));
175 map.insert("platform".to_string(), string(&shell.platform));
176 map.insert("available".to_string(), VmValue::Bool(shell.available));
177 map.insert(
178 "supports_login".to_string(),
179 VmValue::Bool(shell.supports_login),
180 );
181 map.insert(
182 "supports_interactive".to_string(),
183 VmValue::Bool(shell.supports_interactive),
184 );
185 map.insert("default_args".to_string(), string_list(&shell.default_args));
186 map.insert("login_args".to_string(), string_list(&shell.login_args));
187 map.insert("source".to_string(), string(&shell.source));
188 VmValue::dict(map)
189}
190
191pub fn shell_invocation_to_vm_value(invocation: &ShellInvocation) -> VmValue {
192 let mut map = crate::value::DictMap::new();
193 map.insert("program".to_string(), string(&invocation.program));
194 map.insert("args".to_string(), string_list(&invocation.args));
195 map.insert(
196 "command_arg_index".to_string(),
197 VmValue::Int(invocation.command_arg_index as i64),
198 );
199 map.insert(
200 "shell".to_string(),
201 shell_descriptor_to_vm_value(&invocation.shell),
202 );
203 VmValue::dict(map)
204}
205
206fn shell_catalog_to_vm_value(catalog: &ShellCatalog) -> VmValue {
207 let mut map = crate::value::DictMap::new();
208 map.insert(
209 "shells".to_string(),
210 VmValue::List(std::sync::Arc::new(
211 catalog
212 .shells
213 .iter()
214 .map(shell_descriptor_to_vm_value)
215 .collect(),
216 )),
217 );
218 map.insert(
219 "default_shell_id".to_string(),
220 catalog
221 .default_shell_id
222 .as_ref()
223 .map(|id| string(id))
224 .unwrap_or(VmValue::Nil),
225 );
226 VmValue::dict(map)
227}
228
229fn shell_descriptor_from_vm_dict(dict: &crate::value::DictMap) -> Result<ShellDescriptor, String> {
230 if let Some(path) = dict.get("path").and_then(vm_string) {
231 let id = dict
232 .get("id")
233 .and_then(vm_string)
234 .map(ToString::to_string)
235 .unwrap_or_else(|| shell_id_from_path(path));
236 let platform = dict
237 .get("platform")
238 .and_then(vm_string)
239 .unwrap_or(platform_name())
240 .to_string();
241 let label = dict
242 .get("label")
243 .and_then(vm_string)
244 .map(ToString::to_string)
245 .unwrap_or_else(|| id.clone());
246 let default_args = dict
247 .get("default_args")
248 .and_then(vm_string_list)
249 .unwrap_or_else(|| default_args_for_id(&id));
250 let login_args = dict
251 .get("login_args")
252 .and_then(vm_string_list)
253 .unwrap_or_else(|| login_args_for_id(&id));
254 let available = dict
255 .get("available")
256 .and_then(|value| match value {
257 VmValue::Bool(value) => Some(*value),
258 _ => None,
259 })
260 .unwrap_or_else(|| executable_available(path));
261 let supports_login = dict
262 .get("supports_login")
263 .and_then(|value| match value {
264 VmValue::Bool(value) => Some(*value),
265 _ => None,
266 })
267 .unwrap_or_else(|| supports_login_for_id(&id));
268 let supports_interactive = dict
269 .get("supports_interactive")
270 .and_then(|value| match value {
271 VmValue::Bool(value) => Some(*value),
272 _ => None,
273 })
274 .unwrap_or_else(|| supports_interactive_for_id(&id));
275 return Ok(ShellDescriptor {
276 id,
277 label,
278 path: path.to_string(),
279 platform,
280 available,
281 supports_login,
282 supports_interactive,
283 default_args,
284 login_args,
285 source: dict
286 .get("source")
287 .and_then(vm_string)
288 .unwrap_or("host")
289 .to_string(),
290 });
291 }
292 if let Some(id) = dict.get("id").and_then(vm_string) {
293 return shell_by_id(id);
294 }
295 Err("shell object requires `path` or `id`".to_string())
296}
297
298fn shell_by_id(shell_id: &str) -> Result<ShellDescriptor, String> {
299 discover_shells()
300 .shells
301 .into_iter()
302 .find(|shell| shell.id == shell_id)
303 .ok_or_else(|| format!("unknown shell id {shell_id:?}"))
304}
305
306fn invocation_for_shell(
307 shell: ShellDescriptor,
308 command: String,
309 login: bool,
310 interactive: bool,
311) -> ShellInvocation {
312 let mut args = if login && shell.supports_login && !shell.login_args.is_empty() {
313 shell.login_args.clone()
314 } else {
315 shell.default_args.clone()
316 };
317 if interactive && shell.supports_interactive && !args.iter().any(|arg| arg == "-i") {
318 args.insert(0, "-i".to_string());
319 }
320 let command_arg_index = args.len();
321 args.push(command);
322 ShellInvocation {
323 program: shell.path.clone(),
324 args,
325 command_arg_index,
326 shell,
327 }
328}
329
330#[cfg(windows)]
331fn platform_shells() -> Vec<ShellDescriptor> {
332 let mut shells = Vec::new();
333 if let Ok(value) = std::env::var("HARN_DEFAULT_SHELL") {
334 push_shell(&mut shells, descriptor_for_path(&value, "configured"));
335 }
336 if let Ok(value) = std::env::var("COMSPEC") {
337 push_shell(&mut shells, descriptor_for_path(&value, "env"));
338 }
339 for (id, label, executable) in [
340 ("pwsh", "PowerShell 7", "pwsh.exe"),
341 ("powershell", "Windows PowerShell", "powershell.exe"),
342 ("cmd", "cmd", "cmd.exe"),
343 ] {
344 let path = find_on_path(executable).unwrap_or_else(|| executable.to_string());
345 let mut shell = descriptor_for_path(&path, "fallback");
346 shell.id = id.to_string();
347 shell.label = label.to_string();
348 push_shell(&mut shells, shell);
349 }
350 shells
351}
352
353#[cfg(not(windows))]
354fn platform_shells() -> Vec<ShellDescriptor> {
355 let mut shells = Vec::new();
356 if let Ok(value) = std::env::var("HARN_DEFAULT_SHELL") {
357 push_shell(&mut shells, descriptor_for_path(&value, "configured"));
358 }
359 if let Ok(value) = std::env::var("SHELL") {
360 push_shell(&mut shells, descriptor_for_path(&value, "env"));
361 }
362 if let Some(value) = login_shell_from_passwd() {
363 push_shell(&mut shells, descriptor_for_path(&value, "login"));
364 }
365 for value in shells_from_etc_shells() {
366 push_shell(&mut shells, descriptor_for_path(&value, "etc_shells"));
367 }
368 for value in [
369 "/bin/zsh",
370 "/bin/bash",
371 "/bin/sh",
372 "/usr/bin/zsh",
373 "/usr/bin/bash",
374 "/usr/bin/sh",
375 ] {
376 push_shell(&mut shells, descriptor_for_path(value, "fallback"));
377 }
378 shells
379}
380
381fn push_shell(shells: &mut Vec<ShellDescriptor>, shell: ShellDescriptor) {
382 if shells.iter().any(|existing| existing.id == shell.id) {
383 return;
384 }
385 shells.push(shell);
386}
387
388fn descriptor_for_path(path: &str, source: &str) -> ShellDescriptor {
389 let id = shell_id_from_path(path);
390 ShellDescriptor {
391 id: id.clone(),
392 label: label_for_id(&id),
393 path: path.to_string(),
394 platform: platform_name().to_string(),
395 available: executable_available(path),
396 supports_login: supports_login_for_id(&id),
397 supports_interactive: supports_interactive_for_id(&id),
398 default_args: default_args_for_id(&id),
399 login_args: login_args_for_id(&id),
400 source: source.to_string(),
401 }
402}
403
404fn shell_id_from_path(path: &str) -> String {
405 let raw = Path::new(path)
406 .file_name()
407 .and_then(|value| value.to_str())
408 .unwrap_or(path)
409 .to_ascii_lowercase();
410 let file_name = raw.strip_suffix(".exe").unwrap_or(&raw);
411 match file_name {
412 "powershell" | "windowspowershell" => "powershell".to_string(),
413 "pwsh" => "pwsh".to_string(),
414 "cmd" => "cmd".to_string(),
415 "bash" => "bash".to_string(),
416 "zsh" => "zsh".to_string(),
417 "fish" => "fish".to_string(),
418 _ if file_name.is_empty() => "shell".to_string(),
419 _ => file_name.to_string(),
420 }
421}
422
423fn label_for_id(id: &str) -> String {
424 match id {
425 "pwsh" => "PowerShell 7",
426 "powershell" => "Windows PowerShell",
427 "cmd" => "cmd",
428 "bash" => "bash",
429 "zsh" => "zsh",
430 "fish" => "fish",
431 "sh" => "sh",
432 other => other,
433 }
434 .to_string()
435}
436
437fn default_args_for_id(id: &str) -> Vec<String> {
438 match id {
439 "cmd" => vec!["/C".to_string()],
440 "pwsh" | "powershell" => vec!["-NoProfile".to_string(), "-Command".to_string()],
441 _ => vec!["-c".to_string()],
442 }
443}
444
445fn login_args_for_id(id: &str) -> Vec<String> {
446 match id {
447 "cmd" | "pwsh" | "powershell" => default_args_for_id(id),
448 _ => vec!["-l".to_string(), "-c".to_string()],
449 }
450}
451
452fn supports_login_for_id(id: &str) -> bool {
453 !matches!(id, "cmd" | "pwsh" | "powershell")
454}
455
456fn supports_interactive_for_id(id: &str) -> bool {
457 !matches!(id, "cmd" | "pwsh" | "powershell")
458}
459
460fn platform_name() -> &'static str {
461 if cfg!(target_os = "macos") {
462 "darwin"
463 } else if cfg!(target_os = "windows") {
464 "windows"
465 } else if cfg!(target_os = "linux") {
466 "linux"
467 } else {
468 std::env::consts::OS
469 }
470}
471
472fn executable_available(path: &str) -> bool {
473 let path_obj = Path::new(path);
474 if path_obj.components().count() > 1 || path_obj.is_absolute() {
475 return path_obj.is_file();
476 }
477 find_on_path(path).is_some()
478}
479
480fn find_on_path(program: &str) -> Option<String> {
481 let path = std::env::var_os("PATH")?;
482 let candidates = path_candidates(program);
483 for dir in std::env::split_paths(&path) {
484 for candidate in &candidates {
485 let full = dir.join(candidate);
486 if full.is_file() {
487 return Some(full.display().to_string());
488 }
489 }
490 }
491 None
492}
493
494#[cfg(windows)]
495fn path_candidates(program: &str) -> Vec<PathBuf> {
496 let mut candidates = vec![PathBuf::from(program)];
497 if Path::new(program).extension().is_none() {
498 for ext in [".exe", ".cmd", ".bat"] {
499 candidates.push(PathBuf::from(format!("{program}{ext}")));
500 }
501 }
502 candidates
503}
504
505#[cfg(not(windows))]
506fn path_candidates(program: &str) -> Vec<PathBuf> {
507 vec![PathBuf::from(program)]
508}
509
510#[cfg(not(windows))]
511fn login_shell_from_passwd() -> Option<String> {
512 let username = std::env::var("USER")
513 .or_else(|_| std::env::var("LOGNAME"))
514 .ok()?;
515 let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
516 passwd.lines().find_map(|line| {
517 let mut parts = line.split(':');
518 let name = parts.next()?;
519 if name != username {
520 return None;
521 }
522 parts
523 .nth(5)
524 .map(str::trim)
525 .filter(|shell| {
526 !shell.is_empty() && !shell.ends_with("/false") && !shell.ends_with("/nologin")
527 })
528 .map(ToString::to_string)
529 })
530}
531
532#[cfg(not(windows))]
533fn shells_from_etc_shells() -> Vec<String> {
534 let Ok(content) = std::fs::read_to_string("/etc/shells") else {
535 return Vec::new();
536 };
537 let mut seen = BTreeSet::new();
538 content
539 .lines()
540 .map(str::trim)
541 .filter(|line| !line.is_empty() && !line.starts_with('#') && line.starts_with('/'))
542 .filter(|line| seen.insert((*line).to_string()))
543 .map(ToString::to_string)
544 .collect()
545}
546
547fn optional_bool(params: &crate::value::DictMap, key: &str) -> Option<bool> {
548 match params.get(key) {
549 Some(VmValue::Bool(value)) => Some(*value),
550 _ => None,
551 }
552}
553
554fn vm_string(value: &VmValue) -> Option<&str> {
555 match value {
556 VmValue::String(value) => Some(value.as_ref()),
557 _ => None,
558 }
559}
560
561fn vm_string_list(value: &VmValue) -> Option<Vec<String>> {
562 let VmValue::List(values) = value else {
563 return None;
564 };
565 values
566 .iter()
567 .map(|value| vm_string(value).map(ToString::to_string))
568 .collect()
569}
570
571fn string(value: &str) -> VmValue {
572 VmValue::String(std::sync::Arc::from(value.to_string()))
573}
574
575fn string_list(values: &[String]) -> VmValue {
576 VmValue::List(std::sync::Arc::new(
577 values.iter().map(|value| string(value)).collect(),
578 ))
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn unix_shell_descriptor_uses_split_login_args() {
587 let shell = descriptor_for_path("/bin/zsh", "fallback");
588 assert_eq!(shell.id, "zsh");
589 assert_eq!(shell.default_args, vec!["-c"]);
590 assert_eq!(shell.login_args, vec!["-l", "-c"]);
591 assert!(shell.supports_login);
592 assert!(shell.supports_interactive);
593 }
594
595 #[test]
596 fn windows_shell_descriptor_distinguishes_cmd_and_pwsh() {
597 let cmd = descriptor_for_path("cmd.exe", "fallback");
598 assert_eq!(cmd.id, "cmd");
599 assert_eq!(cmd.default_args, vec!["/C"]);
600 assert!(!cmd.supports_login);
601
602 let pwsh = descriptor_for_path("pwsh.exe", "fallback");
603 assert_eq!(pwsh.id, "pwsh");
604 assert_eq!(pwsh.default_args, vec!["-NoProfile", "-Command"]);
605 }
606
607 #[test]
608 fn invocation_appends_command_after_shell_args() {
609 let shell = ShellDescriptor {
610 id: "zsh".to_string(),
611 label: "zsh".to_string(),
612 path: "/bin/zsh".to_string(),
613 platform: "darwin".to_string(),
614 available: true,
615 supports_login: true,
616 supports_interactive: true,
617 default_args: vec!["-c".to_string()],
618 login_args: vec!["-l".to_string(), "-c".to_string()],
619 source: "test".to_string(),
620 };
621 let invocation = invocation_for_shell(shell, "echo ok".to_string(), true, true);
622 assert_eq!(invocation.program, "/bin/zsh");
623 assert_eq!(invocation.args, vec!["-i", "-l", "-c", "echo ok"]);
624 assert_eq!(invocation.command_arg_index, 3);
625 }
626
627 #[test]
628 fn invocation_without_explicit_shell_uses_default_shell() {
629 clear_selected_default_shell_for_test();
630 let default_shell = get_default_shell().expect("test host should expose a default shell");
631
632 let mut params = crate::value::DictMap::new();
633 params.insert("command".to_string(), string("echo default-shell"));
634
635 let invocation = resolve_invocation_from_vm_params(¶ms).unwrap();
636 assert_eq!(invocation.shell, default_shell);
637 assert_eq!(invocation.program, default_shell.path);
638 assert_eq!(
639 invocation.args[invocation.command_arg_index],
640 "echo default-shell"
641 );
642 }
643
644 #[test]
645 fn malformed_explicit_shell_fields_do_not_fall_back_to_default() {
646 let mut params = crate::value::DictMap::new();
647 params.insert("shell".to_string(), VmValue::Int(1));
648 assert_eq!(
649 resolve_shell_from_vm_params(¶ms).unwrap_err(),
650 "shell must be a dict, got int"
651 );
652
653 let mut params = crate::value::DictMap::new();
654 params.insert("shell_id".to_string(), VmValue::Int(1));
655 assert_eq!(
656 resolve_shell_from_vm_params(¶ms).unwrap_err(),
657 "shell_id must be a string, got int"
658 );
659 }
660}