use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use runandlog_core::ExecOptions;
use crate::session::RunHook;
pub const HELPER_NAME: &str = "runandlog-askpass";
const SOCKET_VAR: &str = "RUNANDLOG_ASKPASS_SOCKET";
const RUN_VAR: &str = "RUNANDLOG_ASKPASS_RUN";
const HELPER_VARS: [&str; 3] = ["SUDO_ASKPASS", "SSH_ASKPASS", "GIT_ASKPASS"];
const MAX_PROMPT_BYTES: usize = 4096;
const PROMPT_READ_TIMEOUT: Duration = Duration::from_secs(5);
const PROMPT_END: u8 = 0;
const REPLY_OK: &[u8] = b"OK\n";
const REPLY_CANCEL: &[u8] = b"CANCEL\n";
pub const PROMPT_POLL: Duration = Duration::from_millis(100);
pub type Prompter = Box<dyn Fn(&str, &dyn Fn() -> bool) -> Option<String> + Send + 'static>;
pub struct Askpass {
dir: PathBuf,
shared: Arc<Shared>,
listener: Option<thread::JoinHandle<()>>,
}
struct Shared {
socket: PathBuf,
helper: PathBuf,
closing: AtomicBool,
current_run: Mutex<u64>,
next_run: AtomicU64,
}
#[must_use = "dropping this ends the run: the helper refuses its prompts from then on"]
pub struct RunInProgress {
shared: Arc<Shared>,
run: u64,
}
impl Drop for RunInProgress {
fn drop(&mut self) {
let mut current = self.shared.current_run();
if *current == self.run {
*current = 0;
}
}
}
impl Shared {
fn current_run(&self) -> std::sync::MutexGuard<'_, u64> {
self.current_run
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
}
impl Askpass {
pub fn start(prompter: Prompter) -> io::Result<Askpass> {
Askpass::start_with_helper(prompter, &std::env::current_exe()?)
}
pub fn start_with_helper(prompter: Prompter, program: &Path) -> io::Result<Askpass> {
let dir = private_dir()?;
let socket = dir.join("socket");
let helper = dir.join(HELPER_NAME);
let started = (|| {
std::os::unix::fs::symlink(program, &helper)?;
UnixListener::bind(&socket)
})();
let listener = match started {
Ok(listener) => listener,
Err(error) => {
let _ = std::fs::remove_dir_all(&dir);
return Err(error);
}
};
let shared = Arc::new(Shared {
socket,
helper,
closing: AtomicBool::new(false),
current_run: Mutex::new(0),
next_run: AtomicU64::new(1),
});
let listener = {
let shared = Arc::clone(&shared);
thread::spawn(move || serve(listener, &shared, prompter))
};
Ok(Askpass {
dir,
shared,
listener: Some(listener),
})
}
pub fn apply(&self, options: &mut ExecOptions) -> RunInProgress {
apply(&self.shared, options)
}
pub fn hook(&self) -> RunHook {
let shared = Arc::clone(&self.shared);
Arc::new(move |options| Box::new(apply(&shared, options)))
}
}
fn apply(shared: &Arc<Shared>, options: &mut ExecOptions) -> RunInProgress {
let run = shared.next_run.fetch_add(1, Ordering::SeqCst);
*shared.current_run() = run;
options
.env
.extend(environment(shared, run, |name| std::env::var_os(name)));
RunInProgress {
shared: Arc::clone(shared),
run,
}
}
fn environment(
shared: &Shared,
run: u64,
current: impl Fn(&str) -> Option<OsString>,
) -> Vec<(OsString, OsString)> {
let mut env = vec![
(SOCKET_VAR.into(), shared.socket.clone().into_os_string()),
(RUN_VAR.into(), run.to_string().into()),
];
for name in HELPER_VARS {
if current(name).is_some_and(|value| !value.is_empty()) {
continue;
}
env.push((name.into(), shared.helper.clone().into_os_string()));
}
env.push(("SSH_ASKPASS_REQUIRE".into(), "force".into()));
env
}
impl Drop for Askpass {
fn drop(&mut self) {
self.shared.closing.store(true, Ordering::SeqCst);
let _ = UnixStream::connect(&self.shared.socket);
if let Some(listener) = self.listener.take() {
let _ = listener.join();
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn private_dir() -> io::Result<PathBuf> {
static SERIAL: AtomicU64 = AtomicU64::new(0);
const ATTEMPTS: u32 = 16;
let pid = std::process::id();
let mut last = None;
for _ in 0..ATTEMPTS {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.subsec_nanos())
.unwrap_or(0);
let serial = SERIAL.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("runandlog-{pid}-{nanos:08x}-{serial}"));
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => return Ok(dir),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => last = Some(error),
Err(error) => return Err(error),
}
}
Err(last.unwrap_or_else(|| io::Error::other("no free name for the askpass directory")))
}
fn serve(listener: UnixListener, shared: &Shared, prompter: Prompter) {
for stream in listener.incoming() {
if shared.closing.load(Ordering::SeqCst) {
return;
}
if let Ok(stream) = stream {
let _ = answer(stream, &prompter, shared);
}
}
}
fn answer(mut stream: UnixStream, prompter: &Prompter, shared: &Shared) -> io::Result<()> {
stream.set_read_timeout(Some(PROMPT_READ_TIMEOUT))?;
stream.set_write_timeout(Some(PROMPT_POLL))?;
let Some(message) = read_prompt(&mut stream)? else {
return stream.write_all(REPLY_CANCEL);
};
let Some((run, prompt)) = split_run(&message) else {
return stream.write_all(REPLY_CANCEL);
};
if run == 0 || run != *shared.current_run() {
return stream.write_all(REPLY_CANCEL);
}
let prompt = String::from_utf8_lossy(prompt);
let current = || *shared.current_run() == run;
let gone = || shared.closing.load(Ordering::SeqCst) || !current() || peer_has_gone(&stream);
match prompter(prompt.trim_end(), &gone) {
Some(secret) => {
let current = shared.current_run();
if *current != run {
drop(current);
return stream.write_all(REPLY_CANCEL);
}
stream.write_all(REPLY_OK)?;
stream.write_all(secret.as_bytes())
}
None => stream.write_all(REPLY_CANCEL),
}
}
fn split_run(message: &[u8]) -> Option<(u64, &[u8])> {
let end = message.iter().position(|&byte| byte == b'\n')?;
let run = std::str::from_utf8(&message[..end]).ok()?.parse().ok()?;
Some((run, &message[end + 1..]))
}
fn read_prompt(stream: &mut UnixStream) -> io::Result<Option<Vec<u8>>> {
let mut prompt = Vec::new();
let mut chunk = [0u8; 256];
loop {
let read = stream.read(&mut chunk)?;
if read == 0 {
return Ok(None);
}
let end = chunk[..read].iter().position(|&byte| byte == PROMPT_END);
prompt.extend_from_slice(&chunk[..end.unwrap_or(read)]);
if prompt.len() > MAX_PROMPT_BYTES {
return Ok(None);
}
if end.is_some() {
return Ok(Some(prompt));
}
}
}
fn peer_has_gone(stream: &UnixStream) -> bool {
if stream.set_nonblocking(true).is_err() {
return true;
}
let mut byte = [0u8; 1];
let gone = match (&*stream).read(&mut byte) {
Ok(0) => true,
Ok(_) => false,
Err(error) => error.kind() != io::ErrorKind::WouldBlock,
};
gone || stream.set_nonblocking(false).is_err()
}
pub fn terminal_prompter(abandoned: fn() -> bool) -> Option<Prompter> {
open_terminal().ok()?;
Some(Box::new(move |prompt, gone| {
read_hidden(prompt, &|| abandoned() || gone())
.ok()
.flatten()
}))
}
pub fn wait_for_answer(
answer: &mpsc::Receiver<Option<String>>,
gone: &dyn Fn() -> bool,
) -> Option<String> {
loop {
match answer.recv_timeout(PROMPT_POLL) {
Ok(answer) => return answer,
Err(mpsc::RecvTimeoutError::Disconnected) => return None,
Err(mpsc::RecvTimeoutError::Timeout) => {
if gone() {
return None;
}
}
}
}
}
fn open_terminal() -> io::Result<std::fs::File> {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
}
struct RestoreTerminal {
fd: std::os::fd::RawFd,
saved: libc::termios,
}
impl Drop for RestoreTerminal {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.saved);
}
}
}
fn read_hidden(prompt: &str, abandoned: &dyn Fn() -> bool) -> io::Result<Option<String>> {
use std::os::fd::AsRawFd;
let mut terminal = open_terminal()?;
let fd = terminal.as_raw_fd();
let mut saved: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut saved) } != 0 {
return Err(io::Error::last_os_error());
}
let mut hidden = saved;
hidden.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &hidden) } != 0 {
return Err(io::Error::last_os_error());
}
let restore = RestoreTerminal { fd, saved };
write!(terminal, "\r\n{prompt} ")?;
terminal.flush()?;
let mut line = Vec::new();
let answer = loop {
if abandoned() {
break None;
}
let mut poll_fd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
if unsafe { libc::poll(&mut poll_fd, 1, PROMPT_POLL.as_millis() as libc::c_int) } <= 0 {
continue;
}
let mut chunk = [0u8; 256];
match terminal.read(&mut chunk) {
Ok(0) => break None,
Ok(read) => {
line.extend_from_slice(&chunk[..read]);
if let Some(end) = line.iter().position(|&byte| byte == b'\n') {
line.truncate(end);
break Some(());
}
}
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
};
drop(restore);
let _ = write!(terminal, "\r\n");
Ok(answer.map(|()| {
if line.last() == Some(&b'\r') {
line.pop();
}
String::from_utf8_lossy(&line).into_owned()
}))
}
pub fn started_as_helper(argv0: &std::ffi::OsStr) -> bool {
Path::new(argv0).file_name() == Some(std::ffi::OsStr::new(HELPER_NAME))
}
pub fn helper_main(prompt: Option<String>) -> ExitCode {
let prompt = prompt.unwrap_or_else(|| "Password:".to_string());
match ask(&prompt) {
Ok(Some(secret)) => {
let mut stdout = io::stdout();
if writeln!(stdout, "{secret}")
.and_then(|()| stdout.flush())
.is_ok()
{
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Ok(None) => ExitCode::FAILURE,
Err(error) => {
eprintln!("runandlog-askpass: {error}");
ExitCode::FAILURE
}
}
}
fn ask(prompt: &str) -> io::Result<Option<String>> {
let socket = std::env::var_os(SOCKET_VAR)
.ok_or_else(|| io::Error::other(format!("{SOCKET_VAR} is not set")))?;
let run =
std::env::var(RUN_VAR).map_err(|_| io::Error::other(format!("{RUN_VAR} is not set")))?;
let mut stream = UnixStream::connect(socket)?;
stream.write_all(run.as_bytes())?;
stream.write_all(b"\n")?;
stream.write_all(prompt.as_bytes())?;
stream.write_all(&[PROMPT_END])?;
let mut reply = Vec::new();
stream.read_to_end(&mut reply)?;
match reply.strip_prefix(REPLY_OK) {
Some(secret) => Ok(Some(String::from_utf8_lossy(secret).into_owned())),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::mpsc;
fn start_run(askpass: &Askpass) -> (u64, RunInProgress) {
let mut options = ExecOptions::new(std::env::temp_dir());
let run = askpass.apply(&mut options);
(run.run, run)
}
fn message(run: u64, prompt: &str) -> Vec<u8> {
let mut message = format!("{run}\n{prompt}").into_bytes();
message.push(PROMPT_END);
message
}
fn helper(askpass: &Askpass, run: u64, prompt: &str) -> UnixStream {
let mut stream = UnixStream::connect(&askpass.shared.socket).unwrap();
stream.write_all(&message(run, prompt)).unwrap();
stream
}
fn reply_of(mut stream: UnixStream) -> Vec<u8> {
let mut reply = Vec::new();
stream.read_to_end(&mut reply).unwrap();
reply
}
#[test]
fn a_prompt_goes_to_the_front_end_and_the_answer_comes_back() {
let (asked_tx, asked_rx) = mpsc::channel();
let asked_tx = Mutex::new(asked_tx);
let askpass = Askpass::start(Box::new(move |prompt, _| {
asked_tx.lock().unwrap().send(prompt.to_string()).unwrap();
Some("s3cret".to_string())
}))
.unwrap();
let (run, _run) = start_run(&askpass);
let mut stream = helper(&askpass, run, "[sudo] password for me: ");
let mut reply = Vec::new();
stream.read_to_end(&mut reply).unwrap();
assert_eq!(asked_rx.recv().unwrap(), "[sudo] password for me:");
assert_eq!(reply, b"OK\ns3cret");
}
#[test]
fn a_declined_prompt_is_told_apart_from_an_empty_password() {
let askpass = Askpass::start(Box::new(|_, _| None)).unwrap();
let (run, _run) = start_run(&askpass);
assert_eq!(reply_of(helper(&askpass, run, "Password:")), REPLY_CANCEL);
}
#[test]
fn only_the_current_run_is_answered() {
let asked = Arc::new(AtomicBool::new(false));
let askpass = Askpass::start({
let asked = Arc::clone(&asked);
Box::new(move |_, _| {
asked.store(true, Ordering::SeqCst);
Some("s3cret".to_string())
})
})
.unwrap();
assert_eq!(reply_of(helper(&askpass, 0, "Password:")), REPLY_CANCEL);
assert_eq!(reply_of(helper(&askpass, 1, "Password:")), REPLY_CANCEL);
let mut stream = UnixStream::connect(&askpass.shared.socket).unwrap();
stream.write_all(b"Password:\0").unwrap();
assert_eq!(reply_of(stream), REPLY_CANCEL);
assert!(!asked.load(Ordering::SeqCst));
let (first, first_run) = start_run(&askpass);
assert_eq!(
reply_of(helper(&askpass, first, "Password:")),
b"OK\ns3cret"
);
asked.store(false, Ordering::SeqCst);
drop(first_run);
assert_eq!(reply_of(helper(&askpass, first, "Password:")), REPLY_CANCEL);
assert!(!asked.load(Ordering::SeqCst));
let (second, _second_run) = start_run(&askpass);
assert_ne!(first, second);
assert_eq!(reply_of(helper(&askpass, first, "Password:")), REPLY_CANCEL);
assert!(!asked.load(Ordering::SeqCst));
assert_eq!(
reply_of(helper(&askpass, second, "Password:")),
b"OK\ns3cret"
);
}
#[test]
fn a_prompt_still_up_when_its_run_ends_is_given_up() {
let (asked_tx, asked_rx) = mpsc::channel();
let asked_tx = Mutex::new(asked_tx);
let askpass = Askpass::start(Box::new(move |_, gone| {
asked_tx.lock().unwrap().send(()).unwrap();
let (_keep, answer) = mpsc::channel();
wait_for_answer(&answer, gone)
}))
.unwrap();
let (run, in_progress) = start_run(&askpass);
let stream = helper(&askpass, run, "Password:");
asked_rx.recv_timeout(Duration::from_secs(5)).unwrap();
drop(in_progress);
assert_eq!(reply_of(stream), REPLY_CANCEL);
}
#[test]
fn an_answer_arriving_after_the_run_ended_is_not_delivered() {
let (asked_tx, asked_rx) = mpsc::channel();
let asked_tx = Mutex::new(asked_tx);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Mutex::new(release_rx);
let askpass = Askpass::start(Box::new(move |_, _| {
asked_tx.lock().unwrap().send(()).unwrap();
release_rx.lock().unwrap().recv().unwrap();
Some("too late".to_string())
}))
.unwrap();
let (run, in_progress) = start_run(&askpass);
let stream = helper(&askpass, run, "Password:");
asked_rx.recv_timeout(Duration::from_secs(5)).unwrap();
drop(in_progress);
let (_, _next) = start_run(&askpass);
release_tx.send(()).unwrap();
assert_eq!(reply_of(stream), REPLY_CANCEL);
}
#[test]
fn the_hook_starts_a_run_like_apply_does() {
let askpass = Askpass::start(Box::new(|_, _| None)).unwrap();
let current = || *askpass.shared.current_run();
let hook = askpass.hook();
let mut options = ExecOptions::new(std::env::temp_dir());
let first = hook(&mut options);
assert_eq!(current(), 1);
let value = options
.env
.iter()
.find(|(key, _)| key == RUN_VAR)
.map(|(_, value)| value.to_string_lossy().into_owned());
assert_eq!(value.as_deref(), Some("1"));
drop(first);
assert_eq!(current(), 0);
let first = hook(&mut options);
let second = hook(&mut options);
assert_eq!(current(), 3);
drop(first);
assert_eq!(current(), 3);
drop(second);
assert_eq!(current(), 0);
}
#[test]
fn a_prompt_longer_than_a_prompt_can_be_is_refused() {
let asked = Arc::new(AtomicBool::new(false));
let askpass = Askpass::start({
let asked = Arc::clone(&asked);
Box::new(move |_, _| {
asked.store(true, Ordering::SeqCst);
Some("never sent".to_string())
})
})
.unwrap();
let (run, _run) = start_run(&askpass);
let head = format!("{run}\n");
let exchange = |length: usize| {
let prompt = "x".repeat(length - head.len());
reply_of(helper(&askpass, run, &prompt))
};
assert_eq!(exchange(MAX_PROMPT_BYTES), b"OK\nnever sent");
asked.store(false, Ordering::SeqCst);
assert_eq!(exchange(MAX_PROMPT_BYTES + 1), REPLY_CANCEL);
assert!(!asked.load(Ordering::SeqCst));
}
#[test]
fn a_helper_that_leaves_before_finishing_its_prompt_is_not_asked_for() {
let asked = Arc::new(AtomicBool::new(false));
let askpass = Askpass::start({
let asked = Arc::clone(&asked);
Box::new(move |_, _| {
asked.store(true, Ordering::SeqCst);
None
})
})
.unwrap();
let (run, _run) = start_run(&askpass);
let mut stream = UnixStream::connect(&askpass.shared.socket).unwrap();
stream
.write_all(format!("{run}\nPassword:").as_bytes())
.unwrap();
stream.shutdown(std::net::Shutdown::Write).unwrap();
let mut reply = Vec::new();
let _ = stream.read_to_end(&mut reply);
assert!(reply.is_empty() || reply == REPLY_CANCEL);
assert!(!asked.load(Ordering::SeqCst));
}
#[test]
fn the_directory_is_private_and_removed_afterwards() {
use std::os::unix::fs::PermissionsExt;
let askpass = Askpass::start(Box::new(|_, _| None)).unwrap();
let dir = askpass.dir.clone();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
drop(askpass);
assert!(!dir.exists());
}
#[test]
fn askpasses_started_in_the_same_tick_get_directories_of_their_own() {
let started: Vec<_> = (0..8)
.map(|_| thread::spawn(|| Askpass::start(Box::new(|_, _| None)).unwrap()))
.collect();
let askpasses: Vec<_> = started
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
let mut dirs: Vec<_> = askpasses.iter().map(|a| a.dir.clone()).collect();
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), askpasses.len());
}
#[test]
fn an_askpass_the_user_chose_is_kept() {
let askpass = Askpass::start(Box::new(|_, _| None)).unwrap();
let env = environment(&askpass.shared, 1, |name| {
(name == "SUDO_ASKPASS").then(|| OsString::from("/usr/bin/my-askpass"))
});
let names: Vec<_> = env
.iter()
.map(|(key, _)| key.to_string_lossy().into_owned())
.collect();
assert!(!names.contains(&"SUDO_ASKPASS".to_string()));
assert!(names.contains(&"SSH_ASKPASS".to_string()));
assert!(names.contains(&"SSH_ASKPASS_REQUIRE".to_string()));
assert!(names.contains(&"GIT_ASKPASS".to_string()));
assert!(names.contains(&SOCKET_VAR.to_string()));
assert!(names.contains(&RUN_VAR.to_string()));
}
#[test]
fn ssh_is_made_to_use_the_askpass_whichever_one_it_is() {
let askpass = Askpass::start(Box::new(|_, _| None)).unwrap();
let env = environment(&askpass.shared, 1, |name| match name {
"SSH_ASKPASS" => Some(OsString::from("/usr/bin/my-askpass")),
"SSH_ASKPASS_REQUIRE" => Some(OsString::from("prefer")),
_ => None,
});
let value = |wanted: &str| {
env.iter()
.find(|(key, _)| key == wanted)
.map(|(_, value)| value.to_string_lossy().into_owned())
};
assert_eq!(value("SSH_ASKPASS"), None);
assert_eq!(value("SSH_ASKPASS_REQUIRE").as_deref(), Some("force"));
}
#[test]
fn a_prompt_is_given_up_once_the_helper_has_gone() {
let (asked_tx, asked_rx) = mpsc::channel();
let asked_tx = Mutex::new(asked_tx);
let (gave_up_tx, gave_up_rx) = mpsc::channel();
let gave_up_tx = Mutex::new(gave_up_tx);
let askpass = Askpass::start(Box::new(move |_, gone| {
asked_tx.lock().unwrap().send(()).unwrap();
let started = std::time::Instant::now();
while !gone() {
if started.elapsed() > Duration::from_secs(10) {
return None;
}
thread::sleep(PROMPT_POLL);
}
gave_up_tx.lock().unwrap().send(()).unwrap();
None
}))
.unwrap();
let (run, _run) = start_run(&askpass);
let stream = helper(&askpass, run, "Password:");
asked_rx.recv_timeout(Duration::from_secs(5)).unwrap();
drop(stream);
assert!(gave_up_rx.recv_timeout(Duration::from_secs(5)).is_ok());
}
#[test]
fn dropping_waits_for_a_prompt_to_give_up() {
let (asked_tx, asked_rx) = mpsc::channel();
let asked_tx = Mutex::new(asked_tx);
let askpass = Askpass::start(Box::new(move |_, gone| {
asked_tx.lock().unwrap().send(()).unwrap();
let (_keep, answer) = mpsc::channel();
wait_for_answer(&answer, gone)
}))
.unwrap();
let (run, _run) = start_run(&askpass);
let stream = helper(&askpass, run, "Password:");
asked_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let started = std::time::Instant::now();
drop(askpass);
assert!(started.elapsed() < Duration::from_secs(5));
assert_eq!(reply_of(stream), REPLY_CANCEL);
}
#[test]
fn the_helper_is_recognised_by_its_name() {
assert!(started_as_helper(std::ffi::OsStr::new(
"/tmp/x/runandlog-askpass"
)));
assert!(started_as_helper(std::ffi::OsStr::new("runandlog-askpass")));
assert!(!started_as_helper(std::ffi::OsStr::new(
"/usr/bin/runandlog"
)));
}
}