Skip to main content

reqsign_aws_v4/provide_credential/
process.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// Process Credentials Provider
26///
27/// This provider executes an external process to retrieve credentials.
28/// The process must output JSON in a specific format to stdout.
29///
30/// # Configuration
31/// Process credentials are typically configured in ~/.aws/config:
32/// ```ini
33/// [profile my-process-profile]
34/// credential_process = /path/to/credential/helper --arg1 value1
35/// ```
36///
37/// # Output Format
38/// The process must output JSON with the following structure:
39/// ```json
40/// {
41///   "Version": 1,
42///   "AccessKeyId": "access_key",
43///   "SecretAccessKey": "secret_key",
44///   "SessionToken": "session_token",
45///   "Expiration": "2023-12-01T00:00:00Z"
46/// }
47/// ```
48#[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    /// Create a new process credential provider
62    pub fn new() -> Self {
63        Self {
64            profile: None,
65            command: None,
66        }
67    }
68
69    /// Set the profile name to use
70    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
71        self.profile = Some(profile.into());
72        self
73    }
74
75    /// Set the command directly
76    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 command is directly provided, use it
83        if let Some(cmd) = &self.command {
84            return Ok(cmd.clone());
85        }
86
87        // Otherwise, load from config file
88        // Priority: 1. self.profile, 2. AWS_PROFILE env var, 3. "default"
89        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        // Load AWS config file
99        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        // Parse command into program and arguments
147        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        // Execute the process using Context's command executor
158        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        // Parse the output
169        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        // Validate version
175        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        // This test would need a real command that outputs valid JSON
256        // In practice, you'd use a mock or test helper
257    }
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}