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