use std::io::Write;
use cellos_broker_file::FileSecretBroker;
use cellos_core::ports::SecretBroker;
use tempfile::NamedTempFile;
fn unique_key(suffix: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
format!("BFILE_VAL_{suffix}_{n}")
}
#[tokio::test]
async fn rejects_empty_key() {
let broker = FileSecretBroker::new();
let err = broker
.resolve("", "cell-empty", 60)
.await
.expect_err("empty key must be rejected");
let msg = err.to_string();
assert!(
msg.contains("empty"),
"empty-key error should mention emptiness: {msg}"
);
}
#[tokio::test]
async fn rejects_key_with_nul_byte() {
let broker = FileSecretBroker::new();
let err = broker
.resolve("FOO\0BAR", "cell-nul", 60)
.await
.expect_err("NUL-byte key must be rejected");
let msg = err.to_string();
assert!(
msg.contains("NUL") || msg.contains("nul"),
"NUL-key error should mention NUL: {msg}"
);
}
#[tokio::test]
async fn rejects_key_with_dotdot_traversal() {
let broker = FileSecretBroker::new();
let err = broker
.resolve("../etc/passwd", "cell-traversal", 60)
.await
.expect_err("`../`-bearing key must not resolve");
let msg = err.to_string();
assert!(
msg.contains("env var") || msg.contains("not set"),
"dotdot key should fail at env-var lookup, got: {msg}"
);
}
#[tokio::test]
async fn rejects_key_with_leading_slash_absolute_path() {
let broker = FileSecretBroker::new();
let err = broker
.resolve("/etc/passwd", "cell-abs", 60)
.await
.expect_err("absolute-path-shaped key must not resolve");
let msg = err.to_string();
assert!(
!msg.is_empty(),
"absolute-path key must produce a non-empty error"
);
}
#[tokio::test]
async fn rejects_key_with_forward_slash() {
let broker = FileSecretBroker::new();
let err = broker
.resolve("foo/bar", "cell-slash", 60)
.await
.expect_err("slash-bearing key must not resolve");
assert!(!err.to_string().is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_pointing_outside_base_is_not_followed() {
let tmp = tempfile::tempdir().expect("tempdir");
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).expect("mkdir outside");
let target = outside.join("target");
std::fs::write(&target, b"PWNED-SHOULD-NEVER-BE-RETURNED").expect("write target");
let inside = tmp.path().join("inside_secret");
std::os::unix::fs::symlink(&target, &inside).expect("symlink");
let key = unique_key("SYMLINK_OUT");
let env_var = FileSecretBroker::path_env_var(&key);
std::env::set_var(&env_var, inside.to_str().unwrap());
let broker = FileSecretBroker::new();
let result = broker.resolve(&key, "cell-symlink", 60).await;
std::env::remove_var(&env_var);
let err = result.expect_err(
"symlink at the configured path must NOT be followed — \
O_NOFOLLOW should refuse the open and produce an error",
);
let msg = err.to_string();
assert!(
!msg.contains("PWNED-SHOULD-NEVER-BE-RETURNED"),
"error must not embed the symlink target's bytes: {msg}"
);
assert!(
msg.contains("read secret file") || msg.contains("ELOOP") || msg.contains("symbolic"),
"error should be a read failure attributable to the broker / O_NOFOLLOW: {msg}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_to_arbitrary_host_file_does_not_exfiltrate() {
let mut victim = NamedTempFile::new().expect("victim tempfile");
write!(victim, "VICTIM-CONTENT-DO-NOT-LEAK").expect("write victim");
let tmp = tempfile::tempdir().expect("tempdir");
let inside = tmp.path().join("secret_via_symlink");
std::os::unix::fs::symlink(victim.path(), &inside).expect("symlink");
let key = unique_key("SYMLINK_HOST");
let env_var = FileSecretBroker::path_env_var(&key);
std::env::set_var(&env_var, inside.to_str().unwrap());
let broker = FileSecretBroker::new();
let result = broker.resolve(&key, "cell-host-symlink", 60).await;
std::env::remove_var(&env_var);
match result {
Ok(view) => panic!(
"symlink-follow must be refused, but resolve returned a value of len {}",
view.value.as_str().len()
),
Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("VICTIM-CONTENT-DO-NOT-LEAK"),
"error must not embed victim file's bytes: {msg}"
);
}
}
}
#[cfg(unix)]
#[tokio::test]
async fn regular_file_outside_tempdir_still_works() {
let mut f = NamedTempFile::new().expect("tempfile");
write!(f, "happy-path-secret").expect("write");
let key = unique_key("HAPPY");
let env_var = FileSecretBroker::path_env_var(&key);
std::env::set_var(&env_var, f.path().to_str().unwrap());
let broker = FileSecretBroker::new();
let view = broker.resolve(&key, "cell-happy", 60).await;
std::env::remove_var(&env_var);
let view = view.expect("regular file must still resolve after O_NOFOLLOW fix");
assert_eq!(view.value.as_str(), "happy-path-secret");
}
#[cfg(unix)]
#[tokio::test]
async fn symlink_inside_tempdir_pointing_to_sibling_is_still_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let real = tmp.path().join("real");
std::fs::write(&real, b"sibling-content").expect("write");
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).expect("symlink");
let key = unique_key("SIBLING_SYMLINK");
let env_var = FileSecretBroker::path_env_var(&key);
std::env::set_var(&env_var, link.to_str().unwrap());
let broker = FileSecretBroker::new();
let result = broker.resolve(&key, "cell-sibling", 60).await;
std::env::remove_var(&env_var);
let err = result.expect_err("final-component symlink must be refused even with in-dir target");
assert!(
!err.to_string().contains("sibling-content"),
"error must not embed target bytes: {err}"
);
}