Skip to main content

purple_ssh/runtime/
helpers.rs

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
15/// Expand `~/`, `${HOME}/` and `$HOME/` prefixes against the user's home
16/// directory. MCPB clients (e.g. Claude Desktop) do not always substitute
17/// `${HOME}` before passing CLI args, so the binary must handle it.
18pub 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
47/// Replace the spinner frame prefix in a status text. Returns None if the
48/// text does not start with a known spinner frame.
49///
50/// Animated statuses MUST start with a character from
51/// [`crate::animation::SPINNER_FRAMES`] followed by a space, otherwise
52/// `event_loop::handle_tick` cannot rotate the frame and the animation
53/// silently stops.
54pub 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
65/// Thin re-export. The real implementation lives in `crate::messages` so
66/// every user-facing string funnels through one module.
67pub 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
89/// Footer status that surfaces in-flight providers as the batch progresses.
90/// While a sync is running the line is `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`,
91/// where the leading char is a braille spinner frame rotated by
92/// `event_loop::handle_tick` and the names are the providers that have not yet
93/// reported back. Once every provider in the batch has resolved the line
94/// becomes `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)` and the
95/// batch state resets. Persists `sync_history.tsv` on completion.
96pub 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
143/// First-launch initialization: create ~/.purple/ and back up the original SSH config.
144/// Returns `Some(has_backup)` if this was a first launch, or `None` if already initialized.
145pub 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
191/// Check and renew Vault SSH certificate if the host has a vault role configured.
192/// Writes the cert file to ~/.purple/certs/ AND sets CertificateFile on the host
193/// block when it is empty, so `ssh` actually uses the freshly signed cert.
194pub 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
265/// Resolve the effective ProxyJump chain for `target_alias` and run
266/// `ensure_vault_ssh_if_needed` for every host in it.
267pub 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
308/// Per-alias locks serializing concurrent Vault SSH renewals. Container
309/// listing, inspect, logs and action fetches run on separate worker threads
310/// and can target the same host at once. Without this, two threads would
311/// sign the same cert in parallel and both rewrite the sacred ~/.ssh/config.
312static 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
324/// Worker-thread-safe Vault SSH renewal for a single host alias.
325///
326/// The connect path renews via `ensure_vault_ssh_chain_if_needed` with the
327/// main-thread App config in hand. The SSH chokepoints (container listing,
328/// exec, logs, actions and the file browser) run on worker threads with no
329/// App access, so this loads the provider and SSH config from disk itself.
330/// Serialized per alias so concurrent fetches never sign the same cert twice.
331/// Returns the same `(message, is_error)` summary as the chain helper;
332/// chokepoints log it and continue, the SSH call surfaces any real failure.
333pub 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
355/// Decide whether to write a `CertificateFile` directive after a successful
356/// Vault SSH signing. Only write when the host has no existing
357/// `CertificateFile`. A user-set custom path must never be silently
358/// overwritten with purple's default cert path. Whitespace-only values count
359/// as empty.
360pub fn should_write_certificate_file(existing: &str) -> bool {
361    existing.trim().is_empty()
362}
363
364/// Pre-flight check for Bitwarden vault. If the askpass source uses `bw:` and
365/// no session token is cached, prompts the user to unlock the vault.
366pub 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
412/// Pre-flight Proton Pass login. If the askpass source is `proton:` and the
413/// CLI is installed but the user is not authenticated, prompt for a Personal
414/// Access Token on stdin and run `pass-cli login`.
415pub 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
421/// Test seam for `ensure_proton_login`. Inject the status check and the PAT
422/// prompt so the routing logic can be exercised without a real `pass-cli` or a
423/// real stdin tty.
424pub 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
490/// Apply saved sort/group/view preferences to a fresh App. Reads
491/// `~/.purple/preferences`, restores sort/group/view modes, clears stale
492/// group keys, and re-runs `apply_sort` plus `select_first_host` so the
493/// first row visible after startup matches the saved sort order.
494pub 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
514/// Pre-flight check for keychain password. If the askpass source is `keychain` and
515/// no password is stored yet, prompts the user to enter one and stores it.
516pub 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;