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
79 if env.var("SSH_ASKPASS_PROMPT") == Some("none") {
82 if !crate::askpass_env::runs_in_background(env) {
83 crate::security_key::show_on_tty(&prompt);
84 }
85 debug!("[purple] Askpass passed on a notice for {alias}");
86 return Ok(());
87 }
88
89 if let Some(pin_prompt) = crate::security_key::parse_pin_prompt(&prompt) {
92 return handle_pin_prompt(env, &alias, &prompt, pin_prompt);
93 }
94
95 let prompt_lower = prompt.to_ascii_lowercase();
96 if prompt_lower.contains("passphrase")
97 || prompt_lower.contains("yes/no")
98 || prompt_lower.contains("(yes/no/")
99 {
100 std::process::exit(1);
102 }
103
104 if alias.is_empty() || config_path.is_empty() {
105 std::process::exit(1);
106 }
107
108 let config = SshConfigFile::parse_with_env(&PathBuf::from(&config_path), env)
114 .context("Failed to parse SSH config")?;
115
116 let chain = build_proxy_chain(&config, &alias);
124 let prompt_host = parse_password_prompt_host(&prompt);
125 let prompt_hop = prompt_host.and_then(|h| find_alias_for_host(&config, h, &chain));
126 let resolved_alias = prompt_hop.clone().unwrap_or_else(|| alias.clone());
127
128 let marker = marker_path(env.paths(), &resolved_alias);
131
132 let attributable = prompt_hop.is_some()
144 || match ssh_resolves_a_proxy(env, &config_path, &alias) {
145 Some(proxied) => !proxied,
146 None => prompt_host.is_none() && !chain_uses_jump_host(&config, &chain),
151 };
152
153 if let (Some(oneshot_alias), Some(secret)) = (
163 env.var(crate::askpass_env::ONESHOT_ALIAS_VAR),
164 env.var(crate::askpass_env::ONESHOT_SECRET_VAR),
165 ) && oneshot_alias == resolved_alias
166 && attributable
167 {
168 debug!("[purple] Askpass one-shot password used for {resolved_alias}");
169 print!("{}", secret);
170 return Ok(());
171 }
172
173 if !attributable {
182 debug!(
183 "[purple] Askpass withheld for {resolved_alias}: prompt_host={:?} placed_in_chain=false chain_size={}",
184 prompt_host,
185 chain.len()
186 );
187 if let Some(path) = withheld_marker_path(env.paths(), &alias) {
188 arm_marker(&path);
189 }
190 std::process::exit(1);
191 }
192
193 let single_attempt = env
202 .var(crate::askpass_env::SINGLE_ATTEMPT_VAR)
203 .is_some_and(|v| v == "1");
204 if let Some(marker_path) = &marker
205 && !single_attempt
206 {
207 if is_recent_marker(marker_path) {
208 debug!("[purple] Askpass retry detected for {resolved_alias}");
209 let _ = std::fs::remove_file(marker_path);
210 std::process::exit(1);
211 }
212 arm_marker(marker_path);
213 }
214
215 let source = find_askpass_source(&config, env.paths(), &resolved_alias);
216
217 let source = match source {
218 Some(s) => s,
219 None => std::process::exit(1),
220 };
221
222 debug!("[purple] Askpass invoked for alias={resolved_alias} source={source}");
223
224 let hostname = find_hostname(&config, &resolved_alias);
225 match retrieve_password(env, &source, &resolved_alias, &hostname) {
226 Ok(password) => {
227 debug!("[purple] Askpass retrieved password for {resolved_alias} via {source}");
228 print!("{}", password);
229 Ok(())
230 }
231 Err(err) => {
232 warn!("[external] Password retrieval failed via {source}");
233 debug!("[external] Password retrieval detail: {err}");
234 if let Some(m) = &marker {
235 let _ = std::fs::remove_file(m);
236 }
237 std::process::exit(1);
238 }
239 }
240}
241
242fn handle_pin_prompt(
247 env: &crate::runtime::env::Env,
248 alias: &str,
249 prompt: &str,
250 pin_prompt: crate::security_key::PinPrompt<'_>,
251) -> Result<()> {
252 use crate::security_key as sk;
253 let key_path = pin_prompt.key_path;
254 if !crate::askpass_env::runs_in_background(env) {
255 match sk::read_pin_from_tty(prompt) {
256 Ok(Some(pin)) => {
257 debug!("[purple] Askpass read the security key PIN on the terminal for {key_path}");
258 print!("{}", pin);
259 return Ok(());
260 }
261 Ok(None) => debug!("[purple] Askpass security key PIN canceled for {key_path}"),
262 Err(e) => debug!("[purple] Askpass could not ask for the security key PIN: {e}"),
263 }
264 std::process::exit(1);
265 }
266 let paths = env.paths();
267 let ssh_pid = parent_pid();
268 if let Some(pin) = sk::pin_from_env(env, key_path) {
269 if let Some(reason) = sk::refusal_for(paths, key_path) {
270 debug!("[purple] Askpass withheld the PIN for {key_path}: refused before ({reason:?})");
271 } else if sk::handed_out(paths, alias, ssh_pid, key_path) {
272 debug!("[purple] Askpass withheld the PIN for {key_path}: this ssh already had it");
275 std::process::exit(1);
276 } else if sk::claim_hand_out(paths, alias, ssh_pid, key_path) {
277 print!("{}", pin);
278 return Ok(());
279 } else {
280 debug!("[purple] Askpass withheld the PIN for {key_path}: refused while it waited");
281 }
282 }
283 if let Some(flow) = sk::flow_from_env(env)
284 && !alias.is_empty()
285 {
286 sk::arm_pin_request(paths, flow, alias, key_path);
287 }
288 std::process::exit(1);
289}
290
291fn parent_pid() -> u32 {
293 #[cfg(unix)]
294 {
295 std::os::unix::process::parent_id()
296 }
297 #[cfg(not(unix))]
298 {
299 0
300 }
301}
302
303fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
310 let idx = prompt.find("'s password")?;
311 let head = &prompt[..idx];
312 let (_, host) = head.rsplit_once('@')?;
313 let host = host.trim();
314 let host = host
315 .strip_prefix('[')
316 .and_then(|s| s.strip_suffix(']'))
317 .unwrap_or(host);
318 if host.is_empty() { None } else { Some(host) }
319}
320
321fn find_alias_for_host(
328 config: &SshConfigFile,
329 host: &str,
330 permitted: &HashSet<String>,
331) -> Option<String> {
332 let mut by_hostname: Option<String> = None;
333 for entry in config.host_entries() {
334 if !permitted.contains(&entry.alias) {
335 continue;
336 }
337 if entry.alias.eq_ignore_ascii_case(host) {
338 return Some(entry.alias.clone());
339 }
340 if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
341 by_hostname = Some(entry.alias.clone());
342 }
343 }
344 by_hostname
345}
346
347fn chain_uses_jump_host(config: &SshConfigFile, chain: &HashSet<String>) -> bool {
354 config.host_entries().iter().any(|e| {
355 chain.contains(&e.alias) && (!e.proxy_jump.trim().is_empty() || e.has_proxy_command)
356 })
357}
358
359const PROXY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
365
366fn ssh_resolves_a_proxy(
374 env: &crate::runtime::env::Env,
375 config_path: &str,
376 alias: &str,
377) -> Option<bool> {
378 let mut child = env
379 .command("ssh")
380 .arg("-G")
381 .arg("-F")
382 .arg(config_path)
383 .arg("--")
384 .arg(alias)
385 .stdin(std::process::Stdio::null())
386 .stdout(std::process::Stdio::piped())
387 .stderr(std::process::Stdio::null())
388 .spawn()
389 .ok()?;
390 let Some(mut stdout) = child.stdout.take() else {
393 let _ = child.kill();
394 let _ = child.wait();
395 return None;
396 };
397 let (tx, rx) = std::sync::mpsc::channel();
398 std::thread::spawn(move || {
399 let mut text = String::new();
400 let read = std::io::Read::read_to_string(&mut stdout, &mut text);
401 let _ = tx.send(read.ok().map(|_| text));
402 });
403 let Ok(Some(text)) = rx.recv_timeout(PROXY_PROBE_TIMEOUT) else {
404 debug!("[purple] Askpass proxy probe gave no answer for {alias}");
405 let _ = child.kill();
406 let _ = child.wait();
407 return None;
408 };
409 if !child.wait().ok()?.success() {
410 return None;
411 }
412 Some(
413 text.lines()
414 .any(|l| l.starts_with("proxycommand ") || l.starts_with("proxyjump ")),
415 )
416}
417
418fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
424 let entries = config.host_entries();
425 let mut chain: HashSet<String> = HashSet::new();
426 let mut queue: Vec<String> = vec![target.to_string()];
427 while let Some(current) = queue.pop() {
428 if !chain.insert(current.clone()) {
429 continue;
430 }
431 let Some(entry) = entries.iter().find(|e| e.alias == current) else {
432 continue;
433 };
434 if entry.proxy_jump.is_empty() {
435 continue;
436 }
437 for jump in entry.proxy_jump.split(',') {
438 let host = parse_proxy_jump_host(jump);
439 if host.is_empty() {
440 continue;
441 }
442 for e in &entries {
443 if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
444 queue.push(e.alias.clone());
445 }
446 }
447 }
448 }
449 chain
450}
451
452fn parse_proxy_jump_host(jump: &str) -> &str {
455 let trimmed = jump.trim();
456 let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
457 if let Some(rest) = after_user.strip_prefix('[')
458 && let Some(end) = rest.find(']')
459 {
460 return &rest[..end];
461 }
462 after_user.split(':').next().unwrap_or(after_user)
463}
464
465fn find_askpass_source(
467 config: &SshConfigFile,
468 paths: Option<&crate::runtime::env::Paths>,
469 alias: &str,
470) -> Option<String> {
471 for entry in config.host_entries() {
473 if entry.alias == alias
474 && let Some(ref source) = entry.askpass
475 {
476 return Some(source.clone());
477 }
478 }
479 load_askpass_default_direct(paths)
481}
482
483fn load_askpass_default_direct(paths: Option<&crate::runtime::env::Paths>) -> Option<String> {
486 let path = paths?.preferences();
487 let content = std::fs::read_to_string(path).ok()?;
488 for line in content.lines() {
489 let line = line.trim();
490 if line.starts_with('#') || line.is_empty() {
491 continue;
492 }
493 if let Some((k, v)) = line.split_once('=')
494 && k.trim() == "askpass"
495 {
496 let val = v.trim();
497 if !val.is_empty() {
498 return Some(val.to_string());
499 }
500 }
501 }
502 None
503}
504
505fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
507 for entry in config.host_entries() {
508 if entry.alias == alias {
509 return entry.hostname.clone();
510 }
511 }
512 alias.to_string()
513}
514
515fn retrieve_password(
517 env: &crate::runtime::env::Env,
518 source: &str,
519 alias: &str,
520 hostname: &str,
521) -> Result<String> {
522 if source == "keychain" {
523 return retrieve_from_keychain(env, alias);
524 }
525 if let Some(uri) = source.strip_prefix("op://") {
526 return retrieve_from_1password(env, &format!("op://{}", uri));
527 }
528 if let Some(entry) = source.strip_prefix("pass:") {
529 return retrieve_from_pass(env, entry);
530 }
531 if let Some(item_id) = source.strip_prefix("bw:") {
532 return retrieve_from_bitwarden(env, item_id);
533 }
534 if let Some(rest) = source.strip_prefix("vault:") {
535 return retrieve_from_vault(env, rest);
536 }
537 if let Some(spec) = source.strip_prefix("proton:") {
538 return retrieve_from_proton_pass(env, spec);
539 }
540 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
542 retrieve_from_command(env, cmd, alias, hostname)
543}
544
545fn retrieve_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
547 #[cfg(target_os = "macos")]
548 {
549 let output = env
550 .command("security")
551 .args([
552 "find-generic-password",
553 "-a",
554 alias,
555 "-s",
556 "purple-ssh",
557 "-w",
558 ])
559 .output()
560 .context("Failed to run security command")?;
561 if !output.status.success() {
562 let stderr = String::from_utf8_lossy(&output.stderr);
563 log::warn!(
564 "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
565 alias,
566 output.status.code().unwrap_or(-1),
567 stderr.trim().lines().next().unwrap_or("<empty>"),
568 );
569 anyhow::bail!("Keychain lookup failed");
570 }
571 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
572 }
573 #[cfg(not(target_os = "macos"))]
574 {
575 let output = env
576 .command("secret-tool")
577 .args(["lookup", "application", "purple-ssh", "host", alias])
578 .output()
579 .context("Failed to run secret-tool")?;
580 if !output.status.success() {
581 let stderr = String::from_utf8_lossy(&output.stderr);
582 log::warn!(
583 "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
584 alias,
585 output.status.code().unwrap_or(-1),
586 stderr.trim().lines().next().unwrap_or("<empty>"),
587 );
588 anyhow::bail!("Secret-tool lookup failed");
589 }
590 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
591 }
592}
593
594pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
596 retrieve_from_keychain(env, alias).is_ok()
597}
598
599pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
601 retrieve_from_keychain(env, alias)
602}
603
604pub fn store_in_keychain(
606 env: &crate::runtime::env::Env,
607 alias: &str,
608 password: &str,
609) -> Result<()> {
610 #[cfg(target_os = "macos")]
611 {
612 let status = env
613 .command("security")
614 .args([
615 "add-generic-password",
616 "-U",
617 "-a",
618 alias,
619 "-s",
620 "purple-ssh",
621 "-w",
622 password,
623 ])
624 .status()
625 .context("Failed to run security command")?;
626 if !status.success() {
627 anyhow::bail!("Failed to store password in Keychain");
628 }
629 Ok(())
630 }
631 #[cfg(not(target_os = "macos"))]
632 {
633 let mut child = env
634 .command("secret-tool")
635 .args([
636 "store",
637 "--label",
638 &format!("purple-ssh: {}", alias),
639 "application",
640 "purple-ssh",
641 "host",
642 alias,
643 ])
644 .stdin(std::process::Stdio::piped())
645 .spawn()
646 .context("Failed to run secret-tool")?;
647 if let Some(ref mut stdin) = child.stdin {
648 use std::io::Write;
649 stdin.write_all(password.as_bytes())?;
650 }
651 let status = child.wait()?;
652 if !status.success() {
653 anyhow::bail!("Failed to store password with secret-tool");
654 }
655 Ok(())
656 }
657}
658
659pub fn remove_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<()> {
661 #[cfg(target_os = "macos")]
662 {
663 let status = env
664 .command("security")
665 .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
666 .status()
667 .context("Failed to run security command")?;
668 if !status.success() {
669 anyhow::bail!("No password found for '{}' in Keychain", alias);
670 }
671 Ok(())
672 }
673 #[cfg(not(target_os = "macos"))]
674 {
675 let status = env
676 .command("secret-tool")
677 .args(["clear", "application", "purple-ssh", "host", alias])
678 .status()
679 .context("Failed to run secret-tool")?;
680 if !status.success() {
681 anyhow::bail!("Failed to remove password with secret-tool");
682 }
683 Ok(())
684 }
685}
686
687fn retrieve_from_1password(env: &crate::runtime::env::Env, uri: &str) -> Result<String> {
689 let result = env
690 .command("op")
691 .args(["read", uri, "--no-newline"])
692 .output();
693 let output = match result {
694 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
695 error!("[config] Password manager binary not found: op");
696 return Err(e).context("Failed to run 1Password CLI (op)");
697 }
698 other => other.context("Failed to run 1Password CLI (op)")?,
699 };
700 if !output.status.success() {
701 let stderr = String::from_utf8_lossy(&output.stderr);
702 log::warn!(
703 "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
704 uri,
705 output.status.code().unwrap_or(-1),
706 stderr.trim().lines().next().unwrap_or("<empty>"),
707 );
708 anyhow::bail!("1Password lookup failed");
709 }
710 Ok(String::from_utf8_lossy(&output.stdout).to_string())
711}
712
713fn retrieve_from_pass(env: &crate::runtime::env::Env, entry: &str) -> Result<String> {
715 let result = env.command("pass").args(["show", entry]).output();
716 let output = match result {
717 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
718 error!("[config] Password manager binary not found: pass");
719 return Err(e).context("Failed to run pass");
720 }
721 other => other.context("Failed to run pass")?,
722 };
723 if !output.status.success() {
724 let stderr = String::from_utf8_lossy(&output.stderr);
725 log::warn!(
726 "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
727 entry,
728 output.status.code().unwrap_or(-1),
729 stderr.trim().lines().next().unwrap_or("<empty>"),
730 );
731 anyhow::bail!("pass lookup failed");
732 }
733 let full = String::from_utf8_lossy(&output.stdout);
734 Ok(full.lines().next().unwrap_or("").to_string())
735}
736
737fn retrieve_from_bitwarden(env: &crate::runtime::env::Env, item_id: &str) -> Result<String> {
740 let result = env
741 .command("bw")
742 .args(["get", "password", item_id])
743 .output();
744 let output = match result {
745 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
746 error!("[config] Password manager binary not found: bw");
747 return Err(e).context("Failed to run Bitwarden CLI (bw)");
748 }
749 other => other.context("Failed to run Bitwarden CLI (bw)")?,
750 };
751 if !output.status.success() {
752 let stderr = String::from_utf8_lossy(&output.stderr);
753 log::warn!(
754 "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
755 item_id,
756 output.status.code().unwrap_or(-1),
757 stderr.trim().lines().next().unwrap_or("<empty>"),
758 );
759 anyhow::bail!("Bitwarden lookup failed");
760 }
761 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
762}
763
764fn retrieve_from_vault(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
769 let (path, field) = match spec.rsplit_once('#') {
770 Some((p, f)) => (p, f),
771 None => (spec, "password"),
772 };
773 let result = env
774 .command("vault")
775 .args(["kv", "get", &format!("-field={}", field), path])
776 .output();
777 let output = match result {
778 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
779 error!("[config] Password manager binary not found: vault");
780 return Err(e).context("Failed to run vault CLI");
781 }
782 other => other.context("Failed to run vault CLI")?,
783 };
784 if !output.status.success() {
785 let stderr = String::from_utf8_lossy(&output.stderr);
786 log::warn!(
787 "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
788 path,
789 field,
790 output.status.code().unwrap_or(-1),
791 stderr.trim().lines().next().unwrap_or("<empty>"),
792 );
793 anyhow::bail!("Vault lookup failed");
794 }
795 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
796}
797
798fn retrieve_from_command(
801 env: &crate::runtime::env::Env,
802 cmd: &str,
803 alias: &str,
804 hostname: &str,
805) -> Result<String> {
806 let safe_alias = crate::snippet::shell_escape(alias);
807 let safe_hostname = crate::snippet::shell_escape(hostname);
808 let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
809 let output = env
810 .command("sh")
811 .args(["-c", &expanded])
812 .output()
813 .context("Failed to run custom askpass command")?;
814 if !output.status.success() {
815 let stderr = String::from_utf8_lossy(&output.stderr);
816 log::warn!(
817 "[external] askpass custom command failed: alias={} exit={} stderr={}",
818 alias,
819 output.status.code().unwrap_or(-1),
820 stderr.trim().lines().next().unwrap_or("<empty>"),
821 );
822 anyhow::bail!("Custom askpass command failed");
823 }
824 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
825}
826
827fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
830 paths.map(|p| p.askpass_marker(alias))
831}
832
833pub(crate) const WITHHELD_MARKER_PREFIX: &str = ".askpass_withheld.";
837
838fn withheld_marker_path(
842 paths: Option<&crate::runtime::env::Paths>,
843 alias: &str,
844) -> Option<PathBuf> {
845 paths.map(|p| p.askpass_withheld_marker(alias))
846}
847
848pub fn take_withheld(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> bool {
856 let Some(path) = withheld_marker_path(paths, alias) else {
857 return false;
858 };
859 let recent = is_recent_marker(&path);
860 let _ = std::fs::remove_file(&path);
861 recent
862}
863
864fn arm_marker(path: &Path) {
867 if let Err(e) = std::fs::create_dir_all(path.parent().unwrap()) {
868 debug!("[purple] Failed to create askpass marker directory: {e}");
869 }
870 if let Err(e) = crate::fs_util::atomic_write(path, b"") {
871 debug!("[purple] Failed to write askpass marker: {e}");
872 }
873}
874
875fn is_recent_marker(path: &PathBuf) -> bool {
877 if let Ok(meta) = std::fs::metadata(path)
878 && let Ok(modified) = meta.modified()
879 && let Ok(elapsed) = SystemTime::now().duration_since(modified)
880 {
881 return elapsed.as_secs() < 60;
882 }
883 false
884}
885
886pub fn cleanup_marker(paths: Option<&crate::runtime::env::Paths>, _alias: &str) {
893 let Some(paths) = paths else {
894 return;
895 };
896 let Ok(read) = std::fs::read_dir(paths.state_dir()) else {
897 return;
898 };
899 for entry in read.flatten() {
900 if entry.file_name().to_str().is_some_and(|s| {
904 s.starts_with(".askpass_")
905 && !s.starts_with(WITHHELD_MARKER_PREFIX)
906 && !s.starts_with(crate::security_key::PIN_MARKER_PREFIX)
907 }) {
908 let _ = std::fs::remove_file(entry.path());
909 }
910 }
911}
912
913#[allow(dead_code)]
915pub fn describe_source(source: &str) -> &str {
916 if source == "keychain" {
917 "OS Keychain"
918 } else if source.starts_with("op://") {
919 "1Password"
920 } else if source.starts_with("proton:") {
921 "Proton Pass"
922 } else if source.starts_with("pass:") {
923 "pass"
924 } else if source.starts_with("bw:") {
925 "Bitwarden"
926 } else if source.starts_with("vault:") {
927 "HashiCorp Vault KV"
928 } else {
929 "Custom command"
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq)]
935pub enum BwStatus {
936 Unlocked,
937 Locked,
938 NotAuthenticated,
939 NotInstalled,
940}
941
942fn parse_bw_status(stdout: &str) -> BwStatus {
944 if let Some(status) = stdout
945 .split("\"status\":")
946 .nth(1)
947 .and_then(|s| s.split('"').nth(1))
948 {
949 match status {
950 "unlocked" => BwStatus::Unlocked,
951 "locked" => BwStatus::Locked,
952 "unauthenticated" => BwStatus::NotAuthenticated,
953 _ => BwStatus::Locked,
954 }
955 } else {
956 BwStatus::NotInstalled
957 }
958}
959
960pub fn bw_vault_status(env: &crate::runtime::env::Env) -> BwStatus {
962 let output = match env.command("bw").arg("status").output() {
963 Ok(o) => o,
964 Err(_) => return BwStatus::NotInstalled,
965 };
966 let stdout = String::from_utf8_lossy(&output.stdout);
967 parse_bw_status(&stdout)
968}
969
970pub fn bw_unlock(env: &crate::runtime::env::Env, password: &str) -> Result<String> {
974 let output = env
975 .command("bw")
976 .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
977 .env("PURPLE_BW_MASTER", password)
978 .output()
979 .context("Failed to run Bitwarden CLI (bw)")?;
980 if !output.status.success() {
981 let stderr = String::from_utf8_lossy(&output.stderr);
982 anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
983 }
984 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
985 if token.is_empty() {
986 anyhow::bail!("Bitwarden unlock returned empty session token");
987 }
988 Ok(token)
989}
990
991#[derive(Debug, Clone, Copy, PartialEq)]
993pub enum ProtonStatus {
994 Authenticated,
995 NotAuthenticated,
996 NotInstalled,
997}
998
999pub fn proton_status(env: &crate::runtime::env::Env) -> ProtonStatus {
1004 let result = env.command("pass-cli").arg("test").output();
1005 let status = match result {
1006 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
1007 Err(_) => ProtonStatus::NotAuthenticated,
1008 Ok(out) if out.status.success() => ProtonStatus::Authenticated,
1009 Ok(_) => ProtonStatus::NotAuthenticated,
1010 };
1011 debug!("[external] Proton Pass status: {status:?}");
1012 status
1013}
1014
1015pub fn proton_login(env: &crate::runtime::env::Env, pat: &str) -> Result<()> {
1020 if pat.is_empty() {
1021 anyhow::bail!("empty PAT");
1022 }
1023 let output = env
1024 .command("pass-cli")
1025 .arg("login")
1026 .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
1027 .output()
1028 .context("Failed to run Proton Pass CLI (pass-cli)")?;
1029 if !output.status.success() {
1030 let stderr = String::from_utf8_lossy(&output.stderr);
1031 debug!("[external] Proton Pass login failed: {}", stderr.trim());
1032 anyhow::bail!("{}", stderr.trim());
1033 }
1034 debug!("[external] Proton Pass login succeeded");
1035 Ok(())
1036}
1037
1038fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
1042 let (vault, rest) = spec
1043 .split_once('/')
1044 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
1045 let (item, field) = rest
1046 .split_once('/')
1047 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
1048 if vault.is_empty() || item.is_empty() || field.is_empty() {
1049 anyhow::bail!("Proton Pass spec segments must be non-empty");
1050 }
1051 Ok((vault, item, field))
1052}
1053
1054fn retrieve_from_proton_pass(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
1060 let (vault, item, field) = parse_proton_spec(spec)?;
1061 let result = env
1062 .command("pass-cli")
1063 .args([
1064 "item",
1065 "view",
1066 "--vault-name",
1067 vault,
1068 "--item-title",
1069 item,
1070 "--field",
1071 field,
1072 ])
1073 .output();
1074 let output = match result {
1075 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1076 error!("[config] Password manager binary not found: pass-cli");
1077 return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
1078 }
1079 other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
1080 };
1081 if !output.status.success() {
1082 let stderr = String::from_utf8_lossy(&output.stderr);
1083 warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
1084 anyhow::bail!("Proton Pass lookup failed");
1085 }
1086 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1087 if value.is_empty() {
1088 warn!("[external] Proton Pass returned empty secret");
1089 anyhow::bail!("Proton Pass returned empty secret");
1090 }
1091 debug!("[external] Proton Pass lookup succeeded");
1092 Ok(value)
1093}
1094
1095#[cfg(test)]
1096#[path = "askpass_tests.rs"]
1097mod tests;