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.providers.sync_done.clear();
137 app.providers.sync_had_errors = false;
138 app.providers.batch_added = 0;
139 app.providers.batch_updated = 0;
140 app.providers.batch_stale = 0;
141 app.providers.batch_total = 0;
142 app::SyncRecord::save_all(&app.providers.sync_history);
143 }
144}
145
146pub fn first_launch_init(purple_dir: &Path, config_path: &Path) -> Option<bool> {
149 let markers = [
150 "config.original",
151 "preferences",
152 "history.tsv",
153 "container_cache.jsonl",
154 "last_version_check",
155 "providers",
156 "snippets.toml",
157 "themes",
158 ];
159 if markers.iter().any(|m| purple_dir.join(m).exists()) {
160 return None;
161 }
162 if let Err(e) = std::fs::create_dir_all(purple_dir) {
163 warn!("[config] Failed to create ~/.purple directory: {e}");
164 }
165 #[cfg(unix)]
166 {
167 use std::os::unix::fs::PermissionsExt;
168 if let Err(e) = std::fs::set_permissions(purple_dir, std::fs::Permissions::from_mode(0o700))
169 {
170 warn!("[config] Failed to set ~/.purple directory permissions: {e}");
171 }
172 }
173 let original_backup = purple_dir.join("config.original");
174 if config_path.exists() {
175 if let Err(e) = std::fs::copy(config_path, &original_backup) {
176 warn!(
177 "[config] Failed to backup SSH config to {}: {e}",
178 original_backup.display()
179 );
180 }
181 #[cfg(unix)]
182 {
183 use std::os::unix::fs::PermissionsExt;
184 if let Err(e) =
185 std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
186 {
187 warn!("[config] Failed to set backup permissions: {e}");
188 }
189 }
190 }
191 Some(original_backup.exists())
192}
193
194pub fn ensure_vault_ssh_if_needed(
198 alias: &str,
199 host: &ssh_config::model::HostEntry,
200 provider_config: &providers::config::ProviderConfig,
201 config: &mut ssh_config::model::SshConfigFile,
202) -> Option<(String, bool)> {
203 let role = vault_ssh::resolve_vault_role(
204 host.vault_ssh.as_deref(),
205 host.provider.as_deref(),
206 host.provider_label.as_deref(),
207 provider_config,
208 )?;
209
210 let pubkey = match vault_ssh::resolve_pubkey_path(&host.identity_file) {
211 Ok(p) => p,
212 Err(e) => {
213 return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
214 }
215 };
216
217 let check_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
218 let status = vault_ssh::check_cert_validity(&check_path);
219 if !vault_ssh::needs_renewal(&status) {
220 return None;
221 }
222
223 let vault_addr = vault_ssh::resolve_vault_addr(
224 host.vault_addr.as_deref(),
225 host.provider.as_deref(),
226 host.provider_label.as_deref(),
227 provider_config,
228 );
229 match vault_ssh::ensure_cert(
230 &role,
231 &pubkey,
232 alias,
233 &host.certificate_file,
234 vault_addr.as_deref(),
235 ) {
236 Ok(cert_path) => {
237 if should_write_certificate_file(&host.certificate_file) {
238 let cert_str = cert_path.to_string_lossy().to_string();
239 let updated = config.set_host_certificate_file(alias, &cert_str);
240 if !updated {
241 eprintln!(
242 "{}",
243 crate::messages::vault_cert_host_block_missing(alias, &cert_path)
244 );
245 } else if let Err(e) = config.write() {
246 eprintln!(
247 "{}",
248 crate::messages::vault_cert_config_write_failed(alias, &e)
249 );
250 }
251 }
252 Some((crate::messages::vault_signed_pre_connect(alias), false))
253 }
254 Err(e) => {
255 let msg = e.to_string();
256 eprintln!(
257 "{}",
258 crate::messages::vault_sign_failed_pre_connect(alias, &msg)
259 );
260 Some((
261 crate::messages::vault_sign_failed_pre_connect(alias, &msg),
262 true,
263 ))
264 }
265 }
266}
267
268pub fn ensure_vault_ssh_chain_if_needed(
271 target_alias: &str,
272 config_path: &Path,
273 provider_config: &providers::config::ProviderConfig,
274 config: &mut ssh_config::model::SshConfigFile,
275) -> Option<(String, bool)> {
276 let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
277 let mut signed_count: usize = 0;
278 let mut last_error: Option<String> = None;
279
280 for hop_alias in &chain {
281 let host_entry = config
282 .host_entries()
283 .into_iter()
284 .find(|h| h.alias == *hop_alias);
285 let Some(host) = host_entry else {
286 continue;
287 };
288 if let Some((msg, is_error)) =
289 ensure_vault_ssh_if_needed(hop_alias, &host, provider_config, config)
290 {
291 if is_error {
292 last_error = Some(msg);
293 } else {
294 signed_count += 1;
295 }
296 }
297 }
298
299 if let Some(err) = last_error {
300 return Some((err, true));
301 }
302 if signed_count == 0 {
303 return None;
304 }
305 Some((
306 crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
307 false,
308 ))
309}
310
311pub fn should_write_certificate_file(existing: &str) -> bool {
317 existing.trim().is_empty()
318}
319
320pub fn ensure_bw_session(existing: Option<&str>, askpass: Option<&str>) -> Option<String> {
323 let askpass = askpass?;
324 if !askpass.starts_with("bw:") || existing.is_some() {
325 return None;
326 }
327 let status = askpass::bw_vault_status();
328 match status {
329 askpass::BwStatus::Unlocked => None,
330 askpass::BwStatus::NotInstalled => {
331 eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
332 None
333 }
334 askpass::BwStatus::NotAuthenticated => {
335 eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
336 None
337 }
338 askpass::BwStatus::Locked => {
339 for attempt in 0..2 {
340 let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
341 Ok(Some(p)) if !p.is_empty() => p,
342 Ok(Some(_)) => {
343 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
344 return None;
345 }
346 Ok(None) => return None,
347 Err(e) => {
348 eprintln!("{}", crate::messages::askpass::read_failed(&e));
349 return None;
350 }
351 };
352 match askpass::bw_unlock(&password) {
353 Ok(token) => return Some(token),
354 Err(e) => {
355 if attempt == 0 {
356 eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
357 } else {
358 eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
359 }
360 }
361 }
362 }
363 None
364 }
365 }
366}
367
368pub fn ensure_proton_login(askpass: Option<&str>) {
372 ensure_proton_login_with(askpass, askpass::proton_status, || {
373 cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT)
374 });
375}
376
377pub fn ensure_proton_login_with<S, P>(askpass: Option<&str>, status_fn: S, mut prompt_pat: P)
381where
382 S: FnOnce() -> askpass::ProtonStatus,
383 P: FnMut() -> Result<Option<String>>,
384{
385 let Some(askpass) = askpass else {
386 return;
387 };
388 if !askpass.starts_with("proton:") {
389 return;
390 }
391 match status_fn() {
392 askpass::ProtonStatus::Authenticated => {
393 debug!("Proton Pass pre-flight: already authenticated");
394 }
395 askpass::ProtonStatus::NotInstalled => {
396 debug!("Proton Pass pre-flight: pass-cli not installed");
397 eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
398 }
399 askpass::ProtonStatus::NotAuthenticated => {
400 debug!("Proton Pass pre-flight: not authenticated, prompting for PAT");
401 for attempt in 0..2 {
402 let pat = match prompt_pat() {
403 Ok(Some(p)) if !p.is_empty() => p,
404 Ok(Some(_)) => {
405 debug!("Proton Pass pre-flight: empty PAT, aborting");
406 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
407 return;
408 }
409 Ok(None) => {
410 debug!("Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
411 return;
412 }
413 Err(e) => {
414 warn!("[config] Proton Pass PAT prompt read failed: {e}");
415 eprintln!("{}", crate::messages::askpass::read_failed(&e));
416 return;
417 }
418 };
419 match askpass::proton_login(&pat) {
420 Ok(()) => {
421 debug!("Proton Pass pre-flight: login succeeded on attempt {attempt}");
422 eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
423 return;
424 }
425 Err(e) => {
426 debug!("Proton Pass pre-flight: login attempt {attempt} failed: {e}");
427 if attempt == 0 {
428 eprintln!(
429 "{}",
430 crate::messages::askpass::proton_login_failed_retry(&e)
431 );
432 } else {
433 warn!("[external] Proton Pass login failed after retries: {e}");
434 eprintln!(
435 "{}",
436 crate::messages::askpass::proton_login_failed_prompt(&e)
437 );
438 }
439 }
440 }
441 }
442 }
443 }
444}
445
446pub fn apply_saved_sort(app: &mut App) {
451 let saved = crate::preferences::load_sort_mode();
452 let group = crate::preferences::load_group_by();
453 app.hosts_state.sort_mode = saved;
454 app.hosts_state.group_by = group;
455 app.hosts_state.view_mode = crate::preferences::load_view_mode();
456 app.containers_overview.hydrate_from_prefs();
457 if app.clear_stale_group_tag() {
458 if let Err(e) = crate::preferences::save_group_by(&app.hosts_state.group_by) {
459 app.notify_error(crate::messages::group_pref_reset_failed(&e));
460 }
461 }
462 if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by, app::GroupBy::None) {
463 app.apply_sort();
464 app.select_first_host();
465 }
466}
467
468pub fn ensure_keychain_password(alias: &str, askpass: Option<&str>) {
471 if askpass != Some("keychain") {
472 return;
473 }
474 if askpass::keychain_has_password(alias) {
475 return;
476 }
477 let password = match cli::prompt_hidden_input(
478 &crate::messages::askpass::keychain_password_prompt(alias),
479 ) {
480 Ok(Some(p)) if !p.is_empty() => p,
481 Ok(Some(_)) => {
482 eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
483 return;
484 }
485 Ok(None) => return,
486 Err(_) => return,
487 };
488 match askpass::store_in_keychain(alias, &password) {
489 Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
490 Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
491 }
492}
493
494#[cfg(test)]
495#[path = "../main_tests.rs"]
496mod tests;