mod file;
#[cfg(target_os = "macos")]
mod keychain;
#[cfg(any(test, feature = "test-support"))]
pub mod memory;
#[cfg(not(target_os = "macos"))]
mod vault;
use crate::context::Context;
use serde_json::Value;
use std::path::PathBuf;
pub(crate) const SECURITY: &str = "/usr/bin/security";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Backend {
Keychain,
File,
Absent,
}
impl Backend {
pub fn name(self) -> &'static str {
match self {
Backend::Keychain => "keychain",
Backend::File => "file",
Backend::Absent => "absent",
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("the credential store could not be read: {0}")]
Unreadable(String),
#[error("the stored credential is not valid JSON: {0}")]
Malformed(String),
#[error("writing the credential failed: {0}")]
Write(String),
#[error("the credential did not survive the write: {0}")]
NotDurable(String),
}
impl Error {
pub fn code(&self) -> &'static str {
match self {
Error::Unreadable(_) => "credential_store_unreadable",
Error::Malformed(_) => "credential_not_json",
Error::Write(_) => "credential_write_failed",
Error::NotDurable(_) => "credential_not_durable",
}
}
pub fn exit_code(&self) -> u8 {
match self {
Error::Malformed(_) => 3,
_ => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cost {
pub needs: usize,
pub limit: usize,
pub second_route: bool,
}
impl Cost {
pub fn over(self) -> bool {
self.needs > self.limit
}
pub fn on_the_second_route(self) -> bool {
self.over() && self.second_route
}
pub fn refused(self) -> bool {
self.over() && !self.second_route
}
}
pub(crate) trait RawStore: Send + Sync {
fn kind(&self) -> Backend;
fn contains(&self, service: &str) -> Result<bool, Error>;
fn read(&self, service: &str) -> Result<Option<String>, Error>;
fn write(&self, service: &str, contents: &str) -> Result<(), Error>;
fn delete(&self, service: &str) -> Result<(), Error>;
fn list(&self) -> Result<Option<Vec<String>>, Error> {
Ok(None)
}
fn cost(&self, _service: &str, _contents: &str) -> Option<Cost> {
None
}
}
pub(crate) trait Host: Send + Sync + std::fmt::Debug {
fn foreign_keychain(&self, ctx: &Context, account: &str) -> Option<Box<dyn RawStore>>;
fn file(&self, path: PathBuf) -> Box<dyn RawStore>;
fn vault(&self, ctx: &Context) -> Box<dyn RawStore>;
fn vault_is_shared(&self) -> bool;
fn running(&self, program: &str) -> Option<usize> {
crate::process::running(program)
}
}
#[cfg(target_os = "macos")]
pub(crate) fn vault_account(ctx: &Context) -> String {
crate::provider::claude::slot::account_name(ctx)
}
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy)]
pub(crate) struct MacOs;
#[cfg(target_os = "macos")]
impl Host for MacOs {
fn foreign_keychain(&self, ctx: &Context, account: &str) -> Option<Box<dyn RawStore>> {
Some(Box::new(keychain::Keychain::foreign(
ctx,
account.to_string(),
)))
}
fn file(&self, path: PathBuf) -> Box<dyn RawStore> {
Box::new(file::PlainFile::at(path))
}
fn vault(&self, ctx: &Context) -> Box<dyn RawStore> {
Box::new(keychain::Keychain::vault(ctx))
}
fn vault_is_shared(&self) -> bool {
true
}
}
#[cfg(not(target_os = "macos"))]
#[derive(Debug, Clone, Copy)]
pub(crate) struct PlainUnix;
#[cfg(not(target_os = "macos"))]
impl Host for PlainUnix {
fn foreign_keychain(&self, _ctx: &Context, _account: &str) -> Option<Box<dyn RawStore>> {
None
}
fn file(&self, path: PathBuf) -> Box<dyn RawStore> {
Box::new(file::PlainFile::at(path))
}
fn vault(&self, ctx: &Context) -> Box<dyn RawStore> {
Box::new(vault::FileVault::new(ctx))
}
fn vault_is_shared(&self) -> bool {
false
}
}
pub(crate) fn host() -> std::sync::Arc<dyn Host> {
#[cfg(target_os = "macos")]
{
std::sync::Arc::new(MacOs)
}
#[cfg(not(target_os = "macos"))]
{
std::sync::Arc::new(PlainUnix)
}
}
fn vault(ctx: &Context) -> Box<dyn RawStore> {
ctx.host().vault(ctx)
}
fn exists(path: &std::path::Path) -> Result<bool, Error> {
path.try_exists()
.map_err(|e| Error::Unreadable(format!("cannot look for {}: {e}", path.display())))
}
pub fn vault_dir(ctx: &Context) -> PathBuf {
crate::home::dir(ctx).join("vault")
}
pub struct Live(Vec<Box<dyn RawStore>>);
impl Live {
pub(crate) fn of(backends: Vec<Box<dyn RawStore>>) -> Live {
assert!(
!backends.is_empty(),
"a live chain with no backends can hold nothing"
);
Live(backends)
}
fn refs(&self) -> Vec<&dyn RawStore> {
self.0.iter().map(Box::as_ref).collect()
}
}
fn resolve_in<'a>(
chain: &[&'a dyn RawStore],
service: &str,
) -> Result<Option<&'a dyn RawStore>, Error> {
for backend in chain {
if backend.contains(service)? {
return Ok(Some(*backend));
}
}
Ok(None)
}
fn write_in(chain: &[&dyn RawStore], service: &str, contents: &str) -> Result<(), Error> {
let backend = resolve_in(chain, service)?.unwrap_or(chain[0]);
backend.write(service, contents)
}
fn with_live<T>(live: &Live, run: impl FnOnce(&[&dyn RawStore]) -> T) -> T {
run(&live.refs())
}
pub fn resolve(live: &Live, service: &str) -> Result<Backend, Error> {
with_live(live, |chain| {
Ok(resolve_in(chain, service)?.map_or(Backend::Absent, |b| b.kind()))
})
}
pub fn read_raw(live: &Live, service: &str) -> Result<Option<String>, Error> {
with_live(live, |chain| match resolve_in(chain, service)? {
Some(backend) => backend.read(service),
None => Ok(None),
})
}
pub fn read(live: &Live, service: &str) -> Result<Option<Value>, Error> {
match read_raw(live, service)? {
None => Ok(None),
Some(raw) => serde_json::from_str(&raw)
.map(Some)
.map_err(|e| Error::Malformed(e.to_string())),
}
}
pub fn write_raw(live: &Live, service: &str, contents: &str) -> Result<(), Error> {
with_live(live, |chain| write_in(chain, service, contents))
}
pub fn vault_read(ctx: &Context, service: &str) -> Result<Option<String>, Error> {
vault(ctx).read(service)
}
pub fn vault_write(ctx: &Context, service: &str, contents: &str) -> Result<(), Error> {
vault(ctx).write(service, contents)
}
pub fn vault_cost(ctx: &Context, service: &str, contents: &str) -> Option<Cost> {
vault(ctx).cost(service, contents)
}
pub fn vault_delete(ctx: &Context, service: &str) -> Result<(), Error> {
vault(ctx).delete(service)
}
pub fn vault_list(ctx: &Context) -> Result<Option<Vec<String>>, Error> {
vault(ctx).list()
}
pub fn vault_is_shared(ctx: &Context) -> bool {
ctx.host().vault_is_shared()
}
pub fn cost(live: &Live, service: &str, contents: &str) -> Option<Cost> {
with_live(live, |chain| {
resolve_in(chain, service)
.ok()
.flatten()
.unwrap_or(chain[0])
.cost(service, contents)
})
}
pub fn fingerprint(secret: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(&Sha256::digest(secret.as_bytes())[..8])
}
#[cfg(test)]
mod tests {
use super::memory::{Fault, MemoryStore};
use super::*;
use std::sync::Arc;
fn store(kind: Backend) -> Arc<MemoryStore> {
MemoryStore::of(kind)
}
fn holding(kind: Backend, service: &str, value: &str) -> Arc<MemoryStore> {
let s = store(kind);
s.plant(service, value);
s
}
#[test]
fn a_failed_keychain_write_never_falls_through_to_the_plaintext_file() {
let keychain = holding(Backend::Keychain, "svc", "before");
keychain.fault("svc", Fault::FailWrite("told to".into()));
let plaintext = store(Backend::File);
let chain: [&dyn RawStore; 2] = [&keychain, &plaintext];
let result = write_in(&chain, "svc", "after");
assert!(matches!(result, Err(Error::Write(_))));
assert_eq!(
plaintext.read("svc").unwrap(),
None,
"demoting the credential to a plaintext file is Claude Code's decision, not ours"
);
assert_eq!(keychain.read("svc").unwrap().as_deref(), Some("before"));
}
#[test]
fn a_write_that_does_not_read_back_is_reported_rather_than_believed() {
let keychain = holding(Backend::Keychain, "svc", "before");
keychain.fault("svc", Fault::CorruptWrite("something else".into()));
let chain: [&dyn RawStore; 1] = [&keychain];
assert!(matches!(
write_in(&chain, "svc", "after"),
Err(Error::NotDurable(_))
));
}
#[test]
fn a_credential_already_in_the_file_backend_stays_there() {
let keychain = store(Backend::Keychain);
let plaintext = holding(Backend::File, "svc", "before");
let chain: [&dyn RawStore; 2] = [&keychain, &plaintext];
write_in(&chain, "svc", "after").unwrap();
assert_eq!(plaintext.read("svc").unwrap().as_deref(), Some("after"));
assert_eq!(
keychain.read("svc").unwrap(),
None,
"a credential must not be promoted behind Claude Code's back either"
);
}
#[test]
fn an_absent_credential_is_written_to_the_preferred_backend() {
let keychain = store(Backend::Keychain);
let plaintext = store(Backend::File);
let chain: [&dyn RawStore; 2] = [&keychain, &plaintext];
write_in(&chain, "svc", "fresh").unwrap();
assert_eq!(keychain.read("svc").unwrap().as_deref(), Some("fresh"));
assert_eq!(plaintext.read("svc").unwrap(), None);
}
#[test]
fn an_unreadable_backend_aborts_instead_of_looking_further_down_the_chain() {
struct Broken;
impl RawStore for Broken {
fn kind(&self) -> Backend {
Backend::Keychain
}
fn contains(&self, _: &str) -> Result<bool, Error> {
Err(Error::Unreadable("security exited 1".into()))
}
fn read(&self, _: &str) -> Result<Option<String>, Error> {
unreachable!()
}
fn write(&self, _: &str, _: &str) -> Result<(), Error> {
unreachable!()
}
fn delete(&self, _: &str) -> Result<(), Error> {
unreachable!()
}
}
let plaintext = store(Backend::File);
let chain: [&dyn RawStore; 2] = [&Broken, &plaintext];
assert!(matches!(
write_in(&chain, "svc", "x"),
Err(Error::Unreadable(_))
));
assert_eq!(
plaintext.read("svc").unwrap(),
None,
"could-not-tell must never be read as nothing-there"
);
}
#[test]
fn fingerprints_are_short_stable_and_not_the_secret() {
let fp = fingerprint("sk-ant-example");
assert_eq!(fp.len(), 16);
assert_eq!(fp, fingerprint("sk-ant-example"));
assert_ne!(fp, fingerprint("sk-ant-example2"));
assert!(!fp.contains("sk-ant"));
}
#[test]
fn a_keychain_is_offered_only_where_there_is_one() {
let ctx = Context::from_env();
let offered = ctx.host().foreign_keychain(&ctx, "someone");
assert_eq!(
offered.map(|k| k.kind()),
cfg!(target_os = "macos").then_some(Backend::Keychain)
);
assert_eq!(
ctx.host().file(PathBuf::from("/nowhere/at/all")).kind(),
Backend::File
);
}
}