1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::time::SystemTime;
4
5use anyhow::{Context, Result};
6use log::{debug, error, warn};
7
8use crate::ssh_config::model::SshConfigFile;
9
10pub struct PasswordSourceOption {
12 pub label: &'static str,
13 pub value: &'static str,
14 pub hint: &'static str,
15}
16
17pub const PASSWORD_SOURCES: &[PasswordSourceOption] = &[
18 PasswordSourceOption {
19 label: "OS Keychain",
20 value: "keychain",
21 hint: "keychain",
22 },
23 PasswordSourceOption {
24 label: "1Password",
25 value: "op://",
26 hint: "op://Vault/Item/field",
27 },
28 PasswordSourceOption {
29 label: "Bitwarden",
30 value: "bw:",
31 hint: "bw:item-name",
32 },
33 PasswordSourceOption {
34 label: "pass",
35 value: "pass:",
36 hint: "pass:path/to/entry",
37 },
38 PasswordSourceOption {
42 label: "HashiCorp Vault KV",
43 value: "vault:",
44 hint: "vault:secret/path#field",
45 },
46 PasswordSourceOption {
47 label: "Proton Pass",
48 value: "proton:",
49 hint: "proton:Vault/Item/field",
50 },
51 PasswordSourceOption {
52 label: "Custom command",
53 value: "cmd:",
54 hint: "cmd %a %h",
55 },
56 PasswordSourceOption {
57 label: "None",
58 value: "",
59 hint: "(remove)",
60 },
61];
62
63pub fn handle(env: &crate::runtime::env::Env) -> Result<()> {
66 crate::logging::init(false, false, env);
69
70 let alias = env.var("PURPLE_HOST_ALIAS").unwrap_or_default().to_string();
71 let config_path = env
72 .var("PURPLE_CONFIG_PATH")
73 .unwrap_or_default()
74 .to_string();
75
76 let prompt = std::env::args().nth(1).unwrap_or_default();
78 let prompt_lower = prompt.to_ascii_lowercase();
79 if prompt_lower.contains("passphrase")
80 || prompt_lower.contains("yes/no")
81 || prompt_lower.contains("(yes/no/")
82 {
83 std::process::exit(1);
85 }
86
87 if alias.is_empty() || config_path.is_empty() {
88 std::process::exit(1);
89 }
90
91 let config = SshConfigFile::parse_with_env(&PathBuf::from(&config_path), env)
97 .context("Failed to parse SSH config")?;
98
99 let chain = build_proxy_chain(&config, &alias);
107 let prompt_host = parse_password_prompt_host(&prompt);
108 let prompt_hop = prompt_host.and_then(|h| find_alias_for_host(&config, h, &chain));
109 let resolved_alias = prompt_hop.clone().unwrap_or_else(|| alias.clone());
110
111 let marker = marker_path(env.paths(), &resolved_alias);
114
115 if let (Some(oneshot_alias), Some(secret)) = (
138 env.var(crate::askpass_env::ONESHOT_ALIAS_VAR),
139 env.var(crate::askpass_env::ONESHOT_SECRET_VAR),
140 ) && oneshot_alias == resolved_alias
141 {
142 let named_this_hop = prompt_hop.is_some();
143 let direct = prompt_host.is_none() && !chain_uses_proxy_jump(&config, &chain);
144 if named_this_hop || direct {
145 debug!("[purple] Askpass one-shot password used for {resolved_alias}");
146 print!("{}", secret);
147 return Ok(());
148 }
149 debug!(
150 "[purple] Askpass one-shot withheld for {resolved_alias}: prompt_host={:?} placed_in_chain=false chain_size={}",
151 prompt_host,
152 chain.len()
153 );
154 }
155
156 let single_attempt = env
165 .var(crate::askpass_env::SINGLE_ATTEMPT_VAR)
166 .is_some_and(|v| v == "1");
167 if let Some(marker_path) = &marker
168 && !single_attempt
169 {
170 if is_recent_marker(marker_path) {
171 debug!("[purple] Askpass retry detected for {resolved_alias}");
172 let _ = std::fs::remove_file(marker_path);
173 std::process::exit(1);
174 }
175 arm_marker(marker_path);
176 }
177
178 let source = find_askpass_source(&config, env.paths(), &resolved_alias);
179
180 let source = match source {
181 Some(s) => s,
182 None => std::process::exit(1),
183 };
184
185 debug!("[purple] Askpass invoked for alias={resolved_alias} source={source}");
186
187 let hostname = find_hostname(&config, &resolved_alias);
188 match retrieve_password(env, &source, &resolved_alias, &hostname) {
189 Ok(password) => {
190 debug!("[purple] Askpass retrieved password for {resolved_alias} via {source}");
191 print!("{}", password);
192 Ok(())
193 }
194 Err(err) => {
195 warn!("[external] Password retrieval failed via {source}");
196 debug!("[external] Password retrieval detail: {err}");
197 if let Some(m) = &marker {
198 let _ = std::fs::remove_file(m);
199 }
200 std::process::exit(1);
201 }
202 }
203}
204
205fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
212 let idx = prompt.find("'s password")?;
213 let head = &prompt[..idx];
214 let (_, host) = head.rsplit_once('@')?;
215 let host = host.trim();
216 let host = host
217 .strip_prefix('[')
218 .and_then(|s| s.strip_suffix(']'))
219 .unwrap_or(host);
220 if host.is_empty() { None } else { Some(host) }
221}
222
223fn find_alias_for_host(
230 config: &SshConfigFile,
231 host: &str,
232 permitted: &HashSet<String>,
233) -> Option<String> {
234 let mut by_hostname: Option<String> = None;
235 for entry in config.host_entries() {
236 if !permitted.contains(&entry.alias) {
237 continue;
238 }
239 if entry.alias.eq_ignore_ascii_case(host) {
240 return Some(entry.alias.clone());
241 }
242 if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
243 by_hostname = Some(entry.alias.clone());
244 }
245 }
246 by_hostname
247}
248
249fn chain_uses_proxy_jump(config: &SshConfigFile, chain: &HashSet<String>) -> bool {
254 config
255 .host_entries()
256 .iter()
257 .any(|e| chain.contains(&e.alias) && !e.proxy_jump.trim().is_empty())
258}
259
260fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
266 let entries = config.host_entries();
267 let mut chain: HashSet<String> = HashSet::new();
268 let mut queue: Vec<String> = vec![target.to_string()];
269 while let Some(current) = queue.pop() {
270 if !chain.insert(current.clone()) {
271 continue;
272 }
273 let Some(entry) = entries.iter().find(|e| e.alias == current) else {
274 continue;
275 };
276 if entry.proxy_jump.is_empty() {
277 continue;
278 }
279 for jump in entry.proxy_jump.split(',') {
280 let host = parse_proxy_jump_host(jump);
281 if host.is_empty() {
282 continue;
283 }
284 for e in &entries {
285 if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
286 queue.push(e.alias.clone());
287 }
288 }
289 }
290 }
291 chain
292}
293
294fn parse_proxy_jump_host(jump: &str) -> &str {
297 let trimmed = jump.trim();
298 let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
299 if let Some(rest) = after_user.strip_prefix('[')
300 && let Some(end) = rest.find(']')
301 {
302 return &rest[..end];
303 }
304 after_user.split(':').next().unwrap_or(after_user)
305}
306
307fn find_askpass_source(
309 config: &SshConfigFile,
310 paths: Option<&crate::runtime::env::Paths>,
311 alias: &str,
312) -> Option<String> {
313 for entry in config.host_entries() {
315 if entry.alias == alias
316 && let Some(ref source) = entry.askpass
317 {
318 return Some(source.clone());
319 }
320 }
321 load_askpass_default_direct(paths)
323}
324
325fn load_askpass_default_direct(paths: Option<&crate::runtime::env::Paths>) -> Option<String> {
328 let path = paths?.preferences();
329 let content = std::fs::read_to_string(path).ok()?;
330 for line in content.lines() {
331 let line = line.trim();
332 if line.starts_with('#') || line.is_empty() {
333 continue;
334 }
335 if let Some((k, v)) = line.split_once('=')
336 && k.trim() == "askpass"
337 {
338 let val = v.trim();
339 if !val.is_empty() {
340 return Some(val.to_string());
341 }
342 }
343 }
344 None
345}
346
347fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
349 for entry in config.host_entries() {
350 if entry.alias == alias {
351 return entry.hostname.clone();
352 }
353 }
354 alias.to_string()
355}
356
357fn retrieve_password(
359 env: &crate::runtime::env::Env,
360 source: &str,
361 alias: &str,
362 hostname: &str,
363) -> Result<String> {
364 if source == "keychain" {
365 return retrieve_from_keychain(env, alias);
366 }
367 if let Some(uri) = source.strip_prefix("op://") {
368 return retrieve_from_1password(env, &format!("op://{}", uri));
369 }
370 if let Some(entry) = source.strip_prefix("pass:") {
371 return retrieve_from_pass(env, entry);
372 }
373 if let Some(item_id) = source.strip_prefix("bw:") {
374 return retrieve_from_bitwarden(env, item_id);
375 }
376 if let Some(rest) = source.strip_prefix("vault:") {
377 return retrieve_from_vault(env, rest);
378 }
379 if let Some(spec) = source.strip_prefix("proton:") {
380 return retrieve_from_proton_pass(env, spec);
381 }
382 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
384 retrieve_from_command(env, cmd, alias, hostname)
385}
386
387fn retrieve_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
389 #[cfg(target_os = "macos")]
390 {
391 let output = env
392 .command("security")
393 .args([
394 "find-generic-password",
395 "-a",
396 alias,
397 "-s",
398 "purple-ssh",
399 "-w",
400 ])
401 .output()
402 .context("Failed to run security command")?;
403 if !output.status.success() {
404 let stderr = String::from_utf8_lossy(&output.stderr);
405 log::warn!(
406 "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
407 alias,
408 output.status.code().unwrap_or(-1),
409 stderr.trim().lines().next().unwrap_or("<empty>"),
410 );
411 anyhow::bail!("Keychain lookup failed");
412 }
413 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
414 }
415 #[cfg(not(target_os = "macos"))]
416 {
417 let output = env
418 .command("secret-tool")
419 .args(["lookup", "application", "purple-ssh", "host", alias])
420 .output()
421 .context("Failed to run secret-tool")?;
422 if !output.status.success() {
423 let stderr = String::from_utf8_lossy(&output.stderr);
424 log::warn!(
425 "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
426 alias,
427 output.status.code().unwrap_or(-1),
428 stderr.trim().lines().next().unwrap_or("<empty>"),
429 );
430 anyhow::bail!("Secret-tool lookup failed");
431 }
432 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
433 }
434}
435
436pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
438 retrieve_from_keychain(env, alias).is_ok()
439}
440
441pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
443 retrieve_from_keychain(env, alias)
444}
445
446pub fn store_in_keychain(
448 env: &crate::runtime::env::Env,
449 alias: &str,
450 password: &str,
451) -> Result<()> {
452 #[cfg(target_os = "macos")]
453 {
454 let status = env
455 .command("security")
456 .args([
457 "add-generic-password",
458 "-U",
459 "-a",
460 alias,
461 "-s",
462 "purple-ssh",
463 "-w",
464 password,
465 ])
466 .status()
467 .context("Failed to run security command")?;
468 if !status.success() {
469 anyhow::bail!("Failed to store password in Keychain");
470 }
471 Ok(())
472 }
473 #[cfg(not(target_os = "macos"))]
474 {
475 let mut child = env
476 .command("secret-tool")
477 .args([
478 "store",
479 "--label",
480 &format!("purple-ssh: {}", alias),
481 "application",
482 "purple-ssh",
483 "host",
484 alias,
485 ])
486 .stdin(std::process::Stdio::piped())
487 .spawn()
488 .context("Failed to run secret-tool")?;
489 if let Some(ref mut stdin) = child.stdin {
490 use std::io::Write;
491 stdin.write_all(password.as_bytes())?;
492 }
493 let status = child.wait()?;
494 if !status.success() {
495 anyhow::bail!("Failed to store password with secret-tool");
496 }
497 Ok(())
498 }
499}
500
501pub fn remove_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<()> {
503 #[cfg(target_os = "macos")]
504 {
505 let status = env
506 .command("security")
507 .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
508 .status()
509 .context("Failed to run security command")?;
510 if !status.success() {
511 anyhow::bail!("No password found for '{}' in Keychain", alias);
512 }
513 Ok(())
514 }
515 #[cfg(not(target_os = "macos"))]
516 {
517 let status = env
518 .command("secret-tool")
519 .args(["clear", "application", "purple-ssh", "host", alias])
520 .status()
521 .context("Failed to run secret-tool")?;
522 if !status.success() {
523 anyhow::bail!("Failed to remove password with secret-tool");
524 }
525 Ok(())
526 }
527}
528
529fn retrieve_from_1password(env: &crate::runtime::env::Env, uri: &str) -> Result<String> {
531 let result = env
532 .command("op")
533 .args(["read", uri, "--no-newline"])
534 .output();
535 let output = match result {
536 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
537 error!("[config] Password manager binary not found: op");
538 return Err(e).context("Failed to run 1Password CLI (op)");
539 }
540 other => other.context("Failed to run 1Password CLI (op)")?,
541 };
542 if !output.status.success() {
543 let stderr = String::from_utf8_lossy(&output.stderr);
544 log::warn!(
545 "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
546 uri,
547 output.status.code().unwrap_or(-1),
548 stderr.trim().lines().next().unwrap_or("<empty>"),
549 );
550 anyhow::bail!("1Password lookup failed");
551 }
552 Ok(String::from_utf8_lossy(&output.stdout).to_string())
553}
554
555fn retrieve_from_pass(env: &crate::runtime::env::Env, entry: &str) -> Result<String> {
557 let result = env.command("pass").args(["show", entry]).output();
558 let output = match result {
559 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
560 error!("[config] Password manager binary not found: pass");
561 return Err(e).context("Failed to run pass");
562 }
563 other => other.context("Failed to run pass")?,
564 };
565 if !output.status.success() {
566 let stderr = String::from_utf8_lossy(&output.stderr);
567 log::warn!(
568 "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
569 entry,
570 output.status.code().unwrap_or(-1),
571 stderr.trim().lines().next().unwrap_or("<empty>"),
572 );
573 anyhow::bail!("pass lookup failed");
574 }
575 let full = String::from_utf8_lossy(&output.stdout);
576 Ok(full.lines().next().unwrap_or("").to_string())
577}
578
579fn retrieve_from_bitwarden(env: &crate::runtime::env::Env, item_id: &str) -> Result<String> {
582 let result = env
583 .command("bw")
584 .args(["get", "password", item_id])
585 .output();
586 let output = match result {
587 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
588 error!("[config] Password manager binary not found: bw");
589 return Err(e).context("Failed to run Bitwarden CLI (bw)");
590 }
591 other => other.context("Failed to run Bitwarden CLI (bw)")?,
592 };
593 if !output.status.success() {
594 let stderr = String::from_utf8_lossy(&output.stderr);
595 log::warn!(
596 "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
597 item_id,
598 output.status.code().unwrap_or(-1),
599 stderr.trim().lines().next().unwrap_or("<empty>"),
600 );
601 anyhow::bail!("Bitwarden lookup failed");
602 }
603 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
604}
605
606fn retrieve_from_vault(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
611 let (path, field) = match spec.rsplit_once('#') {
612 Some((p, f)) => (p, f),
613 None => (spec, "password"),
614 };
615 let result = env
616 .command("vault")
617 .args(["kv", "get", &format!("-field={}", field), path])
618 .output();
619 let output = match result {
620 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
621 error!("[config] Password manager binary not found: vault");
622 return Err(e).context("Failed to run vault CLI");
623 }
624 other => other.context("Failed to run vault CLI")?,
625 };
626 if !output.status.success() {
627 let stderr = String::from_utf8_lossy(&output.stderr);
628 log::warn!(
629 "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
630 path,
631 field,
632 output.status.code().unwrap_or(-1),
633 stderr.trim().lines().next().unwrap_or("<empty>"),
634 );
635 anyhow::bail!("Vault lookup failed");
636 }
637 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
638}
639
640fn retrieve_from_command(
643 env: &crate::runtime::env::Env,
644 cmd: &str,
645 alias: &str,
646 hostname: &str,
647) -> Result<String> {
648 let safe_alias = crate::snippet::shell_escape(alias);
649 let safe_hostname = crate::snippet::shell_escape(hostname);
650 let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
651 let output = env
652 .command("sh")
653 .args(["-c", &expanded])
654 .output()
655 .context("Failed to run custom askpass command")?;
656 if !output.status.success() {
657 let stderr = String::from_utf8_lossy(&output.stderr);
658 log::warn!(
659 "[external] askpass custom command failed: alias={} exit={} stderr={}",
660 alias,
661 output.status.code().unwrap_or(-1),
662 stderr.trim().lines().next().unwrap_or("<empty>"),
663 );
664 anyhow::bail!("Custom askpass command failed");
665 }
666 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
667}
668
669fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
672 paths.map(|p| p.askpass_marker(alias))
673}
674
675fn arm_marker(path: &Path) {
678 if let Err(e) = std::fs::create_dir_all(path.parent().unwrap()) {
679 debug!("[purple] Failed to create askpass marker directory: {e}");
680 }
681 if let Err(e) = crate::fs_util::atomic_write(path, b"") {
682 debug!("[purple] Failed to write askpass marker: {e}");
683 }
684}
685
686fn is_recent_marker(path: &PathBuf) -> bool {
688 if let Ok(meta) = std::fs::metadata(path)
689 && let Ok(modified) = meta.modified()
690 && let Ok(elapsed) = SystemTime::now().duration_since(modified)
691 {
692 return elapsed.as_secs() < 60;
693 }
694 false
695}
696
697pub fn cleanup_marker(paths: Option<&crate::runtime::env::Paths>, _alias: &str) {
704 let Some(paths) = paths else {
705 return;
706 };
707 let Ok(read) = std::fs::read_dir(paths.state_dir()) else {
708 return;
709 };
710 for entry in read.flatten() {
711 if entry
712 .file_name()
713 .to_str()
714 .is_some_and(|s| s.starts_with(".askpass_"))
715 {
716 let _ = std::fs::remove_file(entry.path());
717 }
718 }
719}
720
721#[allow(dead_code)]
723pub fn describe_source(source: &str) -> &str {
724 if source == "keychain" {
725 "OS Keychain"
726 } else if source.starts_with("op://") {
727 "1Password"
728 } else if source.starts_with("proton:") {
729 "Proton Pass"
730 } else if source.starts_with("pass:") {
731 "pass"
732 } else if source.starts_with("bw:") {
733 "Bitwarden"
734 } else if source.starts_with("vault:") {
735 "HashiCorp Vault KV"
736 } else {
737 "Custom command"
738 }
739}
740
741#[derive(Debug, Clone, Copy, PartialEq)]
743pub enum BwStatus {
744 Unlocked,
745 Locked,
746 NotAuthenticated,
747 NotInstalled,
748}
749
750fn parse_bw_status(stdout: &str) -> BwStatus {
752 if let Some(status) = stdout
753 .split("\"status\":")
754 .nth(1)
755 .and_then(|s| s.split('"').nth(1))
756 {
757 match status {
758 "unlocked" => BwStatus::Unlocked,
759 "locked" => BwStatus::Locked,
760 "unauthenticated" => BwStatus::NotAuthenticated,
761 _ => BwStatus::Locked,
762 }
763 } else {
764 BwStatus::NotInstalled
765 }
766}
767
768pub fn bw_vault_status(env: &crate::runtime::env::Env) -> BwStatus {
770 let output = match env.command("bw").arg("status").output() {
771 Ok(o) => o,
772 Err(_) => return BwStatus::NotInstalled,
773 };
774 let stdout = String::from_utf8_lossy(&output.stdout);
775 parse_bw_status(&stdout)
776}
777
778pub fn bw_unlock(env: &crate::runtime::env::Env, password: &str) -> Result<String> {
782 let output = env
783 .command("bw")
784 .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
785 .env("PURPLE_BW_MASTER", password)
786 .output()
787 .context("Failed to run Bitwarden CLI (bw)")?;
788 if !output.status.success() {
789 let stderr = String::from_utf8_lossy(&output.stderr);
790 anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
791 }
792 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
793 if token.is_empty() {
794 anyhow::bail!("Bitwarden unlock returned empty session token");
795 }
796 Ok(token)
797}
798
799#[derive(Debug, Clone, Copy, PartialEq)]
801pub enum ProtonStatus {
802 Authenticated,
803 NotAuthenticated,
804 NotInstalled,
805}
806
807pub fn proton_status(env: &crate::runtime::env::Env) -> ProtonStatus {
812 let result = env.command("pass-cli").arg("test").output();
813 let status = match result {
814 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
815 Err(_) => ProtonStatus::NotAuthenticated,
816 Ok(out) if out.status.success() => ProtonStatus::Authenticated,
817 Ok(_) => ProtonStatus::NotAuthenticated,
818 };
819 debug!("[external] Proton Pass status: {status:?}");
820 status
821}
822
823pub fn proton_login(env: &crate::runtime::env::Env, pat: &str) -> Result<()> {
828 if pat.is_empty() {
829 anyhow::bail!("empty PAT");
830 }
831 let output = env
832 .command("pass-cli")
833 .arg("login")
834 .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
835 .output()
836 .context("Failed to run Proton Pass CLI (pass-cli)")?;
837 if !output.status.success() {
838 let stderr = String::from_utf8_lossy(&output.stderr);
839 debug!("[external] Proton Pass login failed: {}", stderr.trim());
840 anyhow::bail!("{}", stderr.trim());
841 }
842 debug!("[external] Proton Pass login succeeded");
843 Ok(())
844}
845
846fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
850 let (vault, rest) = spec
851 .split_once('/')
852 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
853 let (item, field) = rest
854 .split_once('/')
855 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
856 if vault.is_empty() || item.is_empty() || field.is_empty() {
857 anyhow::bail!("Proton Pass spec segments must be non-empty");
858 }
859 Ok((vault, item, field))
860}
861
862fn retrieve_from_proton_pass(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
868 let (vault, item, field) = parse_proton_spec(spec)?;
869 let result = env
870 .command("pass-cli")
871 .args([
872 "item",
873 "view",
874 "--vault-name",
875 vault,
876 "--item-title",
877 item,
878 "--field",
879 field,
880 ])
881 .output();
882 let output = match result {
883 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
884 error!("[config] Password manager binary not found: pass-cli");
885 return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
886 }
887 other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
888 };
889 if !output.status.success() {
890 let stderr = String::from_utf8_lossy(&output.stderr);
891 warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
892 anyhow::bail!("Proton Pass lookup failed");
893 }
894 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
895 if value.is_empty() {
896 warn!("[external] Proton Pass returned empty secret");
897 anyhow::bail!("Proton Pass returned empty secret");
898 }
899 debug!("[external] Proton Pass lookup succeeded");
900 Ok(value)
901}
902
903#[cfg(test)]
904#[path = "askpass_tests.rs"]
905mod tests;