use crate::context::Environment;
use crate::host::LoginPath;
use std::ffi::CString;
use std::io::{self, Read};
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub(crate) const PATIENCE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Ran {
Said(String),
Failed,
Late,
}
pub(crate) fn login_path(env: &Environment, spawn_flags: libc::c_short) -> LoginPath {
let marker = marker();
path(env, &marker, |shell, arguments| {
run(shell, arguments, env, PATIENCE, spawn_flags)
})
}
pub(crate) fn path(
env: &Environment,
marker: &str,
run: impl FnOnce(&Path, &[String]) -> Ran,
) -> LoginPath {
let Some(shell) = shell(env) else {
return LoginPath::Unknown;
};
match run(&shell, &arguments(marker)) {
Ran::Said(output) => path_in(&output, marker).map_or(LoginPath::Unknown, LoginPath::Said),
Ran::Failed => LoginPath::Unknown,
Ran::Late => LoginPath::Late,
}
}
fn marker() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
format!("pitboard-path-{}-{nanos:x}", std::process::id())
}
pub(crate) fn arguments(marker: &str) -> Vec<String> {
vec![
"-l".into(),
"-i".into(),
"-c".into(),
format!("echo {marker}; /usr/bin/printenv PATH; echo {marker}"),
]
}
pub(crate) fn shell(env: &Environment) -> Option<PathBuf> {
env.path("SHELL")
.filter(|shell| !shell.is_empty())
.map(PathBuf::from)
.or_else(super::user::login_shell)
}
pub(crate) fn path_in(output: &str, marker: &str) -> Option<String> {
let (_, after) = output.split_once(marker)?;
let (between, _) = after.split_once(marker)?;
let newline = |c: char| {
matches!(c, '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}')
|| matches!(c, '\u{2028}' | '\u{2029}')
};
let path = between.trim_matches(newline);
(!path.is_empty()).then(|| path.to_owned())
}
pub(crate) fn run(
shell: &Path,
arguments: &[String],
env: &Environment,
limit: Duration,
spawn_flags: libc::c_short,
) -> Ran {
let Ok((mut reading, writing)) = io::pipe() else {
return Ran::Failed;
};
let Some(pid) = spawn(shell, arguments, env, writing.as_raw_fd(), spawn_flags) else {
return Ran::Failed;
};
drop(writing);
let deadline = Instant::now() + limit;
let mut output = Vec::new();
let mut ended = false;
let mut status: libc::c_int = 0;
loop {
let reaped = unsafe { libc::waitpid(pid, &raw mut status, libc::WNOHANG) };
if reaped == pid {
break;
}
if reaped < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
return Ran::Failed;
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
stop(pid);
return Ran::Late;
}
if ended {
std::thread::sleep(Duration::from_millis(10));
} else if readable(&reading, left.min(Duration::from_millis(50))) {
ended = !read_some(&mut reading, &mut output);
}
}
while !ended && Instant::now() < deadline && readable(&reading, Duration::ZERO) {
ended = !read_some(&mut reading, &mut output);
}
if !(libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0) {
return Ran::Failed;
}
Ran::Said(String::from_utf8_lossy(&output).into_owned())
}
fn spawn(
shell: &Path,
arguments: &[String],
env: &Environment,
output: libc::c_int,
spawn_flags: libc::c_short,
) -> Option<libc::pid_t> {
let program = CString::new(shell.as_os_str().as_bytes()).ok()?;
let mut argv = vec![program.clone()];
for argument in arguments {
argv.push(CString::new(argument.as_str()).ok()?);
}
let envp: Vec<CString> = env
.iter()
.filter_map(|(name, value)| {
let mut pair = name.as_bytes().to_vec();
pair.push(b'=');
pair.extend_from_slice(value.as_bytes());
CString::new(pair).ok()
})
.collect();
let pointers = |strings: &[CString]| -> Vec<*mut libc::c_char> {
strings
.iter()
.map(|s| s.as_ptr().cast_mut())
.chain(std::iter::once(std::ptr::null_mut()))
.collect()
};
let (argv, envp) = (pointers(&argv), pointers(&envp));
let null = c"/dev/null";
let mut actions = Actions::new()?;
let mut attributes = Attributes::new()?;
let prepared = unsafe {
libc::posix_spawn_file_actions_addopen(actions.raw(), 0, null.as_ptr(), libc::O_RDONLY, 0)
== 0
&& libc::posix_spawn_file_actions_adddup2(actions.raw(), output, 1) == 0
&& libc::posix_spawn_file_actions_addopen(
actions.raw(),
2,
null.as_ptr(),
libc::O_WRONLY,
0,
) == 0
&& libc::posix_spawnattr_setflags(attributes.raw(), spawn_flags) == 0
};
if !prepared {
return None;
}
let mut pid: libc::pid_t = 0;
let started = unsafe {
libc::posix_spawn(
&raw mut pid,
program.as_ptr(),
actions.raw(),
attributes.raw(),
argv.as_ptr(),
envp.as_ptr(),
)
};
(started == 0).then_some(pid)
}
struct Actions(Box<std::mem::MaybeUninit<libc::posix_spawn_file_actions_t>>);
impl Actions {
fn new() -> Option<Actions> {
let mut raw = Box::new(std::mem::MaybeUninit::uninit());
let made = unsafe { libc::posix_spawn_file_actions_init(raw.as_mut_ptr()) };
(made == 0).then(|| Actions(raw))
}
fn raw(&mut self) -> *mut libc::posix_spawn_file_actions_t {
self.0.as_mut_ptr()
}
}
impl Drop for Actions {
fn drop(&mut self) {
unsafe { libc::posix_spawn_file_actions_destroy(self.raw()) };
}
}
struct Attributes(Box<std::mem::MaybeUninit<libc::posix_spawnattr_t>>);
impl Attributes {
fn new() -> Option<Attributes> {
let mut raw = Box::new(std::mem::MaybeUninit::uninit());
let made = unsafe { libc::posix_spawnattr_init(raw.as_mut_ptr()) };
(made == 0).then(|| Attributes(raw))
}
fn raw(&mut self) -> *mut libc::posix_spawnattr_t {
self.0.as_mut_ptr()
}
}
impl Drop for Attributes {
fn drop(&mut self) {
unsafe { libc::posix_spawnattr_destroy(self.raw()) };
}
}
fn readable(pipe: &io::PipeReader, wait: Duration) -> bool {
let mut ready = libc::pollfd {
fd: pipe.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let millis = libc::c_int::try_from(wait.as_millis()).unwrap_or(libc::c_int::MAX);
unsafe { libc::poll(&raw mut ready, 1, millis) > 0 }
}
fn read_some(pipe: &mut io::PipeReader, output: &mut Vec<u8>) -> bool {
let mut buffer = [0_u8; 4096];
match pipe.read(&mut buffer) {
Ok(0) => false,
Ok(count) => {
output.extend_from_slice(&buffer[..count]);
true
}
Err(e) => matches!(
e.kind(),
io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
),
}
}
fn stop(pid: libc::pid_t) {
unsafe {
libc::kill(-pid, libc::SIGKILL);
libc::kill(pid, libc::SIGKILL);
}
let mut status: libc::c_int = 0;
loop {
let reaped = unsafe { libc::waitpid(pid, &raw mut status, 0) };
if reaped >= 0 || io::Error::last_os_error().kind() != io::ErrorKind::Interrupted {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MARKER: &str = "pitboard-path-test";
fn env(pairs: &[(&str, &str)]) -> Environment {
pairs.iter().copied().collect()
}
fn flags() -> libc::c_short {
crate::host::os::SPAWN_FLAGS
}
#[test]
fn the_path_is_what_stands_between_the_markers() {
let said = format!(
"Last login: never\nnvm is loaded{MARKER}\n\
/Users/x/.nvm/bin:/usr/bin:/bin\n{MARKER}\nbye\n"
);
assert_eq!(
path_in(&said, MARKER).as_deref(),
Some("/Users/x/.nvm/bin:/usr/bin:/bin")
);
assert_eq!(
path_in(&format!("{MARKER}\r\n/usr/bin\r\n{MARKER}\r\n"), MARKER).as_deref(),
Some("/usr/bin")
);
}
#[test]
fn without_both_markers_there_is_no_answer() {
assert_eq!(path_in("", MARKER), None);
assert_eq!(path_in("/usr/bin:/bin\n", MARKER), None);
assert_eq!(path_in(&format!("{MARKER}\n/usr/bin:/bin\n"), MARKER), None);
assert_eq!(path_in(&format!("{MARKER}\n\n{MARKER}\n"), MARKER), None);
}
#[test]
fn the_login_shell_is_asked_for_its_path() {
let mut asked = None;
let fish = env(&[("SHELL", "/opt/homebrew/bin/fish")]);
let answer = path(&fish, MARKER, |shell, arguments| {
asked = Some((shell.to_path_buf(), arguments.to_vec()));
Ran::Said(format!(
"Welcome to fish\n{MARKER}\n/opt/homebrew/bin:/usr/bin\n{MARKER}\n"
))
});
assert_eq!(answer, LoginPath::Said("/opt/homebrew/bin:/usr/bin".into()));
let (shell, arguments) = asked.expect("a shell was asked");
assert_eq!(shell, PathBuf::from("/opt/homebrew/bin/fish"));
assert_eq!(
arguments,
[
"-l".to_string(),
"-i".into(),
"-c".into(),
format!("echo {MARKER}; /usr/bin/printenv PATH; echo {MARKER}")
]
);
}
#[test]
fn without_shell_the_accounts_own_is_asked() {
for given in [env(&[]), env(&[("SHELL", "")])] {
let mut asked = None;
let _ = path(&given, MARKER, |shell, _| {
asked = Some(shell.to_path_buf());
Ran::Failed
});
let asked = asked.expect("a shell was asked");
assert!(asked.is_absolute(), "{}", asked.display());
}
}
#[test]
fn a_shell_that_failed_has_no_answer_and_a_late_one_says_so() {
let zsh = env(&[("SHELL", "/bin/zsh")]);
assert_eq!(path(&zsh, MARKER, |_, _| Ran::Failed), LoginPath::Unknown);
assert_eq!(path(&zsh, MARKER, |_, _| Ran::Late), LoginPath::Late);
assert_eq!(
path(&zsh, MARKER, |_, _| Ran::Said("no markers\n".into())),
LoginPath::Unknown
);
}
struct FakeShell {
dir: PathBuf,
}
impl FakeShell {
fn new(name: &str, before: &str) -> FakeShell {
let dir = std::env::temp_dir().join(format!(
"pitboard-shell-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a directory for the shell");
let script = format!(
"#!/bin/sh\n[ \"$1 $2 $3\" = \"-l -i -c\" ] || exit 64\n\
cd \"$(/usr/bin/dirname \"$0\")\" || exit 65\n{before}\nexec /bin/sh -c \"$4\"\n"
);
let status = std::process::Command::new("/bin/sh")
.args(["-c", "printf %s \"$2\" > \"$1\" && chmod 755 \"$1\"", "sh"])
.arg(dir.join("shell"))
.arg(script)
.status()
.expect("sh runs");
assert!(status.success(), "the shell could not be written");
FakeShell { dir }
}
fn path(&self) -> PathBuf {
self.dir.join("shell")
}
fn pids(&self) -> Vec<libc::pid_t> {
std::fs::read_to_string(self.dir.join("pids"))
.unwrap_or_default()
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
}
}
impl Drop for FakeShell {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn gone(pid: libc::pid_t) -> bool {
(0..200).any(|_| {
let state = std::process::Command::new("ps")
.args(["-o", "stat=", "-p", &pid.to_string()])
.output()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
.unwrap_or_default();
if state.is_empty() || state.starts_with('Z') {
return true;
}
std::thread::sleep(Duration::from_millis(10));
false
})
}
#[test]
fn a_shell_is_run_and_its_path_read_back() {
let shell = FakeShell::new("round", "echo 'Last login: never'");
let given = shell.path();
let answer = login_path(
&env(&[
("SHELL", given.to_str().expect("a path in UTF-8")),
("PATH", "/from/the/test/bin:/usr/bin:/bin"),
]),
flags(),
);
assert_eq!(
answer,
LoginPath::Said("/from/the/test/bin:/usr/bin:/bin".into())
);
}
#[test]
fn a_shell_that_fails_has_no_answer() {
let failing = FakeShell::new("fails", "exit 3");
assert_eq!(
run(
&failing.path(),
&arguments(MARKER),
&env(&[]),
Duration::from_secs(5),
flags()
),
Ran::Failed
);
assert_eq!(
run(
Path::new("/nowhere/at/all/sh"),
&arguments(MARKER),
&env(&[]),
Duration::from_secs(5),
flags()
),
Ran::Failed,
"a shell that is not there"
);
}
#[test]
fn a_shell_that_hangs_is_stopped_with_everything_it_started() {
let hanging = FakeShell::new(
"hangs",
"echo $$ > pids; /bin/sleep 30 & echo $! >> pids; wait",
);
let started = Instant::now();
assert_eq!(
run(
&hanging.path(),
&arguments(MARKER),
&env(&[]),
Duration::from_secs(2),
flags()
),
Ran::Late
);
assert!(
started.elapsed() < Duration::from_secs(5),
"it was stopped, not waited for: {:?}",
started.elapsed()
);
let pids = hanging.pids();
assert_eq!(
pids.len(),
2,
"the shell and what it started, both started before the end"
);
for pid in pids {
assert!(gone(pid), "{pid} is still running");
}
}
#[test]
fn a_shell_is_done_when_it_exits_even_if_something_it_started_is_not() {
let shell = FakeShell::new("leaves", "/bin/sleep 10 & echo $! > pids");
let given = shell.path();
let answer = login_path(
&env(&[
("SHELL", given.to_str().expect("a path in UTF-8")),
("PATH", "/usr/bin:/bin"),
]),
flags(),
);
for pid in shell.pids() {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
assert_eq!(answer, LoginPath::Said("/usr/bin:/bin".into()));
}
}