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