use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
};
use rho_providers::credentials::{
open_credential_store, probe_credential_store, CredentialError, CredentialResult,
CredentialStore, CredentialStoreBackend, CredentialStoreProbe,
};
use crate::config::Config;
const LEGACY_POLICY_FILE: &str = "credential-store";
const ENV_BACKEND: &str = "RHO_CREDENTIAL_STORE";
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct AppCredentialStore;
#[derive(Clone, Debug)]
pub(crate) struct StoreChoiceRequest {
pub os: CredentialStoreProbe,
pub file: CredentialStoreProbe,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct StoreChoiceOption {
pub backend: CredentialStoreBackend,
pub available: bool,
}
impl StoreChoiceRequest {
pub fn probe() -> Self {
Self {
os: probe(CredentialStoreBackend::Os),
file: probe(CredentialStoreBackend::File),
}
}
pub fn options(&self) -> [StoreChoiceOption; 2] {
[
StoreChoiceOption {
backend: CredentialStoreBackend::Os,
available: self.os.available,
},
StoreChoiceOption {
backend: CredentialStoreBackend::File,
available: self.file.available,
},
]
}
pub fn available_backends(&self) -> Vec<CredentialStoreBackend> {
self.options()
.into_iter()
.filter(|option| option.available)
.map(|option| option.backend)
.collect()
}
pub fn default_backend(&self) -> Option<CredentialStoreBackend> {
self.available_backends().into_iter().next()
}
pub fn detail_for(&self, backend: CredentialStoreBackend) -> &str {
match backend {
CredentialStoreBackend::Os => self.os.detail.as_str(),
CredentialStoreBackend::File => self.file.detail.as_str(),
}
}
}
impl CredentialStore for AppCredentialStore {
fn get_secret(&self, account: &str) -> CredentialResult<Option<String>> {
selected_store()?.get_secret(account)
}
fn set_secret(&self, account: &str, secret: &str) -> CredentialResult<()> {
selected_store()?.set_secret(account, secret)
}
fn delete_secret(&self, account: &str) -> CredentialResult<bool> {
selected_store()?.delete_secret(account)
}
}
pub(crate) fn probe(backend: CredentialStoreBackend) -> CredentialStoreProbe {
probe_credential_store(backend)
}
pub(crate) fn saved_policy_backend(
config_path: Option<&Path>,
) -> anyhow::Result<Option<CredentialStoreBackend>> {
let path = match config_path {
Some(path) => path.to_path_buf(),
None => Config::default_path()?,
};
if path.exists() {
let config = Config::load_settings_only(path)?;
if config.credential_store.is_some() {
return Ok(config.credential_store);
}
}
Ok(read_legacy_policy_file()?)
}
pub(crate) fn set_backend(
backend: CredentialStoreBackend,
config_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let availability = probe(backend);
if !availability.available {
anyhow::bail!(availability.detail);
}
let path = match config_path {
Some(path) => path,
None => Config::default_path()?,
};
let mut config = if path.exists() {
Config::load_settings_only(path.clone())?
} else {
Config::default()
};
config.credential_store = Some(backend);
config.write_settings(path.clone())?;
remember_config_path(&path);
activate(backend)?;
remove_legacy_policy_file()?;
Ok(path)
}
pub(crate) fn initialize_from_config(
config: &mut Config,
config_path: &Path,
) -> anyhow::Result<()> {
remember_config_path(config_path);
let mut dirty = absorb_legacy_policy(config)?;
let backend = resolve_backend(config.credential_store)?;
activate(backend)?;
if matches!(
config.migrate_legacy_web_search_credentials(&AppCredentialStore),
Ok(true)
) {
dirty = true;
}
if dirty {
config.write_settings(config_path.to_path_buf())?;
}
Ok(())
}
pub(crate) fn needs_explicit_choice(config: &Config) -> bool {
env_backend_override().is_none() && config.credential_store.is_none()
}
pub(crate) fn choice_request(config: &Config) -> Option<StoreChoiceRequest> {
needs_explicit_choice(config).then(StoreChoiceRequest::probe)
}
pub(crate) fn configured_backend() -> CredentialResult<CredentialStoreBackend> {
resolve_backend(read_config_backend().ok().flatten())
}
pub(crate) fn build_provider_from_config(
config: &Config,
credential_store: Arc<dyn CredentialStore>,
) -> Result<Arc<dyn rho_sdk::provider::ModelProvider>, rho_providers::model::ModelError> {
let options = crate::app::sdk_config::provider_options_from_config(config)?;
let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
credential_store,
);
rho_providers::providers::build_sdk_provider_with_source(options, &credentials)
}
pub(crate) fn build_provider(
provider: &str,
model: &str,
reasoning: rho_providers::reasoning::ReasoningLevel,
auth: &str,
) -> Result<Arc<dyn rho_sdk::provider::ModelProvider>, rho_providers::model::ModelError> {
let config = Config {
provider: provider.into(),
model: model.into(),
reasoning,
auth: auth.into(),
..Config::default()
};
build_provider_from_config(&config, Arc::new(AppCredentialStore))
}
fn env_backend_override() -> Option<String> {
std::env::var(ENV_BACKEND)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_backend(
config_backend: Option<CredentialStoreBackend>,
) -> CredentialResult<CredentialStoreBackend> {
if let Some(value) = env_backend_override() {
return CredentialStoreBackend::parse(&value);
}
Ok(config_backend.unwrap_or(CredentialStoreBackend::Os))
}
fn activate(backend: CredentialStoreBackend) -> CredentialResult<()> {
let store = open_credential_store(backend)?;
let mut guard = process_state()
.lock()
.unwrap_or_else(|error| error.into_inner());
guard.store = Some(store);
Ok(())
}
fn remember_config_path(path: &Path) {
let mut guard = process_state()
.lock()
.unwrap_or_else(|error| error.into_inner());
guard.config_path = Some(path.to_path_buf());
}
fn selected_store() -> CredentialResult<Arc<dyn CredentialStore>> {
{
let guard = process_state()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(store) = guard.store.as_ref() {
return Ok(Arc::clone(store));
}
}
let backend = configured_backend()?;
activate(backend)?;
let guard = process_state()
.lock()
.unwrap_or_else(|error| error.into_inner());
guard.store.clone().ok_or_else(|| {
CredentialError::StoreUnavailable("credential store failed to initialize".into())
})
}
struct ProcessState {
store: Option<Arc<dyn CredentialStore>>,
config_path: Option<PathBuf>,
}
fn process_state() -> &'static Mutex<ProcessState> {
static STATE: OnceLock<Mutex<ProcessState>> = OnceLock::new();
STATE.get_or_init(|| {
Mutex::new(ProcessState {
store: None,
config_path: None,
})
})
}
fn read_config_backend() -> anyhow::Result<Option<CredentialStoreBackend>> {
let path = {
let guard = process_state()
.lock()
.unwrap_or_else(|error| error.into_inner());
guard.config_path.clone()
};
let path = match path {
Some(path) => path,
None => Config::default_path()?,
};
if !path.exists() {
return Ok(None);
}
Ok(Config::load_settings_only(path)?.credential_store)
}
fn absorb_legacy_policy(config: &mut Config) -> anyhow::Result<bool> {
let Some(backend) = read_legacy_policy_file()? else {
return Ok(false);
};
let changed = if config.credential_store.is_none() {
config.credential_store = Some(backend);
true
} else {
false
};
remove_legacy_policy_file()?;
Ok(changed)
}
fn read_legacy_policy_file() -> CredentialResult<Option<CredentialStoreBackend>> {
let path = match legacy_policy_path() {
Ok(path) => path,
Err(_) => return Ok(None),
};
if !path.exists() {
return Ok(None);
}
let value = std::fs::read_to_string(&path).map_err(|error| {
CredentialError::StoreUnavailable(format!(
"could not read legacy credential-store policy {}: {error}",
path.display()
))
})?;
Ok(Some(CredentialStoreBackend::parse(&value)?))
}
fn remove_legacy_policy_file() -> anyhow::Result<()> {
let path = legacy_policy_path()?;
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
fn legacy_policy_path() -> anyhow::Result<PathBuf> {
Ok(crate::paths::rho_dir()?.join(LEGACY_POLICY_FILE))
}
#[cfg(test)]
fn resolve_backend_from(
environment: Option<&str>,
config_backend: Option<CredentialStoreBackend>,
) -> CredentialResult<CredentialStoreBackend> {
if let Some(value) = environment.map(str::trim).filter(|value| !value.is_empty()) {
return CredentialStoreBackend::parse(value);
}
Ok(config_backend.unwrap_or(CredentialStoreBackend::Os))
}
#[cfg(test)]
fn needs_explicit_choice_from(
env_set: bool,
config_backend: Option<CredentialStoreBackend>,
) -> bool {
!env_set && config_backend.is_none()
}
#[cfg(test)]
#[path = "credential_store_tests.rs"]
mod tests;