use std::io;
use std::os::fd::{AsRawFd, OwnedFd};
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use arcbox_pty::RunAs;
use tokio::io::AsyncReadExt as _;
use tokio::process::Command;
use crate::agent::login_session::Account;
const ROOT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const USER_PATH: &str = "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games";
const PROBE_SHELL: &str = "/bin/sh";
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
const RESULT_FD: i32 = 3;
pub(super) fn sshd_default(uid: u32) -> &'static str {
if uid == 0 { ROOT_PATH } else { USER_PATH }
}
pub(super) async fn login_path(account: &Account, run_as: &RunAs) -> String {
let default = sshd_default(account.uid);
match probe(account, run_as, default).await {
Ok(path) => path,
Err(error) => {
tracing::warn!(
user = %account.name,
%error,
"login PATH probe failed; using the sshd default"
);
default.to_owned()
}
}
}
async fn probe(account: &Account, run_as: &RunAs, default: &str) -> io::Result<String> {
let (reader, writer) = io::pipe()?;
let working_dir = if account.home.is_dir() {
account.home.as_path()
} else {
Path::new("/")
};
let mut command = Command::new(PROBE_SHELL);
command
.arg("-l")
.arg("-c")
.arg(format!("printf '%s' \"$PATH\" >&{RESULT_FD}"))
.env_clear()
.env("HOME", &account.home)
.env("USER", &account.name)
.env("LOGNAME", &account.name)
.env("SHELL", account.shell_program())
.env("PATH", default)
.current_dir(working_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let run_as = run_as.clone();
let writer_fd = writer.as_raw_fd();
unsafe {
command.pre_exec(move || {
if writer_fd == RESULT_FD {
if libc::fcntl(RESULT_FD, libc::F_SETFD, 0) < 0 {
return Err(io::Error::last_os_error());
}
} else if libc::dup2(writer_fd, RESULT_FD) < 0 {
return Err(io::Error::last_os_error());
}
run_as.apply()
});
}
let mut child = command.spawn()?;
drop(writer);
let mut reader = tokio::net::unix::pipe::Receiver::from_owned_fd(OwnedFd::from(reader))?;
let mut path = Vec::new();
let status = tokio::time::timeout(PROBE_TIMEOUT, async {
reader.read_to_end(&mut path).await?;
child.wait().await
})
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "the profile did not finish"))??;
if !status.success() {
return Err(io::Error::other(format!(
"{PROBE_SHELL} -l exited with {status}"
)));
}
let path = String::from_utf8(path)
.map_err(|_| io::Error::other("the profile left PATH as non-UTF-8"))?;
if path.is_empty() || path.contains(['\n', '\0']) {
return Err(io::Error::other(format!(
"the profile left PATH unusable: {path:?}"
)));
}
Ok(path)
}
pub(super) fn join_paths(first: &str, second: &str) -> String {
let mut dirs: Vec<&str> = first.split(':').filter(|dir| !dir.is_empty()).collect();
for dir in second.split(':').filter(|dir| !dir.is_empty()) {
if !dirs.contains(&dir) {
dirs.push(dir);
}
}
dirs.join(":")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sshd_default_gives_sbin_to_root_only() {
assert!(sshd_default(0).contains("/usr/sbin"));
assert!(!sshd_default(1000).contains("sbin"));
}
#[test]
fn joined_paths_keep_the_first_set_in_front_and_add_only_new_directories() {
assert_eq!(
join_paths("/run/current-system/sw/bin:/bin", "/bin:/sbin:/usr/bin"),
"/run/current-system/sw/bin:/bin:/sbin:/usr/bin"
);
assert_eq!(join_paths("/opt/bin", ""), "/opt/bin");
assert_eq!(join_paths("", "/bin::/sbin"), "/bin:/sbin");
}
}