diff --git a/src/core/cli.rs b/src/core/cli.rs index 805edca37..1c7063d78 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -191,7 +191,7 @@ fn run_sentry_test_command(args: &[String]) -> Result<()> { /// 1. Variables already set in the process environment are **not** overwritten. /// 2. If `OPENHUMAN_DOTENV_PATH` is set, that file is loaded. /// 3. Otherwise, it searches for `.env` in the current working directory. -fn load_dotenv_for_cli() -> Result<()> { +pub(crate) fn load_dotenv_for_cli() -> Result<()> { match std::env::var("OPENHUMAN_DOTENV_PATH") { Ok(path) if !path.trim().is_empty() => { dotenvy::from_path(&path).map_err(|e| { diff --git a/src/lib.rs b/src/lib.rs index 166b5c4c0..fea1142cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub use openhuman::memory_store::{MemoryClient, MemoryState}; /// /// Returns an error if command execution fails. pub fn run_core_from_args(args: &[String]) -> anyhow::Result<()> { + core::cli::load_dotenv_for_cli()?; openhuman::service::apply_startup_restart_delay_from_env(); openhuman::keyring::init_master_key(); core::cli::run_from_cli_args(args) diff --git a/src/openhuman/keyring/encrypted_file_backend.rs b/src/openhuman/keyring/encrypted_file_backend.rs index cba7b2951..f788dcb65 100644 --- a/src/openhuman/keyring/encrypted_file_backend.rs +++ b/src/openhuman/keyring/encrypted_file_backend.rs @@ -18,6 +18,7 @@ use parking_lot::Mutex; use crate::openhuman::keyring::backend::KeyringBackend; use crate::openhuman::keyring::crypto::{self, KEY_LEN}; use crate::openhuman::keyring::error::KeyringError; +use crate::openhuman::keyring::store::BackendKind; const KEYCHAIN_SERVICE: &str = "openhuman"; const KEYCHAIN_MASTER_KEY_USERNAME: &str = "app:master_key"; @@ -42,8 +43,11 @@ pub fn init_master_key() { crate::openhuman::keyring::init_workspace(&dir); MASTER_KEY.get_or_init(|| { - if !is_staging_or_production() { - log::debug!("[keyring:encrypted_file] skipping master key init (dev environment)"); + let backend_kind = crate::openhuman::keyring::store::effective_backend_kind(); + if backend_kind != BackendKind::EncryptedFile { + log::debug!( + "[keyring:encrypted_file] skipping master key init backend={backend_kind:?}" + ); return None; } @@ -68,13 +72,6 @@ pub fn init_master_key() { }); } -fn is_staging_or_production() -> bool { - matches!( - std::env::var("OPENHUMAN_APP_ENV").as_deref(), - Ok("staging") | Ok("production") - ) -} - /// Returns `true` if the master key has been successfully loaded. pub fn is_master_key_available() -> bool { MASTER_KEY.get().and_then(|k| k.as_ref()).is_some() diff --git a/src/openhuman/keyring/store.rs b/src/openhuman/keyring/store.rs index 1c90359c1..8fcc835b8 100644 --- a/src/openhuman/keyring/store.rs +++ b/src/openhuman/keyring/store.rs @@ -9,6 +9,13 @@ use std::sync::OnceLock; use crate::openhuman::keyring::backend::{self, KeyringBackend}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum BackendKind { + Os, + File, + EncryptedFile, +} + // ── Global state ───────────────────────────────────────────────────────────── /// The workspace directory provided by the caller at startup. @@ -42,12 +49,12 @@ pub(super) fn backend() -> &'static dyn KeyringBackend { pub(super) fn build_backend() -> Box { // Priority 1: explicit env var override. if let Ok(env_val) = std::env::var("OPENHUMAN_KEYRING_BACKEND") { - match env_val.trim() { - "os" => { + match backend_kind_from_env_value(&env_val) { + Some(BackendKind::Os) => { log::info!("[keyring] backend=os (OPENHUMAN_KEYRING_BACKEND override)"); return Box::new(backend::OsBackend); } - "file" => { + Some(BackendKind::File) => { let path = workspace_dir_for_file_backend(); log::info!( "[keyring] backend=file path={} (OPENHUMAN_KEYRING_BACKEND override)", @@ -55,7 +62,7 @@ pub(super) fn build_backend() -> Box { ); return Box::new(backend::FileBackend::new(&path)); } - "encrypted_file" => { + Some(BackendKind::EncryptedFile) => { let path = workspace_dir_for_file_backend(); log::info!( "[keyring] backend=encrypted_file path={} (OPENHUMAN_KEYRING_BACKEND override)", @@ -65,9 +72,10 @@ pub(super) fn build_backend() -> Box { &path, )); } - other => { + None => { log::warn!( - "[keyring] unknown OPENHUMAN_KEYRING_BACKEND={other:?}; falling through to defaults" + "[keyring] unknown OPENHUMAN_KEYRING_BACKEND={:?}; falling through to defaults", + env_val.trim() ); } } @@ -98,12 +106,48 @@ pub(super) fn build_backend() -> Box { } fn is_staging_or_production() -> bool { - matches!( - std::env::var("OPENHUMAN_APP_ENV").as_deref(), - Ok("staging") | Ok("production") + is_staging_or_production_value(std::env::var("OPENHUMAN_APP_ENV").as_deref().ok()) +} + +pub(super) fn effective_backend_kind() -> BackendKind { + effective_backend_kind_for( + std::env::var("OPENHUMAN_APP_ENV").as_deref().ok(), + std::env::var("OPENHUMAN_KEYRING_BACKEND").as_deref().ok(), + cfg!(test), ) } +fn effective_backend_kind_for( + app_env: Option<&str>, + backend_override: Option<&str>, + cfg_test: bool, +) -> BackendKind { + if let Some(kind) = backend_override.and_then(backend_kind_from_env_value) { + return kind; + } + if cfg_test { + return BackendKind::File; + } + if is_staging_or_production_value(app_env) { + BackendKind::EncryptedFile + } else { + BackendKind::File + } +} + +fn backend_kind_from_env_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "os" => Some(BackendKind::Os), + "file" => Some(BackendKind::File), + "encrypted_file" => Some(BackendKind::EncryptedFile), + _ => None, + } +} + +fn is_staging_or_production_value(app_env: Option<&str>) -> bool { + matches!(app_env.map(str::trim), Some("staging") | Some("production")) +} + /// Derive the directory for keyring files (`secrets.enc`, `dev-keychain.json`). /// /// Uses the registered value from [`init_workspace`] if set; otherwise falls @@ -127,3 +171,40 @@ pub fn workspace_dir_for_file_backend() -> PathBuf { }; openhuman_dir } + +#[cfg(test)] +mod tests { + use super::{effective_backend_kind_for, BackendKind}; + + #[test] + fn explicit_file_backend_wins_over_staging_environment() { + assert_eq!( + effective_backend_kind_for(Some("staging"), Some("file"), false), + BackendKind::File + ); + } + + #[test] + fn explicit_encrypted_file_backend_wins_in_dev_environment() { + assert_eq!( + effective_backend_kind_for(Some("development"), Some("encrypted_file"), false), + BackendKind::EncryptedFile + ); + } + + #[test] + fn staging_defaults_to_encrypted_file_without_override() { + assert_eq!( + effective_backend_kind_for(Some(" staging "), None, false), + BackendKind::EncryptedFile + ); + } + + #[test] + fn unknown_backend_override_falls_back_to_environment_default() { + assert_eq!( + effective_backend_kind_for(Some("production"), Some("bogus"), false), + BackendKind::EncryptedFile + ); + } +}