use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crate::profile::ClaudeCredentials;
const SECURITY_BIN: &str = "/usr/bin/security";
const SECURITY_TIMEOUT: Duration = Duration::from_secs(20);
fn run_with_deadline(
mut cmd: Command,
timeout: Duration,
stdin_payload: Option<&str>,
) -> Result<Output> {
let mut child = cmd
.stdin(if stdin_payload.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("failed to spawn {SECURITY_BIN}"))?;
if let Some(payload) = stdin_payload {
use std::io::Write;
let write_result: Result<()> = child
.stdin
.take()
.context("child stdin unexpectedly absent")
.and_then(|mut stdin| {
stdin
.write_all(payload.as_bytes())
.with_context(|| format!("failed to write {SECURITY_BIN} stdin"))
});
if let Err(e) = write_result {
let _ = child.kill();
let _ = child.wait();
return Err(e);
}
}
let deadline = Instant::now() + timeout;
loop {
match child
.try_wait()
.with_context(|| format!("failed to poll {SECURITY_BIN}"))?
{
Some(_status) => {
return child
.wait_with_output()
.with_context(|| format!("failed to collect {SECURITY_BIN} output"));
}
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"{SECURITY_BIN} exceeded its {}s deadline and was killed \
(keychain locked or an ACL prompt left unanswered?)",
timeout.as_secs()
);
}
std::thread::sleep(Duration::from_millis(25));
}
}
}
}
const SERVICE: &str = "Claude Code-credentials";
const EXIT_ITEM_NOT_FOUND: i32 = 44;
#[cfg(not(test))]
pub(crate) fn enabled() -> bool {
true
}
#[cfg(test)]
pub(crate) fn enabled() -> bool {
false
}
fn account() -> Result<String> {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.context("cannot determine macOS login name ($USER/$LOGNAME unset) for Keychain access")
}
#[cfg(test)]
fn read_at(service: &str, account: &str) -> Result<Option<ClaudeCredentials>> {
let mut cmd = Command::new(SECURITY_BIN);
cmd.args(["find-generic-password", "-s", service, "-a", account, "-w"]);
let output = run_with_deadline(cmd, SECURITY_TIMEOUT, None)
.with_context(|| format!("failed to run {SECURITY_BIN} find-generic-password"))?;
if output.status.success() {
let json = String::from_utf8(output.stdout).context("Keychain password is not UTF-8")?;
let creds: ClaudeCredentials = serde_json::from_str(json.trim_end())
.context("Keychain item is not valid Claude credentials JSON")?;
Ok(Some(creds))
} else if output.status.code() == Some(EXIT_ITEM_NOT_FOUND) {
Ok(None)
} else {
Err(security_error("read", &output))
}
}
fn security_quote(s: &str) -> Result<String> {
if s.contains('\n') || s.contains('\r') {
anyhow::bail!("refusing to pass a value with an embedded newline to `security -i`");
}
Ok(format!(
"\"{}\"",
s.replace('\\', "\\\\").replace('"', "\\\"")
))
}
fn write_at(service: &str, account: &str, creds: &ClaudeCredentials) -> Result<()> {
let json = serde_json::to_string(creds).context("failed to serialize Claude credentials")?;
let line = format!(
"add-generic-password -U -s {} -a {} -w {}\n",
security_quote(service)?,
security_quote(account)?,
security_quote(&json)?,
);
let mut cmd = Command::new(SECURITY_BIN);
cmd.arg("-i");
let output = run_with_deadline(cmd, SECURITY_TIMEOUT, Some(&line))
.with_context(|| format!("failed to run {SECURITY_BIN} add-generic-password"))?;
if output.status.success() {
Ok(())
} else {
Err(security_error("write", &output))
}
}
fn delete_at(service: &str, account: &str) -> Result<()> {
let mut cmd = Command::new(SECURITY_BIN);
cmd.args(["delete-generic-password", "-s", service, "-a", account]);
let output = run_with_deadline(cmd, SECURITY_TIMEOUT, None)
.with_context(|| format!("failed to run {SECURITY_BIN} delete-generic-password"))?;
if output.status.success() || output.status.code() == Some(EXIT_ITEM_NOT_FOUND) {
Ok(())
} else {
Err(security_error("delete", &output))
}
}
fn security_error(op: &str, output: &std::process::Output) -> anyhow::Error {
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output
.status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string());
anyhow::anyhow!(
"Keychain {op} failed (security exit {code}): {}",
stderr.trim()
)
}
pub(crate) fn keychain_write(creds: &ClaudeCredentials) -> Result<()> {
write_at(SERVICE, &account()?, creds)
}
pub(crate) fn keychain_delete() -> Result<()> {
delete_at(SERVICE, &account()?)
}
#[cfg(test)]
#[path = "../tests/inline/keychain.rs"]
mod tests;