Skip to main content

gpui_cn/
shell.rs

1//! The user's shell, how a terminal starts it, and the environment it gives
2//! the program, resolved as Ghostty does.
3//!
4//! These need no pty and no feature, so an application that starts its own
5//! processes, runs its own pty, or starts a shell on a remote machine gets
6//! the same shell as the terminal's `LocalPty`. `terminal_env`, which names
7//! Ghostty's terminfo, needs the `ghostty` feature.
8//!
9//! ```
10//! use gpui_cn::shell;
11//!
12//! // The user's shell, such as /bin/zsh.
13//! println!("{}", shell::default_shell().display());
14//! // How a terminal starts it as a login shell, as one line.
15//! let login = shell::login_shell();
16//! println!("{login}");
17//! // The same as a process to spawn.
18//! let command: std::process::Command = login.command();
19//! # let _ = command;
20//! ```
21//!
22//! The rules come from Ghostty's `src/config/Config.zig` (the default
23//! command), `src/os/passwd.zig` (the passwd entry) and
24//! `src/termio/Exec.zig` (the login shell on macOS).
25
26use std::ffi::OsString;
27use std::fmt;
28use std::path::{Path, PathBuf};
29
30/// Variables other terminals set that a program in this terminal must not
31/// see, since they describe that other terminal. The terminal's `LocalPty`
32/// removes them.
33pub 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
46/// The user's default shell, as Ghostty picks it with no `command` set.
47///
48/// In a command-line environment, one with `TERM_PROGRAM` set, it is
49/// `$SHELL`. Otherwise, such as an application opened from the Dock or
50/// the Finder, `$SHELL` is whatever the launcher had, so the shell comes
51/// from the user's passwd entry instead. Then `/bin/sh`.
52///
53/// Windows has no `$SHELL`, and Ghostty does not run there, so it follows
54/// herdr: the first `pwsh.exe` on `PATH`, as a full path, which is what
55/// other Windows terminals prefer, else `powershell.exe`, which every
56/// Windows has.
57pub 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
69/// herdr's Windows choice: the first `pwsh.exe` in an absolute `PATH`
70/// directory that is a Windows executable, else `powershell.exe`.
71fn 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
81/// Whether `path` is a file that starts as a Windows executable does, with
82/// the `MZ` header, so a stray file of that name is passed over.
83fn 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
91/// Ghostty's choice between `$SHELL`, the passwd entry and `/bin/sh`.
92fn 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/// A program and its arguments, such as [`login_command`] returns.
104#[derive(Debug, Clone, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct ShellCommand {
107    program: PathBuf,
108    args: Vec<String>,
109}
110
111impl ShellCommand {
112    /// `program` with `args`.
113    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    /// The program to run.
124    pub fn program(&self) -> &Path {
125        &self.program
126    }
127
128    /// The arguments, without the program.
129    pub fn args(&self) -> &[String] {
130        &self.args
131    }
132
133    /// The program and the arguments.
134    pub fn into_parts(self) -> (PathBuf, Vec<String>) {
135        (self.program, self.args)
136    }
137
138    /// A process that runs it, for the caller to give an environment, a
139    /// working directory and stdio, and to spawn.
140    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
147/// The command as a POSIX shell line, each word quoted when it needs it.
148impl 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
158/// `value` as one shell word: as it is when it is plain, quoted otherwise.
159fn 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
171/// The user's default shell as a login shell, the way a terminal starts
172/// it: [`login_command`] for [`default_shell`] with no arguments.
173pub fn login_shell() -> ShellCommand {
174    login_command(&default_shell(), &[])
175}
176
177/// The program and arguments that start `shell` with `args` as a login
178/// shell, as Ghostty does.
179///
180/// On macOS that is `login(1)`, so `getlogin`, `SHELL` and the rest of a
181/// login behave as a user expects there: `/usr/bin/login -flp <user>
182/// /bin/bash --noprofile --norc -c "exec -l <shell> <args>"`, with `-q`
183/// when `~/.hushlogin` exists. `-p` keeps the environment, so shell
184/// integration still reaches the shell. Elsewhere, and on macOS when the
185/// user is unknown, it is the shell with `-l` first. Windows has no login
186/// shell, so there it is the shell and `args` as they are.
187pub 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(&quote(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
241/// `value` in single quotes for a POSIX shell, with its own single quotes
242/// escaped.
243fn quote(value: &str) -> String {
244    format!("'{}'", value.replace('\'', r"'\''"))
245}
246
247/// The variables a terminal sets for its program, as Ghostty sets them:
248/// `TERM=xterm-ghostty` with `TERMINFO` pointing at the shipped database,
249/// `COLORTERM=truecolor`, `TERM_PROGRAM=ghostty` with its version, and
250/// `GHOSTTY_RESOURCES_DIR` for the shell integration scripts.
251///
252/// The database and the scripts are written to a cache directory on first
253/// use; see `ghostty_vt::terminfo::dir`. A remote program needs only
254/// `TERM`, `COLORTERM` and `TERM_PROGRAM`, plus the terminfo entry on the
255/// remote machine.
256#[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/// The parts of the user's passwd entry a terminal needs.
275#[derive(Debug, Default)]
276struct Passwd {
277    name: Option<String>,
278    home: Option<PathBuf>,
279    shell: Option<PathBuf>,
280}
281
282/// The current user's passwd entry, through `getpwuid_r`.
283#[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    // SAFETY: every pointer is valid for the call and `buf` outlives the
292    // strings `entry` points into, which are copied out before it drops.
293    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        // SAFETY: a non-null field of a filled entry is a C string in `buf`.
307        (!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}