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(purple_dir: &Path, config_path: &Path) -> Option<bool> {
157 let markers = [
158 "config.original",
159 "preferences",
160 "history.tsv",
161 "container_cache.jsonl",
162 "last_version_check",
163 "providers",
164 "snippets.toml",
165 "themes",
166 ];
167 if markers.iter().any(|m| purple_dir.join(m).exists()) {
168 return None;
169 }
170 if let Err(e) = std::fs::create_dir_all(purple_dir) {
171 warn!("[config] Failed to create ~/.purple directory: {e}");
172 }
173 #[cfg(unix)]
174 {
175 use std::os::unix::fs::PermissionsExt;
176 if let Err(e) = std::fs::set_permissions(purple_dir, std::fs::Permissions::from_mode(0o700))
177 {
178 warn!("[config] Failed to set ~/.purple directory permissions: {e}");
179 }
180 }
181 let original_backup = purple_dir.join("config.original");
182 if config_path.exists() {
183 if let Err(e) = std::fs::copy(config_path, &original_backup) {
184 warn!(
185 "[config] Failed to backup SSH config to {}: {e}",
186 original_backup.display()
187 );
188 }
189 #[cfg(unix)]
190 {
191 use std::os::unix::fs::PermissionsExt;
192 if let Err(e) =
193 std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
194 {
195 warn!("[config] Failed to set backup permissions: {e}");
196 }
197 }
198 }
199 Some(original_backup.exists())
200}
201
202pub fn ensure_vault_ssh_if_needed(
206 env: &crate::runtime::env::Env,
207 alias: &str,
208 host: &ssh_config::model::HostEntry,
209 provider_config: &providers::config::ProviderConfig,
210 config: &mut ssh_config::model::SshConfigFile,
211) -> Option<(String, bool)> {
212 let role = vault_ssh::resolve_vault_role(
213 host.vault_ssh.as_deref(),
214 host.provider.as_deref(),
215 host.provider_label.as_deref(),
216 provider_config,
217 )?;
218
219 let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &host.identity_file) {
220 Ok(p) => p,
221 Err(e) => {
222 return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
223 }
224 };
225
226 let check_path =
227 vault_ssh::resolve_cert_path(env.paths(), alias, &host.certificate_file).ok()?;
228 let status = vault_ssh::check_cert_validity(env, &check_path);
229 if !vault_ssh::needs_renewal(&status) {
230 return None;
231 }
232
233 let vault_addr = vault_ssh::resolve_vault_addr(
234 host.vault_addr.as_deref(),
235 host.provider.as_deref(),
236 host.provider_label.as_deref(),
237 provider_config,
238 );
239 match vault_ssh::ensure_cert(
240 env,
241 &role,
242 &pubkey,
243 alias,
244 &host.certificate_file,
245 vault_addr.as_deref(),
246 ) {
247 Ok(cert_path) => {
248 if should_write_certificate_file(&host.certificate_file) {
249 let cert_str = cert_path.to_string_lossy().to_string();
250 let updated = config.set_host_certificate_file(alias, &cert_str);
251 if !updated {
252 eprintln!(
253 "{}",
254 crate::messages::vault_cert_host_block_missing(alias, &cert_path)
255 );
256 } else if let Err(e) = config.write() {
257 eprintln!(
258 "{}",
259 crate::messages::vault_cert_config_write_failed(alias, &e)
260 );
261 }
262 }
263 Some((crate::messages::vault_signed_pre_connect(alias), false))
264 }
265 Err(e) => {
266 let msg = e.to_string();
267 eprintln!(
268 "{}",
269 crate::messages::vault_sign_failed_pre_connect(alias, &msg)
270 );
271 Some((
272 crate::messages::vault_sign_failed_pre_connect(alias, &msg),
273 true,
274 ))
275 }
276 }
277}
278
279pub fn ensure_vault_ssh_chain_if_needed(
282 env: &crate::runtime::env::Env,
283 target_alias: &str,
284 config_path: &Path,
285 provider_config: &providers::config::ProviderConfig,
286 config: &mut ssh_config::model::SshConfigFile,
287) -> Option<(String, bool)> {
288 let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
289 let mut signed_count: usize = 0;
290 let mut last_error: Option<String> = None;
291
292 for hop_alias in &chain {
293 let host_entry = config
294 .host_entries()
295 .into_iter()
296 .find(|h| h.alias == *hop_alias);
297 let Some(host) = host_entry else {
298 continue;
299 };
300 if let Some((msg, is_error)) =
301 ensure_vault_ssh_if_needed(env, hop_alias, &host, provider_config, config)
302 {
303 if is_error {
304 last_error = Some(msg);
305 } else {
306 signed_count += 1;
307 }
308 }
309 }
310
311 if let Some(err) = last_error {
312 return Some((err, true));
313 }
314 if signed_count == 0 {
315 return None;
316 }
317 Some((
318 crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
319 false,
320 ))
321}
322
323static RENEWAL_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
328
329fn renewal_lock(alias: &str) -> Arc<Mutex<()>> {
330 RENEWAL_LOCKS
331 .get_or_init(|| Mutex::new(HashMap::new()))
332 .lock()
333 .unwrap_or_else(|p| p.into_inner())
334 .entry(alias.to_string())
335 .or_default()
336 .clone()
337}
338
339pub fn ensure_vault_cert_for_alias(
349 env: &crate::runtime::env::Env,
350 alias: &str,
351 config_path: &Path,
352) -> Option<(String, bool)> {
353 let lock = renewal_lock(alias);
354 let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
355
356 let mut config = match ssh_config::model::SshConfigFile::parse_with_env(config_path, env) {
357 Ok(c) => c,
358 Err(e) => {
359 warn!("[config] Vault SSH renewal skipped for '{alias}': {e}");
360 return None;
361 }
362 };
363 let provider_config = providers::config::ProviderConfig::load(env.paths());
364 let result =
365 ensure_vault_ssh_chain_if_needed(env, alias, config_path, &provider_config, &mut config);
366 match &result {
367 Some((msg, true)) => warn!("[external] Vault SSH renewal for '{alias}': {msg}"),
368 Some((msg, false)) => debug!("Vault SSH renewal for '{alias}': {msg}"),
369 None => {}
370 }
371 result
372}
373
374pub fn should_write_certificate_file(existing: &str) -> bool {
380 existing.trim().is_empty()
381}
382
383pub fn ensure_bw_session(
386 env: &crate::runtime::env::Env,
387 existing: Option<&str>,
388 askpass: Option<&str>,
389) -> Option<String> {
390 let askpass = askpass?;
391 if !askpass.starts_with("bw:") || existing.is_some() {
392 return None;
393 }
394 let status = askpass::bw_vault_status(env);
395 match status {
396 askpass::BwStatus::Unlocked => None,
397 askpass::BwStatus::NotInstalled => {
398 eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
399 None
400 }
401 askpass::BwStatus::NotAuthenticated => {
402 eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
403 None
404 }
405 askpass::BwStatus::Locked => {
406 for attempt in 0..2 {
407 let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
408 Ok(Some(p)) if !p.is_empty() => p,
409 Ok(Some(_)) => {
410 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
411 return None;
412 }
413 Ok(None) => return None,
414 Err(e) => {
415 eprintln!("{}", crate::messages::askpass::read_failed(&e));
416 return None;
417 }
418 };
419 match askpass::bw_unlock(env, &password) {
420 Ok(token) => return Some(token),
421 Err(e) => {
422 if attempt == 0 {
423 eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
424 } else {
425 eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
426 }
427 }
428 }
429 }
430 None
431 }
432 }
433}
434
435pub fn ensure_proton_login(env: &crate::runtime::env::Env, askpass: Option<&str>) {
439 ensure_proton_login_with(
440 env,
441 askpass,
442 || askpass::proton_status(env),
443 || cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT),
444 );
445}
446
447pub fn ensure_proton_login_with<S, P>(
451 env: &crate::runtime::env::Env,
452 askpass: Option<&str>,
453 status_fn: S,
454 mut prompt_pat: P,
455) where
456 S: FnOnce() -> askpass::ProtonStatus,
457 P: FnMut() -> Result<Option<String>>,
458{
459 let Some(askpass) = askpass else {
460 return;
461 };
462 if !askpass.starts_with("proton:") {
463 return;
464 }
465 match status_fn() {
466 askpass::ProtonStatus::Authenticated => {
467 debug!("Proton Pass pre-flight: already authenticated");
468 }
469 askpass::ProtonStatus::NotInstalled => {
470 debug!("Proton Pass pre-flight: pass-cli not installed");
471 eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
472 }
473 askpass::ProtonStatus::NotAuthenticated => {
474 debug!("Proton Pass pre-flight: not authenticated, prompting for PAT");
475 for attempt in 0..2 {
476 let pat = match prompt_pat() {
477 Ok(Some(p)) if !p.is_empty() => p,
478 Ok(Some(_)) => {
479 debug!("Proton Pass pre-flight: empty PAT, aborting");
480 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
481 return;
482 }
483 Ok(None) => {
484 debug!("Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
485 return;
486 }
487 Err(e) => {
488 warn!("[config] Proton Pass PAT prompt read failed: {e}");
489 eprintln!("{}", crate::messages::askpass::read_failed(&e));
490 return;
491 }
492 };
493 match askpass::proton_login(env, &pat) {
494 Ok(()) => {
495 debug!("Proton Pass pre-flight: login succeeded on attempt {attempt}");
496 eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
497 return;
498 }
499 Err(e) => {
500 debug!("Proton Pass pre-flight: login attempt {attempt} failed: {e}");
501 if attempt == 0 {
502 eprintln!(
503 "{}",
504 crate::messages::askpass::proton_login_failed_retry(&e)
505 );
506 } else {
507 warn!("[external] Proton Pass login failed after retries: {e}");
508 eprintln!(
509 "{}",
510 crate::messages::askpass::proton_login_failed_prompt(&e)
511 );
512 }
513 }
514 }
515 }
516 }
517 }
518}
519
520pub fn apply_saved_sort(app: &mut App) {
525 let paths = app.env().paths().cloned();
526 let p = paths.as_ref();
527 let saved = crate::preferences::load_sort_mode(p);
528 let group = crate::preferences::load_group_by(p);
529 app.hosts_state.set_sort_mode(saved);
530 app.hosts_state.set_group_by_raw(group);
531 app.hosts_state
532 .set_view_mode(crate::preferences::load_view_mode(p));
533 app.containers_overview.hydrate_from_prefs(p);
534 if app.clear_stale_group_tag() {
535 if let Err(e) = crate::preferences::save_group_by(p, app.hosts_state.group_by()) {
536 app.notify_error(crate::messages::group_pref_reset_failed(&e));
537 }
538 }
539 if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by(), app::GroupBy::None)
540 {
541 app.apply_sort();
542 app.select_first_host();
543 }
544}
545
546pub fn ensure_keychain_password(
549 env: &crate::runtime::env::Env,
550 alias: &str,
551 askpass: Option<&str>,
552) {
553 if askpass != Some("keychain") {
554 return;
555 }
556 if askpass::keychain_has_password(env, alias) {
557 return;
558 }
559 let password = match cli::prompt_hidden_input(
560 &crate::messages::askpass::keychain_password_prompt(alias),
561 ) {
562 Ok(Some(p)) if !p.is_empty() => p,
563 Ok(Some(_)) => {
564 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
565 return;
566 }
567 Ok(None) => return,
568 Err(_) => return,
569 };
570 match askpass::store_in_keychain(env, alias, &password) {
571 Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
572 Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
573 }
574}
575
576#[cfg(test)]
577#[path = "../main_tests.rs"]
578mod tests;