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