mod file;
pub(crate) mod folder;
#[cfg(any(test, feature = "test-support"))]
pub(crate) mod memory;
#[cfg(any(target_os = "linux", windows, test))]
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)]
pub(crate) enum Backend {
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
Keychain,
File,
#[cfg_attr(not(windows), allow(dead_code))]
SealedFile,
CredentialManager,
Absent,
Unknown,
}
impl Backend {
pub(crate) fn name(self) -> &'static str {
match self {
Backend::Keychain => "keychain",
Backend::File => "file",
Backend::SealedFile => "sealed_file",
Backend::CredentialManager => "credential_manager",
Backend::Absent => "absent",
Backend::Unknown => "unknown",
}
}
}
pub(crate) struct Refused {
kind: Backend,
error: Error,
}
impl Refused {
pub(crate) fn new(kind: Backend, error: Error) -> Refused {
Refused { kind, error }
}
fn refused<T>(&self) -> Result<T, Error> {
Err(self.error.clone())
}
}
impl RawStore for Refused {
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()
}
}
pub(crate) const LOCKED: &str = "the keychain is locked and cannot ask to be unlocked from \
here; unlock it with `security unlock-keychain`, or run \
Pitboard from a desktop session";
const UNREACHABLE: &str = "Credential Manager cannot be reached from this logon, as in a sign-in \
over SSH with a key; run Pitboard from a desktop session";
pub(crate) fn locked(os: crate::host::Os) -> &'static str {
match os {
crate::host::Os::MacOs | crate::host::Os::Linux => LOCKED,
crate::host::Os::Windows => UNREACHABLE,
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("the credential store could not be read: {0}")]
Unreadable(String),
#[error("the credential store could not be read: {}", locked(crate::host::OS))]
Locked,
#[error("the credential store could not be read: {0}")]
Unfinished(String),
#[error("the sealed credential does not open in this logon: {detail}")]
Sealed { code: Option<i32>, detail: 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(_) | Error::Unfinished(_) => "credential_store_unreadable",
Error::Locked => "credential_store_locked",
Error::Sealed { .. } => "credential_sealed",
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(crate) struct Cost {
pub(crate) needs: usize,
pub(crate) limit: usize,
pub(crate) second_route: bool,
}
impl Cost {
pub(crate) fn over(self) -> bool {
self.needs > self.limit
}
pub(crate) fn on_the_second_route(self) -> bool {
self.over() && self.second_route
}
pub(crate) 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(crate) fn vault_dir(ctx: &Context) -> PathBuf {
crate::home::dir(ctx).join("vault")
}
pub(crate) struct Live {
backends: Box<[Box<dyn RawStore>]>,
takes_over: bool,
}
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: backends.into_boxed_slice(),
takes_over: false,
}
}
pub(crate) fn taken_over_by_the_first(backends: Vec<Box<dyn RawStore>>) -> Live {
Live {
takes_over: true,
..Live::of(backends)
}
}
fn refs(&self) -> Vec<&dyn RawStore> {
self.backends.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 take_over_in(
chain: &[&dyn RawStore],
permit: Permit,
service: &str,
contents: &str,
) -> Result<(), Error> {
let held = chain[0].contains(service)?;
chain[0].write(permit, service, contents)?;
crate::fault::point("store.taken_over");
if !held {
for rest in &chain[1..] {
let _ = rest.delete(permit, service);
}
}
Ok(())
}
fn with_live<T>(live: &Live, run: impl FnOnce(&[&dyn RawStore]) -> T) -> T {
run(&live.refs())
}
pub(crate) fn resolve(live: &Live, service: &str) -> Result<Backend, Error> {
with_live(live, |chain| {
Ok(resolve_in(chain, service)?.map_or(Backend::Absent, RawStore::kind))
})
}
fn behind_in<'a>(
chain: &[&'a dyn RawStore],
service: &str,
) -> Result<Option<&'a dyn RawStore>, Error> {
let mut in_use = false;
for backend in chain {
if !in_use {
in_use = backend.contains(service)?;
} else if backend.contains(service)? {
return Ok(Some(*backend));
}
}
Ok(None)
}
pub(crate) fn behind<'a>(live: &'a Live, service: &str) -> Result<Option<&'a dyn RawStore>, Error> {
behind_in(&live.refs(), service)
}
pub(crate) fn holder<'a>(live: &'a Live, service: &str) -> Result<Option<&'a dyn RawStore>, Error> {
resolve_in(&live.refs(), service)
}
pub(crate) fn of_kind<'a>(live: &'a Live, kind: &str) -> Option<&'a dyn RawStore> {
live.refs()
.into_iter()
.find(|backend| backend.kind().name() == kind)
}
pub(crate) 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(crate) 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(crate) fn write_raw(
live: &Live,
permit: Permit,
service: &str,
contents: &str,
) -> Result<(), Error> {
with_live(live, |chain| {
if live.takes_over {
take_over_in(chain, permit, service, contents)
} else {
write_in(chain, permit, service, contents)
}
})
}
#[cfg_attr(not(feature = "test-support"), allow(unreachable_pub))]
pub fn vault_read(ctx: &Context, service: &str) -> Result<Option<String>, Error> {
vault(ctx).read(service)
}
pub(crate) fn vault_write(
ctx: &Context,
permit: Permit,
service: &str,
contents: &str,
) -> Result<(), Error> {
vault(ctx).write(permit, service, contents)
}
pub(crate) fn vault_cost(ctx: &Context, service: &str, contents: &str) -> Option<Cost> {
vault(ctx).cost(service, contents)
}
pub(crate) fn vault_delete(ctx: &Context, permit: Permit, service: &str) -> Result<(), Error> {
vault(ctx).delete(permit, service)
}
pub(crate) fn vault_list(ctx: &Context) -> Result<Option<Vec<String>>, Error> {
vault(ctx).list()
}
pub(crate) fn vault_is_shared(ctx: &Context) -> bool {
ctx.host().vault_is_shared()
}
pub(crate) fn vault_kind(ctx: &Context) -> Backend {
vault(ctx).kind()
}
pub(crate) fn cost(live: &Live, service: &str, contents: &str) -> Option<Cost> {
with_live(live, |chain| {
let first = (!live.takes_over).then(|| resolve_in(chain, service).ok().flatten());
first.flatten().unwrap_or(chain[0]).cost(service, contents)
})
}
pub(crate) fn fingerprint(secret: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(&Sha256::digest(secret.as_bytes())[..8])
}
#[cfg(test)]
#[allow(
clippy::disallowed_methods,
reason = "a test sets up its scratch files as a person or a tool would"
)]
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 what_is_behind_the_login_in_use_is_the_next_backend_that_holds_it() {
let keychain = store(Backend::Keychain);
let between = store(Backend::File);
let plaintext = store(Backend::File);
let chain: [&dyn RawStore; 3] = [&keychain, &between, &plaintext];
let held = |chain: &[&dyn RawStore]| -> Option<String> {
behind_in(chain, "svc")
.unwrap()
.map(|behind| behind.read("svc").unwrap().expect("there"))
};
assert_eq!(held(&chain), None);
plaintext.plant("svc", "left");
assert_eq!(
held(&chain),
None,
"the file is the login when the keychain holds none"
);
keychain.plant("svc", "in use");
assert_eq!(held(&chain).as_deref(), Some("left"));
between.plant("svc", "nearer");
assert_eq!(held(&chain).as_deref(), Some("nearer"));
keychain.fault("svc", Fault::Locked);
assert!(
matches!(behind_in(&chain, "svc"), Err(Error::Locked)),
"which backend is in use cannot be told, so neither can what is behind it"
);
}
#[test]
fn a_file_that_cannot_be_read_is_still_behind() {
let dir = std::env::temp_dir().join(format!(
"pitboard-store-behind-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(".credentials.json");
std::fs::write(&path, [0xff, 0xfe, 0x00]).unwrap();
let keychain = holding(Backend::Keychain, "svc", "in use");
let file = PlainFile::at(path);
let chain: [&dyn RawStore; 2] = [&keychain, &file];
let behind = behind_in(&chain, "svc")
.expect("which backend is in use is told")
.expect("the file is behind")
.read("svc");
let _ = std::fs::remove_dir_all(&dir);
assert!(matches!(behind, Err(Error::Unreadable(_))), "{behind:?}");
}
#[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_chain_taken_over_writes_the_first_and_empties_the_rest_only_where_it_held_nothing() {
let first = store(Backend::CredentialManager);
let file = holding(Backend::File, "svc", "in the file");
let live = |first: &Arc<MemoryStore>, file: &Arc<MemoryStore>| {
Live::taken_over_by_the_first(vec![
Box::new(Arc::clone(first)),
Box::new(Arc::clone(file)),
])
};
write_raw(&live(&first, &file), Permit::for_a_test(), "svc", "moved").unwrap();
assert_eq!(first.peek("svc").as_deref(), Some("moved"));
assert_eq!(
file.peek("svc"),
None,
"a login the first held none of leaves the file"
);
file.plant("svc", "left behind");
write_raw(&live(&first, &file), Permit::for_a_test(), "svc", "again").unwrap();
assert_eq!(first.peek("svc").as_deref(), Some("again"));
assert_eq!(
file.peek("svc").as_deref(),
Some("left behind"),
"Claude Code deletes no file behind a store that held a login"
);
let first = store(Backend::CredentialManager);
first.fault("svc", Fault::FailWrite("unreachable".into()));
first.takes_on_stdin(2560);
let file = holding(Backend::File, "svc", "in the file");
assert!(matches!(
write_raw(&live(&first, &file), Permit::for_a_test(), "svc", "lost"),
Err(Error::Write(_))
));
assert_eq!(
file.peek("svc").as_deref(),
Some("in the file"),
"a failed write moves nothing into the file and takes nothing out of it"
);
assert_eq!(
cost(&live(&first, &file), "svc", "x"),
Some(Cost {
needs: 1,
limit: 2560,
second_route: true
}),
"the first store's ceiling, while the file holds the login"
);
}
#[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_refused_cannot_be_read() {
let store = Refused::new(Backend::Unknown, Error::Unreadable("not built yet".into()));
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(Refused::new(
Backend::Unknown,
Error::Unreadable("not built yet".into()),
))]);
unreadable(resolve(&live, "svc").map(|b| b.name().to_string()));
}
#[test]
fn a_store_out_of_reach_says_so_in_each_systems_words() {
use crate::host::Os;
assert_eq!(Error::Locked.code(), "credential_store_locked");
assert_eq!(locked(Os::MacOs), LOCKED);
assert_eq!(locked(Os::Linux), LOCKED);
assert!(
locked(Os::Windows).starts_with("Credential Manager cannot be reached from this logon")
);
assert!(!locked(Os::Windows).contains("keychain"));
}
#[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"));
}
}