git-credential-idcat 0.1.0

Git credential helper that obtains GitHub installation tokens from idcat
use anyhow::{Context, anyhow, bail};
use serde::Deserialize;
use std::env;
use std::io::ErrorKind;
use std::path::PathBuf;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RawConfig {
    github_app: String,
    idcat_endpoint: String,
    token_path: Option<PathBuf>,
    token_command: Option<String>,
}

#[derive(Debug)]
pub struct Config {
    pub github_app: String,
    pub idcat_endpoint: String,
    pub token_source: TokenSource,
}

#[derive(Debug)]
pub enum TokenSource {
    Path(PathBuf),
    Command(String),
}

impl Config {
    pub fn load(path: Option<PathBuf>) -> anyhow::Result<Self> {
        let path = match path {
            Some(path) => path,
            None => default_config_path()?,
        };
        let config = match std::fs::read_to_string(&path) {
            Ok(config) => config,
            Err(error) if error.kind() == ErrorKind::NotFound => {
                bail!("configuration file not found: {}", path.display());
            }
            Err(error) => {
                return Err(error).with_context(|| format!("failed to read {}", path.display()));
            }
        };
        let config: RawConfig = toml::from_str(&config)
            .with_context(|| format!("failed to parse {}", path.display()))?;
        config.try_into()
    }
}

impl TryFrom<RawConfig> for Config {
    type Error = anyhow::Error;

    fn try_from(config: RawConfig) -> Result<Self, Self::Error> {
        let token_source = match (config.token_path, config.token_command) {
            (Some(path), None) => TokenSource::Path(path),
            (None, Some(command)) => TokenSource::Command(command),
            (None, None) => bail!("configuration must set either token-path or token-command"),
            (Some(_), Some(_)) => {
                bail!("configuration must not set both token-path and token-command")
            }
        };

        Ok(Self {
            github_app: config.github_app,
            idcat_endpoint: config.idcat_endpoint,
            token_source,
        })
    }
}

fn default_config_path() -> anyhow::Result<PathBuf> {
    let home = env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?;
    Ok(PathBuf::from(home)
        .join(".config")
        .join("idcat")
        .join("credential-helper.toml"))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn raw_config() -> RawConfig {
        RawConfig {
            github_app: "deployments".to_owned(),
            idcat_endpoint: "https://idcat.example.test".to_owned(),
            token_path: None,
            token_command: None,
        }
    }

    #[test]
    fn accepts_token_path() {
        let config = RawConfig {
            token_path: Some("/var/run/secrets/tokens/idcat".into()),
            ..raw_config()
        };

        let config = Config::try_from(config).expect("config validates");

        assert!(matches!(config.token_source, TokenSource::Path(_)));
    }

    #[test]
    fn accepts_token_command() {
        let config = RawConfig {
            token_command: Some("cat /var/run/secrets/tokens/idcat".to_owned()),
            ..raw_config()
        };

        let config = Config::try_from(config).expect("config validates");

        assert!(matches!(config.token_source, TokenSource::Command(_)));
    }

    #[test]
    fn rejects_missing_token_source() {
        let error = Config::try_from(raw_config()).expect_err("config should be rejected");

        assert!(
            error
                .to_string()
                .contains("must set either token-path or token-command")
        );
    }

    #[test]
    fn rejects_multiple_token_sources() {
        let config = RawConfig {
            token_path: Some("/var/run/secrets/tokens/idcat".into()),
            token_command: Some("cat /var/run/secrets/tokens/idcat".to_owned()),
            ..raw_config()
        };

        let error = Config::try_from(config).expect_err("config should be rejected");

        assert!(
            error
                .to_string()
                .contains("must not set both token-path and token-command")
        );
    }
}