Skip to main content

harn_vm/secrets/
env.rs

1use async_trait::async_trait;
2
3use super::{
4    emit_secret_access_event, RotationHandle, SecretBytes, SecretError, SecretId, SecretMeta,
5    SecretProvider, SecretVersion,
6};
7
8#[derive(Debug, Clone)]
9pub struct EnvSecretProvider {
10    namespace: String,
11}
12
13impl EnvSecretProvider {
14    pub fn new(namespace: impl Into<String>) -> Self {
15        Self {
16            namespace: namespace.into(),
17        }
18    }
19
20    pub fn env_var_name(&self, id: &SecretId) -> String {
21        let namespace = normalize_env_component(&id.namespace);
22        let name = normalize_env_component(&id.name);
23        match id.version {
24            SecretVersion::Latest => format!("HARN_SECRET_{namespace}_{name}"),
25            SecretVersion::Exact(version) => format!("HARN_SECRET_{namespace}_{name}_V{version}"),
26        }
27    }
28}
29
30#[async_trait]
31impl SecretProvider for EnvSecretProvider {
32    async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
33        let env_name = self.env_var_name(id);
34        match std::env::var(&env_name) {
35            Ok(value) if !value.is_empty() => {
36                emit_secret_access_event("env", id);
37                Ok(SecretBytes::from(value))
38            }
39            _ => Err(SecretError::NotFound {
40                provider: "env".to_string(),
41                id: id.clone(),
42            }),
43        }
44    }
45
46    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
47        let env_name = self.env_var_name(id);
48        let rendered = value.with_exposed(|bytes| {
49            std::str::from_utf8(bytes)
50                .map(|text| text.to_string())
51                .map_err(|error| SecretError::Backend {
52                    provider: "env".to_string(),
53                    message: format!("env secrets must be valid UTF-8: {error}"),
54                })
55        })?;
56        std::env::set_var(&env_name, rendered);
57        Ok(())
58    }
59
60    async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
61        Err(SecretError::Unsupported {
62            provider: "env".to_string(),
63            operation: "rotate",
64        })
65    }
66
67    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
68        let env_prefix = if prefix.name.is_empty() {
69            format!(
70                "HARN_SECRET_{}_",
71                normalize_env_component(&prefix.namespace)
72            )
73        } else {
74            self.env_var_name(prefix)
75        };
76
77        let items = std::env::vars()
78            .filter_map(|(name, _)| {
79                if !name.starts_with(&env_prefix) {
80                    return None;
81                }
82                let suffix = name
83                    .strip_prefix(&format!(
84                        "HARN_SECRET_{}_",
85                        normalize_env_component(&prefix.namespace)
86                    ))
87                    .unwrap_or_default()
88                    .trim_start_matches('_')
89                    .to_ascii_lowercase();
90                Some(SecretMeta {
91                    id: SecretId::new(prefix.namespace.clone(), suffix),
92                    provider: "env".to_string(),
93                })
94            })
95            .collect::<Vec<_>>();
96        Ok(items)
97    }
98
99    fn namespace(&self) -> &str {
100        &self.namespace
101    }
102
103    fn supports_versions(&self) -> bool {
104        false
105    }
106}
107
108fn normalize_env_component(value: &str) -> String {
109    let mut normalized = String::with_capacity(value.len());
110    let mut last_was_underscore = false;
111    for ch in value.chars() {
112        let mapped = if ch.is_ascii_alphanumeric() {
113            ch.to_ascii_uppercase()
114        } else {
115            '_'
116        };
117        if mapped == '_' {
118            if !last_was_underscore {
119                normalized.push(mapped);
120            }
121            last_was_underscore = true;
122        } else {
123            normalized.push(mapped);
124            last_was_underscore = false;
125        }
126    }
127
128    normalized.trim_matches('_').to_string()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn env_provider_uses_expected_variable_name() {
137        let provider = EnvSecretProvider::new("harn/test");
138        let id = SecretId::new("harn.orchestrator.github", "installation-12345/private-key");
139        assert_eq!(
140            provider.env_var_name(&id),
141            "HARN_SECRET_HARN_ORCHESTRATOR_GITHUB_INSTALLATION_12345_PRIVATE_KEY"
142        );
143    }
144}