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
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 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 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
157fn 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
175fn 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
201fn 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
235fn 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
248fn find_askpass_source(
250 config: &SshConfigFile,
251 paths: Option<&crate::runtime::env::Paths>,
252 alias: &str,
253) -> Option<String> {
254 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 load_askpass_default_direct(paths)
264}
265
266fn 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
288fn 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
298fn 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 let cmd = source.strip_prefix("cmd:").unwrap_or(source);
325 retrieve_from_command(env, cmd, alias, hostname)
326}
327
328fn 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
377pub fn keychain_has_password(env: &crate::runtime::env::Env, alias: &str) -> bool {
379 retrieve_from_keychain(env, alias).is_ok()
380}
381
382pub fn retrieve_keychain_password(env: &crate::runtime::env::Env, alias: &str) -> Result<String> {
384 retrieve_from_keychain(env, alias)
385}
386
387pub 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
442pub 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
470fn 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
496fn 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
520fn 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
547fn 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
581fn 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
610fn marker_path(paths: Option<&crate::runtime::env::Paths>, alias: &str) -> Option<PathBuf> {
613 paths.map(|p| p.askpass_marker(alias))
614}
615
616fn 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
628pub 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
673pub enum BwStatus {
674 Unlocked,
675 Locked,
676 NotAuthenticated,
677 NotInstalled,
678}
679
680fn 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
698pub 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
708pub 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#[derive(Debug, Clone, Copy, PartialEq)]
731pub enum ProtonStatus {
732 Authenticated,
733 NotAuthenticated,
734 NotInstalled,
735}
736
737pub 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
753pub 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
776fn 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
792fn 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;