Skip to main content

purple_ssh/
askpass.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::SystemTime;
4
5use anyhow::{Context, Result};
6use log::{debug, error, warn};
7
8use crate::ssh_config::model::SshConfigFile;
9
10/// A password source option for the picker overlay.
11pub 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    // Vault KV secrets engine (key/value store). Distinct from the Vault SSH
39    // secrets engine used for signed SSH certificates, which has its own
40    // "Vault SSH role" field on the host form.
41    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
63/// Handle an SSH_ASKPASS invocation. Called when purple is invoked as an askpass program.
64/// Reads the password source from the host's `# purple:askpass` comment and retrieves it.
65pub fn handle(env: &crate::runtime::env::Env) -> Result<()> {
66    // Initialize file-only logging for askpass subprocess
67    // verbose is determined by PURPLE_LOG env var only (no CLI flag in subprocess)
68    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    // Check the prompt (argv[1]) to skip passphrase and host key verification prompts
77    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        // Not a password prompt. Exit with error so SSH falls back to interactive.
84        std::process::exit(1);
85    }
86
87    if alias.is_empty() || config_path.is_empty() {
88        std::process::exit(1);
89    }
90
91    // Parse config first so we can resolve the prompt's host to the right entry.
92    // With ProxyJump, ssh fires askpass for each hop. The prompt argv[1] tells us
93    // which hop is being authenticated; PURPLE_HOST_ALIAS only knows the final
94    // target. Resolving the prompt host to its own alias scopes the keychain
95    // lookup to the correct entry per hop.
96    let config = SshConfigFile::parse_with_env(&PathBuf::from(&config_path), env)
97        .context("Failed to parse SSH config")?;
98
99    // Restrict prompt-based resolution to PURPLE_HOST_ALIAS and the hosts
100    // reachable via its ProxyJump chain. Without this scope, a malicious
101    // server could send a keyboard-interactive prompt formatted like a
102    // password prompt for an unrelated host (`attacker@victim's password:`)
103    // and exfiltrate that host's credential. Chain membership ensures we
104    // only ever supply credentials for hosts the user has wired into this
105    // connection.
106    let chain = build_proxy_chain(&config, &alias);
107    let resolved_alias = parse_password_prompt_host(&prompt)
108        .and_then(|h| find_alias_for_host(&config, h, &chain))
109        .unwrap_or_else(|| alias.clone());
110
111    // Retry detection: if we've been called recently for this resolved alias,
112    // the password was wrong. Exit with error so SSH falls back to interactive.
113    // The marker is keyed on the resolved alias so retries on one ProxyJump hop
114    // do not block askpass on the next hop.
115    let marker = marker_path(env.paths(), &resolved_alias);
116    if let Some(marker_path) = &marker {
117        if is_recent_marker(marker_path) {
118            debug!("Askpass retry detected for {resolved_alias}");
119            let _ = std::fs::remove_file(marker_path);
120            std::process::exit(1);
121        }
122        if let Err(e) = std::fs::create_dir_all(marker_path.parent().unwrap()) {
123            debug!("[config] Failed to create askpass marker directory: {e}");
124        }
125        if let Err(e) = crate::fs_util::atomic_write(marker_path, b"") {
126            debug!("[config] Failed to write askpass marker: {e}");
127        }
128    }
129
130    let source = find_askpass_source(&config, env.paths(), &resolved_alias);
131
132    let source = match source {
133        Some(s) => s,
134        None => std::process::exit(1),
135    };
136
137    debug!("Askpass invoked for alias={resolved_alias} source={source}");
138
139    let hostname = find_hostname(&config, &resolved_alias);
140    match retrieve_password(env, &source, &resolved_alias, &hostname) {
141        Ok(password) => {
142            debug!("Askpass retrieved password for {resolved_alias} via {source}");
143            print!("{}", password);
144            Ok(())
145        }
146        Err(err) => {
147            warn!("[external] Password retrieval failed via {source}");
148            debug!("[external] Password retrieval detail: {err}");
149            if let Some(m) = &marker {
150                let _ = std::fs::remove_file(m);
151            }
152            std::process::exit(1);
153        }
154    }
155}
156
157/// Extract the host being authenticated from an OpenSSH password prompt.
158/// OpenSSH builds prompts as `<user>@<host>'s password:` (see `userauth_passwd`
159/// in openssh-portable). IPv6 hosts are rendered with brackets (`user@[::1]`),
160/// which we strip so the result matches a plain `HostName` entry. Returns
161/// `None` for keyboard-interactive prompts or any other format we cannot parse,
162/// so the caller falls back to PURPLE_HOST_ALIAS.
163fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
164    let idx = prompt.find("'s password")?;
165    let head = &prompt[..idx];
166    let (_, host) = head.rsplit_once('@')?;
167    let host = host.trim();
168    let host = host
169        .strip_prefix('[')
170        .and_then(|s| s.strip_suffix(']'))
171        .unwrap_or(host);
172    if host.is_empty() { None } else { Some(host) }
173}
174
175/// Find the alias whose entry matches `host` by alias or hostname, restricted
176/// to entries in `permitted`. Alias match takes priority over hostname match
177/// in a single pass. Used to map the SSH prompt's host (which may be a bastion
178/// in a ProxyJump chain) back to the entry that owns its `# purple:askpass`
179/// config. The `permitted` scope blocks malicious-server attempts to coax a
180/// credential lookup for an unrelated host in `~/.ssh/config`.
181fn find_alias_for_host(
182    config: &SshConfigFile,
183    host: &str,
184    permitted: &HashSet<String>,
185) -> Option<String> {
186    let mut by_hostname: Option<String> = None;
187    for entry in config.host_entries() {
188        if !permitted.contains(&entry.alias) {
189            continue;
190        }
191        if entry.alias.eq_ignore_ascii_case(host) {
192            return Some(entry.alias.clone());
193        }
194        if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
195            by_hostname = Some(entry.alias.clone());
196        }
197    }
198    by_hostname
199}
200
201/// Build the set of aliases reachable from `target` via its ProxyJump chain,
202/// including `target` itself. ProxyJump values can be comma-separated and
203/// formatted `[user@]host[:port]`, including bracketed IPv6 hosts. Cycles are
204/// broken by the visited-set; entries that reference unknown hosts contribute
205/// nothing to the chain.
206fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
207    let entries = config.host_entries();
208    let mut chain: HashSet<String> = HashSet::new();
209    let mut queue: Vec<String> = vec![target.to_string()];
210    while let Some(current) = queue.pop() {
211        if !chain.insert(current.clone()) {
212            continue;
213        }
214        let Some(entry) = entries.iter().find(|e| e.alias == current) else {
215            continue;
216        };
217        if entry.proxy_jump.is_empty() {
218            continue;
219        }
220        for jump in entry.proxy_jump.split(',') {
221            let host = parse_proxy_jump_host(jump);
222            if host.is_empty() {
223                continue;
224            }
225            for e in &entries {
226                if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
227                    queue.push(e.alias.clone());
228                }
229            }
230        }
231    }
232    chain
233}
234
235/// Extract the host portion from a single ProxyJump entry of the form
236/// `[user@]host[:port]`. Handles bracketed IPv6 hosts (`[::1]:22`).
237fn parse_proxy_jump_host(jump: &str) -> &str {
238    let trimmed = jump.trim();
239    let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
240    if let Some(rest) = after_user.strip_prefix('[') {
241        if let Some(end) = rest.find(']') {
242            return &rest[..end];
243        }
244    }
245    after_user.split(':').next().unwrap_or(after_user)
246}
247
248/// Find the askpass source for a host. Checks per-host config, then global default.
249fn find_askpass_source(
250    config: &SshConfigFile,
251    paths: Option<&crate::runtime::env::Paths>,
252    alias: &str,
253) -> Option<String> {
254    // Per-host source
255    for entry in config.host_entries() {
256        if entry.alias == alias {
257            if let Some(ref source) = entry.askpass {
258                return Some(source.clone());
259            }
260        }
261    }
262    // Global default from preferences file
263    load_askpass_default_direct(paths)
264}
265
266/// Read askpass default directly from ~/.purple/preferences without depending on the
267/// preferences module (which requires crate::app and isn't available in askpass subprocess).
268fn load_askpass_default_direct(paths: Option<&crate::runtime::env::Paths>) -> Option<String> {
269    let path = paths?.preferences();
270    let content = std::fs::read_to_string(path).ok()?;
271    for line in content.lines() {
272        let line = line.trim();
273        if line.starts_with('#') || line.is_empty() {
274            continue;
275        }
276        if let Some((k, v)) = line.split_once('=') {
277            if k.trim() == "askpass" {
278                let val = v.trim();
279                if !val.is_empty() {
280                    return Some(val.to_string());
281                }
282            }
283        }
284    }
285    None
286}
287
288/// Find the hostname for an alias (for %h substitution).
289fn find_hostname(config: &SshConfigFile, alias: &str) -> String {
290    for entry in config.host_entries() {
291        if entry.alias == alias {
292            return entry.hostname.clone();
293        }
294    }
295    alias.to_string()
296}
297
298/// Retrieve a password from the given source.
299fn retrieve_password(
300    env: &crate::runtime::env::Env,
301    source: &str,
302    alias: &str,
303    hostname: &str,
304) -> Result<String> {
305    if source == "keychain" {
306        return retrieve_from_keychain(env, alias);
307    }
308    if let Some(uri) = source.strip_prefix("op://") {
309        return retrieve_from_1password(env, &format!("op://{}", uri));
310    }
311    if let Some(entry) = source.strip_prefix("pass:") {
312        return retrieve_from_pass(env, entry);
313    }
314    if let Some(item_id) = source.strip_prefix("bw:") {
315        return retrieve_from_bitwarden(env, item_id);
316    }
317    if let Some(rest) = source.strip_prefix("vault:") {
318        return retrieve_from_vault(env, rest);
319    }
320    if let Some(spec) = source.strip_prefix("proton:") {
321        return retrieve_from_proton_pass(env, spec);
322    }
323    // Custom command (with or without cmd: prefix)
324    let cmd = source.strip_prefix("cmd:").unwrap_or(source);
325    retrieve_from_command(env, cmd, alias, hostname)
326}
327
328/// Retrieve from OS keychain (macOS: Keychain, Linux: secret-tool).
329fn retrieve_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
330    #[cfg(target_os = "macos")]
331    {
332        let output = env
333            .command("security")
334            .args([
335                "find-generic-password",
336                "-a",
337                alias,
338                "-s",
339                "purple-ssh",
340                "-w",
341            ])
342            .output()
343            .context("Failed to run security command")?;
344        if !output.status.success() {
345            let stderr = String::from_utf8_lossy(&output.stderr);
346            log::warn!(
347                "[external] askpass keychain lookup failed: alias={} exit={} stderr={}",
348                alias,
349                output.status.code().unwrap_or(-1),
350                stderr.trim().lines().next().unwrap_or("<empty>"),
351            );
352            anyhow::bail!("Keychain lookup failed");
353        }
354        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
355    }
356    #[cfg(not(target_os = "macos"))]
357    {
358        let output = env
359            .command("secret-tool")
360            .args(["lookup", "application", "purple-ssh", "host", alias])
361            .output()
362            .context("Failed to run secret-tool")?;
363        if !output.status.success() {
364            let stderr = String::from_utf8_lossy(&output.stderr);
365            log::warn!(
366                "[external] askpass secret-tool lookup failed: alias={} exit={} stderr={}",
367                alias,
368                output.status.code().unwrap_or(-1),
369                stderr.trim().lines().next().unwrap_or("<empty>"),
370            );
371            anyhow::bail!("Secret-tool lookup failed");
372        }
373        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
374    }
375}
376
377/// Check if a password exists in the OS keychain for this alias.
378pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
379    retrieve_from_keychain(env, alias).is_ok()
380}
381
382/// Retrieve a password from the OS keychain. Public for keychain migration on alias rename.
383pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
384    retrieve_from_keychain(env, alias)
385}
386
387/// Store a password in the OS keychain.
388pub fn store_in_keychain(
389    env: &crate::runtime::env::Env,
390    alias: &str,
391    password: &str,
392) -> Result<()> {
393    #[cfg(target_os = "macos")]
394    {
395        let status = env
396            .command("security")
397            .args([
398                "add-generic-password",
399                "-U",
400                "-a",
401                alias,
402                "-s",
403                "purple-ssh",
404                "-w",
405                password,
406            ])
407            .status()
408            .context("Failed to run security command")?;
409        if !status.success() {
410            anyhow::bail!("Failed to store password in Keychain");
411        }
412        Ok(())
413    }
414    #[cfg(not(target_os = "macos"))]
415    {
416        let mut child = env
417            .command("secret-tool")
418            .args([
419                "store",
420                "--label",
421                &format!("purple-ssh: {}", alias),
422                "application",
423                "purple-ssh",
424                "host",
425                alias,
426            ])
427            .stdin(std::process::Stdio::piped())
428            .spawn()
429            .context("Failed to run secret-tool")?;
430        if let Some(ref mut stdin) = child.stdin {
431            use std::io::Write;
432            stdin.write_all(password.as_bytes())?;
433        }
434        let status = child.wait()?;
435        if !status.success() {
436            anyhow::bail!("Failed to store password with secret-tool");
437        }
438        Ok(())
439    }
440}
441
442/// Remove a password from the OS keychain.
443pub fn remove_from_keychain(env: &crate::runtime::env::Env, alias: &str) -> Result<()> {
444    #[cfg(target_os = "macos")]
445    {
446        let status = env
447            .command("security")
448            .args(["delete-generic-password", "-a", alias, "-s", "purple-ssh"])
449            .status()
450            .context("Failed to run security command")?;
451        if !status.success() {
452            anyhow::bail!("No password found for '{}' in Keychain", alias);
453        }
454        Ok(())
455    }
456    #[cfg(not(target_os = "macos"))]
457    {
458        let status = env
459            .command("secret-tool")
460            .args(["clear", "application", "purple-ssh", "host", alias])
461            .status()
462            .context("Failed to run secret-tool")?;
463        if !status.success() {
464            anyhow::bail!("Failed to remove password with secret-tool");
465        }
466        Ok(())
467    }
468}
469
470/// Retrieve from 1Password CLI.
471fn retrieve_from_1password(env: &crate::runtime::env::Env, uri: &str) -> Result<String> {
472    let result = env
473        .command("op")
474        .args(["read", uri, "--no-newline"])
475        .output();
476    let output = match result {
477        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
478            error!("[config] Password manager binary not found: op");
479            return Err(e).context("Failed to run 1Password CLI (op)");
480        }
481        other => other.context("Failed to run 1Password CLI (op)")?,
482    };
483    if !output.status.success() {
484        let stderr = String::from_utf8_lossy(&output.stderr);
485        log::warn!(
486            "[external] askpass 1Password lookup failed: uri={} exit={} stderr={}",
487            uri,
488            output.status.code().unwrap_or(-1),
489            stderr.trim().lines().next().unwrap_or("<empty>"),
490        );
491        anyhow::bail!("1Password lookup failed");
492    }
493    Ok(String::from_utf8_lossy(&output.stdout).to_string())
494}
495
496/// Retrieve from pass (password-store). Returns the first line.
497fn retrieve_from_pass(env: &crate::runtime::env::Env, entry: &str) -> Result<String> {
498    let result = env.command("pass").args(["show", entry]).output();
499    let output = match result {
500        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
501            error!("[config] Password manager binary not found: pass");
502            return Err(e).context("Failed to run pass");
503        }
504        other => other.context("Failed to run pass")?,
505    };
506    if !output.status.success() {
507        let stderr = String::from_utf8_lossy(&output.stderr);
508        log::warn!(
509            "[external] askpass pass lookup failed: entry={} exit={} stderr={}",
510            entry,
511            output.status.code().unwrap_or(-1),
512            stderr.trim().lines().next().unwrap_or("<empty>"),
513        );
514        anyhow::bail!("pass lookup failed");
515    }
516    let full = String::from_utf8_lossy(&output.stdout);
517    Ok(full.lines().next().unwrap_or("").to_string())
518}
519
520/// Retrieve from Bitwarden CLI. The item_id can be an item ID or search term.
521/// Uses `bw get password <item_id>` which requires an unlocked vault (BW_SESSION).
522fn retrieve_from_bitwarden(env: &crate::runtime::env::Env, item_id: &str) -> Result<String> {
523    let result = env
524        .command("bw")
525        .args(["get", "password", item_id])
526        .output();
527    let output = match result {
528        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
529            error!("[config] Password manager binary not found: bw");
530            return Err(e).context("Failed to run Bitwarden CLI (bw)");
531        }
532        other => other.context("Failed to run Bitwarden CLI (bw)")?,
533    };
534    if !output.status.success() {
535        let stderr = String::from_utf8_lossy(&output.stderr);
536        log::warn!(
537            "[external] askpass Bitwarden lookup failed: item={} exit={} stderr={}",
538            item_id,
539            output.status.code().unwrap_or(-1),
540            stderr.trim().lines().next().unwrap_or("<empty>"),
541        );
542        anyhow::bail!("Bitwarden lookup failed");
543    }
544    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
545}
546
547/// Retrieve from the HashiCorp Vault KV secrets engine via the `vault` CLI.
548/// Spec format: `path#field` or just `path` (defaults to `password`).
549/// Distinct from the Vault SSH secrets engine (see src/vault_ssh.rs), which
550/// signs SSH certificates rather than storing passwords.
551fn retrieve_from_vault(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
552    let (path, field) = match spec.rsplit_once('#') {
553        Some((p, f)) => (p, f),
554        None => (spec, "password"),
555    };
556    let result = env
557        .command("vault")
558        .args(["kv", "get", &format!("-field={}", field), path])
559        .output();
560    let output = match result {
561        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
562            error!("[config] Password manager binary not found: vault");
563            return Err(e).context("Failed to run vault CLI");
564        }
565        other => other.context("Failed to run vault CLI")?,
566    };
567    if !output.status.success() {
568        let stderr = String::from_utf8_lossy(&output.stderr);
569        log::warn!(
570            "[external] askpass Vault KV lookup failed: path={} field={} exit={} stderr={}",
571            path,
572            field,
573            output.status.code().unwrap_or(-1),
574            stderr.trim().lines().next().unwrap_or("<empty>"),
575        );
576        anyhow::bail!("Vault lookup failed");
577    }
578    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
579}
580
581/// Retrieve via custom command. Supports %h (hostname) and %a (alias) substitution.
582/// Values are shell-escaped to prevent metacharacter injection.
583fn retrieve_from_command(
584    env: &crate::runtime::env::Env,
585    cmd: &str,
586    alias: &str,
587    hostname: &str,
588) -> Result<String> {
589    let safe_alias = crate::snippet::shell_escape(alias);
590    let safe_hostname = crate::snippet::shell_escape(hostname);
591    let expanded = cmd.replace("%a", &safe_alias).replace("%h", &safe_hostname);
592    let output = env
593        .command("sh")
594        .args(["-c", &expanded])
595        .output()
596        .context("Failed to run custom askpass command")?;
597    if !output.status.success() {
598        let stderr = String::from_utf8_lossy(&output.stderr);
599        log::warn!(
600            "[external] askpass custom command failed: alias={} exit={} stderr={}",
601            alias,
602            output.status.code().unwrap_or(-1),
603            stderr.trim().lines().next().unwrap_or("<empty>"),
604        );
605        anyhow::bail!("Custom askpass command failed");
606    }
607    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
608}
609
610/// Get the path for the retry marker file.
611/// Sanitizes the alias to prevent path traversal (replaces `/` and `\` with `_`).
612fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
613    paths.map(|p| p.askpass_marker(alias))
614}
615
616/// Check if a marker file exists and is recent (< 60 seconds old).
617fn is_recent_marker(path: &PathBuf) -> bool {
618    if let Ok(meta) = std::fs::metadata(path) {
619        if let Ok(modified) = meta.modified() {
620            if let Ok(elapsed) = SystemTime::now().duration_since(modified) {
621                return elapsed.as_secs() < 60;
622            }
623        }
624    }
625    false
626}
627
628/// Clean up retry markers after a successful connection. ProxyJump connections
629/// create one marker per hop and the parent process only knows the final
630/// target alias, so we clear every `~/.purple/.askpass_*` file on success.
631/// Each marker has a 60s expiry; this just keeps rapid reconnects snappy and
632/// prevents a stranded bastion marker from blocking the next attempt.
633pub fn cleanup_marker(paths: Option<&crate::runtime::env::Paths>, _alias: &str) {
634    let Some(paths) = paths else {
635        return;
636    };
637    let Ok(read) = std::fs::read_dir(paths.purple_dir()) else {
638        return;
639    };
640    for entry in read.flatten() {
641        if entry
642            .file_name()
643            .to_str()
644            .is_some_and(|s| s.starts_with(".askpass_"))
645        {
646            let _ = std::fs::remove_file(entry.path());
647        }
648    }
649}
650
651/// Parse an askpass source string and return a description for display.
652#[allow(dead_code)]
653pub fn describe_source(source: &str) -> &str {
654    if source == "keychain" {
655        "OS Keychain"
656    } else if source.starts_with("op://") {
657        "1Password"
658    } else if source.starts_with("proton:") {
659        "Proton Pass"
660    } else if source.starts_with("pass:") {
661        "pass"
662    } else if source.starts_with("bw:") {
663        "Bitwarden"
664    } else if source.starts_with("vault:") {
665        "HashiCorp Vault KV"
666    } else {
667        "Custom command"
668    }
669}
670
671/// Bitwarden vault status.
672#[derive(Debug, Clone, Copy, PartialEq)]
673pub enum BwStatus {
674    Unlocked,
675    Locked,
676    NotAuthenticated,
677    NotInstalled,
678}
679
680/// Parse the Bitwarden vault status from `bw status` JSON output.
681fn parse_bw_status(stdout: &str) -> BwStatus {
682    if let Some(status) = stdout
683        .split("\"status\":")
684        .nth(1)
685        .and_then(|s| s.split('"').nth(1))
686    {
687        match status {
688            "unlocked" => BwStatus::Unlocked,
689            "locked" => BwStatus::Locked,
690            "unauthenticated" => BwStatus::NotAuthenticated,
691            _ => BwStatus::Locked,
692        }
693    } else {
694        BwStatus::NotInstalled
695    }
696}
697
698/// Check the Bitwarden vault status by running `bw status`.
699pub fn bw_vault_status(env: &crate::runtime::env::Env) -> BwStatus {
700    let output = match env.command("bw").arg("status").output() {
701        Ok(o) => o,
702        Err(_) => return BwStatus::NotInstalled,
703    };
704    let stdout = String::from_utf8_lossy(&output.stdout);
705    parse_bw_status(&stdout)
706}
707
708/// Unlock the Bitwarden vault with the given master password.
709/// Passes the password via env var to avoid exposure in `ps` output.
710/// Returns the session token on success.
711pub fn bw_unlock(env: &crate::runtime::env::Env, password: &str) -> Result<String> {
712    let output = env
713        .command("bw")
714        .args(["unlock", "--passwordenv", "PURPLE_BW_MASTER", "--raw"])
715        .env("PURPLE_BW_MASTER", password)
716        .output()
717        .context("Failed to run Bitwarden CLI (bw)")?;
718    if !output.status.success() {
719        let stderr = String::from_utf8_lossy(&output.stderr);
720        anyhow::bail!("Bitwarden unlock failed: {}", stderr.trim());
721    }
722    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
723    if token.is_empty() {
724        anyhow::bail!("Bitwarden unlock returned empty session token");
725    }
726    Ok(token)
727}
728
729/// Proton Pass CLI authentication status.
730#[derive(Debug, Clone, Copy, PartialEq)]
731pub enum ProtonStatus {
732    Authenticated,
733    NotAuthenticated,
734    NotInstalled,
735}
736
737/// Check whether `pass-cli` is installed and the user is logged in. Uses
738/// `pass-cli test` (not `info`) because in pass-cli 2.x `info` exits 0 even
739/// without a session and only reports the error on stderr. `test` is the
740/// command that actually exits non-zero when authentication is missing.
741pub fn proton_status(env: &crate::runtime::env::Env) -> ProtonStatus {
742    let result = env.command("pass-cli").arg("test").output();
743    let status = match result {
744        Err(e) if e.kind() == std::io::ErrorKind::NotFound => ProtonStatus::NotInstalled,
745        Err(_) => ProtonStatus::NotAuthenticated,
746        Ok(out) if out.status.success() => ProtonStatus::Authenticated,
747        Ok(_) => ProtonStatus::NotAuthenticated,
748    };
749    debug!("Proton Pass status: {status:?}");
750    status
751}
752
753/// Log in to Proton Pass with a Personal Access Token.
754/// PAT is supplied via the `PROTON_PASS_PERSONAL_ACCESS_TOKEN` env var so it
755/// never appears in argv. Returns an error wrapping pass-cli's stderr on
756/// non-zero exit so the prompt loop can surface it.
757pub fn proton_login(env: &crate::runtime::env::Env, pat: &str) -> Result<()> {
758    if pat.is_empty() {
759        anyhow::bail!("empty PAT");
760    }
761    let output = env
762        .command("pass-cli")
763        .arg("login")
764        .env("PROTON_PASS_PERSONAL_ACCESS_TOKEN", pat)
765        .output()
766        .context("Failed to run Proton Pass CLI (pass-cli)")?;
767    if !output.status.success() {
768        let stderr = String::from_utf8_lossy(&output.stderr);
769        debug!("Proton Pass login failed: {}", stderr.trim());
770        anyhow::bail!("{}", stderr.trim());
771    }
772    debug!("Proton Pass login succeeded");
773    Ok(())
774}
775
776/// Parse a `proton:Vault/Item/field` askpass spec into its three components.
777/// Vault and item segments cannot contain `/`; the field segment is everything
778/// after the second `/`. All three segments must be non-empty.
779fn parse_proton_spec(spec: &str) -> Result<(&str, &str, &str)> {
780    let (vault, rest) = spec
781        .split_once('/')
782        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
783    let (item, field) = rest
784        .split_once('/')
785        .ok_or_else(|| anyhow::anyhow!("Proton Pass spec must be Vault/Item/field"))?;
786    if vault.is_empty() || item.is_empty() || field.is_empty() {
787        anyhow::bail!("Proton Pass spec segments must be non-empty");
788    }
789    Ok((vault, item, field))
790}
791
792/// Retrieve a secret from Proton Pass via `pass-cli item view`. The askpass
793/// spec `proton:Vault/Item/field` is mapped to name-based lookup flags
794/// (`--vault-name`, `--item-title`, `--field`) rather than the URI form, so
795/// purple users can refer to their vaults and items by human-readable names
796/// instead of opaque share/item IDs.
797fn retrieve_from_proton_pass(env: &crate::runtime::env::Env, spec: &str) -> Result<String> {
798    let (vault, item, field) = parse_proton_spec(spec)?;
799    let result = env
800        .command("pass-cli")
801        .args([
802            "item",
803            "view",
804            "--vault-name",
805            vault,
806            "--item-title",
807            item,
808            "--field",
809            field,
810        ])
811        .output();
812    let output = match result {
813        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
814            error!("[config] Password manager binary not found: pass-cli");
815            return Err(e).context("Failed to run Proton Pass CLI (pass-cli)");
816        }
817        other => other.context("Failed to run Proton Pass CLI (pass-cli)")?,
818    };
819    if !output.status.success() {
820        let stderr = String::from_utf8_lossy(&output.stderr);
821        warn!("[external] Proton Pass lookup failed: {}", stderr.trim());
822        anyhow::bail!("Proton Pass lookup failed");
823    }
824    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
825    if value.is_empty() {
826        warn!("[external] Proton Pass returned empty secret");
827        anyhow::bail!("Proton Pass returned empty secret");
828    }
829    debug!("Proton Pass lookup succeeded");
830    Ok(value)
831}
832
833#[cfg(test)]
834#[path = "askpass_tests.rs"]
835mod tests;