use async_trait::async_trait;
use cellos_core::ports::SecretBroker;
use cellos_core::{CellosError, SecretView};
use tracing::instrument;
pub struct FileSecretBroker;
impl FileSecretBroker {
pub fn new() -> Self {
Self
}
pub fn path_env_var(key: &str) -> String {
format!(
"CELLOS_SECRET_FILE_{}",
key.to_uppercase().replace('-', "_")
)
}
}
impl Default for FileSecretBroker {
fn default() -> Self {
Self::new()
}
}
async fn read_secret_file(path: &str) -> std::io::Result<String> {
#[cfg(unix)]
{
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
#[cfg(target_os = "linux")]
const O_NOFOLLOW: i32 = 0x20000;
#[cfg(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
))]
const O_NOFOLLOW: i32 = 0x100;
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly",
)))]
compile_error!(
"cellos-broker-file: O_NOFOLLOW value not yet defined for this Unix target — \
add the platform-specific value (see <fcntl.h>) before building."
);
let path_owned = path.to_string();
tokio::task::spawn_blocking(move || {
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
opts.custom_flags(O_NOFOLLOW);
let mut file = opts.open(&path_owned)?;
let mut buf = String::new();
file.read_to_string(&mut buf)?;
Ok::<String, std::io::Error>(buf)
})
.await
.map_err(|join_err| std::io::Error::other(format!("spawn_blocking join: {join_err}")))?
}
#[cfg(not(unix))]
{
tokio::fs::read_to_string(path).await
}
}
#[async_trait]
impl SecretBroker for FileSecretBroker {
#[instrument(skip(self), fields(key = %key, cell_id = %cell_id))]
async fn resolve(
&self,
key: &str,
cell_id: &str,
_ttl_seconds: u64,
) -> Result<SecretView, CellosError> {
if key.is_empty() {
return Err(CellosError::SecretBroker(
"secret key must not be empty".into(),
));
}
if key.contains('\0') {
return Err(CellosError::SecretBroker(
"secret key must not contain NUL bytes".into(),
));
}
let env_var = Self::path_env_var(key);
let path = std::env::var(&env_var).map_err(|_| {
CellosError::SecretBroker(format!(
"env var {env_var} not set (no file path configured for secret key {key:?})"
))
})?;
let raw = read_secret_file(&path).await.map_err(|e| {
CellosError::SecretBroker(format!("read secret file for key {key:?} at {path:?}: {e}"))
})?;
let value = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
if value.is_empty() {
tracing::warn!(
key = %key,
path = %path,
"secret file is empty after trim"
);
}
Ok(SecretView {
key: key.to_string(),
value: zeroize::Zeroizing::new(value),
})
}
async fn revoke_for_cell(&self, _cell_id: &str) -> Result<(), CellosError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test]
async fn resolves_secret_from_file() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "super-secret-value").unwrap();
let env_var = FileSecretBroker::path_env_var("DB_PASSWORD");
std::env::set_var(&env_var, f.path().to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker.resolve("DB_PASSWORD", "cell-1", 60).await.unwrap();
std::env::remove_var(&env_var);
assert_eq!(view.key, "DB_PASSWORD");
assert_eq!(view.value.as_str(), "super-secret-value");
}
#[tokio::test]
async fn strips_trailing_newline() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "token-value").unwrap();
let env_var = FileSecretBroker::path_env_var("API_TOKEN");
std::env::set_var(&env_var, f.path().to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker.resolve("API_TOKEN", "cell-1", 60).await.unwrap();
std::env::remove_var(&env_var);
assert_eq!(view.value.as_str(), "token-value"); }
#[tokio::test]
async fn normalizes_hyphenated_key() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "my-secret").unwrap();
let env_var = FileSecretBroker::path_env_var("my-api-key");
std::env::set_var(&env_var, f.path().to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker.resolve("my-api-key", "cell-1", 60).await.unwrap();
std::env::remove_var(&env_var);
assert_eq!(view.value.as_str(), "my-secret");
}
#[tokio::test]
async fn errors_when_env_var_not_set() {
let env_var = FileSecretBroker::path_env_var("MISSING_KEY_XYZ");
std::env::remove_var(&env_var);
let broker = FileSecretBroker::new();
let err = broker
.resolve("MISSING_KEY_XYZ", "cell-1", 60)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("CELLOS_SECRET_FILE_MISSING_KEY_XYZ"),
"error should mention the env var: {msg}"
);
}
#[tokio::test]
async fn errors_when_file_does_not_exist() {
let env_var = FileSecretBroker::path_env_var("GHOST_KEY");
std::env::set_var(&env_var, "/tmp/cellos-nonexistent-secret-file-xyzzy");
let broker = FileSecretBroker::new();
let err = broker.resolve("GHOST_KEY", "cell-1", 60).await.unwrap_err();
std::env::remove_var(&env_var);
let msg = err.to_string();
assert!(
msg.contains("GHOST_KEY"),
"error should mention the key: {msg}"
);
}
#[tokio::test]
async fn revoke_is_noop() {
let broker = FileSecretBroker::new();
broker.revoke_for_cell("any-cell").await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn rejects_symlink_at_final_component() {
let dir = tempfile::tempdir().expect("tmpdir");
let real_path = dir.path().join("real-secret");
let symlink_path = dir.path().join("symlinked-secret");
std::fs::write(&real_path, b"real-value").expect("write real secret");
std::os::unix::fs::symlink(&real_path, &symlink_path).expect("symlink");
let env_var_real = FileSecretBroker::path_env_var("REAL_KEY_WAVE2");
std::env::set_var(&env_var_real, real_path.to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker
.resolve("REAL_KEY_WAVE2", "cell-1", 60)
.await
.expect("real path opens");
assert_eq!(view.value.as_str(), "real-value");
std::env::remove_var(&env_var_real);
let env_var_sym = FileSecretBroker::path_env_var("SYMLINK_KEY_WAVE2");
std::env::set_var(&env_var_sym, symlink_path.to_str().unwrap());
let err = broker
.resolve("SYMLINK_KEY_WAVE2", "cell-1", 60)
.await
.expect_err("symlink final component must be rejected");
std::env::remove_var(&env_var_sym);
let msg = err.to_string();
assert!(msg.contains("SYMLINK_KEY_WAVE2"), "got: {msg}");
assert!(msg.contains("read secret file"), "got: {msg}");
}
#[tokio::test]
async fn preserves_multiline_content_minus_final_newline() {
let mut f = NamedTempFile::new().unwrap();
write!(f, "line1\nline2\nline3\n").unwrap();
let env_var = FileSecretBroker::path_env_var("CERT_PEM");
std::env::set_var(&env_var, f.path().to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker.resolve("CERT_PEM", "cell-1", 60).await.unwrap();
std::env::remove_var(&env_var);
assert_eq!(view.value.as_str(), "line1\nline2\nline3");
}
}