use super::{
sanitize_line, CallerPolicy, Refusal, SystemPolicy, HELPER_NAME, MAX_PROMPT, MAX_REPLY,
SOCKET_NAME, TOKEN_NAME,
};
use std::io::{Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use zeroize::Zeroizing;
static DISPATCH: AtomicBool = AtomicBool::new(false);
pub(crate) fn dispatch_available() -> bool {
DISPATCH.load(Ordering::SeqCst)
}
pub fn run_helper_if_invoked() -> Option<i32> {
DISPATCH.store(true, Ordering::SeqCst);
let mut args = std::env::args_os();
let argv0 = std::path::PathBuf::from(args.next()?);
if argv0.file_name() != Some(std::ffi::OsStr::new(HELPER_NAME)) {
return None;
}
let prompt = args
.next()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
Some(helper_main(
&argv0,
&prompt,
&SystemPolicy,
stdout_is_pipe(),
&mut Stdout,
))
}
struct Stdout;
impl Write for Stdout {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = unsafe { libc::write(1, buf.as_ptr().cast(), buf.len()) };
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn stdout_is_pipe() -> bool {
unsafe {
let mut st: libc::stat = std::mem::zeroed();
libc::fstat(1, &mut st) == 0 && st.st_mode & libc::S_IFMT == libc::S_IFIFO
}
}
pub(crate) fn helper_main(
argv0: &Path,
prompt: &str,
policy: &dyn CallerPolicy,
stdout_is_pipe: bool,
out: &mut dyn Write,
) -> i32 {
match ask(argv0, prompt, policy, stdout_is_pipe) {
Ok(secret) => {
let mut line = Zeroizing::new(Vec::with_capacity(secret.len() + 1));
line.extend_from_slice(&secret);
line.push(b'\n');
match out.write_all(&line).and_then(|()| out.flush()) {
Ok(()) => 0,
Err(e) => {
eprintln!("{HELPER_NAME}: cannot hand the password to sudo: {e}");
1
}
}
}
Err(refusal) => {
eprintln!("{HELPER_NAME}: {refusal}");
1
}
}
}
fn ask(
argv0: &Path,
prompt: &str,
policy: &dyn CallerPolicy,
stdout_is_pipe: bool,
) -> Result<Zeroizing<Vec<u8>>, Refusal> {
let parent = unsafe { libc::getppid() } as u32;
policy.check_sudo(parent)?;
if !stdout_is_pipe {
return Err(Refusal::new("stdout is not a pipe to sudo"));
}
let dir = argv0
.parent()
.filter(|_| argv0.is_absolute())
.ok_or_else(|| Refusal::new("not started through the synaps askpass link"))?;
request_secret(dir, prompt)
}
pub(crate) fn request_secret(dir: &Path, prompt: &str) -> Result<Zeroizing<Vec<u8>>, Refusal> {
check_private(dir, true)?;
let token_path = dir.join(TOKEN_NAME);
check_private(&token_path, false)?;
let mut token = Zeroizing::new(Vec::new());
std::fs::File::open(&token_path)
.and_then(|f| f.take(256).read_to_end(&mut token))
.map_err(|e| Refusal::new(format!("cannot read the askpass token: {e}")))?;
let mut stream = std::os::unix::net::UnixStream::connect(dir.join(SOCKET_NAME))
.map_err(|e| Refusal::new(format!("cannot reach the bash tool call: {e}")))?;
let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(5)));
let mut request = Zeroizing::new(Vec::new());
request.extend_from_slice(token.trim_ascii());
request.push(b'\n');
request.extend(
bounded(prompt, MAX_PROMPT)
.bytes()
.map(|b| if b == b'\n' { b' ' } else { b }),
);
request.push(b'\n');
stream
.write_all(&request)
.and_then(|()| stream.shutdown(std::net::Shutdown::Write))
.map_err(|e| Refusal::new(format!("cannot ask the bash tool call: {e}")))?;
let mut reply = Zeroizing::new(Vec::with_capacity(MAX_REPLY + 1));
(&stream)
.take(MAX_REPLY as u64 + 1)
.read_to_end(&mut reply)
.map_err(|e| Refusal::new(format!("no answer from the bash tool call: {e}")))?;
if reply.len() > MAX_REPLY {
return Err(Refusal::new("oversized answer from the bash tool call"));
}
match reply.first() {
Some(b'+') => Ok(Zeroizing::new(reply[1..].to_vec())),
Some(b'-') => Err(Refusal::new(sanitize_line(
&String::from_utf8_lossy(&reply[1..]),
200,
))),
_ => Err(Refusal::new("the bash tool call gave no answer")),
}
}
fn bounded(text: &str, max: usize) -> &str {
let mut end = text.len().min(max);
while !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
fn check_private(path: &Path, dir: bool) -> Result<(), Refusal> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::symlink_metadata(path)
.map_err(|e| Refusal::new(format!("{}: {e}", path.display())))?;
let kind = if dir { meta.is_dir() } else { meta.is_file() };
if !kind || meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 {
return Err(Refusal::new(format!(
"{} is not private to this user",
path.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
struct Policy(Result<(), Refusal>);
impl CallerPolicy for Policy {
fn check_sudo(&self, _pid: u32) -> Result<(), Refusal> {
self.0.clone()
}
fn check_helper(&self, _pid: u32) -> Result<(), Refusal> {
self.0.clone()
}
}
fn accept() -> Policy {
Policy(Ok(()))
}
struct Fixture {
dir: tempfile::TempDir,
listener: std::os::unix::net::UnixListener,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
let token = dir.path().join(TOKEN_NAME);
std::fs::write(&token, "abc123\n").unwrap();
std::fs::set_permissions(&token, std::fs::Permissions::from_mode(0o600)).unwrap();
let listener =
std::os::unix::net::UnixListener::bind(dir.path().join(SOCKET_NAME)).unwrap();
listener.set_nonblocking(true).unwrap();
Self { dir, listener }
}
fn argv0(&self) -> std::path::PathBuf {
self.dir.path().join(HELPER_NAME)
}
fn serve(&self, reply: &[u8]) -> std::thread::JoinHandle<Vec<u8>> {
let listener = self.listener.try_clone().unwrap();
let reply = reply.to_vec();
std::thread::spawn(move || {
let (mut stream, _) = loop {
match listener.accept() {
Ok(conn) => break conn,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(e) => panic!("accept: {e}"),
}
};
stream.set_nonblocking(false).unwrap();
let mut request = Vec::new();
stream.read_to_end(&mut request).unwrap();
stream.write_all(&reply).unwrap();
request
})
}
fn was_contacted(&self) -> bool {
self.listener.accept().is_ok()
}
}
#[test]
fn hands_the_answer_to_stdout_with_a_newline() {
let fx = Fixture::new();
let server = fx.serve(b"+swordfish");
let mut out = Vec::new();
let code = helper_main(
&fx.argv0(),
"[sudo] password for me: ",
&accept(),
true,
&mut out,
);
assert_eq!(code, 0);
assert_eq!(out, b"swordfish\n");
assert_eq!(
server.join().unwrap(),
b"abc123\n[sudo] password for me: \n"
);
}
#[test]
fn a_parent_that_is_not_sudo_is_refused_before_anything_else() {
let fx = Fixture::new();
let mut out = Vec::new();
let policy = Policy(Err(Refusal::new("the parent process is not sudo")));
assert_eq!(helper_main(&fx.argv0(), "p", &policy, true, &mut out), 1);
assert!(out.is_empty());
assert!(!fx.was_contacted(), "refused before connecting");
}
#[test]
fn stdout_must_be_a_pipe() {
let fx = Fixture::new();
let mut out = Vec::new();
assert_eq!(helper_main(&fx.argv0(), "p", &accept(), false, &mut out), 1);
assert!(out.is_empty());
assert!(!fx.was_contacted());
}
#[test]
fn a_relative_link_path_is_refused() {
let mut out = Vec::new();
let code = helper_main(Path::new(HELPER_NAME), "p", &accept(), true, &mut out);
assert_eq!(code, 1);
assert!(out.is_empty());
}
#[test]
fn a_refusal_writes_nothing() {
let fx = Fixture::new();
let server = fx.serve(b"-no interactive client\x1b[2J");
let err = request_secret(fx.dir.path(), "p").unwrap_err();
server.join().unwrap();
assert_eq!(err.0, r"no interactive client\u{1b}[2J");
let mut out = Vec::new();
let server = fx.serve(b"-cancelled");
assert_eq!(helper_main(&fx.argv0(), "p", &accept(), true, &mut out), 1);
server.join().unwrap();
assert!(out.is_empty());
}
#[test]
fn an_oversized_or_empty_answer_is_refused() {
let fx = Fixture::new();
let server = fx.serve(&vec![b'+'; MAX_REPLY + 2]);
assert!(request_secret(fx.dir.path(), "p")
.unwrap_err()
.0
.contains("oversized"));
server.join().unwrap();
let server = fx.serve(b"");
assert!(request_secret(fx.dir.path(), "p")
.unwrap_err()
.0
.contains("no answer"));
server.join().unwrap();
}
#[test]
fn the_forwarded_prompt_is_one_bounded_line() {
let fx = Fixture::new();
let server = fx.serve(b"+x");
let prompt = format!("a\nb{}", "é".repeat(MAX_PROMPT));
request_secret(fx.dir.path(), &prompt).unwrap();
let request = server.join().unwrap();
let text = String::from_utf8(request).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text:?}");
assert!(lines[1].starts_with("a b"));
assert!(lines[1].len() <= MAX_PROMPT);
}
#[test]
fn the_directory_and_token_must_be_private() {
let fx = Fixture::new();
let token = fx.dir.path().join(TOKEN_NAME);
std::fs::set_permissions(fx.dir.path(), std::fs::Permissions::from_mode(0o750)).unwrap();
let err = request_secret(fx.dir.path(), "p").unwrap_err();
assert!(err.0.contains("not private"), "{err}");
std::fs::set_permissions(fx.dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::set_permissions(&token, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = request_secret(fx.dir.path(), "p").unwrap_err();
assert!(err.0.contains("not private"), "{err}");
std::fs::remove_file(&token).unwrap();
let elsewhere = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(elsewhere.path(), &token).unwrap();
let err = request_secret(fx.dir.path(), "p").unwrap_err();
assert!(err.0.contains("not private"), "a symlinked token: {err}");
assert!(!fx.was_contacted());
}
}