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 let attributable = prompt_hop.is_some()
127 || match ssh_resolves_a_proxy(env, &config_path, &alias) {
128 Some(proxied) => !proxied,
129 None => prompt_host.is_none() && !chain_uses_jump_host(&config, &chain),
134 };
135
136 if let (Some(oneshot_alias), Some(secret)) = (
146 env.var(crate::askpass_env::ONESHOT_ALIAS_VAR),
147 env.var(crate::askpass_env::ONESHOT_SECRET_VAR),
148 ) && oneshot_alias == resolved_alias
149 && attributable
150 {
151 debug!("[purple] Askpass one-shot password used for {resolved_alias}");
152 print!("{}", secret);
153 return Ok(());
154 }
155
156 if !attributable {
165 debug!(
166 "[purple] Askpass withheld for {resolved_alias}: prompt_host={:?} placed_in_chain=false chain_size={}",
167 prompt_host,
168 chain.len()
169 );
170 if let Some(path) = withheld_marker_path(env.paths(), &alias) {
171 arm_marker(&path);
172 }
173 std::process::exit(1);
174 }
175
176 let single_attempt = env
185 .var(crate::askpass_env::SINGLE_ATTEMPT_VAR)
186 .is_some_and(|v| v == "1");
187 if let Some(marker_path) = &marker
188 && !single_attempt
189 {
190 if is_recent_marker(marker_path) {
191 debug!("[purple] Askpass retry detected for {resolved_alias}");
192 let _ = std::fs::remove_file(marker_path);
193 std::process::exit(1);
194 }
195 arm_marker(marker_path);
196 }
197
198 let source = find_askpass_source(&config, env.paths(), &resolved_alias);
199
200 let source = match source {
201 Some(s) => s,
202 None => std::process::exit(1),
203 };
204
205 debug!("[purple] Askpass invoked for alias={resolved_alias} source={source}");
206
207 let hostname = find_hostname(&config, &resolved_alias);
208 match retrieve_password(env, &source, &resolved_alias, &hostname) {
209 Ok(password) => {
210 debug!("[purple] Askpass retrieved password for {resolved_alias} via {source}");
211 print!("{}", password);
212 Ok(())
213 }
214 Err(err) => {
215 warn!("[external] Password retrieval failed via {source}");
216 debug!("[external] Password retrieval detail: {err}");
217 if let Some(m) = &marker {
218 let _ = std::fs::remove_file(m);
219 }
220 std::process::exit(1);
221 }
222 }
223}
224
225fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
232 let idx = prompt.find("'s password")?;
233 let head = &prompt[..idx];
234 let (_, host) = head.rsplit_once('@')?;
235 let host = host.trim();
236 let host = host
237 .strip_prefix('[')
238 .and_then(|s| s.strip_suffix(']'))
239 .unwrap_or(host);
240 if host.is_empty() { None } else { Some(host) }
241}
242
243fn find_alias_for_host(
250 config: &SshConfigFile,
251 host: &str,
252 permitted: &HashSet<String>,
253) -> Option<String> {
254 let mut by_hostname: Option<String> = None;
255 for entry in config.host_entries() {
256 if !permitted.contains(&entry.alias) {
257 continue;
258 }
259 if entry.alias.eq_ignore_ascii_case(host) {
260 return Some(entry.alias.clone());
261 }
262 if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
263 by_hostname = Some(entry.alias.clone());
264 }
265 }
266 by_hostname
267}
268
269fn chain_uses_jump_host(config: &SshConfigFile, chain: &HashSet<String>) -> bool {
276 config.host_entries().iter().any(|e| {
277 chain.contains(&e.alias) && (!e.proxy_jump.trim().is_empty() || e.has_proxy_command)
278 })
279}
280
281const PROXY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
287
288fn ssh_resolves_a_proxy(
296 env: &crate::runtime::env::Env,
297 config_path: &str,
298 alias: &str,
299) -> Option<bool> {
300 let mut child = env
301 .command("ssh")
302 .arg("-G")
303 .arg("-F")
304 .arg(config_path)
305 .arg("--")
306 .arg(alias)
307 .stdin(std::process::Stdio::null())
308 .stdout(std::process::Stdio::piped())
309 .stderr(std::process::Stdio::null())
310 .spawn()
311 .ok()?;
312 let Some(mut stdout) = child.stdout.take() else {
315 let _ = child.kill();
316 let _ = child.wait();
317 return None;
318 };
319 let (tx, rx) = std::sync::mpsc::channel();
320 std::thread::spawn(move || {
321 let mut text = String::new();
322 let read = std::io::Read::read_to_string(&mut stdout, &mut text);
323 let _ = tx.send(read.ok().map(|_| text));
324 });
325 let Ok(Some(text)) = rx.recv_timeout(PROXY_PROBE_TIMEOUT) else {
326 debug!("[purple] Askpass proxy probe gave no answer for {alias}");
327 let _ = child.kill();
328 let _ = child.wait();
329 return None;
330 };
331 if !child.wait().ok()?.success() {
332 return None;
333 }
334 Some(
335 text.lines()
336 .any(|l| l.starts_with("proxycommand ") || l.starts_with("proxyjump ")),
337 )
338}
339
340fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
346 let entries = config.host_entries();
347 let mut chain: HashSet<String> = HashSet::new();
348 let mut queue: Vec<String> = vec![target.to_string()];
349 while let Some(current) = queue.pop() {
350 if !chain.insert(current.clone()) {
351 continue;
352 }
353 let Some(entry) = entries.iter().find(|e| e.alias == current) else {
354 continue;
355 };
356 if entry.proxy_jump.is_empty() {
357 continue;
358 }
359 for jump in entry.proxy_jump.split(',') {
360 let host = parse_proxy_jump_host(jump);
361 if host.is_empty() {
362 continue;
363 }
364 for e in &entries {
365 if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
366 queue.push(e.alias.clone());
367 }
368 }
369 }
370 }
371 chain
372}
373
374fn parse_proxy_jump_host(jump: &str) -> &str {
377 let trimmed = jump.trim();
378 let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
379 if let Some(rest) = after_user.strip_prefix('[')
380 && let Some(end) = rest.find(']')
381 {
382 return &rest[..end];
383 }
384 after_user.split(':').next().unwrap_or(after_user)
385}
386
387fn find_askpass_source(
389 config: &SshConfigFile,
390 paths: Option<&crate::runtime::env::Paths>,
391 alias: &str,
392) -> Option<String> {
393 for entry in config.host_entries() {
395 if entry.alias == alias
396 && let Some(ref source) = entry.askpass
397 {
398 return Some(source.clone());
399 }
400 }
401 load_askpass_default_direct(paths)
403}
404
405fn load_askpass_default_direct(paths: Option<&crate::runtime::env::Paths>) -> Option<String> {
408 let path = paths?.preferences();
409 let content = std::fs::read_to_string(path).ok()?;
410 for line in content.lines() {
411 let line = line.trim();
412 if line.starts_with('#') || line.is_empty() {
413 continue;
414 }
415 if let Some((k, v)) = line.split_once('=')
416 && k.trim() == "askpass"
417 {
418 let val = v.trim();
419 if !val.is_empty() {
420 return Some(val.to_string());
421 }
422 }
423 }
424 None
425}
426
427fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
429 for entry in config.host_entries() {
430 if entry.alias == alias {
431 return entry.hostname.clone();
432 }
433 }
434 alias.to_string()
435}
436
437fn retrieve_password(
439 env: &crate::runtime::env::Env,
440 source: &str,
441 alias: &str,
442 hostname: &str,
443) -> Result<String> {
444 if source == "keychain" {
445 return retrieve_from_keychain(env, alias);
446 }
447 if let Some(uri) = source.strip_prefix("op://") {
448 return retrieve_from_1password(env, &format!("op://{}", uri));
449 }
450 if let Some(entry) = source.strip_prefix("pass:") {
451 return retrieve_from_pass(env, entry);
452 }
453 if let Some(item_id) = source.strip_prefix("bw:") {
454 return retrieve_from_bitwarden(env, item_id);
455 }
456 if let Some(rest) = source.strip_prefix("vault:") {
457 return retrieve_from_vault(env, rest);
458 }
459 if let Some(spec) = source.strip_prefix("proton:") {
460 return retrieve_from_proton_pass(env, spec);
461 }
462 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
464 retrieve_from_command(env, cmd, alias, hostname)
465}
466
467fn retrieve_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
469 #[cfg(target_os = "macos")]
470 {
471 let output = env
472 .command("security")
473 .args([
474 "find-generic-password",
475 "-a",
476 alias,
477 "-s",
478 "purple-ssh",
479 "-w",
480 ])
481 .output()
482 .context("Failed to run security command")?;
483 if !output.status.success() {
484 let stderr = String::from_utf8_lossy(&output.stderr);
485 log::warn!(
486 "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
487 alias,
488 output.status.code().unwrap_or(-1),
489 stderr.trim().lines().next().unwrap_or("<empty>"),
490 );
491 anyhow::bail!("Keychain lookup failed");
492 }
493 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
494 }
495 #[cfg(not(target_os = "macos"))]
496 {
497 let output = env
498 .command("secret-tool")
499 .args(["lookup", "application", "purple-ssh", "host", alias])
500 .output()
501 .context("Failed to run secret-tool")?;
502 if !output.status.success() {
503 let stderr = String::from_utf8_lossy(&output.stderr);
504 log::warn!(
505 "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
506 alias,
507 output.status.code().unwrap_or(-1),
508 stderr.trim().lines().next().unwrap_or("<empty>"),
509 );
510 anyhow::bail!("Secret-tool lookup failed");
511 }
512 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
513 }
514}
515
516pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
518 retrieve_from_keychain(env, alias).is_ok()
519}
520
521pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
523 retrieve_from_keychain(env, alias)
524}
525
526pub fn store_in_keychain(
528 env: &crate::runtime::env::Env,
529 alias: &str,
530 password: &str,
531) -> Result<()> {
532 #[cfg(target_os = "macos")]
533 {
534 let status = env
535 .command("security")
536 .args([
537 "add-generic-password",
538 "-U",
539 "-a",
540 alias,
541 "-s",
542 "purple-ssh",
543 "-w",
544 password,
545 ])
546 .status()
547 .context("Failed to run security command")?;
548 if !status.success() {
549 anyhow::bail!("Failed to store password in Keychain");
550 }
551 Ok(())
552 }
553 #[cfg(not(target_os = "macos"))]
554 {
555 let mut child = env
556 .command("secret-tool")
557 .args([
558 "store",
559 "--label",
560 &format!("purple-ssh: {}", alias),
561 "application",
562 "purple-ssh",
563 "host",
564 alias,
565 ])
566 .stdin(std::process::Stdio::piped())
567 .spawn()
568 .context("Failed to run secret-tool")?;
569 if let Some(ref mut stdin) = child.stdin {
570 use std::io::Write;
571 stdin.write_all(password.as_bytes())?;
572 }
573 let status = child.wait()?;
574 if !status.success() {
575 anyhow::bail!("Failed to store password with secret-tool");
576 }
577 Ok(())
578 }
579}
580
581pub fn remove_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<()> {
583 #[cfg(target_os = "macos")]
584 {
585 let status = env
586 .command("security")
587 .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
588 .status()
589 .context("Failed to run security command")?;
590 if !status.success() {
591 anyhow::bail!("No password found for '{}' in Keychain", alias);
592 }
593 Ok(())
594 }
595 #[cfg(not(target_os = "macos"))]
596 {
597 let status = env
598 .command("secret-tool")
599 .args(["clear", "application", "purple-ssh", "host", alias])
600 .status()
601 .context("Failed to run secret-tool")?;
602 if !status.success() {
603 anyhow::bail!("Failed to remove password with secret-tool");
604 }
605 Ok(())
606 }
607}
608
609fn retrieve_from_1password(env: &crate::runtime::env::Env, uri: &str) -> Result<String> {
611 let result = env
612 .command("op")
613 .args(["read", uri, "--no-newline"])
614 .output();
615 let output = match result {
616 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
617 error!("[config] Password manager binary not found: op");
618 return Err(e).context("Failed to run 1Password CLI (op)");
619 }
620 other => other.context("Failed to run 1Password CLI (op)")?,
621 };
622 if !output.status.success() {
623 let stderr = String::from_utf8_lossy(&output.stderr);
624 log::warn!(
625 "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
626 uri,
627 output.status.code().unwrap_or(-1),
628 stderr.trim().lines().next().unwrap_or("<empty>"),
629 );
630 anyhow::bail!("1Password lookup failed");
631 }
632 Ok(String::from_utf8_lossy(&output.stdout).to_string())
633}
634
635fn retrieve_from_pass(env: &crate::runtime::env::Env, entry: &str) -> Result<String> {
637 let result = env.command("pass").args(["show", entry]).output();
638 let output = match result {
639 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
640 error!("[config] Password manager binary not found: pass");
641 return Err(e).context("Failed to run pass");
642 }
643 other => other.context("Failed to run pass")?,
644 };
645 if !output.status.success() {
646 let stderr = String::from_utf8_lossy(&output.stderr);
647 log::warn!(
648 "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
649 entry,
650 output.status.code().unwrap_or(-1),
651 stderr.trim().lines().next().unwrap_or("<empty>"),
652 );
653 anyhow::bail!("pass lookup failed");
654 }
655 let full = String::from_utf8_lossy(&output.stdout);
656 Ok(full.lines().next().unwrap_or("").to_string())
657}
658
659fn retrieve_from_bitwarden(env: &crate::runtime::env::Env, item_id: &str) -> Result<String> {
662 let result = env
663 .command("bw")
664 .args(["get", "password", item_id])
665 .output();
666 let output = match result {
667 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
668 error!("[config] Password manager binary not found: bw");
669 return Err(e).context("Failed to run Bitwarden CLI (bw)");
670 }
671 other => other.context("Failed to run Bitwarden CLI (bw)")?,
672 };
673 if !output.status.success() {
674 let stderr = String::from_utf8_lossy(&output.stderr);
675 log::warn!(
676 "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
677 item_id,
678 output.status.code().unwrap_or(-1),
679 stderr.trim().lines().next().unwrap_or("<empty>"),
680 );
681 anyhow::bail!("Bitwarden lookup failed");
682 }
683 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
684}
685
686fn retrieve_from_vault(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
691 let (path, field) = match spec.rsplit_once('#') {
692 Some((p, f)) => (p, f),
693 None => (spec, "password"),
694 };
695 let result = env
696 .command("vault")
697 .args(["kv", "get", &format!("-field={}", field), path])
698 .output();
699 let output = match result {
700 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
701 error!("[config] Password manager binary not found: vault");
702 return Err(e).context("Failed to run vault CLI");
703 }
704 other => other.context("Failed to run vault CLI")?,
705 };
706 if !output.status.success() {
707 let stderr = String::from_utf8_lossy(&output.stderr);
708 log::warn!(
709 "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
710 path,
711 field,
712 output.status.code().unwrap_or(-1),
713 stderr.trim().lines().next().unwrap_or("<empty>"),
714 );
715 anyhow::bail!("Vault lookup failed");
716 }
717 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
718}
719
720fn retrieve_from_command(
723 env: &crate::runtime::env::Env,
724 cmd: &str,
725 alias: &str,
726 hostname: &str,
727) -> Result<String> {
728 let safe_alias = crate::snippet::shell_escape(alias);
729 let safe_hostname = crate::snippet::shell_escape(hostname);
730 let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
731 let output = env
732 .command("sh")
733 .args(["-c", &expanded])
734 .output()
735 .context("Failed to run custom askpass command")?;
736 if !output.status.success() {
737 let stderr = String::from_utf8_lossy(&output.stderr);
738 log::warn!(
739 "[external] askpass custom command failed: alias={} exit={} stderr={}",
740 alias,
741 output.status.code().unwrap_or(-1),
742 stderr.trim().lines().next().unwrap_or("<empty>"),
743 );
744 anyhow::bail!("Custom askpass command failed");
745 }
746 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
747}
748
749fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
752 paths.map(|p| p.askpass_marker(alias))
753}
754
755pub(crate) const WITHHELD_MARKER_PREFIX: &str = ".askpass_withheld.";
759
760fn withheld_marker_path(
764 paths: Option<&crate::runtime::env::Paths>,
765 alias: &str,
766) -> Option<PathBuf> {
767 paths.map(|p| p.askpass_withheld_marker(alias))
768}
769
770pub fn take_withheld(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> bool {
778 let Some(path) = withheld_marker_path(paths, alias) else {
779 return false;
780 };
781 let recent = is_recent_marker(&path);
782 let _ = std::fs::remove_file(&path);
783 recent
784}
785
786fn arm_marker(path: &Path) {
789 if let Err(e) = std::fs::create_dir_all(path.parent().unwrap()) {
790 debug!("[purple] Failed to create askpass marker directory: {e}");
791 }
792 if let Err(e) = crate::fs_util::atomic_write(path, b"") {
793 debug!("[purple] Failed to write askpass marker: {e}");
794 }
795}
796
797fn is_recent_marker(path: &PathBuf) -> bool {
799 if let Ok(meta) = std::fs::metadata(path)
800 && let Ok(modified) = meta.modified()
801 && let Ok(elapsed) = SystemTime::now().duration_since(modified)
802 {
803 return elapsed.as_secs() < 60;
804 }
805 false
806}
807
808pub fn cleanup_marker(paths: Option<&crate::runtime::env::Paths>, _alias: &str) {
815 let Some(paths) = paths else {
816 return;
817 };
818 let Ok(read) = std::fs::read_dir(paths.state_dir()) else {
819 return;
820 };
821 for entry in read.flatten() {
822 if entry
826 .file_name()
827 .to_str()
828 .is_some_and(|s| s.starts_with(".askpass_") && !s.starts_with(WITHHELD_MARKER_PREFIX))
829 {
830 let _ = std::fs::remove_file(entry.path());
831 }
832 }
833}
834
835#[allow(dead_code)]
837pub fn describe_source(source: &str) -> &str {
838 if source == "keychain" {
839 "OS Keychain"
840 } else if source.starts_with("op://") {
841 "1Password"
842 } else if source.starts_with("proton:") {
843 "Proton Pass"
844 } else if source.starts_with("pass:") {
845 "pass"
846 } else if source.starts_with("bw:") {
847 "Bitwarden"
848 } else if source.starts_with("vault:") {
849 "HashiCorp Vault KV"
850 } else {
851 "Custom command"
852 }
853}
854
855#[derive(Debug, Clone, Copy, PartialEq)]
857pub enum BwStatus {
858 Unlocked,
859 Locked,
860 NotAuthenticated,
861 NotInstalled,
862}
863
864fn parse_bw_status(stdout: &str) -> BwStatus {
866 if let Some(status) = stdout
867 .split("\"status\":")
868 .nth(1)
869 .and_then(|s| s.split('"').nth(1))
870 {
871 match status {
872 "unlocked" => BwStatus::Unlocked,
873 "locked" => BwStatus::Locked,
874 "unauthenticated" => BwStatus::NotAuthenticated,
875 _ => BwStatus::Locked,
876 }
877 } else {
878 BwStatus::NotInstalled
879 }
880}
881
882pub fn bw_vault_status(env: &crate::runtime::env::Env) -> BwStatus {
884 let output = match env.command("bw").arg("status").output() {
885 Ok(o) => o,
886 Err(_) => return BwStatus::NotInstalled,
887 };
888 let stdout = String::from_utf8_lossy(&output.stdout);
889 parse_bw_status(&stdout)
890}
891
892pub fn bw_unlock(env: &crate::runtime::env::Env, password: &str) -> Result<String> {
896 let output = env
897 .command("bw")
898 .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
899 .env("PURPLE_BW_MASTER", password)
900 .output()
901 .context("Failed to run Bitwarden CLI (bw)")?;
902 if !output.status.success() {
903 let stderr = String::from_utf8_lossy(&output.stderr);
904 anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
905 }
906 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
907 if token.is_empty() {
908 anyhow::bail!("Bitwarden unlock returned empty session token");
909 }
910 Ok(token)
911}
912
913#[derive(Debug, Clone, Copy, PartialEq)]
915pub enum ProtonStatus {
916 Authenticated,
917 NotAuthenticated,
918 NotInstalled,
919}
920
921pub fn proton_status(env: &crate::runtime::env::Env) -> ProtonStatus {
926 let result = env.command("pass-cli").arg("test").output();
927 let status = match result {
928 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
929 Err(_) => ProtonStatus::NotAuthenticated,
930 Ok(out) if out.status.success() => ProtonStatus::Authenticated,
931 Ok(_) => ProtonStatus::NotAuthenticated,
932 };
933 debug!("[external] Proton Pass status: {status:?}");
934 status
935}
936
937pub fn proton_login(env: &crate::runtime::env::Env, pat: &str) -> Result<()> {
942 if pat.is_empty() {
943 anyhow::bail!("empty PAT");
944 }
945 let output = env
946 .command("pass-cli")
947 .arg("login")
948 .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
949 .output()
950 .context("Failed to run Proton Pass CLI (pass-cli)")?;
951 if !output.status.success() {
952 let stderr = String::from_utf8_lossy(&output.stderr);
953 debug!("[external] Proton Pass login failed: {}", stderr.trim());
954 anyhow::bail!("{}", stderr.trim());
955 }
956 debug!("[external] Proton Pass login succeeded");
957 Ok(())
958}
959
960fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
964 let (vault, rest) = spec
965 .split_once('/')
966 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
967 let (item, field) = rest
968 .split_once('/')
969 .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
970 if vault.is_empty() || item.is_empty() || field.is_empty() {
971 anyhow::bail!("Proton Pass spec segments must be non-empty");
972 }
973 Ok((vault, item, field))
974}
975
976fn retrieve_from_proton_pass(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
982 let (vault, item, field) = parse_proton_spec(spec)?;
983 let result = env
984 .command("pass-cli")
985 .args([
986 "item",
987 "view",
988 "--vault-name",
989 vault,
990 "--item-title",
991 item,
992 "--field",
993 field,
994 ])
995 .output();
996 let output = match result {
997 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
998 error!("[config] Password manager binary not found: pass-cli");
999 return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
1000 }
1001 other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
1002 };
1003 if !output.status.success() {
1004 let stderr = String::from_utf8_lossy(&output.stderr);
1005 warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
1006 anyhow::bail!("Proton Pass lookup failed");
1007 }
1008 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1009 if value.is_empty() {
1010 warn!("[external] Proton Pass returned empty secret");
1011 anyhow::bail!("Proton Pass returned empty secret");
1012 }
1013 debug!("[external] Proton Pass lookup succeeded");
1014 Ok(value)
1015}
1016
1017#[cfg(test)]
1018#[path = "askpass_tests.rs"]
1019mod tests;