use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CredentialKind {
SshPassword,
SshKeyPassphrase,
SftpPassword,
SftpKeyPassphrase,
FtpPassword,
PostgresPassword,
}
impl CredentialKind {
pub fn service(self) -> &'static str {
match self {
CredentialKind::SshPassword => "com.r-shell.ssh.password",
CredentialKind::SshKeyPassphrase => "com.r-shell.ssh.passphrase",
CredentialKind::SftpPassword => "com.r-shell.sftp.password",
CredentialKind::SftpKeyPassphrase => "com.r-shell.sftp.passphrase",
CredentialKind::FtpPassword => "com.r-shell.ftp.password",
CredentialKind::PostgresPassword => "com.r-shell.postgres.password",
}
}
pub fn friendly_label(self) -> &'static str {
match self {
CredentialKind::SshPassword => "SSH password",
CredentialKind::SshKeyPassphrase => "SSH key passphrase",
CredentialKind::SftpPassword => "SFTP password",
CredentialKind::SftpKeyPassphrase => "SFTP key passphrase",
CredentialKind::FtpPassword => "FTP password",
CredentialKind::PostgresPassword => "Postgres password",
}
}
}
pub fn is_supported() -> bool {
cfg!(target_os = "macos")
}
#[cfg(target_os = "macos")]
mod platform {
use super::*;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
use security_framework::passwords::{
PasswordOptions, delete_generic_password, get_generic_password, set_generic_password,
set_generic_password_options,
};
use security_framework_sys::base::errSecItemNotFound;
pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
match get_generic_password(kind.service(), account) {
Ok(_) => {
set_generic_password(kind.service(), account, secret.as_bytes()).map_err(|e| {
anyhow::anyhow!(
"keychain update failed for {}/{}: {}",
kind.service(),
account,
e
)
})
}
Err(e) if e.code() == errSecItemNotFound => {
let mut options = PasswordOptions::new_generic_password(kind.service(), account);
options.set_label(&format!("r-shell: {} ({})", kind.friendly_label(), account));
options.set_comment(
"Saved by r-shell. Remove from r-shell → Settings → Security → Saved Credentials, \
or delete here to force a re-prompt on the next connect.",
);
options.set_access_synchronized(Some(false));
set_generic_password_options(secret.as_bytes(), options).map_err(|e| {
anyhow::anyhow!(
"keychain create failed for {}/{}: {}",
kind.service(),
account,
e
)
})
}
Err(e) => Err(anyhow::anyhow!(
"keychain pre-save probe failed for {}/{}: {}",
kind.service(),
account,
e
)),
}
}
pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
let results = match ItemSearchOptions::new()
.class(ItemClass::generic_password())
.service(kind.service())
.load_attributes(true)
.limit(Limit::All)
.search()
{
Ok(r) => r,
Err(e) if e.code() == errSecItemNotFound => return Ok(Vec::new()),
Err(e) => {
return Err(anyhow::anyhow!(
"keychain list failed for {}: {}",
kind.service(),
e
));
}
};
let mut accounts = Vec::with_capacity(results.len());
for r in results {
if let Some(attrs) = r.simplify_dict() {
if let Some(account) = attrs.get("acct") {
accounts.push(account.clone());
}
}
}
accounts.sort();
accounts.dedup();
Ok(accounts)
}
pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
match get_generic_password(kind.service(), account) {
Ok(bytes) => {
let s = String::from_utf8(bytes).map_err(|_| {
anyhow::anyhow!(
"keychain item {}/{} is not valid UTF-8",
kind.service(),
account
)
})?;
Ok(Some(s))
}
Err(e) if e.code() == errSecItemNotFound => Ok(None),
Err(e) => Err(anyhow::anyhow!(
"keychain load failed for {}/{}: {}",
kind.service(),
account,
e
)),
}
}
pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
match delete_generic_password(kind.service(), account) {
Ok(()) => Ok(()),
Err(e) if e.code() == errSecItemNotFound => Ok(()),
Err(e) => Err(anyhow::anyhow!(
"keychain delete failed for {}/{}: {}",
kind.service(),
account,
e
)),
}
}
}
#[cfg(not(target_os = "macos"))]
mod platform {
use super::*;
pub fn save_password(_kind: CredentialKind, _account: &str, _secret: &str) -> Result<()> {
Err(anyhow::anyhow!(
"Keychain integration is only supported on macOS"
))
}
pub fn load_password(_kind: CredentialKind, _account: &str) -> Result<Option<String>> {
Ok(None)
}
pub fn delete_password(_kind: CredentialKind, _account: &str) -> Result<()> {
Err(anyhow::anyhow!(
"Keychain integration is only supported on macOS"
))
}
pub fn list_accounts(_kind: CredentialKind) -> Result<Vec<String>> {
Ok(Vec::new())
}
}
pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
tracing::info!(
"keychain save: service={}, account={}",
kind.service(),
account
);
platform::save_password(kind, account, secret)
}
pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
let result = platform::load_password(kind, account);
tracing::debug!(
"keychain load: service={}, account={}, found={}",
kind.service(),
account,
matches!(&result, Ok(Some(_)))
);
result
}
pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
tracing::info!(
"keychain delete: service={}, account={}",
kind.service(),
account
);
platform::delete_password(kind, account)
}
pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
let result = platform::list_accounts(kind);
tracing::debug!(
"keychain list: service={}, count={}",
kind.service(),
result.as_ref().map(|v| v.len()).unwrap_or(0)
);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_strings_are_stable_and_unique() {
let kinds = [
CredentialKind::SshPassword,
CredentialKind::SshKeyPassphrase,
CredentialKind::SftpPassword,
CredentialKind::SftpKeyPassphrase,
CredentialKind::FtpPassword,
];
for k in kinds {
assert!(
k.service().starts_with("com.r-shell."),
"service {:?} should be namespaced",
k
);
}
let set: std::collections::HashSet<&str> = kinds.iter().map(|k| k.service()).collect();
assert_eq!(set.len(), kinds.len(), "service strings must be unique");
}
#[test]
fn credential_kind_serializes_snake_case() {
let json = serde_json::to_string(&CredentialKind::SshKeyPassphrase).unwrap();
assert_eq!(json, "\"ssh_key_passphrase\"");
}
#[test]
fn credential_kind_deserializes_snake_case() {
let kind: CredentialKind = serde_json::from_str("\"ftp_password\"").unwrap();
assert_eq!(kind, CredentialKind::FtpPassword);
}
#[cfg(target_os = "macos")]
#[test]
#[ignore]
fn save_load_delete_round_trip_on_real_keychain() {
let kind = CredentialKind::SshPassword;
let account = format!("r-shell-test-{}@localhost:22", std::process::id());
let secret = "round-trip-secret-value";
let before = load_password(kind, &account).expect("load");
assert!(before.is_none(), "pre-existing keychain entry?");
save_password(kind, &account, secret).expect("save");
let loaded = load_password(kind, &account).expect("load").expect("some");
assert_eq!(loaded, secret);
save_password(kind, &account, "different-value").expect("overwrite");
let loaded2 = load_password(kind, &account)
.expect("reload")
.expect("some");
assert_eq!(loaded2, "different-value");
delete_password(kind, &account).expect("delete");
let after = load_password(kind, &account).expect("load after delete");
assert!(after.is_none(), "entry should be gone after delete");
delete_password(kind, &account).expect("idempotent delete");
}
#[cfg(target_os = "macos")]
#[test]
#[ignore]
fn list_accounts_round_trip_on_real_keychain() {
let kind = CredentialKind::FtpPassword;
let pid = std::process::id();
let accounts = [
format!("r-shell-list-a-{}@a.test:21", pid),
format!("r-shell-list-b-{}@b.test:21", pid),
];
for a in &accounts {
save_password(kind, a, "x").expect("save");
}
let listed = list_accounts(kind).expect("list");
for a in &accounts {
assert!(
listed.iter().any(|l| l == a),
"expected {} in list, got {:?}",
a,
listed
);
}
for a in &accounts {
delete_password(kind, a).expect("cleanup");
}
let after = list_accounts(kind).expect("list after cleanup");
for a in &accounts {
assert!(
!after.iter().any(|l| l == a),
"entry {} should be gone after cleanup",
a
);
}
}
}