use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
pub const FOREIGN_TERMINAL_ENV: &[&str] = &[
"VTE_VERSION",
"WT_SESSION",
"TERM_SESSION_ID",
"TERMINFO",
"GHOSTTY_RESOURCES_DIR",
"GHOSTTY_SHELL_FEATURES",
"GHOSTTY_SHELL_INTEGRATION_XDG_DIR",
"GHOSTTY_BASH_INJECT",
"GHOSTTY_BASH_ENV",
"GHOSTTY_ZSH_ZDOTDIR",
];
pub fn default_shell() -> PathBuf {
if cfg!(windows) {
return windows_shell(std::env::var_os("PATH"));
}
let cli = std::env::var_os("TERM_PROGRAM").is_some_and(|value| !value.is_empty());
resolve_shell(
std::env::var_os("SHELL"),
cli,
passwd().and_then(|entry| entry.shell),
)
}
fn windows_shell(path: Option<OsString>) -> PathBuf {
path.as_deref()
.into_iter()
.flat_map(std::env::split_paths)
.filter(|dir| dir.is_absolute())
.map(|dir| dir.join("pwsh.exe"))
.find(|candidate| is_executable_image(candidate))
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
}
fn is_executable_image(path: &Path) -> bool {
use std::io::Read as _;
let mut magic = [0u8; 2];
std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut magic))
.is_ok_and(|()| &magic == b"MZ")
}
fn resolve_shell(env: Option<OsString>, cli: bool, passwd: Option<PathBuf>) -> PathBuf {
let env = env.filter(|value| !value.is_empty()).map(PathBuf::from);
if cli && let Some(shell) = env.clone() {
return shell;
}
passwd
.filter(|shell| !shell.as_os_str().is_empty())
.or(env)
.unwrap_or_else(|| PathBuf::from("/bin/sh"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ShellCommand {
program: PathBuf,
args: Vec<String>,
}
impl ShellCommand {
pub fn new(
program: impl Into<PathBuf>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
}
}
pub fn program(&self) -> &Path {
&self.program
}
pub fn args(&self) -> &[String] {
&self.args
}
pub fn into_parts(self) -> (PathBuf, Vec<String>) {
(self.program, self.args)
}
pub fn command(&self) -> std::process::Command {
let mut command = std::process::Command::new(&self.program);
command.args(&self.args);
command
}
}
impl fmt::Display for ShellCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&word(&self.program.to_string_lossy()))?;
for arg in &self.args {
write!(f, " {}", word(arg))?;
}
Ok(())
}
}
fn word(value: &str) -> String {
let plain = !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/._-=+:,@%".contains(c));
if plain {
value.to_owned()
} else {
quote(value)
}
}
pub fn login_shell() -> ShellCommand {
login_command(&default_shell(), &[])
}
pub fn login_command(shell: &Path, args: &[String]) -> ShellCommand {
if cfg!(windows) {
return ShellCommand::new(shell, args.iter().cloned());
}
let entry = if cfg!(target_os = "macos") {
passwd()
} else {
None
};
let hush = entry
.as_ref()
.and_then(|entry| entry.home.as_ref())
.is_some_and(|home| home.join(".hushlogin").exists());
login_command_for(
cfg!(target_os = "macos"),
shell,
args,
entry.and_then(|entry| entry.name).as_deref(),
hush,
)
}
fn login_command_for(
macos: bool,
shell: &Path,
args: &[String],
user: Option<&str>,
hush: bool,
) -> ShellCommand {
match user.filter(|_| macos) {
Some(user) => {
let mut line = format!("exec -l {}", quote(&shell.to_string_lossy()));
for arg in args {
line.push(' ');
line.push_str("e(arg));
}
let mut argv = Vec::new();
if hush {
argv.push("-q".to_owned());
}
argv.extend(
["-flp", user, "/bin/bash", "--noprofile", "--norc", "-c"].map(str::to_owned),
);
argv.push(line);
ShellCommand::new("/usr/bin/login", argv)
}
None => {
let mut argv = vec!["-l".to_owned()];
argv.extend(args.iter().cloned());
ShellCommand::new(shell, argv)
}
}
}
fn quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
#[cfg(feature = "ghostty")]
pub fn terminal_env() -> std::io::Result<Vec<(&'static str, OsString)>> {
Ok(vec![
("TERM", ghostty_vt::terminfo::TERM.into()),
("TERMINFO", ghostty_vt::terminfo::dir()?.into_os_string()),
("COLORTERM", "truecolor".into()),
("TERM_PROGRAM", "ghostty".into()),
(
"TERM_PROGRAM_VERSION",
ghostty_vt::build_info::pinned_version().into(),
),
(
"GHOSTTY_RESOURCES_DIR",
ghostty_vt::shell_integration::resources_dir()?.into_os_string(),
),
])
}
#[derive(Debug, Default)]
struct Passwd {
name: Option<String>,
home: Option<PathBuf>,
shell: Option<PathBuf>,
}
#[cfg(unix)]
fn passwd() -> Option<Passwd> {
use std::ffi::CStr;
use std::os::unix::ffi::OsStrExt as _;
let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
let mut result: *mut libc::passwd = std::ptr::null_mut();
let mut buf = vec![0 as libc::c_char; 16 * 1024];
let status = unsafe {
libc::getpwuid_r(
libc::getuid(),
&raw mut entry,
buf.as_mut_ptr(),
buf.len(),
&raw mut result,
)
};
if status != 0 || result.is_null() {
return None;
}
let text = |pointer: *const libc::c_char| {
(!pointer.is_null())
.then(|| unsafe { CStr::from_ptr(pointer) }.to_bytes().to_vec())
.filter(|bytes| !bytes.is_empty())
};
let path = |bytes: Vec<u8>| PathBuf::from(std::ffi::OsStr::from_bytes(&bytes));
Some(Passwd {
name: text(entry.pw_name).and_then(|bytes| String::from_utf8(bytes).ok()),
home: text(entry.pw_dir).map(path),
shell: text(entry.pw_shell).map(path),
})
}
#[cfg(not(unix))]
fn passwd() -> Option<Passwd> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_is_from_the_environment_only_on_a_command_line() {
let fish = Some(PathBuf::from("/opt/homebrew/bin/fish"));
assert_eq!(
resolve_shell(Some("/bin/zsh".into()), true, fish.clone()),
PathBuf::from("/bin/zsh"),
"a command line keeps its SHELL"
);
assert_eq!(
resolve_shell(Some("/bin/zsh".into()), false, fish.clone()),
PathBuf::from("/opt/homebrew/bin/fish"),
"an app from the desktop takes the passwd entry"
);
assert_eq!(
resolve_shell(Some("/bin/zsh".into()), false, None),
PathBuf::from("/bin/zsh"),
"with no passwd entry, SHELL"
);
assert_eq!(
resolve_shell(Some("".into()), true, None),
PathBuf::from("/bin/sh")
);
assert_eq!(resolve_shell(None, false, None), PathBuf::from("/bin/sh"));
}
#[test]
fn macos_logs_in_through_login_and_bash_exec() {
let args = ["--posix".to_owned(), "it's".to_owned()];
let (program, argv) = login_command_for(
true,
Path::new("/opt/homebrew/bin/fish"),
&args,
Some("ada"),
true,
)
.into_parts();
assert_eq!(program, PathBuf::from("/usr/bin/login"));
assert_eq!(
argv,
[
"-q",
"-flp",
"ada",
"/bin/bash",
"--noprofile",
"--norc",
"-c",
r"exec -l '/opt/homebrew/bin/fish' '--posix' 'it'\''s'",
]
);
let (_, quiet) =
login_command_for(true, Path::new("/bin/zsh"), &[], Some("ada"), false).into_parts();
assert_eq!(quiet[0], "-flp", "no -q without ~/.hushlogin");
}
#[test]
fn elsewhere_the_shell_takes_a_login_flag() {
let (program, argv) = login_command_for(
false,
Path::new("/bin/zsh"),
&["-i".to_owned()],
Some("ada"),
false,
)
.into_parts();
assert_eq!(program, PathBuf::from("/bin/zsh"));
assert_eq!(argv, ["-l", "-i"]);
let (program, _) =
login_command_for(true, Path::new("/bin/zsh"), &[], None, false).into_parts();
assert_eq!(program, PathBuf::from("/bin/zsh"), "no user, no login(1)");
}
#[cfg(unix)]
#[test]
fn the_passwd_entry_names_a_shell_and_a_home() {
let entry = passwd().expect("the current user has an entry");
assert!(entry.name.is_some_and(|name| !name.is_empty()));
assert!(entry.home.is_some_and(|home| home.is_absolute()));
}
#[test]
fn windows_prefers_pwsh_on_the_path_then_powershell() {
let dir = std::env::temp_dir().join(format!("gpui-cn-pwsh-{}", std::process::id()));
let (empty, fake, real) = (dir.join("empty"), dir.join("fake"), dir.join("real"));
for d in [&empty, &fake, &real] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(fake.join("pwsh.exe"), b"#!/bin/sh").unwrap();
std::fs::write(real.join("pwsh.exe"), b"MZ\x90\0").unwrap();
let path = |dirs: &[&PathBuf]| Some(std::env::join_paths(dirs).unwrap());
assert_eq!(
windows_shell(path(&[&empty, &fake, &real])),
real.join("pwsh.exe"),
"the first real executable"
);
assert_eq!(
windows_shell(path(&[&empty, &fake])),
PathBuf::from("powershell.exe")
);
assert_eq!(windows_shell(None), PathBuf::from("powershell.exe"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_command_reads_as_a_shell_line_and_builds_a_process() {
let command = login_command_for(
false,
Path::new("/bin/zsh"),
&["-i".to_owned(), "a b".to_owned()],
None,
false,
);
assert_eq!(command.to_string(), "/bin/zsh -l -i 'a b'");
let process = command.command();
assert_eq!(process.get_program(), "/bin/zsh");
assert_eq!(process.get_args().collect::<Vec<_>>(), ["-l", "-i", "a b"]);
}
#[cfg(unix)]
#[test]
fn the_default_shell_runs_a_command() {
let shell = default_shell();
assert!(shell.is_absolute(), "{}", shell.display());
let status = ShellCommand::new(shell, ["-c", "exit 7"])
.command()
.status();
assert_eq!(status.ok().and_then(|s| s.code()), Some(7));
}
#[cfg(feature = "ghostty")]
#[test]
fn the_terminal_environment_names_ghostty() {
let env = terminal_env().unwrap();
let get = |name: &str| {
env.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.to_string_lossy().into_owned())
};
assert_eq!(get("TERM").as_deref(), Some("xterm-ghostty"));
assert_eq!(get("COLORTERM").as_deref(), Some("truecolor"));
assert_eq!(get("TERM_PROGRAM").as_deref(), Some("ghostty"));
assert!(get("TERMINFO").is_some_and(|dir| Path::new(&dir).is_dir()));
}
}