Skip to main content

reqsign_aws_core/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 ini::Ini;
20use log::debug;
21use reqsign_core::{Context, Error, ProvideCredential, Result};
22use serde::Deserialize;
23
24/// Process Credentials Provider
25///
26/// This provider executes an external process to retrieve credentials.
27/// The process must output JSON in a specific format to stdout.
28///
29/// # Configuration
30/// Process credentials are typically configured in ~/.aws/config:
31/// ```ini
32/// [profile my-process-profile]
33/// credential_process = /path/to/credential/helper --arg1 value1
34/// ```
35///
36/// # Output Format
37/// The process must output JSON with the following structure:
38/// ```json
39/// {
40///   "Version": 1,
41///   "AccessKeyId": "access_key",
42///   "SecretAccessKey": "secret_key",
43///   "SessionToken": "session_token",
44///   "Expiration": "2023-12-01T00:00:00Z"
45/// }
46/// ```
47#[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    /// Create a new process credential provider
61    pub fn new() -> Self {
62        Self {
63            profile: None,
64            command: None,
65        }
66    }
67
68    /// Set the profile name to use
69    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
70        self.profile = Some(profile.into());
71        self
72    }
73
74    /// Set the command directly
75    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 command is directly provided, use it
82        if let Some(cmd) = &self.command {
83            return Ok(cmd.clone());
84        }
85
86        // Otherwise, load from config file
87        // Priority: 1. self.profile, 2. AWS_PROFILE env var, 3. "default"
88        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        // Load AWS config file
98        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        // Parse command into program and arguments
146        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        // Execute the process using Context's command executor
157        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        // Parse the output
168        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        // Validate version
174        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        // This test would need a real command that outputs valid JSON
253        // In practice, you'd use a mock or test helper
254    }
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}