reqsign_aws_core/provide_credential/
process.rs1use crate::Credential;
19use ini::Ini;
20use log::debug;
21use reqsign_core::{Context, Error, ProvideCredential, Result};
22use serde::Deserialize;
23
24#[derive(Debug, Clone)]
48pub struct ProcessCredentialProvider {
49 profile: Option<String>,
50 command: Option<String>,
51}
52
53impl Default for ProcessCredentialProvider {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl ProcessCredentialProvider {
60 pub fn new() -> Self {
62 Self {
63 profile: None,
64 command: None,
65 }
66 }
67
68 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
70 self.profile = Some(profile.into());
71 self
72 }
73
74 pub fn with_command(mut self, command: impl Into<String>) -> Self {
76 self.command = Some(command.into());
77 self
78 }
79
80 async fn get_command(&self, ctx: &Context) -> Result<String> {
81 if let Some(cmd) = &self.command {
83 return Ok(cmd.clone());
84 }
85
86 let profile_name = self
89 .profile
90 .clone()
91 .or_else(|| ctx.env_var("AWS_PROFILE"))
92 .unwrap_or_else(|| "default".to_string());
93 self.load_command_from_config(ctx, &profile_name).await
94 }
95
96 async fn load_command_from_config(&self, ctx: &Context, profile: &str) -> Result<String> {
97 let config_path = ctx
99 .env_var("AWS_CONFIG_FILE")
100 .unwrap_or_else(|| "~/.aws/config".to_string());
101
102 let expanded_path = if config_path.starts_with("~/") {
103 match ctx.expand_home_dir(&config_path) {
104 Some(expanded) => expanded,
105 None => return Err(Error::config_invalid("failed to expand home directory")),
106 }
107 } else {
108 config_path
109 };
110
111 let content = ctx.file_read(&expanded_path).await.map_err(|_| {
112 Error::config_invalid(format!("failed to read config file: {expanded_path}"))
113 })?;
114
115 let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
116 .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
117
118 let profile_section = if profile == "default" {
119 profile.to_string()
120 } else {
121 format!("profile {profile}")
122 };
123
124 let section = conf.section(Some(profile_section)).ok_or_else(|| {
125 Error::config_invalid(format!("profile '{profile}' not found in config"))
126 })?;
127
128 section
129 .get("credential_process")
130 .ok_or_else(|| {
131 Error::config_invalid(format!(
132 "credential_process not found in profile '{profile}'"
133 ))
134 })
135 .map(|s| s.to_string())
136 }
137
138 async fn execute_process(
139 &self,
140 ctx: &Context,
141 command: &str,
142 ) -> Result<ProcessCredentialOutput> {
143 debug!("executing credential process: {command}");
144
145 let parts: Vec<&str> = command.split_whitespace().collect();
147 if parts.is_empty() {
148 return Err(Error::config_invalid(
149 "credential_process command is empty".to_string(),
150 ));
151 }
152
153 let program = parts[0];
154 let args = &parts[1..];
155
156 let output = ctx.command_execute(program, args).await?;
158
159 if !output.success() {
160 let stderr = String::from_utf8_lossy(&output.stderr);
161 return Err(Error::unexpected(format!(
162 "credential process failed with status {}: {}",
163 output.status, stderr
164 )));
165 }
166
167 let stdout = &output.stdout;
169 let creds: ProcessCredentialOutput = serde_json::from_slice(stdout).map_err(|e| {
170 Error::unexpected(format!("failed to parse credential process output: {e}"))
171 })?;
172
173 if creds.version != 1 {
175 return Err(Error::unexpected(format!(
176 "unsupported credential process version: {}",
177 creds.version
178 )));
179 }
180
181 Ok(creds)
182 }
183}
184
185#[derive(Debug, Deserialize)]
186#[serde(rename_all = "PascalCase")]
187struct ProcessCredentialOutput {
188 version: u32,
189 access_key_id: String,
190 secret_access_key: String,
191 #[serde(default)]
192 session_token: Option<String>,
193 #[serde(default)]
194 expiration: Option<String>,
195}
196impl ProvideCredential for ProcessCredentialProvider {
197 type Credential = Credential;
198
199 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
200 let command = match self.get_command(ctx).await {
201 Ok(cmd) => cmd,
202 Err(_) => {
203 debug!("no credential_process configured");
204 return Ok(None);
205 }
206 };
207
208 let output = self.execute_process(ctx, &command).await?;
209 let expires_in = output
210 .expiration
211 .and_then(|expires_in| expires_in.parse().ok());
212 Ok(Some(Credential {
213 access_key_id: output.access_key_id,
214 secret_access_key: output.secret_access_key,
215 session_token: output.session_token,
216 expires_in,
217 }))
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use reqsign_command_execute_tokio::TokioCommandExecute;
225 use reqsign_core::{OsEnv, StaticEnv};
226 use reqsign_file_read_tokio::TokioFileRead;
227 use reqsign_http_send_reqwest::ReqwestHttpSend;
228 use std::collections::HashMap;
229
230 #[tokio::test]
231 async fn test_process_provider_no_config() {
232 let ctx = Context::new()
233 .with_file_read(TokioFileRead)
234 .with_http_send(ReqwestHttpSend::default())
235 .with_command_execute(TokioCommandExecute)
236 .with_env(OsEnv);
237 let ctx = ctx.with_env(StaticEnv {
238 home_dir: Some(std::path::PathBuf::from("/home/test")),
239 envs: HashMap::new(),
240 });
241
242 let provider = ProcessCredentialProvider::new();
243 let result = provider.provide_credential(&ctx).await.unwrap();
244 assert!(result.is_none());
245 }
246
247 #[tokio::test]
248 async fn test_process_provider_with_command() {
249 let _provider = ProcessCredentialProvider::new()
250 .with_command("echo '{\"Version\": 1, \"AccessKeyId\": \"test_key\", \"SecretAccessKey\": \"test_secret\"}'");
251
252 }
255
256 #[test]
257 fn test_parse_process_output() {
258 let json = r#"{
259 "Version": 1,
260 "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
261 "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
262 "SessionToken": "token",
263 "Expiration": "2023-12-01T00:00:00Z"
264 }"#;
265
266 let output: ProcessCredentialOutput = serde_json::from_str(json).unwrap();
267 assert_eq!(output.version, 1);
268 assert_eq!(output.access_key_id, "ASIAIOSFODNN7EXAMPLE");
269 assert_eq!(output.session_token, Some("token".to_string()));
270 }
271}