use anyhow::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiKeySource {
None,
EnvVar,
Keyring,
ConfigFile,
}
pub(crate) const KEYRING_SERVICE: &str = "selfware-api-key";
pub(crate) fn keyring_account_for_endpoint(endpoint: &str) -> String {
if let Ok(url) = url::Url::parse(endpoint) {
let scheme = url.scheme().to_ascii_lowercase();
let host = url.host_str().unwrap_or("").to_ascii_lowercase();
let port = url.port_or_known_default().unwrap_or(0);
format!("{scheme}://{host}:{port}")
} else {
endpoint.trim().to_ascii_lowercase()
}
}
pub fn is_insecure_remote_endpoint(endpoint: &str) -> bool {
match url::Url::parse(endpoint) {
Ok(url) => url.scheme() == "http" && !is_local_endpoint(endpoint),
Err(_) => {
endpoint
.trim_start()
.to_ascii_lowercase()
.starts_with("http://")
&& !is_local_endpoint(endpoint)
}
}
}
pub fn assert_credential_endpoint_safe(endpoint: &str, has_credential: bool) -> Result<()> {
if has_credential && (endpoint_has_userinfo(endpoint) || is_insecure_remote_endpoint(endpoint))
{
anyhow::bail!(
"Refusing to send the API key to endpoint '{}': it would go over plaintext HTTP to a \
remote host or via an embedded-credential URL. Use https:// or a local endpoint \
(localhost / 127.0.0.1).",
endpoint
);
}
Ok(())
}
pub fn authorize_request(
req: reqwest::RequestBuilder,
endpoint: &str,
api_key: Option<&str>,
) -> Result<reqwest::RequestBuilder> {
assert_credential_endpoint_safe(endpoint, api_key.is_some())?;
Ok(match api_key {
Some(key) => req.bearer_auth(key),
None => req,
})
}
pub fn load_api_key_from_keyring(endpoint: &str) -> Result<Option<String>> {
let account = keyring_account_for_endpoint(endpoint);
let entry = keyring::Entry::new(KEYRING_SERVICE, &account)
.map_err(|e| anyhow::anyhow!("Keyring error: {}", e))?;
match entry.get_password() {
Ok(key) => Ok(Some(key)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow::anyhow!("Keyring error: {}", e)),
}
}
pub fn save_api_key_to_keyring(endpoint: &str, api_key: &str) -> Result<()> {
let account = keyring_account_for_endpoint(endpoint);
let entry = keyring::Entry::new(KEYRING_SERVICE, &account)
.map_err(|e| anyhow::anyhow!("Keyring error: {}", e))?;
entry
.set_password(api_key)
.map_err(|e| anyhow::anyhow!("Keyring error: {}", e))?;
Ok(())
}
pub fn is_local_endpoint(endpoint: &str) -> bool {
let Ok(url) = url::Url::parse(endpoint) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if !url.username().is_empty() || url.password().is_some() {
return false;
}
match url.host() {
Some(url::Host::Domain(h)) => h.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_unspecified(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
pub fn endpoint_has_userinfo(endpoint: &str) -> bool {
url::Url::parse(endpoint)
.map(|u| !u.username().is_empty() || u.password().is_some())
.unwrap_or(false)
}
pub fn is_openrouter_endpoint(endpoint: &str) -> bool {
url::Url::parse(endpoint)
.map(|u| {
u.host_str()
.map(|h| h.eq_ignore_ascii_case("openrouter.ai"))
.unwrap_or(false)
})
.unwrap_or(false)
}
pub fn set_api_key_for_endpoint(config: &super::Config, api_key: &str) -> Result<String> {
let trimmed = api_key.trim();
anyhow::ensure!(!trimmed.is_empty(), "API key must not be empty");
save_api_key_to_keyring(&config.endpoint, trimmed)?;
Ok(config.endpoint.clone())
}
#[cfg(test)]
#[path = "../../tests/unit/config/api_key/api_key_test.rs"]
mod tests;