Skip to main content

opal/
secrets.rs

1use anyhow::{Context, Result};
2use std::borrow::Cow;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6const SECRETS_RELATIVE_DIR: &str = ".opal/env";
7const LEGACY_SECRETS_RELATIVE_DIR: &str = ".opal";
8pub const SECRETS_CONTAINER_DIR: &str = "/opal/secrets";
9
10#[derive(Debug, Default, Clone)]
11pub struct SecretsStore {
12    root: Option<PathBuf>,
13    entries: Vec<SecretEntry>,
14}
15
16#[derive(Debug, Clone)]
17struct SecretEntry {
18    name: String,
19    rel_path: Option<PathBuf>,
20    value: Option<String>,
21}
22
23impl SecretsStore {
24    pub fn load(workdir: &Path) -> Result<Self> {
25        let scoped_path = workdir.join(SECRETS_RELATIVE_DIR);
26        if scoped_path.exists() {
27            if scoped_path.is_dir() {
28                return Ok(Self {
29                    root: Some(scoped_path.clone()),
30                    entries: load_secret_entries(&scoped_path)?,
31                });
32            }
33            if scoped_path.is_file() {
34                let entries = load_dotenv_file_entries(&scoped_path)?;
35                if !entries.is_empty() {
36                    return Ok(Self {
37                        root: None,
38                        entries,
39                    });
40                }
41            }
42        }
43
44        let legacy_dir = workdir.join(LEGACY_SECRETS_RELATIVE_DIR);
45        if legacy_dir.exists() && legacy_dir.is_dir() {
46            let entries = load_secret_entries(&legacy_dir)?;
47            if !entries.is_empty() {
48                return Ok(Self {
49                    root: Some(legacy_dir),
50                    entries,
51                });
52            }
53        }
54
55        Ok(Self::default())
56    }
57
58    pub fn has_secrets(&self) -> bool {
59        !self.entries.is_empty()
60    }
61
62    pub fn extend_env(&self, env: &mut Vec<(String, String)>) {
63        for entry in &self.entries {
64            if let Some(value) = &entry.value {
65                upsert_env(env, &entry.name, value);
66            }
67            if let Some(rel_path) = &entry.rel_path {
68                let file_env = format!("{}_FILE", entry.name);
69                let file_path = Path::new(SECRETS_CONTAINER_DIR).join(rel_path);
70                upsert_env(env, &file_env, &file_path.display().to_string());
71            }
72        }
73    }
74
75    pub fn env_pairs(&self) -> Vec<(String, String)> {
76        let mut env = Vec::new();
77        self.extend_env(&mut env);
78        env
79    }
80
81    pub fn mask_fragment<'a>(&self, fragment: &'a str) -> Cow<'a, str> {
82        if self.entries.is_empty() {
83            return Cow::Borrowed(fragment);
84        }
85        let mut masked = Cow::Borrowed(fragment);
86        for entry in &self.entries {
87            if let Some(value) = &entry.value {
88                if value.is_empty() {
89                    continue;
90                }
91                if let Cow::Borrowed(current) = &masked
92                    && !current.contains(value)
93                {
94                    continue;
95                }
96                let replaced = masked.replace(value, "[MASKED]");
97                masked = Cow::Owned(replaced);
98            }
99        }
100        masked
101    }
102
103    pub fn volume_mount(&self) -> Option<(PathBuf, PathBuf)> {
104        let root = self.root.as_ref()?;
105        Some((root.clone(), PathBuf::from(SECRETS_CONTAINER_DIR)))
106    }
107}
108
109fn load_secret_entries(dir: &Path) -> Result<Vec<SecretEntry>> {
110    let mut entries = Vec::new();
111    for entry in fs::read_dir(dir)
112        .with_context(|| format!("failed to read secrets directory at {}", dir.display()))?
113    {
114        let entry = entry?;
115        let path = entry.path();
116        if !path.is_file() {
117            continue;
118        }
119        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
120            continue;
121        };
122        if !is_env_var_name(&name) {
123            continue;
124        }
125        let bytes =
126            fs::read(&path).with_context(|| format!("failed to read secret {}", path.display()))?;
127        let value = String::from_utf8(bytes).ok().map(|v| trim_secret_value(&v));
128        entries.push(SecretEntry {
129            name: name.clone(),
130            rel_path: Some(PathBuf::from(name)),
131            value,
132        });
133    }
134    Ok(entries)
135}
136
137fn load_dotenv_file_entries(path: &Path) -> Result<Vec<SecretEntry>> {
138    let content = fs::read_to_string(path)
139        .with_context(|| format!("failed to read dotenv secrets file at {}", path.display()))?;
140    let mut entries = Vec::new();
141    for raw_line in content.lines() {
142        let line = raw_line.trim();
143        if line.is_empty() || line.starts_with('#') {
144            continue;
145        }
146        let line = line.strip_prefix("export ").unwrap_or(line);
147        let Some((raw_key, raw_value)) = line.split_once('=') else {
148            continue;
149        };
150        let key = raw_key.trim();
151        if !is_env_var_name(key) {
152            continue;
153        }
154        let value = parse_dotenv_value(raw_value.trim());
155        entries.push(SecretEntry {
156            name: key.to_string(),
157            rel_path: None,
158            value: Some(value),
159        });
160    }
161    Ok(entries)
162}
163
164pub fn load_dotenv_env_pairs(path: &Path) -> Result<Vec<(String, String)>> {
165    Ok(load_dotenv_file_entries(path)?
166        .into_iter()
167        .filter_map(|entry| entry.value.map(|value| (entry.name, value)))
168        .collect())
169}
170
171fn trim_secret_value(value: &str) -> String {
172    value.trim_end_matches(&['\r', '\n'][..]).to_string()
173}
174
175fn parse_dotenv_value(value: &str) -> String {
176    let unquoted = if value.len() >= 2
177        && ((value.starts_with('"') && value.ends_with('"'))
178            || (value.starts_with('\'') && value.ends_with('\'')))
179    {
180        &value[1..value.len() - 1]
181    } else {
182        value
183    };
184    trim_secret_value(unquoted)
185}
186
187fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
188    if let Some((_, existing_value)) = env.iter_mut().find(|(existing_key, _)| existing_key == key)
189    {
190        *existing_value = value.to_string();
191    } else {
192        env.push((key.to_string(), value.to_string()));
193    }
194}
195
196fn is_env_var_name(name: &str) -> bool {
197    let mut chars = name.chars();
198    let Some(first) = chars.next() else {
199        return false;
200    };
201    if !(first == '_' || first.is_ascii_alphabetic()) {
202        return false;
203    }
204    chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{SECRETS_CONTAINER_DIR, SecretsStore};
210    use anyhow::Result;
211    use std::fs;
212    use tempfile::tempdir;
213
214    #[test]
215    fn env_pairs_include_value_and_file_reference() -> Result<()> {
216        let dir = tempdir()?;
217        let secrets_dir = dir.path().join(".opal").join("env");
218        fs::create_dir_all(&secrets_dir)?;
219        fs::write(secrets_dir.join("QUAY_PASSWORD"), "dummy-token")?;
220
221        let store = SecretsStore::load(dir.path())?;
222        let pairs = store.env_pairs();
223        assert!(pairs.contains(&("QUAY_PASSWORD".to_string(), "dummy-token".to_string())));
224        assert!(pairs.iter().any(|(k, v)| {
225            k == "QUAY_PASSWORD_FILE" && v == &format!("{SECRETS_CONTAINER_DIR}/QUAY_PASSWORD")
226        }));
227        Ok(())
228    }
229
230    #[test]
231    fn load_supports_legacy_dotopal_secret_files() -> Result<()> {
232        let dir = tempdir()?;
233        let dotopal_dir = dir.path().join(".opal");
234        fs::create_dir_all(&dotopal_dir)?;
235        fs::write(dotopal_dir.join("QUAY_USERNAME"), "robot-user\n")?;
236        fs::write(dotopal_dir.join("config.toml"), "ignored=true")?;
237
238        let store = SecretsStore::load(dir.path())?;
239        let pairs = store.env_pairs();
240        assert!(pairs.contains(&("QUAY_USERNAME".to_string(), "robot-user".to_string())));
241        assert!(!pairs.iter().any(|(k, _)| k == "config.toml"));
242        assert_eq!(
243            store.volume_mount().map(|(host, _)| host),
244            Some(dotopal_dir.clone())
245        );
246        Ok(())
247    }
248
249    #[test]
250    fn scoped_env_dir_precedence_over_legacy_dotopal() -> Result<()> {
251        let dir = tempdir()?;
252        let dotopal_dir = dir.path().join(".opal");
253        let secrets_dir = dotopal_dir.join("env");
254        fs::create_dir_all(&secrets_dir)?;
255        fs::write(dotopal_dir.join("QUAY_USERNAME"), "legacy-user")?;
256        fs::write(secrets_dir.join("QUAY_USERNAME"), "scoped-user")?;
257
258        let store = SecretsStore::load(dir.path())?;
259        let pairs = store.env_pairs();
260        assert!(pairs.contains(&("QUAY_USERNAME".to_string(), "scoped-user".to_string())));
261        assert!(!pairs.contains(&("QUAY_USERNAME".to_string(), "legacy-user".to_string())));
262        assert_eq!(
263            store.volume_mount().map(|(host, _)| host),
264            Some(secrets_dir)
265        );
266        Ok(())
267    }
268
269    #[test]
270    fn scoped_env_dir_ignores_non_env_file_names() -> Result<()> {
271        let dir = tempdir()?;
272        let secrets_dir = dir.path().join(".opal").join("env");
273        fs::create_dir_all(&secrets_dir)?;
274        fs::write(secrets_dir.join("QUAY_USERNAME"), "scoped-user")?;
275        fs::write(secrets_dir.join("config.toml"), "ignored=true")?;
276
277        let store = SecretsStore::load(dir.path())?;
278        let pairs = store.env_pairs();
279        assert!(pairs.contains(&("QUAY_USERNAME".to_string(), "scoped-user".to_string())));
280        assert!(!pairs.iter().any(|(key, _)| key == "config.toml"));
281        Ok(())
282    }
283
284    #[test]
285    fn load_supports_dotenv_file_under_dotopal_env() -> Result<()> {
286        let dir = tempdir()?;
287        let dotopal_dir = dir.path().join(".opal");
288        fs::create_dir_all(&dotopal_dir)?;
289        fs::write(
290            dotopal_dir.join("env"),
291            "export QUAY_USERNAME=robot-user\nQUAY_PASSWORD=\"dummy-token\"\n",
292        )?;
293
294        let store = SecretsStore::load(dir.path())?;
295        let pairs = store.env_pairs();
296        assert!(pairs.contains(&("QUAY_USERNAME".to_string(), "robot-user".to_string())));
297        assert!(pairs.contains(&("QUAY_PASSWORD".to_string(), "dummy-token".to_string())));
298        assert!(!pairs.iter().any(|(key, _)| key.ends_with("_FILE")));
299        assert!(store.volume_mount().is_none());
300        Ok(())
301    }
302
303    #[test]
304    fn secret_values_override_existing_env_entries() -> Result<()> {
305        let dir = tempdir()?;
306        let secrets_dir = dir.path().join(".opal").join("env");
307        fs::create_dir_all(&secrets_dir)?;
308        fs::write(secrets_dir.join("QUAY_USERNAME"), "secret-user")?;
309
310        let store = SecretsStore::load(dir.path())?;
311        let mut env = vec![("QUAY_USERNAME".to_string(), "".to_string())];
312        store.extend_env(&mut env);
313
314        assert!(env.contains(&("QUAY_USERNAME".to_string(), "secret-user".to_string())));
315        let username_count = env.iter().filter(|(k, _)| k == "QUAY_USERNAME").count();
316        assert_eq!(username_count, 1);
317        Ok(())
318    }
319}