Skip to main content

cuenv_secrets/resolvers/
exec.rs

1//! Command execution secret resolver
2
3use crate::{SecretError, SecretResolver, SecretSpec};
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8use tokio::process::Command;
9
10/// Configuration for exec-based secret resolution
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct ExecSecretConfig {
13    /// Command to execute
14    pub command: String,
15
16    /// Arguments to pass to the command
17    #[serde(default)]
18    pub args: Vec<String>,
19
20    /// Additional fields for extensibility
21    #[serde(flatten)]
22    pub extra: HashMap<String, Value>,
23}
24
25impl ExecSecretConfig {
26    /// Create a new exec secret config
27    #[must_use]
28    #[allow(dead_code)] // Used in tests; #[expect] incompatible with --all-targets
29    pub fn new(command: impl Into<String>, args: Vec<String>) -> Self {
30        Self {
31            command: command.into(),
32            args,
33            extra: HashMap::new(),
34        }
35    }
36}
37
38/// Resolves secrets by executing commands
39///
40/// The `source` field in [`SecretSpec`] is interpreted as a JSON-encoded
41/// [`ExecSecretConfig`], or as a simple command string if parsing fails.
42#[derive(Debug, Clone, Default)]
43pub struct ExecSecretResolver;
44
45impl ExecSecretResolver {
46    /// Create a new command execution resolver
47    #[must_use]
48    pub const fn new() -> Self {
49        Self
50    }
51
52    /// Execute a command and return its output
53    async fn execute_command(
54        &self,
55        name: &str,
56        command: &str,
57        args: &[String],
58    ) -> Result<String, SecretError> {
59        let output = Command::new(command)
60            .args(args)
61            .output()
62            .await
63            .map_err(|e| SecretError::ResolutionFailed {
64                name: name.to_string(),
65                message: format!("Failed to execute command '{command}': {e}"),
66            })?;
67
68        if !output.status.success() {
69            let stderr = String::from_utf8_lossy(&output.stderr);
70            return Err(SecretError::ResolutionFailed {
71                name: name.to_string(),
72                message: format!("Command '{command}' failed: {stderr}"),
73            });
74        }
75
76        let stdout = String::from_utf8_lossy(&output.stdout);
77        Ok(stdout.trim().to_string())
78    }
79}
80
81#[async_trait]
82impl SecretResolver for ExecSecretResolver {
83    fn provider_name(&self) -> &'static str {
84        "exec"
85    }
86
87    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
88        // Try to parse source as JSON ExecSecretConfig
89        if let Ok(config) = serde_json::from_str::<ExecSecretConfig>(&spec.source) {
90            return self
91                .execute_command(name, &config.command, &config.args)
92                .await;
93        }
94
95        // Fallback: treat source as a simple command (shell expansion)
96        self.execute_command(name, "sh", &["-c".to_string(), spec.source.clone()])
97            .await
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[tokio::test]
106    async fn test_exec_simple_command() {
107        let resolver = ExecSecretResolver::new();
108        let spec = SecretSpec::new("echo test_value");
109        let result = resolver.resolve("test", &spec).await;
110
111        assert_eq!(result.unwrap(), "test_value");
112    }
113
114    #[tokio::test]
115    async fn test_exec_json_config() {
116        let config = ExecSecretConfig::new("echo", vec!["json_value".to_string()]);
117        let json_source = serde_json::to_string(&config).unwrap();
118
119        let resolver = ExecSecretResolver::new();
120        let spec = SecretSpec::new(json_source);
121        let result = resolver.resolve("test", &spec).await;
122
123        assert_eq!(result.unwrap(), "json_value");
124    }
125
126    #[tokio::test]
127    async fn test_exec_command_failure() {
128        let resolver = ExecSecretResolver::new();
129        let spec = SecretSpec::new("exit 1");
130        let result = resolver.resolve("test", &spec).await;
131
132        assert!(matches!(result, Err(SecretError::ResolutionFailed { .. })));
133    }
134}