pam-ssh-agent 0.9.1

A PAM module that authenticates using the ssh-agent.
Documentation
use anyhow::{anyhow, Result};
use pam::items::RUser;
use pam::module::PamHandle;
use std::borrow::Cow;
use uzers::get_user_by_name;
use uzers::os::unix::UserExt;

pub trait Environment {
    fn get_homedir(&self, user: &str) -> Result<Cow<str>>;

    fn get_username(&self) -> Result<Cow<str>>;

    fn get_hostname(&self) -> Result<Cow<str>>;

    fn get_fqdn(&self) -> Result<Cow<str>>;

    fn get_uid(&self) -> Result<Cow<str>>;
}

pub struct UnixEnvironment<'a> {
    pam_handle: &'a PamHandle,
}

impl<'a> UnixEnvironment<'a> {
    pub fn new(pam_handle: &'a PamHandle) -> Self {
        Self { pam_handle }
    }
}

impl Environment for UnixEnvironment<'_> {
    fn get_homedir(&self, user: &str) -> Result<Cow<str>> {
        match get_user_by_name(user) {
            Some(user) => Ok(Cow::Owned(user.home_dir().to_string_lossy().to_string())),
            None => Err(anyhow!("homedir for {} not found", user)),
        }
    }

    fn get_username(&self) -> Result<Cow<str>> {
        let service = match self.pam_handle.get_item::<RUser>() {
            Ok(Some(service)) => service,
            _ => {
                return Err(anyhow!(
                    "Failed to obtain the PAM_RUSER item needed for variable expansion"
                ))
            }
        };
        Ok(Cow::from(
            String::from_utf8_lossy(service.0.to_bytes()).to_string(),
        ))
    }

    fn get_hostname(&self) -> Result<Cow<str>> {
        let hostname = get_hostname()?;
        let hostname = hostname
            .split('.')
            .next()
            .ok_or_else(|| anyhow!("Empty hostname"))?;
        Ok(Cow::from(hostname.to_string()))
    }

    fn get_fqdn(&self) -> Result<Cow<str>> {
        Ok(Cow::from(get_hostname()?))
    }

    fn get_uid(&self) -> Result<Cow<str>> {
        let username = self.get_username()?;
        let user = get_user_by_name(&username as &str)
            .ok_or_else(|| anyhow!("Failed to look up user with username {}", username))?;
        Ok(Cow::from(user.uid().to_string()))
    }
}

fn get_hostname() -> Result<String> {
    let result = hostname::get().map_err(|e| anyhow!("Failed to obtain hostname: {}", e))?;
    Ok(result.to_string_lossy().to_string())
}

pub fn expand_vars<'a>(input: &'a str, env: &'a dyn Environment) -> Result<Cow<'a, str>> {
    let get_home = |s: &str| {
        if s.is_empty() {
            let user = env.get_username()?;
            Ok(env.get_homedir(&user)?)
        } else {
            Ok(env.get_homedir(s)?)
        }
    };
    let mut input = expand_homedir(Cow::from(input), get_home)?;
    input = expand_var(input, "%h", || {
        env.get_homedir(env.get_username()?.as_ref())
    })?;
    input = expand_var(input, "%H", || env.get_hostname())?;
    input = expand_var(input, "%u", || env.get_username())?;
    input = expand_var(input, "%f", || env.get_fqdn())?;
    input = expand_var(input, "%U", || env.get_uid())?;
    Ok(input)
}

fn expand_var<'a, F>(input: Cow<'a, str>, pattern: &str, get_value: F) -> Result<Cow<'a, str>>
where
    F: FnOnce() -> Result<Cow<'a, str>>,
{
    let Some(idx) = input.find(pattern) else {
        return Ok(input);
    };
    let mut output = input[..idx].to_owned();
    output.push_str(&get_value()?);
    output.push_str(input[idx + pattern.len()..].into());
    Ok(Cow::from(output))
}

fn expand_homedir<'a, F>(input: Cow<'a, str>, get_homedir: F) -> Result<Cow<'a, str>>
where
    F: FnOnce(&str) -> Result<Cow<'a, str>>,
{
    let Some(idx) = input.find('~') else {
        return Ok(input);
    };
    let user = get_username(&input, idx);
    let mut output = input[..idx].to_string();
    output.push_str(get_homedir(user)?.as_ref());
    output.push_str(&input[idx + 1 + user.len()..]);
    Ok(Cow::from(output))
}

fn get_username(input: &str, idx: usize) -> &str {
    let idx = idx + 1;
    for (offset, char) in input[idx..].bytes().enumerate() {
        if char == b'/' {
            return &input[idx..idx + offset];
        }
    }
    &input[idx..]
}

#[cfg(test)]
mod tests {
    use crate::expansions::{expand_homedir, expand_var, expand_vars, get_username, Environment};
    use anyhow::Result;
    use std::borrow::Cow;
    use std::cell::RefCell;
    use std::collections::VecDeque;

    #[test]
    fn test_find_homedir() -> Result<()> {
        let result = expand_homedir(Cow::from("/foo/bar"), |s| {
            assert_eq!(s, "");
            Ok(Cow::from("/home/noa"))
        })?;
        assert_eq!(result.as_ref(), "/foo/bar");

        let result = expand_homedir(Cow::from("~/.file"), |s| {
            assert_eq!(s, "");
            Ok(Cow::from("/home/noa"))
        })?;
        assert_eq!(result.as_ref(), "/home/noa/.file");

        let result = expand_homedir(Cow::from("~bob/.file"), |s| {
            assert_eq!(s, "bob");
            Ok(Cow::from("/another/bob"))
        })?;
        assert_eq!(result.as_ref(), "/another/bob/.file");

        let result = expand_homedir(Cow::from("~bob"), |s| {
            assert_eq!(s, "bob");
            Ok(Cow::from("/another/bob"))
        })?;
        assert_eq!(result.as_ref(), "/another/bob");
        Ok(())
    }
    #[test]
    fn test_expand_var() -> Result<()> {
        let f = || Ok(Cow::from("hostname"));
        let result = expand_var(Cow::from("/etc/%H/file"), "%H", f)?;
        assert_eq!(&result, "/etc/hostname/file");
        Ok(())
    }

    #[test]
    fn test_expand_var_uid() -> Result<()> {
        let f = || Ok(Cow::from("401"));
        let result = expand_var(Cow::from("/etc/%d/file"), "%d", f)?;
        assert_eq!(&result, "/etc/401/file");
        Ok(())
    }

    #[test]
    fn test_expand_vars() -> Result<()> {
        let env = DummyEnv::new(vec!["/another/bob", "host", "user"]);
        let result = expand_vars("~bob/.foo/%H/%u/file", &env)?;
        assert_eq!("/another/bob/.foo/host/user/file", &result);
        Ok(())
    }

    #[test]
    fn test_get_username() {
        assert_eq!("alice", get_username("~alice/foo", 0));
        assert_eq!("", get_username("~/foo", 0));
        assert_eq!("bob", get_username("~bob", 0));
    }

    struct DummyEnv {
        answers: RefCell<VecDeque<&'static str>>,
    }

    impl DummyEnv {
        fn new(answers: Vec<&'static str>) -> Self {
            DummyEnv {
                answers: RefCell::new(VecDeque::from(answers)),
            }
        }

        fn answer(&self) -> Result<Cow<str>> {
            Ok(Cow::from(
                self.answers.borrow_mut().pop_front().unwrap().to_string(),
            ))
        }
    }

    impl Environment for DummyEnv {
        fn get_homedir(&self, _user: &str) -> Result<Cow<str>> {
            self.answer()
        }

        fn get_username(&self) -> Result<Cow<str>> {
            self.answer()
        }

        fn get_hostname(&self) -> Result<Cow<str>> {
            self.answer()
        }

        fn get_fqdn(&self) -> Result<Cow<str>> {
            self.answer()
        }

        fn get_uid(&self) -> Result<Cow<str>> {
            self.answer()
        }
    }
}