242 lines · 7.4 KB · Rust
RawDownload
use bstr::{BStr, BString};use std::{    io::{self, Write},    process::{Command, Stdio},}; use quick_error::quick_error; /// The result used in [`action()`].pub type Result = std::result::Result<Option<Outcome>, Error>; quick_error! {    /// The error used in the [credentials helper][action()].    #[derive(Debug)]    #[allow(missing_docs)]    pub enum Error {        Io(err: io::Error) {            display("An IO error occurred while communicating to the credentials helper")            from()            source(err)        }        KeyNotFound(name: String) {            display("Could not find '{}' in output of git credentials helper", name)        }        CredentialsHelperFailed(code: Option<i32>) {            display("Credentials helper program failed with status code {:?}", code)        }    }} /// The action to perform by the credentials [`action()`].#[derive(Clone, Debug)]pub enum Action<'a> {    /// Provide credentials using the given repository URL (as &str) as context.    Fill(&'a BStr),    /// Approve the credentials as identified by the previous input provided as `BString`.    Approve(BString),    /// Reject the credentials as identified by the previous input provided as `BString`.    Reject(BString),} impl<'a> Action<'a> {    fn is_fill(&self) -> bool {        matches!(self, Action::Fill(_))    }    fn as_str(&self) -> &str {        match self {            Action::Approve(_) => "approve",            Action::Fill(_) => "fill",            Action::Reject(_) => "reject",        }    }} /// A handle to [approve][NextAction::approve()] or [reject][NextAction::reject()] the outcome of the initial action.#[derive(Clone, Debug)]pub struct NextAction {    previous_output: BString,} impl NextAction {    /// Approve the result of the previous [Action].    pub fn approve(self) -> Action<'static> {        Action::Approve(self.previous_output)    }    /// Reject the result of the previous [Action].    pub fn reject(self) -> Action<'static> {        Action::Reject(self.previous_output)    }} /// The outcome of [`action()`].pub struct Outcome {    /// The obtained identity.    pub identity: git_sec::identity::Account,    /// A handle to the action to perform next using another call to [`action()`].    pub next: NextAction,} // TODO(sec): reimplement helper execution so it won't use the `git credential` anymore to allow enforcing our own security model.//            Currently we support more flexible configuration than downright not working at all./// Call the `git` credentials helper program performing the given `action`.////// Usually the first call is performed with [`Action::Fill`] to obtain an identity, which subsequently can be used./// On successful usage, use [`NextAction::approve()`], otherwise [`NextAction::reject()`].pub fn action(action: Action<'_>) -> Result {    let mut cmd = Command::new(cfg!(windows).then(|| "git.exe").unwrap_or("git"));    cmd.arg("credential")        .arg(action.as_str())        .stdin(Stdio::piped())        .stdout(if action.is_fill() {            Stdio::piped()        } else {            Stdio::null()        });    let mut child = cmd.spawn()?;    let mut stdin = child.stdin.take().expect("stdin to be configured");     match action {        Action::Fill(url) => encode_message(url, stdin)?,        Action::Approve(last) | Action::Reject(last) => {            stdin.write_all(&last)?;            stdin.write_all(&[b'\n'])?        }    }     let output = child.wait_with_output()?;    if !output.status.success() {        return Err(Error::CredentialsHelperFailed(output.status.code()));    }    let stdout = output.stdout;    if stdout.is_empty() {        Ok(None)    } else {        let kvs = decode_message(stdout.as_slice())?;        let find = |name: &str| {            kvs.iter()                .find(|(k, _)| k == name)                .ok_or_else(|| Error::KeyNotFound(name.into()))                .map(|(_, n)| n.to_owned())        };        Ok(Some(Outcome {            identity: git_sec::identity::Account {                username: find("username")?,                password: find("password")?,            },            next: NextAction {                previous_output: stdout.into(),            },        }))    }} /// Encode `url` to `out` for consumption by a `git credentials` helper program.pub fn encode_message(url: &BStr, mut out: impl io::Write) -> io::Result<()> {    validate(url)?;    writeln!(out, "url={}\n", url)} fn validate(url: &BStr) -> io::Result<()> {    if url.contains(&0) || url.contains(&b'\n') {        return Err(io::Error::new(            io::ErrorKind::Other,            "token to encode must not contain newlines or null bytes",        ));    }    Ok(())} /// Decode all lines in `input` as key-value pairs produced by a `git credentials` helper program.pub fn decode_message(mut input: impl io::Read) -> io::Result<Vec<(String, String)>> {    let mut buf = String::new();    input.read_to_string(&mut buf)?;    buf.lines()        .take_while(|l| !l.is_empty())        .map(|l| {            let mut iter = l.splitn(2, '=').map(|s| s.to_owned());            match (iter.next(), iter.next()) {                (Some(key), Some(value)) => validate(key.as_str().into())                    .and_then(|_| validate(value.as_str().into()))                    .map(|_| (key, value)),                _ => Err(io::Error::new(                    io::ErrorKind::Other,                    "Invalid format, expecting key=value",                )),            }        })        .collect::<io::Result<Vec<_>>>()} #[cfg(test)]mod tests {    use super::*;    type Result = std::result::Result<(), Box<dyn std::error::Error>>;     mod encode_message {        use bstr::ByteSlice;         use super::*;         #[test]        fn from_url() -> super::Result {            let mut out = Vec::new();            encode_message("https://github.com/byron/gitoxide".into(), &mut out)?;            assert_eq!(out.as_bstr(), b"url=https://github.com/byron/gitoxide\n\n".as_bstr());            Ok(())        }         mod invalid {            use std::io;             use super::*;             #[test]            fn contains_null() {                assert_eq!(                    encode_message("https://foo\u{0}".into(), Vec::new())                        .err()                        .map(|e| e.kind()),                    Some(io::ErrorKind::Other)                );            }            #[test]            fn contains_newline() {                assert_eq!(                    encode_message("https://foo\n".into(), Vec::new())                        .err()                        .map(|e| e.kind()),                    Some(io::ErrorKind::Other)                );            }        }    }     mod decode_message {        use super::*;         #[test]        fn typical_response() -> super::Result {            assert_eq!(                decode_message(                    "protocol=httpshost=example.comusername=bobpassword=secr3t\n\nthis=is-skipped-past-empty-line"                        .as_bytes()                )?,                vec![                    ("protocol", "https"),                    ("host", "example.com"),                    ("username", "bob"),                    ("password", "secr3t")                ]                .iter()                .map(|(k, v)| (k.to_string(), v.to_string()))                .collect::<Vec<_>>()            );            Ok(())        }    }}