use std::fmt; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; mod local; mod sanitizer; pub use local::LocalSecretsBackend; pub use sanitizer::redact_secrets; const KEYRING_SERVICE: &str = "codex"; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SecretName(String); impl SecretName { pub fn new(raw: &str) -> Result { let trimmed = raw.trim(); anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty"); anyhow::ensure!( trimmed .chars() .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'), "secret name must contain only A-Z, 0-9, or _" ); Ok(Self(trimmed.to_string())) } pub fn as_str(&self) -> &str { self.0.as_str() } } impl fmt::Display for SecretName { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SecretScope { Global, Environment(String), } impl SecretScope { pub fn environment(environment_id: impl Into) -> Result { let env_id = environment_id.into(); let trimmed = env_id.trim(); anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty"); Ok(Self::Environment(trimmed.to_string())) } pub fn canonical_key(&self, name: &SecretName) -> String { // Stable, env-safe identifier used as the on-disk map key. match self { Self::Global => format!("global/{}", name.as_str()), Self::Environment(environment_id) => { format!("env/{environment_id}/{}", name.as_str()) } } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecretListEntry { pub scope: SecretScope, pub name: SecretName, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "lowercase")] pub enum SecretsBackendKind { #[default] Local, } pub trait SecretsBackend: Send + Sync { fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>; fn get(&self, scope: &SecretScope, name: &SecretName) -> Result>; fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result; fn list(&self, scope_filter: Option<&SecretScope>) -> Result>; } #[derive(Clone)] pub struct SecretsManager { backend: Arc, } impl SecretsManager { pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { let backend: Arc = match backend_kind { SecretsBackendKind::Local => { let keyring_store: Arc = Arc::new(DefaultKeyringStore); Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) } }; Self { backend } } pub fn new_with_keyring_store( codex_home: PathBuf, backend_kind: SecretsBackendKind, keyring_store: Arc, ) -> Self { let backend: Arc = match backend_kind { SecretsBackendKind::Local => { Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) } }; Self { backend } } pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { self.backend.set(scope, name, value) } pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { self.backend.get(scope, name) } pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { self.backend.delete(scope, name) } pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { self.backend.list(scope_filter) } } pub fn environment_id_from_cwd(cwd: &Path) -> String { if let Some(repo_root) = get_git_repo_root(cwd) && let Some(name) = repo_root.file_name() { let name = name.to_string_lossy().trim().to_string(); if !name.is_empty() { return name; } } let canonical = cwd .canonicalize() .unwrap_or_else(|_| cwd.to_path_buf()) .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..12).unwrap_or(hex.as_str()); format!("cwd-{short}") } fn get_git_repo_root(base_dir: &Path) -> Option { let mut dir = base_dir.to_path_buf(); loop { if dir.join(".git").exists() { return Some(dir); } if !dir.pop() { break; } } None } pub(crate) fn compute_keyring_account(codex_home: &Path) -> String { let canonical = codex_home .canonicalize() .unwrap_or_else(|_| codex_home.to_path_buf()) .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..16).unwrap_or(hex.as_str()); format!("secrets|{short}") } pub(crate) fn keyring_service() -> &'static str { KEYRING_SERVICE } #[cfg(test)] mod tests { use super::*; use codex_keyring_store::tests::MockKeyringStore; use pretty_assertions::assert_eq; #[test] fn environment_id_fallback_has_cwd_prefix() { let dir = tempfile::tempdir().expect("tempdir"); let env_id = environment_id_from_cwd(dir.path()); let canonical = dir .path() .canonicalize() .expect("tempdir canonical path should exist") .to_string_lossy() .into_owned(); let mut hasher = Sha256::new(); hasher.update(canonical.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let short = hex.get(..12).expect("digest has at least 12 chars"); assert_eq!(env_id, format!("cwd-{short}")); } #[test] fn manager_round_trips_local_backend() -> Result<()> { let codex_home = tempfile::tempdir().expect("tempdir"); let keyring = Arc::new(MockKeyringStore::default()); let manager = SecretsManager::new_with_keyring_store( codex_home.path().to_path_buf(), SecretsBackendKind::Local, keyring, ); let scope = SecretScope::Global; let name = SecretName::new("GITHUB_TOKEN")?; manager.set(&scope, &name, "token-1")?; assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string())); let listed = manager.list(None)?; assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, name); assert!(manager.delete(&scope, &name)?); assert_eq!(manager.get(&scope, &name)?, None); Ok(()) } }