1use std::sync::Arc;
36
37use crate::datastore::CommandRunner;
38use crate::secret::provider::{ProbeResult, SecretProvider};
39use crate::secret::secret_string::SecretString;
40use crate::{DodotError, Result};
41
42pub struct KeychainProvider {
47 runner: Arc<dyn CommandRunner>,
48}
49
50impl KeychainProvider {
51 pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
52 Self { runner }
53 }
54
55 pub fn from_env(runner: Arc<dyn CommandRunner>) -> Self {
56 Self::new(runner)
57 }
58
59 fn parse_reference(suffix: &str) -> Result<(&str, Option<&str>)> {
64 if suffix.is_empty() {
65 return Err(DodotError::Other(
66 "keychain reference is empty. Expected `keychain:<service>[/<account>]`.".into(),
67 ));
68 }
69 let (service, account) = match suffix.split_once('/') {
70 Some((s, a)) => (s, Some(a)),
71 None => (suffix, None),
72 };
73 if service.is_empty() {
74 return Err(DodotError::Other(format!(
75 "keychain reference `keychain:{suffix}` has an empty service name."
76 )));
77 }
78 if let Some(a) = account {
79 if a.is_empty() {
80 return Err(DodotError::Other(format!(
81 "keychain reference `keychain:{suffix}` has an empty account name. \
82 Either drop the trailing `/` (use `keychain:<service>` for a \
83 service-only lookup) or supply an account: `keychain:<service>/<account>`."
84 )));
85 }
86 }
87 Ok((service, account))
88 }
89}
90
91impl SecretProvider for KeychainProvider {
92 fn scheme(&self) -> &str {
93 "keychain"
94 }
95
96 fn probe(&self) -> ProbeResult {
97 match self.runner.run("security", &["-h".into()]) {
101 Ok(_) => {}
102 Err(_) => {
103 return ProbeResult::NotInstalled {
104 hint: "the `security` command is macOS-only. \
109 On Linux / WSL, use the `secret-tool` provider instead \
110 (`[secret.providers.secret_tool] enabled = true`)."
111 .into(),
112 };
113 }
114 }
115 match self.runner.run("security", &["default-keychain".into()]) {
122 Ok(out) if out.exit_code == 0 => ProbeResult::Ok,
123 Ok(_) => ProbeResult::ProbeFailed {
124 details: "`security default-keychain` returned non-zero — \
125 the binary is on PATH but no default keychain is \
126 configured. Run `security login-keychain` to inspect."
127 .into(),
128 },
129 Err(_) => ProbeResult::ProbeFailed {
130 details: "could not run `security default-keychain` after a \
131 successful `security -h`; intermittent subprocess failure"
132 .into(),
133 },
134 }
135 }
136
137 fn resolve(&self, reference: &str) -> Result<SecretString> {
138 let (service, account) = Self::parse_reference(reference)?;
139 let mut args: Vec<String> =
140 vec!["find-generic-password".into(), "-s".into(), service.into()];
141 if let Some(a) = account {
142 args.push("-a".into());
143 args.push(a.into());
144 }
145 args.push("-w".into());
148
149 let out = self.runner.run("security", &args)?;
150 if out.exit_code != 0 {
151 let stderr = out.stderr.trim();
152 let err_msg = if out.exit_code == 44 || stderr.contains("could not be found") {
157 let qualifier = match account {
158 Some(a) => format!("(service `{service}`, account `{a}`)"),
159 None => format!("(service `{service}`)"),
160 };
161 format!(
162 "secret `keychain:{reference}` not found in the keychain {qualifier}. \
163 Verify with `security find-generic-password -s '{service}'`{} \
164 -- or add the item via Keychain Access.app / \
165 `security add-generic-password -s '{service}' [-a '<account>'] -w '<password>'`.",
166 account
167 .map(|a| format!(" -a '{a}'"))
168 .unwrap_or_default(),
169 )
170 } else if out.exit_code == 51
171 || stderr.contains("User interaction is not allowed")
172 || stderr.contains("locked")
173 {
174 format!(
175 "secret resolution for `keychain:{reference}` failed: \
176 the keychain is locked or interaction is not allowed. \
177 Unlock the login keychain (e.g. by signing in / opening \
178 Keychain Access.app) and re-run dodot."
179 )
180 } else if stderr.is_empty() {
181 format!(
182 "`security find-generic-password` exited with code {} \
183 (no diagnostic output)",
184 out.exit_code
185 )
186 } else {
187 format!(
191 "`security find-generic-password` failed (exit {}): {stderr}",
192 out.exit_code
193 )
194 };
195 return Err(DodotError::Other(err_msg));
196 }
197 let mut value = out.stdout;
201 if value.ends_with('\n') {
202 value.pop();
203 }
204 Ok(SecretString::new(value))
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::datastore::CommandOutput;
212 use std::sync::Mutex;
213
214 type ScriptedResponse = (
215 String,
216 Vec<String>,
217 std::result::Result<CommandOutput, String>,
218 );
219
220 struct ScriptedRunner {
221 responses: Mutex<Vec<ScriptedResponse>>,
222 }
223 impl ScriptedRunner {
224 fn new() -> Self {
225 Self {
226 responses: Mutex::new(Vec::new()),
227 }
228 }
229 fn expect(
230 self,
231 exe: impl Into<String>,
232 args: Vec<String>,
233 response: std::result::Result<CommandOutput, String>,
234 ) -> Self {
235 self.responses
236 .lock()
237 .unwrap()
238 .push((exe.into(), args, response));
239 self
240 }
241 }
242 impl CommandRunner for ScriptedRunner {
243 fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
244 let mut r = self.responses.lock().unwrap();
245 if r.is_empty() {
246 return Err(DodotError::Other(format!(
247 "ScriptedRunner: unexpected `{exe} {args:?}`"
248 )));
249 }
250 let (e, a, out) = r.remove(0);
251 assert_eq!(exe, e);
252 assert_eq!(args, a.as_slice());
253 out.map_err(DodotError::Other)
254 }
255 }
256 fn ok(stdout: &str) -> std::result::Result<CommandOutput, String> {
257 Ok(CommandOutput {
258 exit_code: 0,
259 stdout: stdout.into(),
260 stderr: String::new(),
261 })
262 }
263 fn err_out(exit: i32, stderr: &str) -> std::result::Result<CommandOutput, String> {
264 Ok(CommandOutput {
265 exit_code: exit,
266 stdout: String::new(),
267 stderr: stderr.into(),
268 })
269 }
270
271 #[test]
274 fn parse_reference_service_only() {
275 let (s, a) = KeychainProvider::parse_reference("GitHub").unwrap();
276 assert_eq!(s, "GitHub");
277 assert_eq!(a, None);
278 }
279
280 #[test]
281 fn parse_reference_service_and_account() {
282 let (s, a) = KeychainProvider::parse_reference("GitHub/alice").unwrap();
283 assert_eq!(s, "GitHub");
284 assert_eq!(a, Some("alice"));
285 }
286
287 #[test]
288 fn parse_reference_rejects_empty_suffix() {
289 let e = KeychainProvider::parse_reference("")
290 .unwrap_err()
291 .to_string();
292 assert!(e.contains("empty"));
293 }
294
295 #[test]
296 fn parse_reference_rejects_empty_service() {
297 let e = KeychainProvider::parse_reference("/alice")
298 .unwrap_err()
299 .to_string();
300 assert!(e.contains("empty service"));
301 }
302
303 #[test]
304 fn parse_reference_rejects_trailing_slash() {
305 let e = KeychainProvider::parse_reference("GitHub/")
308 .unwrap_err()
309 .to_string();
310 assert!(e.contains("empty account"));
311 assert!(e.contains("drop the trailing"));
312 }
313
314 #[test]
317 fn probe_ok_when_security_present_and_default_keychain_resolves() {
318 let runner = Arc::new(
319 ScriptedRunner::new()
320 .expect("security", vec!["-h".into()], ok(""))
321 .expect(
322 "security",
323 vec!["default-keychain".into()],
324 ok(" \"/Users/x/Library/Keychains/login.keychain-db\"\n"),
325 ),
326 );
327 let p = KeychainProvider::new(runner);
328 assert!(matches!(p.probe(), ProbeResult::Ok));
329 }
330
331 #[test]
332 fn probe_not_installed_when_runner_errors() {
333 let runner = Arc::new(ScriptedRunner::new().expect(
336 "security",
337 vec!["-h".into()],
338 Err("command not found: security".into()),
339 ));
340 let p = KeychainProvider::new(runner);
341 match p.probe() {
342 ProbeResult::NotInstalled { hint } => {
343 assert!(hint.contains("macOS-only"));
344 assert!(hint.contains("secret-tool"));
345 }
346 other => panic!("expected NotInstalled, got {other:?}"),
347 }
348 }
349
350 #[test]
351 fn probe_failed_when_default_keychain_returns_nonzero() {
352 let runner = Arc::new(
353 ScriptedRunner::new()
354 .expect("security", vec!["-h".into()], ok(""))
355 .expect(
356 "security",
357 vec!["default-keychain".into()],
358 err_out(50, "no default keychain"),
359 ),
360 );
361 let p = KeychainProvider::new(runner);
362 assert!(matches!(p.probe(), ProbeResult::ProbeFailed { .. }));
363 }
364
365 #[test]
368 fn resolve_service_only_invokes_find_generic_password_correctly() {
369 let runner = Arc::new(ScriptedRunner::new().expect(
370 "security",
371 vec![
372 "find-generic-password".into(),
373 "-s".into(),
374 "GitHub".into(),
375 "-w".into(),
376 ],
377 ok("ghp_abc123\n"),
378 ));
379 let p = KeychainProvider::new(runner);
380 let v = p.resolve("GitHub").unwrap();
381 assert_eq!(v.expose().unwrap(), "ghp_abc123");
382 }
383
384 #[test]
385 fn resolve_with_account_threads_account_into_args() {
386 let runner = Arc::new(ScriptedRunner::new().expect(
387 "security",
388 vec![
389 "find-generic-password".into(),
390 "-s".into(),
391 "GitHub".into(),
392 "-a".into(),
393 "alice".into(),
394 "-w".into(),
395 ],
396 ok("alice-token\n"),
397 ));
398 let p = KeychainProvider::new(runner);
399 let v = p.resolve("GitHub/alice").unwrap();
400 assert_eq!(v.expose().unwrap(), "alice-token");
401 }
402
403 #[test]
404 fn resolve_maps_exit_44_to_not_found_with_actionable_hint() {
405 let runner = Arc::new(ScriptedRunner::new().expect(
406 "security",
407 vec![
408 "find-generic-password".into(),
409 "-s".into(),
410 "missing".into(),
411 "-w".into(),
412 ],
413 err_out(
414 44,
415 "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.",
416 ),
417 ));
418 let p = KeychainProvider::new(runner);
419 let e = p.resolve("missing").unwrap_err().to_string();
420 assert!(e.contains("not found"));
421 assert!(e.contains("`missing`"));
422 assert!(e.contains("security add-generic-password"));
423 }
424
425 #[test]
426 fn resolve_not_found_qualifier_includes_account_when_provided() {
427 let runner = Arc::new(ScriptedRunner::new().expect(
428 "security",
429 vec![
430 "find-generic-password".into(),
431 "-s".into(),
432 "GitHub".into(),
433 "-a".into(),
434 "missing".into(),
435 "-w".into(),
436 ],
437 err_out(44, "could not be found"),
438 ));
439 let p = KeychainProvider::new(runner);
440 let e = p.resolve("GitHub/missing").unwrap_err().to_string();
441 assert!(e.contains("`GitHub`"));
442 assert!(e.contains("`missing`"));
443 assert!(e.contains("-a 'missing'"));
444 }
445
446 #[test]
447 fn resolve_maps_exit_51_to_locked_keychain_diagnostic() {
448 let runner = Arc::new(ScriptedRunner::new().expect(
449 "security",
450 vec![
451 "find-generic-password".into(),
452 "-s".into(),
453 "GitHub".into(),
454 "-w".into(),
455 ],
456 err_out(51, "security: User interaction is not allowed."),
457 ));
458 let p = KeychainProvider::new(runner);
459 let e = p.resolve("GitHub").unwrap_err().to_string();
460 assert!(e.contains("locked or interaction is not allowed"));
461 assert!(e.contains("Unlock"));
462 }
463
464 #[test]
465 fn resolve_passes_through_unrecognized_stderr() {
466 let runner = Arc::new(ScriptedRunner::new().expect(
467 "security",
468 vec![
469 "find-generic-password".into(),
470 "-s".into(),
471 "GitHub".into(),
472 "-w".into(),
473 ],
474 err_out(1, "weird internal failure"),
475 ));
476 let p = KeychainProvider::new(runner);
477 let e = p.resolve("GitHub").unwrap_err().to_string();
478 assert!(e.contains("weird internal failure"));
479 assert!(e.contains("exit 1"));
480 }
481
482 #[test]
483 fn resolve_strips_exactly_one_trailing_newline() {
484 let runner = Arc::new(ScriptedRunner::new().expect(
485 "security",
486 vec![
487 "find-generic-password".into(),
488 "-s".into(),
489 "k".into(),
490 "-w".into(),
491 ],
492 ok("value-with-trailing-blank\n\n"),
493 ));
494 let p = KeychainProvider::new(runner);
495 let v = p.resolve("k").unwrap();
496 assert_eq!(v.expose().unwrap(), "value-with-trailing-blank\n");
497 }
498}