use crate::Credential;
use ini::Ini;
use log::debug;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct ProcessCredentialProvider {
profile: Option<String>,
command: Option<String>,
}
impl Default for ProcessCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl ProcessCredentialProvider {
pub fn new() -> Self {
Self {
profile: None,
command: None,
}
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
async fn get_command(&self, ctx: &Context) -> Result<String> {
if let Some(cmd) = &self.command {
return Ok(cmd.clone());
}
let profile_name = self
.profile
.clone()
.or_else(|| ctx.env_var("AWS_PROFILE"))
.unwrap_or_else(|| "default".to_string());
self.load_command_from_config(ctx, &profile_name).await
}
async fn load_command_from_config(&self, ctx: &Context, profile: &str) -> Result<String> {
let config_path = ctx
.env_var("AWS_CONFIG_FILE")
.unwrap_or_else(|| "~/.aws/config".to_string());
let expanded_path = if config_path.starts_with("~/") {
match ctx.expand_home_dir(&config_path) {
Some(expanded) => expanded,
None => return Err(Error::config_invalid("failed to expand home directory")),
}
} else {
config_path
};
let content = ctx.file_read(&expanded_path).await.map_err(|_| {
Error::config_invalid(format!("failed to read config file: {expanded_path}"))
})?;
let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
.map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
let profile_section = if profile == "default" {
profile.to_string()
} else {
format!("profile {profile}")
};
let section = conf.section(Some(profile_section)).ok_or_else(|| {
Error::config_invalid(format!("profile '{profile}' not found in config"))
})?;
section
.get("credential_process")
.ok_or_else(|| {
Error::config_invalid(format!(
"credential_process not found in profile '{profile}'"
))
})
.map(|s| s.to_string())
}
async fn execute_process(
&self,
ctx: &Context,
command: &str,
) -> Result<ProcessCredentialOutput> {
debug!("executing credential process: {command}");
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.is_empty() {
return Err(Error::config_invalid(
"credential_process command is empty".to_string(),
));
}
let program = parts[0];
let args = &parts[1..];
let output = ctx.command_execute(program, args).await?;
if !output.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::unexpected(format!(
"credential process failed with status {}: {}",
output.status, stderr
)));
}
let stdout = &output.stdout;
let creds: ProcessCredentialOutput = serde_json::from_slice(stdout).map_err(|e| {
Error::unexpected(format!("failed to parse credential process output: {e}"))
})?;
if creds.version != 1 {
return Err(Error::unexpected(format!(
"unsupported credential process version: {}",
creds.version
)));
}
Ok(creds)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ProcessCredentialOutput {
version: u32,
access_key_id: String,
secret_access_key: String,
#[serde(default)]
session_token: Option<String>,
#[serde(default)]
expiration: Option<String>,
}
impl ProvideCredential for ProcessCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
let command = match self.get_command(ctx).await {
Ok(cmd) => cmd,
Err(_) => {
debug!("no credential_process configured");
return Ok(None);
}
};
let output = self.execute_process(ctx, &command).await?;
let expires_in = output
.expiration
.and_then(|expires_in| expires_in.parse().ok());
Ok(Some(Credential {
access_key_id: output.access_key_id,
secret_access_key: output.secret_access_key,
session_token: output.session_token,
expires_in,
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use reqsign_command_execute_tokio::TokioCommandExecute;
use reqsign_core::{OsEnv, StaticEnv};
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::collections::HashMap;
#[tokio::test]
async fn test_process_provider_no_config() {
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_command_execute(TokioCommandExecute)
.with_env(OsEnv);
let ctx = ctx.with_env(StaticEnv {
home_dir: Some(std::path::PathBuf::from("/home/test")),
envs: HashMap::new(),
});
let provider = ProcessCredentialProvider::new();
let result = provider.provide_credential(&ctx).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_process_provider_with_command() {
let _provider = ProcessCredentialProvider::new()
.with_command("echo '{\"Version\": 1, \"AccessKeyId\": \"test_key\", \"SecretAccessKey\": \"test_secret\"}'");
}
#[test]
fn test_parse_process_output() {
let json = r#"{
"Version": 1,
"AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"SessionToken": "token",
"Expiration": "2023-12-01T00:00:00Z"
}"#;
let output: ProcessCredentialOutput = serde_json::from_str(json).unwrap();
assert_eq!(output.version, 1);
assert_eq!(output.access_key_id, "ASIAIOSFODNN7EXAMPLE");
assert_eq!(output.session_token, Some("token".to_string()));
}
}