1use anyhow::Result;
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum CredentialKind {
31 SshPassword,
32 SshKeyPassphrase,
33 SftpPassword,
34 SftpKeyPassphrase,
35 FtpPassword,
36 PostgresPassword,
37}
38
39impl CredentialKind {
40 pub fn service(self) -> &'static str {
42 match self {
43 CredentialKind::SshPassword => "com.r-shell.ssh.password",
44 CredentialKind::SshKeyPassphrase => "com.r-shell.ssh.passphrase",
45 CredentialKind::SftpPassword => "com.r-shell.sftp.password",
46 CredentialKind::SftpKeyPassphrase => "com.r-shell.sftp.passphrase",
47 CredentialKind::FtpPassword => "com.r-shell.ftp.password",
48 CredentialKind::PostgresPassword => "com.r-shell.postgres.password",
49 }
50 }
51
52 pub fn friendly_label(self) -> &'static str {
56 match self {
57 CredentialKind::SshPassword => "SSH password",
58 CredentialKind::SshKeyPassphrase => "SSH key passphrase",
59 CredentialKind::SftpPassword => "SFTP password",
60 CredentialKind::SftpKeyPassphrase => "SFTP key passphrase",
61 CredentialKind::FtpPassword => "FTP password",
62 CredentialKind::PostgresPassword => "Postgres password",
63 }
64 }
65}
66
67pub fn is_supported() -> bool {
71 cfg!(target_os = "macos")
72}
73
74#[cfg(target_os = "macos")]
78mod platform {
79 use super::*;
80 use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
81 use security_framework::passwords::{
82 PasswordOptions, delete_generic_password, get_generic_password, set_generic_password,
83 set_generic_password_options,
84 };
85 use security_framework_sys::base::errSecItemNotFound;
86
87 pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
88 match get_generic_password(kind.service(), account) {
93 Ok(_) => {
94 set_generic_password(kind.service(), account, secret.as_bytes()).map_err(|e| {
95 anyhow::anyhow!(
96 "keychain update failed for {}/{}: {}",
97 kind.service(),
98 account,
99 e
100 )
101 })
102 }
103 Err(e) if e.code() == errSecItemNotFound => {
104 let mut options = PasswordOptions::new_generic_password(kind.service(), account);
105 options.set_label(&format!("r-shell: {} ({})", kind.friendly_label(), account));
107 options.set_comment(
110 "Saved by r-shell. Remove from r-shell → Settings → Security → Saved Credentials, \
111 or delete here to force a re-prompt on the next connect.",
112 );
113 options.set_access_synchronized(Some(false));
116 set_generic_password_options(secret.as_bytes(), options).map_err(|e| {
117 anyhow::anyhow!(
118 "keychain create failed for {}/{}: {}",
119 kind.service(),
120 account,
121 e
122 )
123 })
124 }
125 Err(e) => Err(anyhow::anyhow!(
126 "keychain pre-save probe failed for {}/{}: {}",
127 kind.service(),
128 account,
129 e
130 )),
131 }
132 }
133
134 pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
135 let results = match ItemSearchOptions::new()
136 .class(ItemClass::generic_password())
137 .service(kind.service())
138 .load_attributes(true)
139 .limit(Limit::All)
140 .search()
141 {
142 Ok(r) => r,
143 Err(e) if e.code() == errSecItemNotFound => return Ok(Vec::new()),
146 Err(e) => {
147 return Err(anyhow::anyhow!(
148 "keychain list failed for {}: {}",
149 kind.service(),
150 e
151 ));
152 }
153 };
154
155 let mut accounts = Vec::with_capacity(results.len());
156 for r in results {
157 if let Some(attrs) = r.simplify_dict() {
158 if let Some(account) = attrs.get("acct") {
161 accounts.push(account.clone());
162 }
163 }
164 }
165 accounts.sort();
166 accounts.dedup();
167 Ok(accounts)
168 }
169
170 pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
171 match get_generic_password(kind.service(), account) {
172 Ok(bytes) => {
173 let s = String::from_utf8(bytes).map_err(|_| {
174 anyhow::anyhow!(
175 "keychain item {}/{} is not valid UTF-8",
176 kind.service(),
177 account
178 )
179 })?;
180 Ok(Some(s))
181 }
182 Err(e) if e.code() == errSecItemNotFound => Ok(None),
183 Err(e) => Err(anyhow::anyhow!(
184 "keychain load failed for {}/{}: {}",
185 kind.service(),
186 account,
187 e
188 )),
189 }
190 }
191
192 pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
193 match delete_generic_password(kind.service(), account) {
195 Ok(()) => Ok(()),
196 Err(e) if e.code() == errSecItemNotFound => Ok(()),
197 Err(e) => Err(anyhow::anyhow!(
198 "keychain delete failed for {}/{}: {}",
199 kind.service(),
200 account,
201 e
202 )),
203 }
204 }
205}
206
207#[cfg(not(target_os = "macos"))]
211mod platform {
212 use super::*;
213
214 pub fn save_password(_kind: CredentialKind, _account: &str, _secret: &str) -> Result<()> {
215 Err(anyhow::anyhow!(
216 "Keychain integration is only supported on macOS"
217 ))
218 }
219
220 pub fn load_password(_kind: CredentialKind, _account: &str) -> Result<Option<String>> {
221 Ok(None)
224 }
225
226 pub fn delete_password(_kind: CredentialKind, _account: &str) -> Result<()> {
227 Err(anyhow::anyhow!(
228 "Keychain integration is only supported on macOS"
229 ))
230 }
231
232 pub fn list_accounts(_kind: CredentialKind) -> Result<Vec<String>> {
233 Ok(Vec::new())
236 }
237}
238
239pub fn save_password(kind: CredentialKind, account: &str, secret: &str) -> Result<()> {
240 tracing::info!(
241 "keychain save: service={}, account={}",
242 kind.service(),
243 account
244 );
245 platform::save_password(kind, account, secret)
246}
247
248pub fn load_password(kind: CredentialKind, account: &str) -> Result<Option<String>> {
249 let result = platform::load_password(kind, account);
250 tracing::debug!(
251 "keychain load: service={}, account={}, found={}",
252 kind.service(),
253 account,
254 matches!(&result, Ok(Some(_)))
255 );
256 result
257}
258
259pub fn delete_password(kind: CredentialKind, account: &str) -> Result<()> {
260 tracing::info!(
261 "keychain delete: service={}, account={}",
262 kind.service(),
263 account
264 );
265 platform::delete_password(kind, account)
266}
267
268pub fn list_accounts(kind: CredentialKind) -> Result<Vec<String>> {
272 let result = platform::list_accounts(kind);
273 tracing::debug!(
274 "keychain list: service={}, count={}",
275 kind.service(),
276 result.as_ref().map(|v| v.len()).unwrap_or(0)
277 );
278 result
279}
280
281#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn service_strings_are_stable_and_unique() {
290 let kinds = [
291 CredentialKind::SshPassword,
292 CredentialKind::SshKeyPassphrase,
293 CredentialKind::SftpPassword,
294 CredentialKind::SftpKeyPassphrase,
295 CredentialKind::FtpPassword,
296 ];
297 for k in kinds {
300 assert!(
301 k.service().starts_with("com.r-shell."),
302 "service {:?} should be namespaced",
303 k
304 );
305 }
306 let set: std::collections::HashSet<&str> = kinds.iter().map(|k| k.service()).collect();
308 assert_eq!(set.len(), kinds.len(), "service strings must be unique");
309 }
310
311 #[test]
312 fn credential_kind_serializes_snake_case() {
313 let json = serde_json::to_string(&CredentialKind::SshKeyPassphrase).unwrap();
314 assert_eq!(json, "\"ssh_key_passphrase\"");
315 }
316
317 #[test]
318 fn credential_kind_deserializes_snake_case() {
319 let kind: CredentialKind = serde_json::from_str("\"ftp_password\"").unwrap();
320 assert_eq!(kind, CredentialKind::FtpPassword);
321 }
322
323 #[cfg(target_os = "macos")]
327 #[test]
328 #[ignore]
329 fn save_load_delete_round_trip_on_real_keychain() {
330 let kind = CredentialKind::SshPassword;
331 let account = format!("r-shell-test-{}@localhost:22", std::process::id());
332 let secret = "round-trip-secret-value";
333
334 let before = load_password(kind, &account).expect("load");
336 assert!(before.is_none(), "pre-existing keychain entry?");
337
338 save_password(kind, &account, secret).expect("save");
339 let loaded = load_password(kind, &account).expect("load").expect("some");
340 assert_eq!(loaded, secret);
341
342 save_password(kind, &account, "different-value").expect("overwrite");
344 let loaded2 = load_password(kind, &account)
345 .expect("reload")
346 .expect("some");
347 assert_eq!(loaded2, "different-value");
348
349 delete_password(kind, &account).expect("delete");
350 let after = load_password(kind, &account).expect("load after delete");
351 assert!(after.is_none(), "entry should be gone after delete");
352
353 delete_password(kind, &account).expect("idempotent delete");
355 }
356
357 #[cfg(target_os = "macos")]
361 #[test]
362 #[ignore]
363 fn list_accounts_round_trip_on_real_keychain() {
364 let kind = CredentialKind::FtpPassword;
365 let pid = std::process::id();
366 let accounts = [
367 format!("r-shell-list-a-{}@a.test:21", pid),
368 format!("r-shell-list-b-{}@b.test:21", pid),
369 ];
370
371 for a in &accounts {
372 save_password(kind, a, "x").expect("save");
373 }
374
375 let listed = list_accounts(kind).expect("list");
376 for a in &accounts {
377 assert!(
378 listed.iter().any(|l| l == a),
379 "expected {} in list, got {:?}",
380 a,
381 listed
382 );
383 }
384
385 for a in &accounts {
386 delete_password(kind, a).expect("cleanup");
387 }
388
389 let after = list_accounts(kind).expect("list after cleanup");
390 for a in &accounts {
391 assert!(
392 !after.iter().any(|l| l == a),
393 "entry {} should be gone after cleanup",
394 a
395 );
396 }
397 }
398}