core-agent-ide/codex-rs/secrets/src/lib.rs
viyatb-oai 64f3827d10
Move sanitizer into codex-secrets (#12306)
## Summary
- move the sanitizer implementation into `codex-secrets`
(`secrets/src/sanitizer.rs`) and re-export `redact_secrets`
- switch `codex-core` to depend on/import `codex-secrets` for sanitizer
usage
- remove the old `utils/sanitizer` crate wiring and refresh lockfiles

## Testing
- `just fmt`
- `cargo test -p codex-secrets`
- `cargo test -p codex-core --no-run`
- `cargo clippy -p codex-secrets -p codex-core --all-targets
--all-features -- -D warnings`
- `just bazel-lock-update`
- `just bazel-lock-check`

## Notes
- not run: `cargo test --all-features` (full workspace suite)
2026-02-20 22:47:54 +00:00

245 lines
7.1 KiB
Rust

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<Self> {
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<String>) -> Result<Self> {
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<Option<String>>;
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>;
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>;
}
#[derive(Clone)]
pub struct SecretsManager {
backend: Arc<dyn SecretsBackend>,
}
impl SecretsManager {
pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self {
let backend: Arc<dyn SecretsBackend> = match backend_kind {
SecretsBackendKind::Local => {
let keyring_store: Arc<dyn KeyringStore> = 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<dyn KeyringStore>,
) -> Self {
let backend: Arc<dyn SecretsBackend> = 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<Option<String>> {
self.backend.get(scope, name)
}
pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
self.backend.delete(scope, name)
}
pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
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<PathBuf> {
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(())
}
}