use keyring::Entry;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_SERVICE: &str = "car";
pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
if let Ok(v) = std::env::var(env_var) {
if !v.is_empty() {
return Some(v);
}
}
let store = SecretStore::new();
if !store.is_available() {
return None;
}
let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
match store.get(&secret_ref) {
Ok(v) if !v.is_empty() => {
tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
Some(v)
}
Ok(_) => None, Err(SecretError::NotFound { .. }) => None,
Err(e) => {
tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
None
}
}
}
#[derive(Debug, Error)]
pub enum SecretError {
#[error("secret store unavailable: {0}")]
Unavailable(String),
#[error("no entry for service={service:?} key={key:?}")]
NotFound { service: String, key: String },
#[error("secret store error: {0}")]
Backend(String),
#[error("stored value is not valid JSON: {0}")]
InvalidJson(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretStatus {
pub service: String,
pub key: String,
pub exists: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailabilityCheck {
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SecretRef {
pub service: String,
pub key: String,
}
impl SecretRef {
pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
Self {
service: service.into(),
key: key.into(),
}
}
pub fn with_default_service(key: impl Into<String>) -> Self {
Self {
service: DEFAULT_SERVICE.to_string(),
key: key.into(),
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SecretStore;
impl SecretStore {
pub fn new() -> Self {
Self
}
pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
platform_put(self, r, value)
}
pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
let s = serde_json::to_string(value)
.map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
self.put(r, &s)
}
pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
platform_get(self, r)
}
pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
let raw = self.get(r)?;
serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
}
pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
platform_delete(self, r)
}
pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
platform_status(self, r)
}
const PROBE_SERVICE: &'static str = "car-internal";
const PROBE_KEY: &'static str = "__availability_probe__";
pub fn is_available(&self) -> bool {
self.availability().available
}
pub fn availability(&self) -> AvailabilityCheck {
let probe = SecretRef::new(Self::PROBE_SERVICE, Self::PROBE_KEY);
match self.entry(&probe) {
Ok(entry) => match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
available: true,
reason: None,
},
Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
available: false,
reason: Some(format!("platform failure: {e}")),
},
Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
available: false,
reason: Some(format!("no storage access: {e}")),
},
Err(_) => AvailabilityCheck {
available: true,
reason: None,
},
},
Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
available: false,
reason: Some(reason),
},
Err(other) => AvailabilityCheck {
available: false,
reason: Some(other.to_string()),
},
}
}
fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
}
}
#[cfg(target_os = "macos")]
fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
mac_put_via_security_cli(&r.service, &r.key, value)
}
#[cfg(not(target_os = "macos"))]
fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
let entry = store.entry(r)?;
entry
.set_password(value)
.map_err(|e| classify(e, "set_password"))
}
#[cfg(target_os = "macos")]
fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
mac_get_via_security_cli(r)
}
#[cfg(not(target_os = "macos"))]
fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
let entry = store.entry(r)?;
match entry.get_password() {
Ok(v) => Ok(v),
Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
service: r.service.clone(),
key: r.key.clone(),
}),
Err(other) => Err(classify(other, "get_password")),
}
}
#[cfg(target_os = "macos")]
fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
mac_delete_via_security_cli(r)
}
#[cfg(not(target_os = "macos"))]
fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
let entry = store.entry(r)?;
match entry.delete_credential() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(other) => Err(classify(other, "delete_credential")),
}
}
#[cfg(target_os = "macos")]
fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
mac_status_via_security_cli(r)
}
#[cfg(not(target_os = "macos"))]
fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
let entry = store.entry(r)?;
let exists = match entry.get_password() {
Ok(_) => true,
Err(keyring::Error::NoEntry) => false,
Err(other) => return Err(classify(other, "status")),
};
Ok(SecretStatus {
service: r.service.clone(),
key: r.key.clone(),
exists,
})
}
#[cfg(target_os = "macos")]
fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
}
#[cfg(target_os = "macos")]
fn mac_put_via_security_cli_with(
service: &str,
account: &str,
value: &str,
cli: &impl SecurityCli,
) -> Result<(), SecretError> {
let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);
let output = cli
.output(&[
"add-generic-password",
"-U", "-A", "-s",
service,
"-a",
account,
"-w",
value,
])
.map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
if output.success {
return Ok(());
}
Err(security_cli_backend_error("add-generic-password", output))
}
#[cfg(target_os = "macos")]
const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
#[cfg(target_os = "macos")]
#[derive(Debug)]
struct SecurityCliOutput {
success: bool,
code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
#[cfg(target_os = "macos")]
trait SecurityCli {
fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
}
#[cfg(target_os = "macos")]
struct SystemSecurityCli;
#[cfg(target_os = "macos")]
impl SecurityCli for SystemSecurityCli {
fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
use std::process::{Command, Stdio};
let output = Command::new("/usr/bin/security")
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
Ok(SecurityCliOutput {
success: output.status.success(),
code: output.status.code(),
stdout: output.stdout,
stderr: output.stderr,
})
}
}
#[cfg(target_os = "macos")]
fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
mac_get_via_security_cli_with(r, &SystemSecurityCli)
}
#[cfg(target_os = "macos")]
fn mac_get_via_security_cli_with(
r: &SecretRef,
cli: &impl SecurityCli,
) -> Result<String, SecretError> {
let output = cli
.output(&[
"find-generic-password",
"-s",
&r.service,
"-a",
&r.key,
"-g",
])
.map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
if !output.success {
return security_cli_not_found_or_backend("find-generic-password", r, output);
}
mac_parse_security_cli_password(&output)
}
#[cfg(target_os = "macos")]
fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
let line = mac_security_cli_text(&output.stderr, "stderr")?
.lines()
.find(|line| line.starts_with("password:"))
.or_else(|| {
mac_security_cli_text(&output.stdout, "stdout")
.ok()
.and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
})
.ok_or_else(|| {
SecretError::Backend(
"/usr/bin/security find-generic-password -g did not print a password line"
.to_string(),
)
})?;
let payload = line
.strip_prefix("password:")
.expect("password line prefix was checked")
.trim_start();
if payload.is_empty() {
return Ok(String::new());
}
let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
mac_decode_security_cli_hex_password(hex_and_preview)?
} else {
mac_decode_security_cli_quoted_password(payload)?
};
String::from_utf8(bytes).map_err(|e| {
SecretError::Backend(format!(
"/usr/bin/security find-generic-password password was not valid utf-8: {}",
e
))
})
}
#[cfg(target_os = "macos")]
fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
std::str::from_utf8(bytes).map_err(|e| {
SecretError::Backend(format!(
"/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
))
})
}
#[cfg(target_os = "macos")]
fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
let hex: String = hex_and_preview
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
if hex.is_empty() || hex.len() % 2 != 0 {
return Err(SecretError::Backend(format!(
"/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
)));
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
SecretError::Backend(format!(
"/usr/bin/security find-generic-password printed invalid password hex: {e}"
))
})
})
.collect()
}
#[cfg(target_os = "macos")]
fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match quoted {
Some(value) => Ok(value.as_bytes().to_vec()),
None => Err(SecretError::Backend(
"/usr/bin/security find-generic-password printed an unrecognized password line"
.to_string(),
)),
}
}
#[cfg(target_os = "macos")]
fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
mac_status_via_security_cli_with(r, &SystemSecurityCli)
}
#[cfg(target_os = "macos")]
fn mac_status_via_security_cli_with(
r: &SecretRef,
cli: &impl SecurityCli,
) -> Result<SecretStatus, SecretError> {
let exists = mac_exists_via_security_cli_with(r, cli)?;
Ok(SecretStatus {
service: r.service.clone(),
key: r.key.clone(),
exists,
})
}
#[cfg(target_os = "macos")]
fn mac_exists_via_security_cli_with(
r: &SecretRef,
cli: &impl SecurityCli,
) -> Result<bool, SecretError> {
let output = cli
.output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
.map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
if output.success {
return Ok(true);
}
if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
return Ok(false);
}
Err(security_cli_backend_error("find-generic-password", output))
}
#[cfg(target_os = "macos")]
fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
mac_delete_via_security_cli_with(r, &SystemSecurityCli)
}
#[cfg(target_os = "macos")]
fn mac_delete_via_security_cli_with(
r: &SecretRef,
cli: &impl SecurityCli,
) -> Result<(), SecretError> {
let output = cli
.output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
.map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
return Ok(());
}
Err(security_cli_backend_error(
"delete-generic-password",
output,
))
}
#[cfg(target_os = "macos")]
fn security_cli_not_found_or_backend<T>(
command: &str,
r: &SecretRef,
output: SecurityCliOutput,
) -> Result<T, SecretError> {
if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
return Err(SecretError::NotFound {
service: r.service.clone(),
key: r.key.clone(),
});
}
Err(security_cli_backend_error(command, output))
}
#[cfg(target_os = "macos")]
fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
}
#[cfg(target_os = "macos")]
fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
let stderr = String::from_utf8_lossy(&output.stderr);
SecretError::Backend(format!(
"/usr/bin/security {command} failed: code={} {}",
output.code.unwrap_or(-1),
stderr.trim()
))
}
fn classify(e: keyring::Error, op: &str) -> SecretError {
use keyring::Error as K;
match e {
K::NoEntry => SecretError::NotFound {
service: String::new(),
key: String::new(),
},
K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
other => SecretError::Backend(format!("{}: {}", op, other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
fn test_service() -> String {
format!(
"car-secrets-tests-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
)
}
fn skip_if_unavailable() -> bool {
!SecretStore::new().is_available()
}
#[cfg(target_os = "macos")]
struct FakeSecurityCli {
outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
calls: std::cell::RefCell<Vec<Vec<String>>>,
}
#[cfg(target_os = "macos")]
impl FakeSecurityCli {
fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
Self {
outputs: std::cell::RefCell::new(outputs.into()),
calls: std::cell::RefCell::new(Vec::new()),
}
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.borrow().clone()
}
}
#[cfg(target_os = "macos")]
impl SecurityCli for FakeSecurityCli {
fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
self.calls
.borrow_mut()
.push(args.iter().map(|arg| (*arg).to_string()).collect());
self.outputs
.borrow_mut()
.pop_front()
.expect("missing fake security output")
}
}
#[cfg(target_os = "macos")]
fn security_output(
code: i32,
stdout: impl Into<Vec<u8>>,
stderr: impl Into<Vec<u8>>,
) -> std::io::Result<SecurityCliOutput> {
Ok(SecurityCliOutput {
success: code == 0,
code: Some(code),
stdout: stdout.into(),
stderr: stderr.into(),
})
}
#[cfg(target_os = "macos")]
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[cfg(target_os = "macos")]
fn assert_backend_contains(err: SecretError, expected: &str) {
match err {
SecretError::Backend(message) => assert!(
message.contains(expected),
"expected backend error to contain {expected:?}, got {message:?}"
),
other => panic!("expected Backend, got {:?}", other),
}
}
#[test]
fn roundtrip_string() {
if skip_if_unavailable() {
eprintln!("skipping: no secret store backend available");
return;
}
let store = SecretStore::new();
let svc = test_service();
let r = SecretRef::new(&svc, "roundtrip");
store.put(&r, "hello world").unwrap();
assert_eq!(store.get(&r).unwrap(), "hello world");
assert!(store.status(&r).unwrap().exists);
store.delete(&r).unwrap();
assert!(!store.status(&r).unwrap().exists);
}
#[test]
fn roundtrip_string_with_trailing_newline() {
if skip_if_unavailable() {
eprintln!("skipping: no secret store backend available");
return;
}
let store = SecretStore::new();
let svc = test_service();
let r = SecretRef::new(&svc, "roundtrip-newline");
let value = "abc\n";
store.put(&r, value).unwrap();
assert_eq!(store.get(&r).unwrap(), value);
store.delete(&r).unwrap();
}
#[test]
fn get_missing_returns_not_found() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "never_written");
match store.get(&r) {
Err(SecretError::NotFound { .. }) => (),
other => panic!("expected NotFound, got {:?}", other),
}
}
#[test]
fn delete_missing_is_idempotent() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "missing");
store.delete(&r).unwrap();
store.delete(&r).unwrap();
}
#[test]
fn json_roundtrip() {
if skip_if_unavailable() {
return;
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Session {
cookies: Vec<String>,
expires_at: i64,
}
let store = SecretStore::new();
let svc = test_service();
let r = SecretRef::new(&svc, "session");
let s = Session {
cookies: vec!["a=1".into(), "b=2".into()],
expires_at: 1_700_000_000,
};
store.put_json(&r, &s).unwrap();
let back: Session = store.get_json(&r).unwrap();
assert_eq!(back, s);
store.delete(&r).unwrap();
}
#[test]
fn status_no_leak() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "status");
store.put(&r, "secret-payload").unwrap();
let st = store.status(&r).unwrap();
let encoded = serde_json::to_string(&st).unwrap();
assert!(!encoded.contains("secret-payload"));
store.delete(&r).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn mac_get_uses_security_cli_and_maps_success() {
let cli = FakeSecurityCli::new(vec![security_output(
0,
b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
b"password: \"secret\"\n",
)]);
let r = SecretRef::new("svc", "key");
assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
assert_eq!(
cli.calls(),
vec![args(&[
"find-generic-password",
"-s",
"svc",
"-a",
"key",
"-g"
])]
);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_get_decodes_hex_password_output_with_trailing_newline() {
let cli = FakeSecurityCli::new(vec![security_output(
0,
b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
b"password: 0x6162630A \"abc\\012\"\n",
)]);
let r = SecretRef::new("svc", "key");
assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
assert_eq!(
cli.calls(),
vec![args(&[
"find-generic-password",
"-s",
"svc",
"-a",
"key",
"-g"
])]
);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_get_maps_not_found_and_backend_errors_without_fallback() {
let r = SecretRef::new("svc", "missing");
let cli = FakeSecurityCli::new(vec![security_output(
SECURITY_ERR_SEC_ITEM_NOT_FOUND,
b"",
b"The specified item could not be found in the keychain.\n",
)]);
match mac_get_via_security_cli_with(&r, &cli) {
Err(SecretError::NotFound { service, key }) => {
assert_eq!(service, "svc");
assert_eq!(key, "missing");
}
other => panic!("expected NotFound, got {:?}", other),
}
assert_eq!(cli.calls().len(), 1);
let cli = FakeSecurityCli::new(vec![security_output(
51,
b"",
b"User interaction is not allowed.\n",
)]);
let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
assert_backend_contains(err, "code=51 User interaction is not allowed.");
assert_eq!(cli.calls().len(), 1);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_status_uses_security_cli_and_maps_results() {
let r = SecretRef::new("svc", "key");
let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
assert!(status.exists);
assert_eq!(
cli.calls(),
vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
);
let cli = FakeSecurityCli::new(vec![security_output(
SECURITY_ERR_SEC_ITEM_NOT_FOUND,
b"",
b"The specified item could not be found in the keychain.\n",
)]);
assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
assert_backend_contains(err, "code=128 auth denied");
}
#[cfg(target_os = "macos")]
#[test]
fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
let cli = FakeSecurityCli::new(vec![
security_output(
SECURITY_ERR_SEC_ITEM_NOT_FOUND,
b"",
b"The specified item could not be found in the keychain.\n",
),
security_output(0, b"", b""),
]);
mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
assert_eq!(
cli.calls(),
vec![
args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
args(&[
"add-generic-password",
"-U",
"-A",
"-s",
"svc",
"-a",
"key",
"-w",
"secret",
]),
]
);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_put_ignores_pre_delete_failure_and_still_adds() {
let cli = FakeSecurityCli::new(vec![
security_output(128, b"", b"some weird backend error\n"),
security_output(0, b"", b""),
]);
mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
assert_eq!(cli.calls().len(), 2);
assert_eq!(
cli.calls()[1],
args(&[
"add-generic-password",
"-U",
"-A",
"-s",
"svc",
"-a",
"key",
"-w",
"secret",
])
);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_put_surfaces_add_failure_as_backend_error() {
let cli = FakeSecurityCli::new(vec![
security_output(0, b"", b""),
security_output(51, b"", b"User interaction is not allowed.\n"),
]);
let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
assert_backend_contains(err, "code=51 User interaction is not allowed.");
}
#[cfg(target_os = "macos")]
#[test]
fn mac_delete_uses_security_cli_and_maps_results() {
let r = SecretRef::new("svc", "key");
let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
mac_delete_via_security_cli_with(&r, &cli).unwrap();
assert_eq!(
cli.calls(),
vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
);
let cli = FakeSecurityCli::new(vec![security_output(
SECURITY_ERR_SEC_ITEM_NOT_FOUND,
b"",
b"The specified item could not be found in the keychain.\n",
)]);
mac_delete_via_security_cli_with(&r, &cli).unwrap();
let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
assert_backend_contains(err, "code=128 auth denied");
}
}