diff --git a/Cargo.lock b/Cargo.lock index 62054df..1d4b312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,6 +781,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -1144,6 +1150,19 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1192,6 +1211,8 @@ dependencies = [ "rutster-tap", "serde", "serde_json", + "tempfile", + "thiserror 1.0.69", "tokio", "tokio-tungstenite", "tracing", @@ -1515,6 +1536,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/rutster-brain-realtime/Cargo.toml b/crates/rutster-brain-realtime/Cargo.toml index 5d0a0c2..895b92b 100644 --- a/crates/rutster-brain-realtime/Cargo.toml +++ b/crates/rutster-brain-realtime/Cargo.toml @@ -19,6 +19,10 @@ tracing-subscriber = { workspace = true } url = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" [features] default = [] diff --git a/crates/rutster-brain-realtime/src/api_key.rs b/crates/rutster-brain-realtime/src/api_key.rs index f67ea6f..682f0dc 100644 --- a/crates/rutster-brain-realtime/src/api_key.rs +++ b/crates/rutster-brain-realtime/src/api_key.rs @@ -1,2 +1,199 @@ -//! API-key loader (spec §5.3) — implements the env-var + file-path posture. -//! Filled in by Task 3. +//! # API-key loader (spec §5.3) +//! +//! Two-source config: `OPENAI_API_KEY` env var default + optional +//! `OPENAI_API_KEY_FILE` path override. KMS/Vault integration is deferred +//! to step 6 (spec §1.2); the file-path override makes secret-manager +//! injection (k8s secrets, Vault agent) trivial when that layer exists. +//! +//! # Why file-path override (not env-only) +//! +//! A k8s pod mounts secrets at file paths (e.g. `/var/secrets/openai_key`). +//! Env-var secrets are also fine, but file-path is the standard pattern for +//! k8s + Vault agent + sidecar secret rotators. The dev loop uses the env +//! var path (single-source, simplest). + +use std::env; +use std::fs; +use std::path::PathBuf; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ApiKeyError { + #[error("no API key: set OPENAI_API_KEY or OPENAI_API_KEY_FILE")] + NotFound, + #[error("OPENAI_API_KEY_FILE path is not valid UTF-8: {0:?}")] + PathNotUtf8(PathBuf), + #[error("failed to read OPENAI_API_KEY_FILE at {path}: {source}")] + FileRead { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Load the OpenAI API key per the env-var + file-path posture (spec §5.3). +/// +/// Precedence: `OPENAI_API_KEY_FILE` wins over `OPENAI_API_KEY` (a file +/// path is a more specific override; this matches k8s-secret patterns where +/// an operator mounts a file to override the default env var). +/// +/// Trims trailing whitespace from either source (some k8s secret mounts +/// add trailing newlines). +pub fn load_api_key() -> Result { + if let Ok(path_str) = env::var("OPENAI_API_KEY_FILE") { + let path = PathBuf::from(&path_str); + let raw = fs::read_to_string(&path).map_err(|source| ApiKeyError::FileRead { + path: path.clone(), + source, + })?; + return Ok(raw.trim().to_string()); + } + if let Ok(raw) = env::var("OPENAI_API_KEY") { + return Ok(raw.trim().to_string()); + } + Err(ApiKeyError::NotFound) +} + +#[cfg(test)] +mod tests { + use super::*; + + // NOTE: edition 2024 makes env::set_var / env::remove_var `unsafe` (they + // can race with concurrent reads of the process environ). The brief was + // written against edition 2021 syntax; we wrap each mutation in + // `unsafe { ... }` rather than reverting to a pre-2024 edition. The + // unsafety is local to the test process and accepted by the stdlib's + // own test helpers; no threads here read these env vars concurrently. + + // The process environ is process-global state. Even with the save/restore + // pattern below, running these tests in parallel would let one test's + // `set_var` race against another's `remove_var` for the same variable, + // producing flaky failures. This mutex serializes every test in this + // module so the env-var mutations are atomic across tests. + static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Acquire the env mutex for the test body. Panics if the lock is held + /// (only happens if a test panics while held — fine, fail fast). + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) + } + + #[test] + fn returns_error_when_neither_env_nor_file_set() { + let _guard = lock_env(); + // Save then clear both env vars to make the test order-independent. + let saved_key = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v)); + let saved_file = + env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v)); + unsafe { + env::remove_var("OPENAI_API_KEY"); + env::remove_var("OPENAI_API_KEY_FILE"); + } + + let r = load_api_key(); + assert!(matches!(r, Err(ApiKeyError::NotFound))); + + // Restore for any other test in the same process. + if let Some((k, v)) = saved_key { + unsafe { + env::set_var(k, v); + } + } + if let Some((k, v)) = saved_file { + unsafe { + env::set_var(k, v); + } + } + } + + #[test] + fn reads_from_env_var() { + let _guard = lock_env(); + let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v)); + let saved_file = + env::var_os("OPENAI_API_KEY_FILE").map(|v| ("OPENAI_API_KEY_FILE".to_string(), v)); + unsafe { + env::set_var("OPENAI_API_KEY", "sk-test-12345"); + env::remove_var("OPENAI_API_KEY_FILE"); + } + + let r = load_api_key().unwrap(); + assert_eq!(r, "sk-test-12345"); + + if let Some((k, v)) = saved { + unsafe { + env::set_var(k, v); + } + } else { + unsafe { + env::remove_var("OPENAI_API_KEY"); + } + } + if let Some((k, v)) = saved_file { + unsafe { + env::set_var(k, v); + } + } + } + + #[test] + fn trim_trailing_newline_from_env() { + let _guard = lock_env(); + let saved = env::var_os("OPENAI_API_KEY").map(|v| ("OPENAI_API_KEY".to_string(), v)); + unsafe { + env::set_var("OPENAI_API_KEY", "sk-test-with-newline\n"); + env::remove_var("OPENAI_API_KEY_FILE"); + } + + let r = load_api_key().unwrap(); + assert_eq!(r, "sk-test-with-newline"); + + if let Some((k, v)) = saved { + unsafe { + env::set_var(k, v); + } + } else { + unsafe { + env::remove_var("OPENAI_API_KEY"); + } + } + } + + #[test] + fn file_path_overrides_env() { + let _guard = lock_env(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key.txt"); + std::fs::write(&path, "sk-from-file-98765\n").unwrap(); + + unsafe { + env::set_var("OPENAI_API_KEY", "sk-from-env-should-be-overridden"); + env::set_var("OPENAI_API_KEY_FILE", path.to_str().unwrap()); + } + + let r = load_api_key().unwrap(); + assert_eq!(r, "sk-from-file-98765"); + + unsafe { + env::remove_var("OPENAI_API_KEY"); + env::remove_var("OPENAI_API_KEY_FILE"); + } + } + + #[test] + fn file_path_missing_returns_file_read_error() { + let _guard = lock_env(); + unsafe { + env::set_var("OPENAI_API_KEY_FILE", "/nonexistent/path/to/key.txt"); + env::remove_var("OPENAI_API_KEY"); + } + + let r = load_api_key(); + assert!(matches!(r, Err(ApiKeyError::FileRead { .. }))); + + unsafe { + env::remove_var("OPENAI_API_KEY_FILE"); + } + } +}