1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt, path::Path};
4#[cfg(windows)]
5use std::{path::PathBuf, sync::LazyLock};
6
7#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
9#[serde(rename_all = "snake_case")]
10pub enum Shell {
11 #[default]
13 System,
14 Program(String),
16 WithArguments {
18 program: String,
20 args: Vec<String>,
22 title_override: Option<String>,
24 },
25}
26
27impl Shell {
28 pub fn program(&self) -> String {
29 match self {
30 Shell::Program(program) => program.clone(),
31 Shell::WithArguments { program, .. } => program.clone(),
32 Shell::System => get_system_shell(),
33 }
34 }
35
36 pub fn program_and_args(&self) -> (String, &[String]) {
37 match self {
38 Shell::Program(program) => (program.clone(), &[]),
39 Shell::WithArguments { program, args, .. } => (program.clone(), args),
40 Shell::System => (get_system_shell(), &[]),
41 }
42 }
43
44 pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
45 match self {
46 Shell::Program(program) => ShellKind::new(program, is_windows),
47 Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
48 Shell::System => ShellKind::system(),
49 }
50 }
51}
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum ShellKind {
55 #[default]
56 Posix,
57 Csh,
58 Tcsh,
59 Rc,
60 Fish,
61 PowerShell,
63 Pwsh,
65 Nushell,
66 Cmd,
67 Xonsh,
68 Elvish,
69}
70
71pub fn get_system_shell() -> String {
72 if cfg!(windows) {
73 get_windows_system_shell()
74 } else {
75 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
76 }
77}
78
79pub fn get_default_system_shell() -> String {
80 if cfg!(windows) {
81 get_windows_system_shell()
82 } else {
83 "/bin/sh".to_string()
84 }
85}
86
87pub fn get_default_system_shell_preferring_bash() -> String {
89 #[cfg(windows)]
90 {
91 get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
92 }
93
94 #[cfg(not(windows))]
95 {
96 "/bin/sh".to_string()
97 }
98}
99
100#[cfg(windows)]
101pub fn get_windows_bash() -> Option<String> {
102 fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
103 if !install_root.join("git-bash.exe").is_file() {
104 return None;
105 }
106 let bash = install_root.join("bin").join("bash.exe");
107 bash.is_file().then_some(bash)
108 }
109
110 fn find_bash_in_git() -> Option<PathBuf> {
111 if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT")
112 .map(PathBuf::from)
113 .and_then(|path| find_bash_in_installation(&path))
114 {
115 return Some(bash);
116 }
117 let git = which::which("git").ok()?;
118 let binary_directory = git.parent()?;
119 let parent = binary_directory.parent()?;
120 find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
121 }
122
123 static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
124 let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
125 if let Some(ref path) = bash {
126 log::info!("Found bash at {}", path);
127 }
128 bash
129 });
130
131 (*BASH).clone()
132}
133
134pub fn get_windows_system_shell() -> String {
135 #[cfg(windows)]
136 return gpui_util::get_windows_system_shell();
137
138 #[cfg(not(windows))]
139 return "cmd.exe".to_string();
140}
141
142impl fmt::Display for ShellKind {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 ShellKind::Posix => write!(f, "sh"),
146 ShellKind::Csh => write!(f, "csh"),
147 ShellKind::Tcsh => write!(f, "tcsh"),
148 ShellKind::Fish => write!(f, "fish"),
149 ShellKind::PowerShell => write!(f, "powershell"),
150 ShellKind::Pwsh => write!(f, "pwsh"),
151 ShellKind::Nushell => write!(f, "nu"),
152 ShellKind::Cmd => write!(f, "cmd"),
153 ShellKind::Rc => write!(f, "rc"),
154 ShellKind::Xonsh => write!(f, "xonsh"),
155 ShellKind::Elvish => write!(f, "elvish"),
156 }
157 }
158}
159
160impl ShellKind {
161 pub fn system() -> Self {
162 Self::new(&get_system_shell(), cfg!(windows))
163 }
164
165 pub fn supports_posix_chaining(&self) -> bool {
189 matches!(
190 self,
191 ShellKind::Posix
192 | ShellKind::Fish
193 | ShellKind::PowerShell
194 | ShellKind::Pwsh
195 | ShellKind::Cmd
196 | ShellKind::Xonsh
197 | ShellKind::Csh
198 | ShellKind::Tcsh
199 | ShellKind::Nushell
200 | ShellKind::Elvish
201 | ShellKind::Rc
202 )
203 }
204
205 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
206 let program = program.as_ref();
207 let program = program
208 .file_stem()
209 .unwrap_or_else(|| program.as_os_str())
210 .to_string_lossy();
211
212 match &*program {
213 "powershell" => ShellKind::PowerShell,
214 "pwsh" => ShellKind::Pwsh,
215 "cmd" => ShellKind::Cmd,
216 "nu" => ShellKind::Nushell,
217 "fish" => ShellKind::Fish,
218 "csh" => ShellKind::Csh,
219 "tcsh" => ShellKind::Tcsh,
220 "rc" => ShellKind::Rc,
221 "xonsh" => ShellKind::Xonsh,
222 "elvish" => ShellKind::Elvish,
223 "sh" | "bash" | "zsh" => ShellKind::Posix,
224 _ if is_windows => ShellKind::PowerShell,
225 _ => ShellKind::Posix,
228 }
229 }
230
231 pub fn to_shell_variable(self, input: &str) -> String {
232 match self {
233 Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
234 Self::Cmd => Self::to_cmd_variable(input),
235 Self::Posix => input.to_owned(),
236 Self::Fish => input.to_owned(),
237 Self::Csh => input.to_owned(),
238 Self::Tcsh => input.to_owned(),
239 Self::Rc => input.to_owned(),
240 Self::Nushell => Self::to_nushell_variable(input),
241 Self::Xonsh => input.to_owned(),
242 Self::Elvish => input.to_owned(),
243 }
244 }
245
246 fn to_cmd_variable(input: &str) -> String {
247 if let Some(var_str) = input.strip_prefix("${") {
248 match var_str.strip_suffix('}') {
249 Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
250 format!("%{var_name}%")
251 }
252 _ => input.into(),
255 }
256 } else if let Some(var_str) = input.strip_prefix('$') {
257 format!("%{}%", var_str)
259 } else {
260 input.into()
262 }
263 }
264
265 fn to_powershell_variable(input: &str) -> String {
266 if let Some(var_str) = input.strip_prefix("${") {
267 match var_str.strip_suffix('}') {
268 Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
269 format!("$env:{var_name}")
270 }
271 _ => input.into(),
274 }
275 } else if let Some(var_str) = input.strip_prefix('$') {
276 format!("$env:{}", var_str)
278 } else {
279 input.into()
281 }
282 }
283
284 fn to_nushell_variable(input: &str) -> String {
285 let mut result = String::new();
286 let mut source = input;
287 let mut is_start = true;
288
289 loop {
290 match source.chars().next() {
291 None => return result,
292 Some('$') => {
293 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
294 is_start = false;
295 }
296 Some(_) => {
297 is_start = false;
298 let chunk_end = source.find('$').unwrap_or(source.len());
299 let (chunk, rest) = source.split_at(chunk_end);
300 result.push_str(chunk);
301 source = rest;
302 }
303 }
304 }
305 }
306
307 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
308 if source.starts_with("env.") {
309 text.push('$');
310 return source;
311 }
312
313 match source.chars().next() {
314 Some('{') => {
315 let source = &source[1..];
316 if let Some(end) = source.find('}') {
317 let var_name = &source[..end];
318 if !var_name.is_empty() {
319 if !is_start {
320 text.push_str("(");
321 }
322 text.push_str("$env.");
323 text.push_str(var_name);
324 if !is_start {
325 text.push_str(")");
326 }
327 &source[end + 1..]
328 } else {
329 text.push_str("${}");
330 &source[end + 1..]
331 }
332 } else {
333 text.push_str("${");
334 source
335 }
336 }
337 Some(c) if c.is_alphabetic() || c == '_' => {
338 let end = source
339 .find(|c: char| !c.is_alphanumeric() && c != '_')
340 .unwrap_or(source.len());
341 let var_name = &source[..end];
342 if !is_start {
343 text.push_str("(");
344 }
345 text.push_str("$env.");
346 text.push_str(var_name);
347 if !is_start {
348 text.push_str(")");
349 }
350 &source[end..]
351 }
352 _ => {
353 text.push('$');
354 source
355 }
356 }
357 }
358
359 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
360 match self {
361 ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
362 ShellKind::Cmd => vec![
363 "/S".to_owned(),
364 "/C".to_owned(),
365 format!("\"{combined_command}\""),
366 ],
367 ShellKind::Posix
368 | ShellKind::Nushell
369 | ShellKind::Fish
370 | ShellKind::Csh
371 | ShellKind::Tcsh
372 | ShellKind::Rc
373 | ShellKind::Xonsh
374 | ShellKind::Elvish => interactive
375 .then(|| "-i".to_owned())
376 .into_iter()
377 .chain(["-c".to_owned(), combined_command])
378 .collect(),
379 }
380 }
381
382 pub const fn command_prefix(&self) -> Option<char> {
383 match self {
384 ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
385 ShellKind::Nushell => Some('^'),
386 ShellKind::Posix
387 | ShellKind::Csh
388 | ShellKind::Tcsh
389 | ShellKind::Rc
390 | ShellKind::Fish
391 | ShellKind::Cmd
392 | ShellKind::Xonsh
393 | ShellKind::Elvish => None,
394 }
395 }
396
397 pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
398 match self.command_prefix() {
399 Some(prefix) if !command.starts_with(prefix) => {
400 Cow::Owned(format!("{prefix}{command}"))
401 }
402 _ => Cow::Borrowed(command),
403 }
404 }
405
406 pub const fn sequential_commands_separator(&self) -> char {
407 match self {
408 ShellKind::Cmd => '&',
409 ShellKind::Posix
410 | ShellKind::Csh
411 | ShellKind::Tcsh
412 | ShellKind::Rc
413 | ShellKind::Fish
414 | ShellKind::PowerShell
415 | ShellKind::Pwsh
416 | ShellKind::Nushell
417 | ShellKind::Xonsh
418 | ShellKind::Elvish => ';',
419 }
420 }
421
422 pub const fn sequential_and_commands_separator(&self) -> &'static str {
423 match self {
424 ShellKind::Cmd
425 | ShellKind::Posix
426 | ShellKind::Csh
427 | ShellKind::Tcsh
428 | ShellKind::Rc
429 | ShellKind::Fish
430 | ShellKind::Pwsh
431 | ShellKind::Xonsh => "&&",
432 ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
433 }
434 }
435
436 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
437 match self {
438 ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
439 ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
440 ShellKind::Cmd => Some(Self::quote_cmd(arg)),
441 ShellKind::Csh | ShellKind::Tcsh => Self::quote_csh(arg),
442 ShellKind::Nushell => Self::quote_nushell(arg),
443 ShellKind::Posix
444 | ShellKind::Rc
445 | ShellKind::Fish
446 | ShellKind::Xonsh
447 | ShellKind::Elvish => shlex::try_quote(arg).ok(),
448 }
449 }
450
451 fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
452 if arg.is_empty() {
453 return Cow::Borrowed("\"\"");
454 }
455
456 let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
457 if !needs_quoting {
458 return Cow::Borrowed(arg);
459 }
460
461 let mut result = String::with_capacity(arg.len() + 2);
462
463 if enclose {
464 result.push('"');
465 }
466
467 let chars: Vec<char> = arg.chars().collect();
468 let mut i = 0;
469
470 while i < chars.len() {
471 if chars[i] == '\\' {
472 let mut num_backslashes = 0;
473 while i < chars.len() && chars[i] == '\\' {
474 num_backslashes += 1;
475 i += 1;
476 }
477
478 if i < chars.len() && chars[i] == '"' {
479 for _ in 0..(num_backslashes * 2 + 1) {
481 result.push('\\');
482 }
483 result.push('"');
484 i += 1;
485 } else if i >= chars.len() {
486 for _ in 0..(num_backslashes * 2) {
488 result.push('\\');
489 }
490 } else {
491 for _ in 0..num_backslashes {
493 result.push('\\');
494 }
495 }
496 } else if chars[i] == '"' {
497 result.push('\\');
499 result.push('"');
500 i += 1;
501 } else {
502 result.push(chars[i]);
503 i += 1;
504 }
505 }
506
507 if enclose {
508 result.push('"');
509 }
510 Cow::Owned(result)
511 }
512
513 fn needs_quoting_powershell(s: &str) -> bool {
514 s.is_empty()
515 || s.chars().any(|c| {
516 c.is_whitespace()
517 || matches!(
518 c,
519 '"' | '`'
520 | '$'
521 | '&'
522 | '|'
523 | '<'
524 | '>'
525 | ';'
526 | '('
527 | ')'
528 | '['
529 | ']'
530 | '{'
531 | '}'
532 | ','
533 | '\''
534 | '@'
535 )
536 })
537 }
538
539 fn need_quotes_powershell(arg: &str) -> bool {
540 let mut quote_count = 0;
541 for c in arg.chars() {
542 if c == '"' {
543 quote_count += 1;
544 } else if c.is_whitespace() && (quote_count % 2 == 0) {
545 return true;
546 }
547 }
548 false
549 }
550
551 fn escape_powershell_quotes(s: &str) -> String {
552 let mut result = String::with_capacity(s.len() + 4);
553 result.push('\'');
554 for c in s.chars() {
555 if c == '\'' {
556 result.push('\'');
557 }
558 result.push(c);
559 }
560 result.push('\'');
561 result
562 }
563
564 pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
565 let ps_will_quote = Self::need_quotes_powershell(arg);
566 let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
567
568 if !Self::needs_quoting_powershell(arg) {
569 return crt_quoted;
570 }
571
572 Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
573 }
574
575 pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
576 if arg.is_empty() {
577 return Cow::Borrowed("''");
578 }
579
580 if !Self::needs_quoting_powershell(arg) {
581 return Cow::Borrowed(arg);
582 }
583
584 Cow::Owned(Self::escape_powershell_quotes(arg))
585 }
586
587 pub fn quote_csh(arg: &str) -> Option<Cow<'_, str>> {
588 if arg.contains('\0') {
589 return None;
590 }
591 if Self::is_unquoted_safe(arg) && !arg.starts_with('%') {
592 return Some(Cow::Borrowed(arg));
593 }
594 if arg.is_empty() {
595 return Some(Cow::Borrowed("''"));
596 }
597 let mut quoted = String::with_capacity(arg.len() + 2);
598 let mut in_quotes = false;
599 for character in arg.chars() {
600 match character {
601 '\'' | '!' | '\\' => {
602 if in_quotes {
603 quoted.push('\'');
604 in_quotes = false;
605 }
606 quoted.push('\\');
607 quoted.push(character);
608 }
609 character => {
610 if !in_quotes {
611 quoted.push('\'');
612 in_quotes = true;
613 }
614 if character == '\n' {
615 quoted.push('\\');
616 }
617 quoted.push(character);
618 }
619 }
620 }
621 if in_quotes {
622 quoted.push('\'');
623 }
624 Some(Cow::Owned(quoted))
625 }
626
627 pub fn quote_nushell(arg: &str) -> Option<Cow<'_, str>> {
628 if arg.contains('\0') {
629 return None;
630 }
631 if Self::is_unquoted_safe(arg) {
632 return Some(Cow::Borrowed(arg));
633 }
634 if !arg.contains('\'') {
635 return Some(Cow::Owned(format!("'{arg}'")));
636 }
637 let mut quoted = String::with_capacity(arg.len() + 2);
638 quoted.push('"');
639 for character in arg.chars() {
640 if character == '\\' || character == '"' {
641 quoted.push('\\');
642 }
643 quoted.push(character);
644 }
645 quoted.push('"');
646 Some(Cow::Owned(quoted))
647 }
648
649 fn is_unquoted_safe(arg: &str) -> bool {
650 !arg.is_empty()
651 && arg
652 .bytes()
653 .all(|byte| byte.is_ascii_alphanumeric() || b"-_./:,+@%".contains(&byte))
654 }
655
656 pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
657 let crt_quoted = Self::quote_windows(arg, true);
658
659 let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
660
661 if !needs_cmd_escaping {
662 return crt_quoted;
663 }
664
665 let mut result = String::with_capacity(crt_quoted.len() * 2);
666 for c in crt_quoted.chars() {
667 match c {
668 '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
669 result.push('^');
670 result.push(c);
671 }
672 '%' => {
673 result.push_str("%%cd:~,%");
674 }
675 _ => result.push(c),
676 }
677 }
678 Cow::Owned(result)
679 }
680
681 pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
686 if let Some(char) = self.command_prefix() {
687 if let Some(arg) = arg.strip_prefix(char) {
688 for quote in ['\'', '"'] {
690 if let Some(arg) = arg
691 .strip_prefix(quote)
692 .and_then(|arg| arg.strip_suffix(quote))
693 {
694 let quoted = self.try_quote(arg)?;
699 return Some(if quoted.starts_with(['\'', '"']) {
700 Cow::Owned(self.prepend_command_prefix("ed).into_owned())
701 } else {
702 Cow::Owned(
703 self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
704 .into_owned(),
705 )
706 });
707 }
708 }
709 return self
710 .try_quote(arg)
711 .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
712 }
713 }
714 self.try_quote(arg).map(|quoted| match quoted {
715 unquoted @ Cow::Borrowed(_) => unquoted,
716 Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()),
717 })
718 }
719
720 pub fn split(&self, input: &str) -> Option<Vec<String>> {
721 shlex::split(input)
722 }
723
724 pub const fn activate_keyword(&self) -> &'static str {
725 match self {
726 ShellKind::Cmd => "",
727 ShellKind::Nushell => "overlay use",
728 ShellKind::PowerShell | ShellKind::Pwsh => ".",
729 ShellKind::Fish
730 | ShellKind::Csh
731 | ShellKind::Tcsh
732 | ShellKind::Posix
733 | ShellKind::Rc
734 | ShellKind::Xonsh
735 | ShellKind::Elvish => "source",
736 }
737 }
738
739 pub const fn clear_screen_command(&self) -> &'static str {
740 match self {
741 ShellKind::Cmd => "cls",
742 ShellKind::Posix
743 | ShellKind::Csh
744 | ShellKind::Tcsh
745 | ShellKind::Rc
746 | ShellKind::Fish
747 | ShellKind::PowerShell
748 | ShellKind::Pwsh
749 | ShellKind::Nushell
750 | ShellKind::Xonsh
751 | ShellKind::Elvish => "clear",
752 }
753 }
754
755 #[cfg(windows)]
756 pub const fn tty_escape_args(&self) -> bool {
759 match self {
760 ShellKind::Cmd => false,
761 ShellKind::Posix
762 | ShellKind::Csh
763 | ShellKind::Tcsh
764 | ShellKind::Rc
765 | ShellKind::Fish
766 | ShellKind::PowerShell
767 | ShellKind::Pwsh
768 | ShellKind::Nushell
769 | ShellKind::Xonsh
770 | ShellKind::Elvish => true,
771 }
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 #[cfg(unix)]
779 use crate::shell_builder::ShellBuilder;
780
781 #[test]
792 fn test_try_quote_powershell() {
793 let shell_kind = ShellKind::PowerShell;
794 assert_eq!(
795 shell_kind
796 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
797 .unwrap()
798 .into_owned(),
799 "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
800 );
801 }
802
803 #[test]
804 fn test_try_quote_cmd() {
805 let shell_kind = ShellKind::Cmd;
806 assert_eq!(
807 shell_kind
808 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
809 .unwrap()
810 .into_owned(),
811 "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
812 );
813 }
814
815 #[test]
816 fn test_try_quote_powershell_edge_cases() {
817 let shell_kind = ShellKind::PowerShell;
818
819 assert_eq!(
821 shell_kind.try_quote("").unwrap().into_owned(),
822 "'\"\"'".to_string()
823 );
824
825 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
827
828 assert_eq!(
830 shell_kind.try_quote("hello world").unwrap().into_owned(),
831 "'hello world'".to_string()
832 );
833
834 assert_eq!(
836 shell_kind.try_quote("$variable").unwrap().into_owned(),
837 "'$variable'".to_string()
838 );
839
840 assert_eq!(
842 shell_kind.try_quote("test`command").unwrap().into_owned(),
843 "'test`command'".to_string()
844 );
845
846 assert_eq!(
848 shell_kind
849 .try_quote("test `\"$var`\" end")
850 .unwrap()
851 .into_owned(),
852 "'test `\\\"$var`\\\" end'".to_string()
853 );
854
855 assert_eq!(
857 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
858 "C:\\path\\to\\file"
859 );
860 }
861
862 #[test]
863 fn test_try_quote_cmd_edge_cases() {
864 let shell_kind = ShellKind::Cmd;
865
866 assert_eq!(
868 shell_kind.try_quote("").unwrap().into_owned(),
869 "^\"^\"".to_string()
870 );
871
872 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
874
875 assert_eq!(
877 shell_kind.try_quote("hello world").unwrap().into_owned(),
878 "^\"hello world^\"".to_string()
879 );
880
881 assert_eq!(
883 shell_kind.try_quote("path\\ test").unwrap().into_owned(),
884 "^\"path\\ test^\"".to_string()
885 );
886
887 assert_eq!(
889 shell_kind.try_quote("test path\\").unwrap().into_owned(),
890 "^\"test path\\\\^\"".to_string()
891 );
892
893 assert_eq!(
895 shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
896 "^\"test path\\\\\\\\^\"".to_string()
897 );
898
899 assert_eq!(
901 shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
902 "^\"test\\\\\\^\"quote^\"".to_string()
903 );
904
905 assert_eq!(
907 shell_kind
908 .try_quote("test\\\\\"quote")
909 .unwrap()
910 .into_owned(),
911 "^\"test\\\\\\\\\\^\"quote^\"".to_string()
912 );
913
914 assert_eq!(
916 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
917 "C:\\path\\to\\file"
918 );
919 }
920
921 #[test]
922 fn test_try_quote_nu_command() {
923 let shell_kind = ShellKind::Nushell;
924 assert_eq!(
925 shell_kind.try_quote("'uname'").unwrap().into_owned(),
926 "\"'uname'\"".to_string()
927 );
928 assert_eq!(
929 shell_kind
930 .try_quote_prefix_aware("'uname'")
931 .unwrap()
932 .into_owned(),
933 "^\"'uname'\"".to_string()
934 );
935 assert_eq!(
936 shell_kind.try_quote("^uname").unwrap().into_owned(),
937 "'^uname'".to_string()
938 );
939 assert_eq!(
940 shell_kind
941 .try_quote_prefix_aware("^uname")
942 .unwrap()
943 .into_owned(),
944 "^uname".to_string()
945 );
946 assert_eq!(
947 shell_kind.try_quote("^'uname'").unwrap().into_owned(),
948 "\"^'uname'\"".to_string()
949 );
950 assert_eq!(
951 shell_kind
952 .try_quote_prefix_aware("^'uname'")
953 .unwrap()
954 .into_owned(),
955 "^'uname'".to_string()
956 );
957 assert_eq!(
958 shell_kind.try_quote("'uname a'").unwrap().into_owned(),
959 "\"'uname a'\"".to_string()
960 );
961 assert_eq!(
962 shell_kind
963 .try_quote_prefix_aware("'uname a'")
964 .unwrap()
965 .into_owned(),
966 "^\"'uname a'\"".to_string()
967 );
968 assert_eq!(
969 shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
970 "\"^'uname a'\"".to_string()
971 );
972 assert_eq!(
973 shell_kind
974 .try_quote_prefix_aware("^'uname a'")
975 .unwrap()
976 .into_owned(),
977 "^'uname a'".to_string()
978 );
979 assert_eq!(
980 shell_kind.try_quote("uname").unwrap().into_owned(),
981 "uname".to_string()
982 );
983 assert_eq!(
984 shell_kind
985 .try_quote_prefix_aware("uname")
986 .unwrap()
987 .into_owned(),
988 "uname".to_string()
989 );
990 }
991
992 #[test]
993 fn test_try_quote_csh() {
994 for shell_kind in [ShellKind::Csh, ShellKind::Tcsh] {
995 assert_eq!(shell_kind.try_quote("plain").unwrap(), "plain");
996 assert_eq!(
997 shell_kind.try_quote("--flag=a/b.c").unwrap(),
998 "'--flag=a/b.c'"
999 );
1000 assert_eq!(shell_kind.try_quote("=0").unwrap(), "'=0'");
1001 assert_eq!(shell_kind.try_quote("%agent").unwrap(), "'%agent'");
1002 assert_eq!(shell_kind.try_quote("50%").unwrap(), "50%");
1003 assert_eq!(shell_kind.try_quote("").unwrap(), "''");
1004 assert_eq!(
1005 shell_kind.try_quote("/home/bang!home/x").unwrap(),
1006 "'/home/bang'\\!'home/x'"
1007 );
1008 assert_eq!(shell_kind.try_quote("a\\!b c").unwrap(), "'a'\\\\\\!'b c'");
1009 assert_eq!(
1010 shell_kind.try_quote("O'Brien!").unwrap(),
1011 "'O'\\''Brien'\\!"
1012 );
1013 assert_eq!(shell_kind.try_quote("!").unwrap(), "\\!");
1014 assert_eq!(shell_kind.try_quote("\\'").unwrap(), "\\\\\\'");
1015 assert_eq!(
1016 shell_kind
1017 .try_quote("/tmp/a\\'; echo INJECTED; true \\'")
1018 .unwrap(),
1019 "'/tmp/a'\\\\\\''; echo INJECTED; true '\\\\\\'"
1020 );
1021 assert_eq!(
1022 shell_kind.try_quote("say \\\"hi\\\"").unwrap(),
1023 "'say '\\\\'\"hi'\\\\'\"'"
1024 );
1025 assert_eq!(
1026 shell_kind.try_quote("line\nbreak").unwrap(),
1027 "'line\\\nbreak'"
1028 );
1029 assert_eq!(
1030 shell_kind.try_quote("line\rbreak").unwrap(),
1031 "'line\rbreak'"
1032 );
1033 assert_eq!(
1034 shell_kind.try_quote("~/*$HOME `id`").unwrap(),
1035 "'~/*$HOME `id`'"
1036 );
1037 assert_eq!(shell_kind.try_quote("nul\0byte"), None);
1038 }
1039 }
1040
1041 #[test]
1042 fn test_try_quote_nushell() {
1043 let shell_kind = ShellKind::Nushell;
1044 assert_eq!(shell_kind.try_quote("plain").unwrap(), "plain");
1045 assert_eq!(shell_kind.try_quote("").unwrap(), "''");
1046 assert_eq!(shell_kind.try_quote("=").unwrap(), "'='");
1047 assert_eq!(shell_kind.try_quote("=a").unwrap(), "'=a'");
1048 assert_eq!(shell_kind.try_quote("+=").unwrap(), "'+='");
1049 assert_eq!(
1050 shell_kind.try_quote("--flag=a/b.c").unwrap(),
1051 "'--flag=a/b.c'"
1052 );
1053 assert_eq!(
1054 shell_kind
1055 .try_quote("/home/a^b/.zed_server/server")
1056 .unwrap(),
1057 "'/home/a^b/.zed_server/server'"
1058 );
1059 assert_eq!(
1060 shell_kind.try_quote("$env.HOME (1 + 1) `id` a\\b").unwrap(),
1061 "'$env.HOME (1 + 1) `id` a\\b'"
1062 );
1063 assert_eq!(
1064 shell_kind.try_quote("O'Brien \\ \"x\" $y").unwrap(),
1065 "\"O'Brien \\\\ \\\"x\\\" $y\""
1066 );
1067 assert_eq!(shell_kind.try_quote("nul\0byte"), None);
1068 }
1069
1070 #[cfg(unix)]
1071 #[test]
1072 fn test_quoted_arguments_survive_real_shells() {
1073 let arguments = [
1074 "SAFE; /usr/bin/printf INJECTED\r",
1075 "/tmp/a\\'; /usr/bin/printf INJECTED; true \\'",
1076 "a\\\\b \\\"c\\\" \\'d\\' \\$e \\!f",
1077 "line\nbreak",
1078 "bang!\nnew\\line",
1079 "O'Brien!",
1080 "a\\!b",
1081 "a\\\\!b",
1082 "!1x",
1083 "!",
1084 "!!",
1085 "!$ !* !:1 !-1 !# !?x?",
1086 "ending!",
1087 "trailing\\",
1088 "~/*$HOME `id` $(id) (1 + 1) \"quoted\"",
1089 "\t tab # hash",
1090 "=",
1091 "=a",
1092 "=0",
1093 "=1",
1094 "+=",
1095 "-=",
1096 "/=",
1097 "a=b",
1098 "--flag=a/b.c",
1099 "1..3",
1100 "/home/a^b/.zed_server/server",
1101 "钥匙🔑",
1102 "",
1103 ];
1104 let csh_settings = [
1105 "",
1106 "set backslash_quote\n",
1107 "set histchars = ''\n",
1108 "set histchars = \\!^\n",
1109 "set backslash_quote\nset histchars = ''\n",
1110 "set backslash_quote\nunset histchars\nset history = 100\n",
1111 ];
1112 let shells = [
1113 ("tcsh", ShellKind::Tcsh, &["-f"][..], &csh_settings[..]),
1114 ("csh", ShellKind::Csh, &["-f"][..], &csh_settings[..]),
1115 (
1116 "nu",
1117 ShellKind::Nushell,
1118 &["--no-config-file"][..],
1119 &[""][..],
1120 ),
1121 ];
1122 for (shell_name, shell_kind, shell_options, settings) in shells {
1123 let Ok(shell) = which::which(shell_name) else {
1124 continue;
1125 };
1126 let shell = shell.to_string_lossy().into_owned();
1127 let separator = shell_kind.sequential_and_commands_separator();
1128 let printf = shell_kind.prepend_command_prefix("/usr/bin/printf");
1129 for (setting, argument) in settings
1130 .iter()
1131 .flat_map(|setting| arguments.iter().map(move |argument| (*setting, *argument)))
1132 {
1133 let quoted = shell_kind.try_quote(argument).unwrap();
1134 let inner = format!("{setting}cd /{separator} {printf} %s {quoted}");
1135 let mut inner_arguments = shell_options.to_vec();
1136 inner_arguments.extend(["-c", &inner]);
1137 let nested = format!(
1138 "{setting}{} {}",
1139 shell_kind.prepend_command_prefix(&shell),
1140 inner_arguments
1141 .iter()
1142 .map(|inner_argument| shell_kind.try_quote(inner_argument).unwrap())
1143 .collect::<Vec<_>>()
1144 .join(" ")
1145 );
1146 for script in [inner.clone(), nested] {
1147 let output = smol::block_on(
1148 smol::process::Command::new(&shell)
1149 .args(shell_options)
1150 .args(["-c", &script])
1151 .env_clear()
1152 .env("HOME", "/")
1153 .env("PATH", "/usr/bin:/bin")
1154 .output(),
1155 )
1156 .unwrap();
1157 assert_eq!(
1158 (
1159 output.status.success(),
1160 String::from_utf8_lossy(&output.stdout).into_owned(),
1161 String::from_utf8_lossy(&output.stderr).into_owned(),
1162 ),
1163 (true, argument.to_owned(), String::new()),
1164 "{shell} {script:?}"
1165 );
1166 }
1167 }
1168 }
1169 }
1170
1171 #[cfg(unix)]
1172 #[test]
1173 fn test_quoted_csh_commands_survive_real_shells() {
1174 use std::os::unix::fs::PermissionsExt as _;
1175
1176 let directory = tempfile::tempdir().unwrap();
1177 let binary_directory = directory.path().join("bin");
1178 std::fs::create_dir(&binary_directory).unwrap();
1179 for program in ["%agent", "50%", "agent"] {
1180 let script = binary_directory.join(program);
1181 std::fs::write(&script, "#!/bin/sh\nprintf '%s' \"$1\"\n").unwrap();
1182 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1183 }
1184 for (shell_name, shell_kind) in [("tcsh", ShellKind::Tcsh), ("csh", ShellKind::Csh)] {
1185 let Ok(shell) = which::which(shell_name) else {
1186 continue;
1187 };
1188 for program in ["%agent", "50%", "agent"] {
1189 let (shell_program, shell_arguments) =
1190 ShellBuilder::new(&Shell::Program(shell.display().to_string()), false)
1191 .non_interactive()
1192 .literal_args()
1193 .build(Some(program.to_owned()), &[String::from("OK")]);
1194 let script = shell_arguments.last().unwrap();
1195 assert_eq!(
1196 script,
1197 &format!("{} OK", shell_kind.try_quote(program).unwrap())
1198 );
1199 let output = smol::block_on(
1200 smol::process::Command::new(&shell_program)
1201 .arg("-f")
1202 .args(&shell_arguments)
1203 .env_clear()
1204 .env("HOME", "/")
1205 .env("PATH", &binary_directory)
1206 .output(),
1207 )
1208 .unwrap();
1209 assert_eq!(
1210 (
1211 output.status.success(),
1212 String::from_utf8_lossy(&output.stdout).into_owned(),
1213 String::from_utf8_lossy(&output.stderr).into_owned(),
1214 ),
1215 (true, String::from("OK"), String::new()),
1216 "{shell_program} {shell_arguments:?}"
1217 );
1218 }
1219 }
1220 }
1221
1222 #[test]
1223 fn test_try_quote_single_quote_paths() {
1224 let path_with_quote = r"C:\Temp\O'Brien\repo";
1225 let shlex_shells = [
1226 ShellKind::Posix,
1227 ShellKind::Fish,
1228 ShellKind::Csh,
1229 ShellKind::Tcsh,
1230 ShellKind::Rc,
1231 ShellKind::Xonsh,
1232 ShellKind::Elvish,
1233 ShellKind::Nushell,
1234 ];
1235
1236 for shell_kind in shlex_shells {
1237 let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
1238 assert_ne!(quoted, path_with_quote);
1239 assert_eq!(
1240 shlex::split("ed),
1241 Some(vec![path_with_quote.to_string()])
1242 );
1243
1244 if shell_kind == ShellKind::Nushell {
1245 let prefixed = shell_kind.prepend_command_prefix("ed);
1246 assert!(prefixed.starts_with('^'));
1247 }
1248 }
1249
1250 for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
1251 let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
1252 assert!(quoted.starts_with('\''));
1253 assert!(quoted.ends_with('\''));
1254 assert!(quoted.contains("O''Brien"));
1255 }
1256 }
1257
1258 #[test]
1259 fn test_to_shell_variable() {
1260 assert_eq!(
1261 ShellKind::PowerShell.to_shell_variable("${FOO}"),
1262 "$env:FOO"
1263 );
1264 assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO");
1265 assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
1266 assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
1267 assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
1268
1269 assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
1270 assert_eq!(
1271 ShellKind::PowerShell.to_shell_variable("${日本}"),
1272 "$env:日本"
1273 );
1274 assert_eq!(
1275 ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
1276 "${FOO:-bar}"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_to_shell_variable_malformed_is_passed_through() {
1282 for input in ["${", "${FOO", "${café", "${}", "${日本"] {
1283 for shell_kind in [
1284 ShellKind::PowerShell,
1285 ShellKind::Pwsh,
1286 ShellKind::Cmd,
1287 ShellKind::Nushell,
1288 ] {
1289 assert_eq!(shell_kind.to_shell_variable(input), input);
1290 }
1291 }
1292 }
1293}