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