1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex, OnceLock};
4
5use anyhow::{Context, Result};
6use log::{debug, warn};
7
8use crate::app::{self, App};
9use crate::{askpass, cli, providers, ssh_config, vault_ssh};
10
11pub fn resolve_config_path(
12 paths: Option<&crate::runtime::env::Paths>,
13 path: &str,
14) -> Result<PathBuf> {
15 expand_user_path(paths, path)
16}
17
18pub fn expand_user_path(paths: Option<&crate::runtime::env::Paths>, path: &str) -> Result<PathBuf> {
22 let home = || {
23 paths
24 .map(|p| p.home().to_path_buf())
25 .context("Could not determine home directory")
26 };
27 let home_prefixes = ["~/", "${HOME}/", "$HOME/"];
28 for prefix in home_prefixes {
29 if let Some(rest) = path.strip_prefix(prefix) {
30 return Ok(home()?.join(rest));
31 }
32 }
33 if path == "~" || path == "${HOME}" || path == "$HOME" {
34 return home();
35 }
36 Ok(PathBuf::from(path))
37}
38
39pub fn resolve_token(
40 env: &crate::runtime::env::Env,
41 explicit: Option<String>,
42 from_stdin: bool,
43) -> Result<String> {
44 if let Some(t) = explicit {
45 return Ok(t);
46 }
47 if from_stdin {
48 let mut buf = String::new();
49 std::io::stdin().read_line(&mut buf)?;
50 return Ok(buf.trim().to_string());
51 }
52 if let Some(t) = env.purple_token() {
53 return Ok(t.to_string());
54 }
55 anyhow::bail!("{}", crate::messages::cli::NO_TOKEN)
56}
57
58pub fn replace_spinner_frame(text: &str, new_frame: &str) -> Option<String> {
66 let starts_with_spinner = crate::animation::SPINNER_FRAMES
67 .iter()
68 .any(|f| text.starts_with(f));
69 if !starts_with_spinner {
70 return None;
71 }
72 text.split_once(' ')
73 .map(|(_, rest)| format!("{} {}", new_frame, rest))
74}
75
76pub fn format_vault_sign_summary(
79 signed: u32,
80 failed: u32,
81 skipped: u32,
82 first_error: Option<&str>,
83) -> String {
84 crate::messages::vault_sign_summary(signed, failed, skipped, first_error)
85}
86
87pub fn format_sync_diff(added: usize, updated: usize, stale: usize) -> String {
88 let diff_parts: Vec<String> = [(added, "+"), (updated, "~"), (stale, "-")]
89 .iter()
90 .filter(|(n, _)| *n > 0)
91 .map(|(n, prefix)| format!("{}{}", prefix, n))
92 .collect();
93 if diff_parts.is_empty() {
94 String::new()
95 } else {
96 format!(" ({})", diff_parts.join(" "))
97 }
98}
99
100pub fn set_sync_summary(app: &mut App) {
108 let still_syncing = !app.providers.syncing().is_empty();
109 let done = app.providers.sync_done().len();
110 let total = app
111 .providers
112 .batch_total()
113 .max(done + app.providers.syncing().len());
114 let added = app.providers.batch_added();
115 let updated = app.providers.batch_updated();
116 let stale = app.providers.batch_stale();
117 if still_syncing {
118 let mut active: Vec<String> = app
119 .providers
120 .syncing()
121 .keys()
122 .map(|name| crate::providers::provider_display_name(name).to_string())
123 .collect();
124 active.sort();
125 let active_names = active.join(", ");
126 let spinner = crate::animation::SPINNER_FRAMES[0];
127 let text = crate::messages::synced_progress(
128 spinner,
129 &active_names,
130 done,
131 total,
132 added,
133 updated,
134 stale,
135 );
136 if app.providers.sync_had_errors() {
137 app.notify_background_error(text);
138 } else {
139 app.notify_background(text);
140 }
141 } else {
142 let names = app.providers.sync_done().join(", ");
143 let text = crate::messages::synced_done(done, total, &names, added, updated, stale);
144 if app.providers.sync_had_errors() {
145 app.notify_background_error(text);
146 } else {
147 app.notify_background(text);
148 }
149 app::SyncRecord::save_all(app.providers.sync_history(), app.env().paths());
150 app.providers.finish_batch();
151 }
152}
153
154pub fn first_launch_init(paths: &crate::runtime::env::Paths, config_path: &Path) -> Option<bool> {
158 let markers = [
159 paths.config_original(),
160 paths.preferences(),
161 paths.history(),
162 paths.container_cache(),
163 paths.last_version_check(),
164 paths.providers_config(),
165 paths.snippets_file(),
166 paths.themes_dir(),
167 ];
168 if markers.iter().any(|m| m.exists()) {
169 return None;
170 }
171 let data_dir = paths.data_dir();
174 if let Err(e) = std::fs::create_dir_all(data_dir) {
175 warn!(
176 "[config] Failed to create data directory {}: {e}",
177 data_dir.display()
178 );
179 }
180 #[cfg(unix)]
181 {
182 use std::os::unix::fs::PermissionsExt;
183 if let Err(e) = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700)) {
184 warn!(
185 "[config] Failed to set data directory permissions on {}: {e}",
186 data_dir.display()
187 );
188 }
189 }
190 let original_backup = paths.config_original();
191 if config_path.exists() {
192 if let Err(e) = std::fs::copy(config_path, &original_backup) {
193 warn!(
194 "[config] Failed to backup SSH config to {}: {e}",
195 original_backup.display()
196 );
197 }
198 #[cfg(unix)]
199 {
200 use std::os::unix::fs::PermissionsExt;
201 if let Err(e) =
202 std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
203 {
204 warn!("[config] Failed to set backup permissions: {e}");
205 }
206 }
207 }
208 Some(original_backup.exists())
209}
210
211pub fn ensure_vault_ssh_if_needed(
215 env: &crate::runtime::env::Env,
216 alias: &str,
217 host: &ssh_config::model::HostEntry,
218 provider_config: &providers::config::ProviderConfig,
219 config: &mut ssh_config::model::SshConfigFile,
220) -> Option<(String, bool)> {
221 let role = vault_ssh::resolve_vault_role(
222 host.vault_ssh.as_deref(),
223 host.provider.as_deref(),
224 host.provider_label.as_deref(),
225 provider_config,
226 )?;
227
228 let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &host.identity_file) {
229 Ok(p) => p,
230 Err(e) => {
231 return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
232 }
233 };
234
235 let check_path =
236 vault_ssh::resolve_cert_path(env.paths(), alias, &host.certificate_file).ok()?;
237 let status = vault_ssh::check_cert_validity(env, &check_path);
238 if !vault_ssh::needs_renewal(&status) {
239 return None;
240 }
241
242 let vault_addr = vault_ssh::resolve_vault_addr(
243 host.vault_addr.as_deref(),
244 host.provider.as_deref(),
245 host.provider_label.as_deref(),
246 provider_config,
247 );
248 match vault_ssh::ensure_cert(
249 env,
250 &role,
251 &pubkey,
252 alias,
253 &host.certificate_file,
254 vault_addr.as_deref(),
255 ) {
256 Ok(cert_path) => {
257 if should_write_certificate_file(&host.certificate_file) {
258 let cert_str = cert_path.to_string_lossy().to_string();
259 let updated = config.set_host_certificate_file(alias, &cert_str);
260 if !updated {
261 eprintln!(
262 "{}",
263 crate::messages::vault_cert_host_block_missing(alias, &cert_path)
264 );
265 } else if let Err(e) = config.write() {
266 eprintln!(
267 "{}",
268 crate::messages::vault_cert_config_write_failed(alias, &e)
269 );
270 }
271 }
272 Some((crate::messages::vault_signed_pre_connect(alias), false))
273 }
274 Err(e) => {
275 let msg = e.to_string();
276 eprintln!(
277 "{}",
278 crate::messages::vault_sign_failed_pre_connect(alias, &msg)
279 );
280 Some((
281 crate::messages::vault_sign_failed_pre_connect(alias, &msg),
282 true,
283 ))
284 }
285 }
286}
287
288pub fn ensure_vault_ssh_chain_if_needed(
291 env: &crate::runtime::env::Env,
292 target_alias: &str,
293 config_path: &Path,
294 provider_config: &providers::config::ProviderConfig,
295 config: &mut ssh_config::model::SshConfigFile,
296) -> Option<(String, bool)> {
297 let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
298 let mut signed_count: usize = 0;
299 let mut last_error: Option<String> = None;
300
301 for hop_alias in &chain {
302 let host_entry = config
303 .host_entries()
304 .into_iter()
305 .find(|h| h.alias == *hop_alias);
306 let Some(host) = host_entry else {
307 continue;
308 };
309 if let Some((msg, is_error)) =
310 ensure_vault_ssh_if_needed(env, hop_alias, &host, provider_config, config)
311 {
312 if is_error {
313 last_error = Some(msg);
314 } else {
315 signed_count += 1;
316 }
317 }
318 }
319
320 if let Some(err) = last_error {
321 return Some((err, true));
322 }
323 if signed_count == 0 {
324 return None;
325 }
326 Some((
327 crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
328 false,
329 ))
330}
331
332static RENEWAL_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
337
338fn renewal_lock(alias: &str) -> Arc<Mutex<()>> {
339 RENEWAL_LOCKS
340 .get_or_init(|| Mutex::new(HashMap::new()))
341 .lock()
342 .unwrap_or_else(|p| p.into_inner())
343 .entry(alias.to_string())
344 .or_default()
345 .clone()
346}
347
348pub fn ensure_vault_cert_for_alias(
358 env: &crate::runtime::env::Env,
359 alias: &str,
360 config_path: &Path,
361) -> Option<(String, bool)> {
362 let lock = renewal_lock(alias);
363 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
364
365 let mut config = match ssh_config::model::SshConfigFile::parse_with_env(config_path, env) {
366 Ok(c) => c,
367 Err(e) => {
368 warn!("[config] Vault SSH renewal skipped for '{alias}': {e}");
369 return None;
370 }
371 };
372 let provider_config = providers::config::ProviderConfig::load(env.paths());
373 let result =
374 ensure_vault_ssh_chain_if_needed(env, alias, config_path, &provider_config, &mut config);
375 match &result {
376 Some((msg, true)) => warn!("[external] Vault SSH renewal for '{alias}': {msg}"),
377 Some((msg, false)) => debug!("[external] Vault SSH renewal for '{alias}': {msg}"),
378 None => {}
379 }
380 result
381}
382
383pub fn should_write_certificate_file(existing: &str) -> bool {
389 existing.trim().is_empty()
390}
391
392pub fn ensure_bw_session(
395 env: &crate::runtime::env::Env,
396 existing: Option<&str>,
397 askpass: Option<&str>,
398) -> Option<String> {
399 let askpass = askpass?;
400 if !askpass.starts_with("bw:") || existing.is_some() {
401 return None;
402 }
403 let status = askpass::bw_vault_status(env);
404 match status {
405 askpass::BwStatus::Unlocked => None,
406 askpass::BwStatus::NotInstalled => {
407 eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
408 None
409 }
410 askpass::BwStatus::NotAuthenticated => {
411 eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
412 None
413 }
414 askpass::BwStatus::Locked => {
415 for attempt in 0..2 {
416 let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
417 Ok(Some(p)) if !p.is_empty() => p,
418 Ok(Some(_)) => {
419 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
420 return None;
421 }
422 Ok(None) => return None,
423 Err(e) => {
424 eprintln!("{}", crate::messages::askpass::read_failed(&e));
425 return None;
426 }
427 };
428 match askpass::bw_unlock(env, &password) {
429 Ok(token) => return Some(token),
430 Err(e) => {
431 if attempt == 0 {
432 eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
433 } else {
434 eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
435 }
436 }
437 }
438 }
439 None
440 }
441 }
442}
443
444pub fn ensure_proton_login(env: &crate::runtime::env::Env, askpass: Option<&str>) {
448 ensure_proton_login_with(
449 env,
450 askpass,
451 || askpass::proton_status(env),
452 || cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT),
453 );
454}
455
456pub fn ensure_proton_login_with<S, P>(
460 env: &crate::runtime::env::Env,
461 askpass: Option<&str>,
462 status_fn: S,
463 mut prompt_pat: P,
464) where
465 S: FnOnce() -> askpass::ProtonStatus,
466 P: FnMut() -> Result<Option<String>>,
467{
468 let Some(askpass) = askpass else {
469 return;
470 };
471 if !askpass.starts_with("proton:") {
472 return;
473 }
474 match status_fn() {
475 askpass::ProtonStatus::Authenticated => {
476 debug!("[external] Proton Pass pre-flight: already authenticated");
477 }
478 askpass::ProtonStatus::NotInstalled => {
479 debug!("[config] Proton Pass pre-flight: pass-cli not installed");
480 eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
481 }
482 askpass::ProtonStatus::NotAuthenticated => {
483 debug!("[external] Proton Pass pre-flight: not authenticated, prompting for PAT");
484 for attempt in 0..2 {
485 let pat = match prompt_pat() {
486 Ok(Some(p)) if !p.is_empty() => p,
487 Ok(Some(_)) => {
488 debug!("[external] Proton Pass pre-flight: empty PAT, aborting");
489 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
490 return;
491 }
492 Ok(None) => {
493 debug!("[external] Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
494 return;
495 }
496 Err(e) => {
497 warn!("[external] Proton Pass PAT prompt read failed: {e}");
498 eprintln!("{}", crate::messages::askpass::read_failed(&e));
499 return;
500 }
501 };
502 match askpass::proton_login(env, &pat) {
503 Ok(()) => {
504 debug!(
505 "[external] Proton Pass pre-flight: login succeeded on attempt {attempt}"
506 );
507 eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
508 return;
509 }
510 Err(e) => {
511 debug!(
512 "[external] Proton Pass pre-flight: login attempt {attempt} failed: {e}"
513 );
514 if attempt == 0 {
515 eprintln!(
516 "{}",
517 crate::messages::askpass::proton_login_failed_retry(&e)
518 );
519 } else {
520 warn!("[external] Proton Pass login failed after retries: {e}");
521 eprintln!(
522 "{}",
523 crate::messages::askpass::proton_login_failed_prompt(&e)
524 );
525 }
526 }
527 }
528 }
529 }
530 }
531}
532
533pub fn apply_saved_sort(app: &mut App) {
538 let paths = app.env().paths().cloned();
539 let p = paths.as_ref();
540 let saved = crate::preferences::load_sort_mode(p);
541 let group = crate::preferences::load_group_by(p);
542 app.hosts_state.set_sort_mode(saved);
543 app.hosts_state.set_group_by_raw(group);
544 app.hosts_state
545 .set_view_mode(crate::preferences::load_view_mode(p));
546 app.containers_overview.hydrate_from_prefs(p);
547 if app.clear_stale_group_tag()
548 && let Err(e) = crate::preferences::save_group_by(p, app.hosts_state.group_by())
549 {
550 app.notify_error(crate::messages::group_pref_reset_failed(&e));
551 }
552 if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by(), app::GroupBy::None)
553 {
554 app.apply_sort();
555 app.select_first_host();
556 }
557}
558
559pub fn ensure_keychain_password(
562 env: &crate::runtime::env::Env,
563 alias: &str,
564 askpass: Option<&str>,
565) {
566 if askpass != Some("keychain") {
567 return;
568 }
569 if askpass::keychain_has_password(env, alias) {
570 return;
571 }
572 let password = match cli::prompt_hidden_input(
573 &crate::messages::askpass::keychain_password_prompt(alias),
574 ) {
575 Ok(Some(p)) if !p.is_empty() => p,
576 Ok(Some(_)) => {
577 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
578 return;
579 }
580 Ok(None) => return,
581 Err(_) => return,
582 };
583 match askpass::store_in_keychain(env, alias, &password) {
584 Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
585 Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
586 }
587}
588
589#[cfg(test)]
590#[path = "../main_tests.rs"]
591mod tests;