cellos_broker_file/
lib.rs1use async_trait::async_trait;
61use cellos_core::ports::SecretBroker;
62use cellos_core::{CellosError, SecretView};
63use tracing::instrument;
64
65pub struct FileSecretBroker;
69
70impl FileSecretBroker {
71 pub fn new() -> Self {
72 Self
73 }
74
75 pub fn path_env_var(key: &str) -> String {
79 format!(
80 "CELLOS_SECRET_FILE_{}",
81 key.to_uppercase().replace('-', "_")
82 )
83 }
84}
85
86impl Default for FileSecretBroker {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92async fn read_secret_file(path: &str) -> std::io::Result<String> {
112 #[cfg(unix)]
113 {
114 use std::io::Read;
115 use std::os::unix::fs::OpenOptionsExt;
116
117 #[cfg(target_os = "linux")]
124 const O_NOFOLLOW: i32 = 0x20000;
125 #[cfg(any(
126 target_os = "macos",
127 target_os = "ios",
128 target_os = "freebsd",
129 target_os = "netbsd",
130 target_os = "openbsd",
131 target_os = "dragonfly",
132 ))]
133 const O_NOFOLLOW: i32 = 0x100;
134 #[cfg(not(any(
135 target_os = "linux",
136 target_os = "macos",
137 target_os = "ios",
138 target_os = "freebsd",
139 target_os = "netbsd",
140 target_os = "openbsd",
141 target_os = "dragonfly",
142 )))]
143 compile_error!(
144 "cellos-broker-file: O_NOFOLLOW value not yet defined for this Unix target — \
145 add the platform-specific value (see <fcntl.h>) before building."
146 );
147
148 let path_owned = path.to_string();
155 tokio::task::spawn_blocking(move || {
156 let mut opts = std::fs::OpenOptions::new();
157 opts.read(true);
158 opts.custom_flags(O_NOFOLLOW);
159 let mut file = opts.open(&path_owned)?;
160 let mut buf = String::new();
161 file.read_to_string(&mut buf)?;
162 Ok::<String, std::io::Error>(buf)
163 })
164 .await
165 .map_err(|join_err| std::io::Error::other(format!("spawn_blocking join: {join_err}")))?
166 }
167 #[cfg(not(unix))]
168 {
169 tokio::fs::read_to_string(path).await
170 }
171}
172
173#[async_trait]
174impl SecretBroker for FileSecretBroker {
175 #[instrument(skip(self), fields(key = %key, cell_id = %cell_id))]
176 async fn resolve(
177 &self,
178 key: &str,
179 cell_id: &str,
180 _ttl_seconds: u64,
181 ) -> Result<SecretView, CellosError> {
182 if key.is_empty() {
188 return Err(CellosError::SecretBroker(
189 "secret key must not be empty".into(),
190 ));
191 }
192 if key.contains('\0') {
193 return Err(CellosError::SecretBroker(
194 "secret key must not contain NUL bytes".into(),
195 ));
196 }
197
198 let env_var = Self::path_env_var(key);
199 let path = std::env::var(&env_var).map_err(|_| {
200 CellosError::SecretBroker(format!(
201 "env var {env_var} not set (no file path configured for secret key {key:?})"
202 ))
203 })?;
204
205 let raw = read_secret_file(&path).await.map_err(|e| {
206 CellosError::SecretBroker(format!("read secret file for key {key:?} at {path:?}: {e}"))
207 })?;
208
209 let value = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
211
212 if value.is_empty() {
213 tracing::warn!(
214 key = %key,
215 path = %path,
216 "secret file is empty after trim"
217 );
218 }
219
220 Ok(SecretView {
221 key: key.to_string(),
222 value: zeroize::Zeroizing::new(value),
223 })
224 }
225
226 async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
229 Ok(())
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::io::Write;
237 use tempfile::NamedTempFile;
238
239 #[tokio::test]
240 async fn resolves_secret_from_file() {
241 let mut f = NamedTempFile::new().unwrap();
242 write!(f, "super-secret-value").unwrap();
243
244 let env_var = FileSecretBroker::path_env_var("DB_PASSWORD");
245 std::env::set_var(&env_var, f.path().to_str().unwrap());
246
247 let broker = FileSecretBroker::new();
248 let view = broker.resolve("DB_PASSWORD", "cell-1", 60).await.unwrap();
249
250 std::env::remove_var(&env_var);
251 assert_eq!(view.key, "DB_PASSWORD");
252 assert_eq!(view.value.as_str(), "super-secret-value");
253 }
254
255 #[tokio::test]
256 async fn strips_trailing_newline() {
257 let mut f = NamedTempFile::new().unwrap();
258 writeln!(f, "token-value").unwrap(); let env_var = FileSecretBroker::path_env_var("API_TOKEN");
261 std::env::set_var(&env_var, f.path().to_str().unwrap());
262
263 let broker = FileSecretBroker::new();
264 let view = broker.resolve("API_TOKEN", "cell-1", 60).await.unwrap();
265
266 std::env::remove_var(&env_var);
267 assert_eq!(view.value.as_str(), "token-value"); }
269
270 #[tokio::test]
271 async fn normalizes_hyphenated_key() {
272 let mut f = NamedTempFile::new().unwrap();
273 write!(f, "my-secret").unwrap();
274
275 let env_var = FileSecretBroker::path_env_var("my-api-key");
277 std::env::set_var(&env_var, f.path().to_str().unwrap());
278
279 let broker = FileSecretBroker::new();
280 let view = broker.resolve("my-api-key", "cell-1", 60).await.unwrap();
281
282 std::env::remove_var(&env_var);
283 assert_eq!(view.value.as_str(), "my-secret");
284 }
285
286 #[tokio::test]
287 async fn errors_when_env_var_not_set() {
288 let env_var = FileSecretBroker::path_env_var("MISSING_KEY_XYZ");
289 std::env::remove_var(&env_var);
290
291 let broker = FileSecretBroker::new();
292 let err = broker
293 .resolve("MISSING_KEY_XYZ", "cell-1", 60)
294 .await
295 .unwrap_err();
296 let msg = err.to_string();
297 assert!(
298 msg.contains("CELLOS_SECRET_FILE_MISSING_KEY_XYZ"),
299 "error should mention the env var: {msg}"
300 );
301 }
302
303 #[tokio::test]
304 async fn errors_when_file_does_not_exist() {
305 let env_var = FileSecretBroker::path_env_var("GHOST_KEY");
306 std::env::set_var(&env_var, "/tmp/cellos-nonexistent-secret-file-xyzzy");
307
308 let broker = FileSecretBroker::new();
309 let err = broker.resolve("GHOST_KEY", "cell-1", 60).await.unwrap_err();
310
311 std::env::remove_var(&env_var);
312 let msg = err.to_string();
313 assert!(
314 msg.contains("GHOST_KEY"),
315 "error should mention the key: {msg}"
316 );
317 }
318
319 #[tokio::test]
320 async fn revoke_is_noop() {
321 let broker = FileSecretBroker::new();
322 broker.revoke_for_cell("any-cell").await.unwrap();
323 }
324
325 #[cfg(unix)]
329 #[tokio::test]
330 async fn rejects_symlink_at_final_component() {
331 let dir = tempfile::tempdir().expect("tmpdir");
332 let real_path = dir.path().join("real-secret");
333 let symlink_path = dir.path().join("symlinked-secret");
334 std::fs::write(&real_path, b"real-value").expect("write real secret");
335 std::os::unix::fs::symlink(&real_path, &symlink_path).expect("symlink");
336
337 let env_var_real = FileSecretBroker::path_env_var("REAL_KEY_WAVE2");
338 std::env::set_var(&env_var_real, real_path.to_str().unwrap());
339 let broker = FileSecretBroker::new();
340 let view = broker
341 .resolve("REAL_KEY_WAVE2", "cell-1", 60)
342 .await
343 .expect("real path opens");
344 assert_eq!(view.value.as_str(), "real-value");
345 std::env::remove_var(&env_var_real);
346
347 let env_var_sym = FileSecretBroker::path_env_var("SYMLINK_KEY_WAVE2");
348 std::env::set_var(&env_var_sym, symlink_path.to_str().unwrap());
349 let err = broker
350 .resolve("SYMLINK_KEY_WAVE2", "cell-1", 60)
351 .await
352 .expect_err("symlink final component must be rejected");
353 std::env::remove_var(&env_var_sym);
354 let msg = err.to_string();
355 assert!(msg.contains("SYMLINK_KEY_WAVE2"), "got: {msg}");
356 assert!(msg.contains("read secret file"), "got: {msg}");
357 }
358
359 #[tokio::test]
360 async fn preserves_multiline_content_minus_final_newline() {
361 let mut f = NamedTempFile::new().unwrap();
363 write!(f, "line1\nline2\nline3\n").unwrap();
364
365 let env_var = FileSecretBroker::path_env_var("CERT_PEM");
366 std::env::set_var(&env_var, f.path().to_str().unwrap());
367
368 let broker = FileSecretBroker::new();
369 let view = broker.resolve("CERT_PEM", "cell-1", 60).await.unwrap();
370
371 std::env::remove_var(&env_var);
372 assert_eq!(view.value.as_str(), "line1\nline2\nline3");
373 }
374}