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}