mod file;
#[cfg(any(test, feature = "test-support"))]
pub mod memory;
#[cfg_attr(any(target_os = "macos", windows), allow(dead_code))]
pub(crate) mod vault;
pub(crate) use file::PlainFile;
use crate::context::Context;
use crate::service::Permit;
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Backend {
Keychain,
File,
Absent,
Unknown,
}
impl Backend {
pub fn name(self) -> &'static str {
match self {
Backend::Keychain => "keychain",
Backend::File => "file",
Backend::Absent => "absent",
Backend::Unknown => "unknown",
}
}
}
pub(crate) struct Unbuilt {
kind: Backend,
why: &'static str,
}
impl Unbuilt {
pub(crate) fn new(kind: Backend, why: &'static str) -> Unbuilt {
Unbuilt { kind, why }
}
fn refused<T>(&self) -> Result<T, Error> {
Err(Error::Unreadable(self.why.into()))
}
}
impl RawStore for Unbuilt {
fn kind(&self) -> Backend {
self.kind
}
fn contains(&self, _service: &str) -> Result<bool, Error> {
self.refused()
}
fn read(&self, _service: &str) -> Result<Option<String>, Error> {
self.refused()
}
fn write(&self, _: Permit, _service: &str, _contents: &str) -> Result<(), Error> {
self.refused()
}
fn delete(&self, _: Permit, _service: &str) -> Result<(), Error> {
self.refused()
}
fn list(&self) -> Result<Option<Vec<String>>, Error> {
self.refused()
}
}
#[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, permit: Permit, service: &str, contents: &str) -> Result<(), Error>;
fn delete(&self, permit: Permit, service: &str) -> Result<(), Error>;
fn list(&self) -> Result<Option<Vec<String>>, Error> {
Ok(None)
}
fn cost(&self, _service: &str, _contents: &str) -> Option<Cost> {
None
}
}
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],
permit: Permit,
service: &str,
contents: &str,
) -> Result<(), Error> {
let backend = resolve_in(chain, service)?.unwrap_or(chain[0]);
backend.write(permit, 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, permit: Permit, service: &str, contents: &str) -> Result<(), Error> {
with_live(live, |chain| write_in(chain, permit, service, contents))
}
pub fn vault_read(ctx: &Context, service: &str) -> Result<Option<String>, Error> {
vault(ctx).read(service)
}
pub fn vault_write(
ctx: &Context,
permit: Permit,
service: &str,
contents: &str,
) -> Result<(), Error> {
vault(ctx).write(permit, 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, permit: Permit, service: &str) -> Result<(), Error> {
vault(ctx).delete(permit, 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, Permit::for_a_test(), "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, Permit::for_a_test(), "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, Permit::for_a_test(), "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, Permit::for_a_test(), "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, _: Permit, _: &str, _: &str) -> Result<(), Error> {
unreachable!()
}
fn delete(&self, _: Permit, _: &str) -> Result<(), Error> {
unreachable!()
}
}
let plaintext = store(Backend::File);
let chain: [&dyn RawStore; 2] = [&Broken, &plaintext];
assert!(matches!(
write_in(&chain, Permit::for_a_test(), "svc", "x"),
Err(Error::Unreadable(_))
));
assert_eq!(
plaintext.read("svc").unwrap(),
None,
"could-not-tell must never be read as nothing-there"
);
}
#[test]
fn a_store_not_built_yet_cannot_be_read() {
let store = Unbuilt::new(Backend::Unknown, "not built yet");
let unreadable = |answer: Result<String, Error>| match answer {
Err(Error::Unreadable(why)) => assert_eq!(why, "not built yet"),
other => panic!("{other:?}"),
};
let permit = Permit::for_a_test();
assert_eq!(store.kind(), Backend::Unknown);
unreadable(store.contains("svc").map(|c| c.to_string()));
unreadable(store.read("svc").map(|r| format!("{r:?}")));
unreadable(store.write(permit, "svc", "x").map(|()| String::new()));
unreadable(store.delete(permit, "svc").map(|()| String::new()));
unreadable(store.list().map(|l| format!("{l:?}")));
assert_eq!(store.cost("svc", "x"), None);
let live = Live::of(vec![Box::new(Unbuilt::new(
Backend::Unknown,
"not built yet",
))]);
unreadable(resolve(&live, "svc").map(|b| b.name().to_string()));
}
#[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"));
}
}