1use std::ffi::OsString;
27use std::fmt;
28use std::path::{Path, PathBuf};
29
30pub const FOREIGN_TERMINAL_ENV: &[&str] = &[
34 "VTE_VERSION",
35 "WT_SESSION",
36 "TERM_SESSION_ID",
37 "TERMINFO",
38 "GHOSTTY_RESOURCES_DIR",
39 "GHOSTTY_SHELL_FEATURES",
40 "GHOSTTY_SHELL_INTEGRATION_XDG_DIR",
41 "GHOSTTY_BASH_INJECT",
42 "GHOSTTY_BASH_ENV",
43 "GHOSTTY_ZSH_ZDOTDIR",
44];
45
46pub fn default_shell() -> PathBuf {
58 if cfg!(windows) {
59 return windows_shell(std::env::var_os("PATH"));
60 }
61 let cli = std::env::var_os("TERM_PROGRAM").is_some_and(|value| !value.is_empty());
62 resolve_shell(
63 std::env::var_os("SHELL"),
64 cli,
65 passwd().and_then(|entry| entry.shell),
66 )
67}
68
69fn windows_shell(path: Option<OsString>) -> PathBuf {
72 path.as_deref()
73 .into_iter()
74 .flat_map(std::env::split_paths)
75 .filter(|dir| dir.is_absolute())
76 .map(|dir| dir.join("pwsh.exe"))
77 .find(|candidate| is_executable_image(candidate))
78 .unwrap_or_else(|| PathBuf::from("powershell.exe"))
79}
80
81fn is_executable_image(path: &Path) -> bool {
84 use std::io::Read as _;
85 let mut magic = [0u8; 2];
86 std::fs::File::open(path)
87 .and_then(|mut file| file.read_exact(&mut magic))
88 .is_ok_and(|()| &magic == b"MZ")
89}
90
91fn resolve_shell(env: Option<OsString>, cli: bool, passwd: Option<PathBuf>) -> PathBuf {
93 let env = env.filter(|value| !value.is_empty()).map(PathBuf::from);
94 if cli && let Some(shell) = env.clone() {
95 return shell;
96 }
97 passwd
98 .filter(|shell| !shell.as_os_str().is_empty())
99 .or(env)
100 .unwrap_or_else(|| PathBuf::from("/bin/sh"))
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct ShellCommand {
107 program: PathBuf,
108 args: Vec<String>,
109}
110
111impl ShellCommand {
112 pub fn new(
114 program: impl Into<PathBuf>,
115 args: impl IntoIterator<Item = impl Into<String>>,
116 ) -> Self {
117 Self {
118 program: program.into(),
119 args: args.into_iter().map(Into::into).collect(),
120 }
121 }
122
123 pub fn program(&self) -> &Path {
125 &self.program
126 }
127
128 pub fn args(&self) -> &[String] {
130 &self.args
131 }
132
133 pub fn into_parts(self) -> (PathBuf, Vec<String>) {
135 (self.program, self.args)
136 }
137
138 pub fn command(&self) -> std::process::Command {
141 let mut command = std::process::Command::new(&self.program);
142 command.args(&self.args);
143 command
144 }
145}
146
147impl fmt::Display for ShellCommand {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 f.write_str(&word(&self.program.to_string_lossy()))?;
151 for arg in &self.args {
152 write!(f, " {}", word(arg))?;
153 }
154 Ok(())
155 }
156}
157
158fn word(value: &str) -> String {
160 let plain = !value.is_empty()
161 && value
162 .chars()
163 .all(|c| c.is_ascii_alphanumeric() || "/._-=+:,@%".contains(c));
164 if plain {
165 value.to_owned()
166 } else {
167 quote(value)
168 }
169}
170
171pub fn login_shell() -> ShellCommand {
174 login_command(&default_shell(), &[])
175}
176
177pub fn login_command(shell: &Path, args: &[String]) -> ShellCommand {
188 if cfg!(windows) {
189 return ShellCommand::new(shell, args.iter().cloned());
190 }
191 let entry = if cfg!(target_os = "macos") {
192 passwd()
193 } else {
194 None
195 };
196 let hush = entry
197 .as_ref()
198 .and_then(|entry| entry.home.as_ref())
199 .is_some_and(|home| home.join(".hushlogin").exists());
200 login_command_for(
201 cfg!(target_os = "macos"),
202 shell,
203 args,
204 entry.and_then(|entry| entry.name).as_deref(),
205 hush,
206 )
207}
208
209fn login_command_for(
210 macos: bool,
211 shell: &Path,
212 args: &[String],
213 user: Option<&str>,
214 hush: bool,
215) -> ShellCommand {
216 match user.filter(|_| macos) {
217 Some(user) => {
218 let mut line = format!("exec -l {}", quote(&shell.to_string_lossy()));
219 for arg in args {
220 line.push(' ');
221 line.push_str("e(arg));
222 }
223 let mut argv = Vec::new();
224 if hush {
225 argv.push("-q".to_owned());
226 }
227 argv.extend(
228 ["-flp", user, "/bin/bash", "--noprofile", "--norc", "-c"].map(str::to_owned),
229 );
230 argv.push(line);
231 ShellCommand::new("/usr/bin/login", argv)
232 }
233 None => {
234 let mut argv = vec!["-l".to_owned()];
235 argv.extend(args.iter().cloned());
236 ShellCommand::new(shell, argv)
237 }
238 }
239}
240
241fn quote(value: &str) -> String {
244 format!("'{}'", value.replace('\'', r"'\''"))
245}
246
247#[cfg(feature = "ghostty")]
257pub fn terminal_env() -> std::io::Result<Vec<(&'static str, OsString)>> {
258 Ok(vec![
259 ("TERM", ghostty_vt::terminfo::TERM.into()),
260 ("TERMINFO", ghostty_vt::terminfo::dir()?.into_os_string()),
261 ("COLORTERM", "truecolor".into()),
262 ("TERM_PROGRAM", "ghostty".into()),
263 (
264 "TERM_PROGRAM_VERSION",
265 ghostty_vt::build_info::pinned_version().into(),
266 ),
267 (
268 "GHOSTTY_RESOURCES_DIR",
269 ghostty_vt::shell_integration::resources_dir()?.into_os_string(),
270 ),
271 ])
272}
273
274#[derive(Debug, Default)]
276struct Passwd {
277 name: Option<String>,
278 home: Option<PathBuf>,
279 shell: Option<PathBuf>,
280}
281
282#[cfg(unix)]
284fn passwd() -> Option<Passwd> {
285 use std::ffi::CStr;
286 use std::os::unix::ffi::OsStrExt as _;
287
288 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
289 let mut result: *mut libc::passwd = std::ptr::null_mut();
290 let mut buf = vec![0 as libc::c_char; 16 * 1024];
291 let status = unsafe {
294 libc::getpwuid_r(
295 libc::getuid(),
296 &raw mut entry,
297 buf.as_mut_ptr(),
298 buf.len(),
299 &raw mut result,
300 )
301 };
302 if status != 0 || result.is_null() {
303 return None;
304 }
305 let text = |pointer: *const libc::c_char| {
306 (!pointer.is_null())
308 .then(|| unsafe { CStr::from_ptr(pointer) }.to_bytes().to_vec())
309 .filter(|bytes| !bytes.is_empty())
310 };
311 let path = |bytes: Vec<u8>| PathBuf::from(std::ffi::OsStr::from_bytes(&bytes));
312 Some(Passwd {
313 name: text(entry.pw_name).and_then(|bytes| String::from_utf8(bytes).ok()),
314 home: text(entry.pw_dir).map(path),
315 shell: text(entry.pw_shell).map(path),
316 })
317}
318
319#[cfg(not(unix))]
320fn passwd() -> Option<Passwd> {
321 None
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn shell_is_from_the_environment_only_on_a_command_line() {
330 let fish = Some(PathBuf::from("/opt/homebrew/bin/fish"));
331 assert_eq!(
332 resolve_shell(Some("/bin/zsh".into()), true, fish.clone()),
333 PathBuf::from("/bin/zsh"),
334 "a command line keeps its SHELL"
335 );
336 assert_eq!(
337 resolve_shell(Some("/bin/zsh".into()), false, fish.clone()),
338 PathBuf::from("/opt/homebrew/bin/fish"),
339 "an app from the desktop takes the passwd entry"
340 );
341 assert_eq!(
342 resolve_shell(Some("/bin/zsh".into()), false, None),
343 PathBuf::from("/bin/zsh"),
344 "with no passwd entry, SHELL"
345 );
346 assert_eq!(
347 resolve_shell(Some("".into()), true, None),
348 PathBuf::from("/bin/sh")
349 );
350 assert_eq!(resolve_shell(None, false, None), PathBuf::from("/bin/sh"));
351 }
352
353 #[test]
354 fn macos_logs_in_through_login_and_bash_exec() {
355 let args = ["--posix".to_owned(), "it's".to_owned()];
356 let (program, argv) = login_command_for(
357 true,
358 Path::new("/opt/homebrew/bin/fish"),
359 &args,
360 Some("ada"),
361 true,
362 )
363 .into_parts();
364 assert_eq!(program, PathBuf::from("/usr/bin/login"));
365 assert_eq!(
366 argv,
367 [
368 "-q",
369 "-flp",
370 "ada",
371 "/bin/bash",
372 "--noprofile",
373 "--norc",
374 "-c",
375 r"exec -l '/opt/homebrew/bin/fish' '--posix' 'it'\''s'",
376 ]
377 );
378 let (_, quiet) =
379 login_command_for(true, Path::new("/bin/zsh"), &[], Some("ada"), false).into_parts();
380 assert_eq!(quiet[0], "-flp", "no -q without ~/.hushlogin");
381 }
382
383 #[test]
384 fn elsewhere_the_shell_takes_a_login_flag() {
385 let (program, argv) = login_command_for(
386 false,
387 Path::new("/bin/zsh"),
388 &["-i".to_owned()],
389 Some("ada"),
390 false,
391 )
392 .into_parts();
393 assert_eq!(program, PathBuf::from("/bin/zsh"));
394 assert_eq!(argv, ["-l", "-i"]);
395 let (program, _) =
396 login_command_for(true, Path::new("/bin/zsh"), &[], None, false).into_parts();
397 assert_eq!(program, PathBuf::from("/bin/zsh"), "no user, no login(1)");
398 }
399
400 #[cfg(unix)]
401 #[test]
402 fn the_passwd_entry_names_a_shell_and_a_home() {
403 let entry = passwd().expect("the current user has an entry");
404 assert!(entry.name.is_some_and(|name| !name.is_empty()));
405 assert!(entry.home.is_some_and(|home| home.is_absolute()));
406 }
407
408 #[test]
409 fn windows_prefers_pwsh_on_the_path_then_powershell() {
410 let dir = std::env::temp_dir().join(format!("gpui-cn-pwsh-{}", std::process::id()));
411 let (empty, fake, real) = (dir.join("empty"), dir.join("fake"), dir.join("real"));
412 for d in [&empty, &fake, &real] {
413 std::fs::create_dir_all(d).unwrap();
414 }
415 std::fs::write(fake.join("pwsh.exe"), b"#!/bin/sh").unwrap();
416 std::fs::write(real.join("pwsh.exe"), b"MZ\x90\0").unwrap();
417 let path = |dirs: &[&PathBuf]| Some(std::env::join_paths(dirs).unwrap());
418 assert_eq!(
419 windows_shell(path(&[&empty, &fake, &real])),
420 real.join("pwsh.exe"),
421 "the first real executable"
422 );
423 assert_eq!(
424 windows_shell(path(&[&empty, &fake])),
425 PathBuf::from("powershell.exe")
426 );
427 assert_eq!(windows_shell(None), PathBuf::from("powershell.exe"));
428 let _ = std::fs::remove_dir_all(dir);
429 }
430
431 #[test]
432 fn a_command_reads_as_a_shell_line_and_builds_a_process() {
433 let command = login_command_for(
434 false,
435 Path::new("/bin/zsh"),
436 &["-i".to_owned(), "a b".to_owned()],
437 None,
438 false,
439 );
440 assert_eq!(command.to_string(), "/bin/zsh -l -i 'a b'");
441 let process = command.command();
442 assert_eq!(process.get_program(), "/bin/zsh");
443 assert_eq!(process.get_args().collect::<Vec<_>>(), ["-l", "-i", "a b"]);
444 }
445
446 #[cfg(unix)]
447 #[test]
448 fn the_default_shell_runs_a_command() {
449 let shell = default_shell();
450 assert!(shell.is_absolute(), "{}", shell.display());
451 let status = ShellCommand::new(shell, ["-c", "exit 7"])
452 .command()
453 .status();
454 assert_eq!(status.ok().and_then(|s| s.code()), Some(7));
455 }
456
457 #[cfg(feature = "ghostty")]
458 #[test]
459 fn the_terminal_environment_names_ghostty() {
460 let env = terminal_env().unwrap();
461 let get = |name: &str| {
462 env.iter()
463 .find(|(key, _)| *key == name)
464 .map(|(_, value)| value.to_string_lossy().into_owned())
465 };
466 assert_eq!(get("TERM").as_deref(), Some("xterm-ghostty"));
467 assert_eq!(get("COLORTERM").as_deref(), Some("truecolor"));
468 assert_eq!(get("TERM_PROGRAM").as_deref(), Some("ghostty"));
469 assert!(get("TERMINFO").is_some_and(|dir| Path::new(&dir).is_dir()));
470 }
471}