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    /// Mutate the process environment so the secret is visible to
47    /// callers (and unfortunately to every child process spawned after
48    /// this point).
49    ///
50    /// # Safety / Soundness
51    ///
52    /// `std::env::set_var` is `unsafe` in Rust 2024 because the
53    /// underlying POSIX `setenv(3)` is not synchronized with concurrent
54    /// `getenv(3)` calls; another thread reading the environment at
55    /// the same moment can observe a half-updated or dangling pointer.
56    /// The harn host process drives a tokio runtime, so this hazard is
57    /// real even though no single call site currently triggers it. We
58    /// mark this `unsafe` block to flag the existing risk; the proper
59    /// fix is tracked as a follow-up — replace this with an in-process
60    /// `Mutex<HashMap<SecretId, SecretBytes>>` consulted by `get`, and
61    /// inject the secret narrowly via `Command::env(key, val)` at
62    /// spawn time so it does not leak into unrelated children.
63    ///
64    /// # Child-process leakage
65    ///
66    /// Beyond the soundness issue: once written here, the secret is
67    /// inherited by every subsequent `shell(...)`, `exec(...)`,
68    /// `tokio::process::Command::spawn`, and any tool we hand a child
69    /// process to. A `bash -c env` or a debugger attaching to a child
70    /// would see it. Treat this provider as a last-resort path; prefer
71    /// the keyring-backed provider when available.
72    async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
73        let env_name = self.env_var_name(id);
74        let rendered = value.with_exposed(|bytes| {
75            std::str::from_utf8(bytes)
76                .map(|text| text.to_string())
77                .map_err(|error| SecretError::Backend {
78                    provider: "env".to_string(),
79                    message: format!("env secrets must be valid UTF-8: {error}"),
80                })
81        })?;
82        // SAFETY: see the doc comment above. We accept the
83        // `setenv` data-race window as a known gap for the env-backed
84        // provider; the in-process Mutex<HashMap> replacement is
85        // tracked as a follow-up issue.
86        unsafe {
87            std::env::set_var(&env_name, rendered);
88        }
89        Ok(())
90    }
91
92    async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
93        Err(SecretError::Unsupported {
94            provider: "env".to_string(),
95            operation: "rotate",
96        })
97    }
98
99    async fn list(&self, prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
100        let env_prefix = if prefix.name.is_empty() {
101            format!(
102                "HARN_SECRET_{}_",
103                normalize_env_component(&prefix.namespace)
104            )
105        } else {
106            self.env_var_name(prefix)
107        };
108
109        let items = std::env::vars()
110            .filter_map(|(name, _)| {
111                if !name.starts_with(&env_prefix) {
112                    return None;
113                }
114                let suffix = name
115                    .strip_prefix(&format!(
116                        "HARN_SECRET_{}_",
117                        normalize_env_component(&prefix.namespace)
118                    ))
119                    .unwrap_or_default()
120                    .trim_start_matches('_')
121                    .to_ascii_lowercase();
122                Some(SecretMeta {
123                    id: SecretId::new(prefix.namespace.clone(), suffix),
124                    provider: "env".to_string(),
125                })
126            })
127            .collect::<Vec<_>>();
128        Ok(items)
129    }
130
131    fn namespace(&self) -> &str {
132        &self.namespace
133    }
134
135    fn supports_versions(&self) -> bool {
136        false
137    }
138}
139
140fn normalize_env_component(value: &str) -> String {
141    let mut normalized = String::with_capacity(value.len());
142    let mut last_was_underscore = false;
143    for ch in value.chars() {
144        let mapped = if ch.is_ascii_alphanumeric() {
145            ch.to_ascii_uppercase()
146        } else {
147            '_'
148        };
149        if mapped == '_' {
150            if !last_was_underscore {
151                normalized.push(mapped);
152            }
153            last_was_underscore = true;
154        } else {
155            normalized.push(mapped);
156            last_was_underscore = false;
157        }
158    }
159
160    normalized.trim_matches('_').to_string()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn env_provider_uses_expected_variable_name() {
169        let provider = EnvSecretProvider::new("harn/test");
170        let id = SecretId::new("harn.orchestrator.github", "installation-12345/private-key");
171        assert_eq!(
172            provider.env_var_name(&id),
173            "HARN_SECRET_HARN_ORCHESTRATOR_GITHUB_INSTALLATION_12345_PRIVATE_KEY"
174        );
175    }
176}