cellos-broker-file 0.5.1

Filesystem SecretBroker for CellOS — resolves spec secretRefs from on-disk files (Kubernetes-mounted secrets, systemd credentials).
Documentation
//! [`SecretBroker`] that reads secret values from filesystem paths.
//!
//! # Motivation
//!
//! Many production runtimes deliver secrets as files rather than environment variables:
//! - **Kubernetes** mounted secrets (`/run/secrets/<name>` or a volume mount path)
//! - **Docker secrets** (`/run/secrets/<name>`)
//! - **systemd credentials** (`/run/credentials/<unit>/name`)
//! - **HashiCorp Vault Agent** writing rendered templates to tmpfs
//! - **CI systems** injecting secrets into a tmpfs path before the job starts
//!
//! This broker maps each logical secret key to a file path via an environment variable,
//! reads the file at resolve time, and returns its contents as a [`SecretView`].
//!
//! # Configuration
//!
//! For each secret key, set:
//! ```text
//! CELLOS_SECRET_FILE_<UPPER_KEY>=<path>
//! ```
//!
//! The key is uppercased and hyphens are replaced with underscores to form the env var suffix.
//!
//! **Examples:**
//! ```text
//! CELLOS_SECRET_FILE_DB_PASSWORD=/run/secrets/db-password
//! CELLOS_SECRET_FILE_API_TOKEN=/run/credentials/myapp.service/api-token
//! ```
//!
//! These would resolve for `secretRefs: ["DB_PASSWORD", "API_TOKEN"]` in the cell spec.
//!
//! # Security properties
//!
//! - File path is operator-controlled via env var — the broker reads only the configured path.
//! - No persistent in-process state. Credentials are not cached between resolve calls.
//! - Contents are returned as [`SecretView`] which is `ZeroizeOnDrop` — bytes are overwritten
//!   when the supervisor is done with them, before export or destroy phases.
//! - Trailing newlines are stripped (standard file-based secret convention).
//! - **`O_NOFOLLOW` on the final path component (Unix)** — if an attacker with write
//!   access to the parent directory swaps the configured secret file for a symlink
//!   to `/etc/shadow`, the open errors out instead of exfiltrating the target.
//!   Mirrors `cellos-core::trust_keys::load_trust_verify_keys_file` (SEC-15b).
//!   Windows lacks an `O_NOFOLLOW` analogue in `std`, so it falls back to
//!   `tokio::fs::read_to_string`.
//!
//! # Revocation
//!
//! `revoke_for_cell` is a documented no-op: this broker holds no persistent state between
//! calls. Isolation relies on the cell model's teardown semantics (cleared subprocess env,
//! short TTLs) and the file's own lifecycle on the host.
//!
//! # Correlation propagation (Tranche-1 seam-freeze G1)
//!
//! Filesystem secrets are stamped before the supervisor starts (Kubernetes /
//! systemd / Vault Agent / CI) so this broker has no upstream session of its
//! own and returns `None` from [`SecretBroker::broker_correlation_id`]. The
//! supervisor falls back to the operator-supplied
//! `spec.correlation.correlationId` for cross-tool correlation in that case.

use async_trait::async_trait;
use cellos_core::ports::SecretBroker;
use cellos_core::{CellosError, SecretView};
use tracing::instrument;

/// Resolves secrets from filesystem files using `CELLOS_SECRET_FILE_<UPPER_KEY>=<path>`.
///
/// The file at the configured path is read at each `resolve` call — not cached.
pub struct FileSecretBroker;

impl FileSecretBroker {
    pub fn new() -> Self {
        Self
    }

    /// Returns the env var name that configures the file path for a given key.
    ///
    /// `"DB_PASSWORD"` → `"CELLOS_SECRET_FILE_DB_PASSWORD"`
    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()
    }
}

/// Read the secret file with `O_NOFOLLOW` on the final path component (Unix).
///
/// Rationale (BFILE-VAL): an attacker with write access to the directory
/// containing the operator-configured secret file can swap the file for a
/// symlink pointing at `/etc/shadow` or any other readable file the
/// supervisor process has access to. `tokio::fs::read_to_string` follows
/// symlinks transparently, which would silently exfiltrate the target.
/// Opening with `O_NOFOLLOW` makes that swap fail with `ELOOP` instead.
///
/// Mirrors the pattern already used in
/// `cellos-core::trust_keys::load_trust_verify_keys_file` and
/// `cellos-supervisor::spec_input` (SEC-15b). We deliberately avoid a
/// `libc` dependency and hard-code the kernel ABI constant — this is the
/// same trade-off cellos-core makes; adding a new Unix variant is a
/// one-line change here, not a libc-crate refactor.
///
/// On non-Unix targets (Windows) `std` has no `O_NOFOLLOW` analogue, so we
/// fall back to a plain async read. This matches every other trust-path
/// loader in the workspace.
async fn read_secret_file(path: &str) -> std::io::Result<String> {
    #[cfg(unix)]
    {
        use std::io::Read;
        use std::os::unix::fs::OpenOptionsExt;

        // O_NOFOLLOW value is platform-specific.
        //   - Linux:                   0x20000  (asm-generic/fcntl.h)
        //   - macOS / *BSD:            0x100    (sys/fcntl.h)
        // Using the wrong constant would silently map to a different flag
        // (on Linux 0x100 is `O_NOCTTY`, which would not refuse a symlink),
        // so this MUST stay accurate per platform.
        #[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."
        );

        // Synchronous open + read inside `spawn_blocking` keeps the
        // O_NOFOLLOW semantics (tokio::fs::OpenOptions has no
        // `custom_flags` shim) without leaking blocking I/O onto the
        // async runtime. Secret files are tiny (single-line tokens or
        // small PEMs), so this is comparable in cost to the previous
        // `tokio::fs::read_to_string` call.
        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> {
        // BFILE-VAL: reject keys that cannot form a legal env var lookup. These
        // pre-checks are defence in depth — `std::env::var` will already error
        // on most of these via `NulError` / "not present", but pinning the
        // failure mode here keeps the contract observable and the error message
        // attributable to the broker layer.
        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}"))
        })?;

        // Strip exactly one trailing newline, consistent with how most secret writers work.
        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),
        })
    }

    /// No-op — this broker holds no persistent state between calls.
    /// The file on disk is managed by the operator's secret delivery mechanism.
    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(); // writeln adds \n

        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"); // no trailing newline
    }

    #[tokio::test]
    async fn normalizes_hyphenated_key() {
        let mut f = NamedTempFile::new().unwrap();
        write!(f, "my-secret").unwrap();

        // hyphen in key → underscore in env var suffix
        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();
    }

    /// Red-team wave-2 T5: `read_secret_file` opens the configured path with
    /// `O_NOFOLLOW`. Pin the symlink-rejection property so a future revert to
    /// `tokio::fs::read_to_string` cannot silently weaken the secret-broker.
    #[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() {
        // Multi-line files (e.g. PEM certificates) should have only the final \n stripped.
        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");
    }
}