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(
12    paths: Option<&crate::runtime::env::Paths>,
13    path: &str,
14) -> Result<PathBuf> {
15    expand_user_path(paths, path)
16}
17
18/// Expand `~/`, `${HOME}/` and `$HOME/` prefixes against the user's home
19/// directory. MCPB clients (e.g. Claude Desktop) do not always substitute
20/// `${HOME}` before passing CLI args, so the binary must handle it.
21pub fn expand_user_path(paths: Option<&crate::runtime::env::Paths>, path: &str) -> Result<PathBuf> {
22    let home = || {
23        paths
24            .map(|p| p.home().to_path_buf())
25            .context("Could not determine home directory")
26    };
27    let home_prefixes = ["~/", "${HOME}/", "$HOME/"];
28    for prefix in home_prefixes {
29        if let Some(rest) = path.strip_prefix(prefix) {
30            return Ok(home()?.join(rest));
31        }
32    }
33    if path == "~" || path == "${HOME}" || path == "$HOME" {
34        return home();
35    }
36    Ok(PathBuf::from(path))
37}
38
39pub fn resolve_token(
40    env: &crate::runtime::env::Env,
41    explicit: Option<String>,
42    from_stdin: bool,
43) -> Result<String> {
44    if let Some(t) = explicit {
45        return Ok(t);
46    }
47    if from_stdin {
48        let mut buf = String::new();
49        std::io::stdin().read_line(&mut buf)?;
50        return Ok(buf.trim().to_string());
51    }
52    if let Some(t) = env.purple_token() {
53        return Ok(t.to_string());
54    }
55    anyhow::bail!("{}", crate::messages::cli::NO_TOKEN)
56}
57
58/// Replace the spinner frame prefix in a status text. Returns None if the
59/// text does not start with a known spinner frame.
60///
61/// Animated statuses MUST start with a character from
62/// [`crate::animation::SPINNER_FRAMES`] followed by a space, otherwise
63/// `event_loop::handle_tick` cannot rotate the frame and the animation
64/// silently stops.
65pub fn replace_spinner_frame(text: &str, new_frame: &str) -> Option<String> {
66    let starts_with_spinner = crate::animation::SPINNER_FRAMES
67        .iter()
68        .any(|f| text.starts_with(f));
69    if !starts_with_spinner {
70        return None;
71    }
72    text.split_once(' ')
73        .map(|(_, rest)| format!("{} {}", new_frame, rest))
74}
75
76/// Thin re-export. The real implementation lives in `crate::messages` so
77/// every user-facing string funnels through one module.
78pub fn format_vault_sign_summary(
79    signed: u32,
80    failed: u32,
81    skipped: u32,
82    first_error: Option<&str>,
83) -> String {
84    crate::messages::vault_sign_summary(signed, failed, skipped, first_error)
85}
86
87pub fn format_sync_diff(added: usize, updated: usize, stale: usize) -> String {
88    let diff_parts: Vec<String> = [(added, "+"), (updated, "~"), (stale, "-")]
89        .iter()
90        .filter(|(n, _)| *n > 0)
91        .map(|(n, prefix)| format!("{}{}", prefix, n))
92        .collect();
93    if diff_parts.is_empty() {
94        String::new()
95    } else {
96        format!(" ({})", diff_parts.join(" "))
97    }
98}
99
100/// Footer status that surfaces in-flight providers as the batch progresses.
101/// While a sync is running the line is `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`,
102/// where the leading char is a braille spinner frame rotated by
103/// `event_loop::handle_tick` and the names are the providers that have not yet
104/// reported back. Once every provider in the batch has resolved the line
105/// becomes `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)` and the
106/// batch state resets. Persists `sync_history.tsv` on completion.
107pub fn set_sync_summary(app: &mut App) {
108    let still_syncing = !app.providers.syncing().is_empty();
109    let done = app.providers.sync_done().len();
110    let total = app
111        .providers
112        .batch_total()
113        .max(done + app.providers.syncing().len());
114    let added = app.providers.batch_added();
115    let updated = app.providers.batch_updated();
116    let stale = app.providers.batch_stale();
117    if still_syncing {
118        let mut active: Vec<String> = app
119            .providers
120            .syncing()
121            .keys()
122            .map(|name| crate::providers::provider_display_name(name).to_string())
123            .collect();
124        active.sort();
125        let active_names = active.join(", ");
126        let spinner = crate::animation::SPINNER_FRAMES[0];
127        let text = crate::messages::synced_progress(
128            spinner,
129            &active_names,
130            done,
131            total,
132            added,
133            updated,
134            stale,
135        );
136        if app.providers.sync_had_errors() {
137            app.notify_background_error(text);
138        } else {
139            app.notify_background(text);
140        }
141    } else {
142        let names = app.providers.sync_done().join(", ");
143        let text = crate::messages::synced_done(done, total, &names, added, updated, stale);
144        if app.providers.sync_had_errors() {
145            app.notify_background_error(text);
146        } else {
147            app.notify_background(text);
148        }
149        app::SyncRecord::save_all(app.providers.sync_history(), app.env().paths());
150        app.providers.finish_batch();
151    }
152}
153
154/// First-launch initialization: create the data directory and back up the
155/// original SSH config there. Returns `Some(has_backup)` on a first launch.
156/// Returns `None` once any purple file exists.
157pub fn first_launch_init(paths: &crate::runtime::env::Paths, config_path: &Path) -> Option<bool> {
158    let markers = [
159        paths.config_original(),
160        paths.preferences(),
161        paths.history(),
162        paths.container_cache(),
163        paths.last_version_check(),
164        paths.providers_config(),
165        paths.snippets_file(),
166        paths.themes_dir(),
167    ];
168    if markers.iter().any(|m| m.exists()) {
169        return None;
170    }
171    // The directory may already exist (logging opened its file moments
172    // ago), so set the mode explicitly instead of only on creation.
173    let data_dir = paths.data_dir();
174    if let Err(e) = std::fs::create_dir_all(data_dir) {
175        warn!(
176            "[config] Failed to create data directory {}: {e}",
177            data_dir.display()
178        );
179    }
180    #[cfg(unix)]
181    {
182        use std::os::unix::fs::PermissionsExt;
183        if let Err(e) = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700)) {
184            warn!(
185                "[config] Failed to set data directory permissions on {}: {e}",
186                data_dir.display()
187            );
188        }
189    }
190    let original_backup = paths.config_original();
191    if config_path.exists() {
192        if let Err(e) = std::fs::copy(config_path, &original_backup) {
193            warn!(
194                "[config] Failed to backup SSH config to {}: {e}",
195                original_backup.display()
196            );
197        }
198        #[cfg(unix)]
199        {
200            use std::os::unix::fs::PermissionsExt;
201            if let Err(e) =
202                std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
203            {
204                warn!("[config] Failed to set backup permissions: {e}");
205            }
206        }
207    }
208    Some(original_backup.exists())
209}
210
211/// Check and renew Vault SSH certificate if the host has a vault role configured.
212/// Writes the cert file to ~/.purple/certs/ AND sets CertificateFile on the host
213/// block when it is empty, so `ssh` actually uses the freshly signed cert.
214pub fn ensure_vault_ssh_if_needed(
215    env: &crate::runtime::env::Env,
216    alias: &str,
217    host: &ssh_config::model::HostEntry,
218    provider_config: &providers::config::ProviderConfig,
219    config: &mut ssh_config::model::SshConfigFile,
220) -> Option<(String, bool)> {
221    let role = vault_ssh::resolve_vault_role(
222        host.vault_ssh.as_deref(),
223        host.provider.as_deref(),
224        host.provider_label.as_deref(),
225        provider_config,
226    )?;
227
228    let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &host.identity_file) {
229        Ok(p) => p,
230        Err(e) => {
231            return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
232        }
233    };
234
235    let check_path =
236        vault_ssh::resolve_cert_path(env.paths(), alias, &host.certificate_file).ok()?;
237    let status = vault_ssh::check_cert_validity(env, &check_path);
238    if !vault_ssh::needs_renewal(&status) {
239        return None;
240    }
241
242    let vault_addr = vault_ssh::resolve_vault_addr(
243        host.vault_addr.as_deref(),
244        host.provider.as_deref(),
245        host.provider_label.as_deref(),
246        provider_config,
247    );
248    match vault_ssh::ensure_cert(
249        env,
250        &role,
251        &pubkey,
252        alias,
253        &host.certificate_file,
254        vault_addr.as_deref(),
255    ) {
256        Ok(cert_path) => {
257            if should_write_certificate_file(&host.certificate_file) {
258                let cert_str = cert_path.to_string_lossy().to_string();
259                let updated = config.set_host_certificate_file(alias, &cert_str);
260                if !updated {
261                    eprintln!(
262                        "{}",
263                        crate::messages::vault_cert_host_block_missing(alias, &cert_path)
264                    );
265                } else if let Err(e) = config.write() {
266                    eprintln!(
267                        "{}",
268                        crate::messages::vault_cert_config_write_failed(alias, &e)
269                    );
270                }
271            }
272            Some((crate::messages::vault_signed_pre_connect(alias), false))
273        }
274        Err(e) => {
275            let msg = e.to_string();
276            eprintln!(
277                "{}",
278                crate::messages::vault_sign_failed_pre_connect(alias, &msg)
279            );
280            Some((
281                crate::messages::vault_sign_failed_pre_connect(alias, &msg),
282                true,
283            ))
284        }
285    }
286}
287
288/// Resolve the effective ProxyJump chain for `target_alias` and run
289/// `ensure_vault_ssh_if_needed` for every host in it.
290pub fn ensure_vault_ssh_chain_if_needed(
291    env: &crate::runtime::env::Env,
292    target_alias: &str,
293    config_path: &Path,
294    provider_config: &providers::config::ProviderConfig,
295    config: &mut ssh_config::model::SshConfigFile,
296) -> Option<(String, bool)> {
297    let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
298    let mut signed_count: usize = 0;
299    let mut last_error: Option<String> = None;
300
301    for hop_alias in &chain {
302        let host_entry = config
303            .host_entries()
304            .into_iter()
305            .find(|h| h.alias == *hop_alias);
306        let Some(host) = host_entry else {
307            continue;
308        };
309        if let Some((msg, is_error)) =
310            ensure_vault_ssh_if_needed(env, hop_alias, &host, provider_config, config)
311        {
312            if is_error {
313                last_error = Some(msg);
314            } else {
315                signed_count += 1;
316            }
317        }
318    }
319
320    if let Some(err) = last_error {
321        return Some((err, true));
322    }
323    if signed_count == 0 {
324        return None;
325    }
326    Some((
327        crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
328        false,
329    ))
330}
331
332/// Per-alias locks serializing concurrent Vault SSH renewals. Container
333/// listing, inspect, logs and action fetches run on separate worker threads
334/// and can target the same host at once. Without this, two threads would
335/// sign the same cert in parallel and both rewrite the sacred ~/.ssh/config.
336static RENEWAL_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
337
338fn renewal_lock(alias: &str) -> Arc<Mutex<()>> {
339    RENEWAL_LOCKS
340        .get_or_init(|| Mutex::new(HashMap::new()))
341        .lock()
342        .unwrap_or_else(|p| p.into_inner())
343        .entry(alias.to_string())
344        .or_default()
345        .clone()
346}
347
348/// Worker-thread-safe Vault SSH renewal for a single host alias.
349///
350/// The connect path renews via `ensure_vault_ssh_chain_if_needed` with the
351/// main-thread App config in hand. The SSH chokepoints (container listing,
352/// exec, logs, actions and the file browser) run on worker threads with no
353/// App access, so this loads the provider and SSH config from disk itself.
354/// Serialized per alias so concurrent fetches never sign the same cert twice.
355/// Returns the same `(message, is_error)` summary as the chain helper;
356/// chokepoints log it and continue, the SSH call surfaces any real failure.
357pub fn ensure_vault_cert_for_alias(
358    env: &crate::runtime::env::Env,
359    alias: &str,
360    config_path: &Path,
361) -> Option<(String, bool)> {
362    let lock = renewal_lock(alias);
363    let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
364
365    let mut config = match ssh_config::model::SshConfigFile::parse_with_env(config_path, env) {
366        Ok(c) => c,
367        Err(e) => {
368            warn!("[config] Vault SSH renewal skipped for '{alias}': {e}");
369            return None;
370        }
371    };
372    let provider_config = providers::config::ProviderConfig::load(env.paths());
373    let result =
374        ensure_vault_ssh_chain_if_needed(env, alias, config_path, &provider_config, &mut config);
375    match &result {
376        Some((msg, true)) => warn!("[external] Vault SSH renewal for '{alias}': {msg}"),
377        Some((msg, false)) => debug!("[external] Vault SSH renewal for '{alias}': {msg}"),
378        None => {}
379    }
380    result
381}
382
383/// Decide whether to write a `CertificateFile` directive after a successful
384/// Vault SSH signing. Only write when the host has no existing
385/// `CertificateFile`. A user-set custom path must never be silently
386/// overwritten with purple's default cert path. Whitespace-only values count
387/// as empty.
388pub fn should_write_certificate_file(existing: &str) -> bool {
389    existing.trim().is_empty()
390}
391
392/// Pre-flight check for Bitwarden vault. If the askpass source uses `bw:` and
393/// no session token is cached, prompts the user to unlock the vault.
394pub fn ensure_bw_session(
395    env: &crate::runtime::env::Env,
396    existing: Option<&str>,
397    askpass: Option<&str>,
398) -> Option<String> {
399    let askpass = askpass?;
400    if !askpass.starts_with("bw:") || existing.is_some() {
401        return None;
402    }
403    let status = askpass::bw_vault_status(env);
404    match status {
405        askpass::BwStatus::Unlocked => None,
406        askpass::BwStatus::NotInstalled => {
407            eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
408            None
409        }
410        askpass::BwStatus::NotAuthenticated => {
411            eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
412            None
413        }
414        askpass::BwStatus::Locked => {
415            for attempt in 0..2 {
416                let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
417                    Ok(Some(p)) if !p.is_empty() => p,
418                    Ok(Some(_)) => {
419                        eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
420                        return None;
421                    }
422                    Ok(None) => return None,
423                    Err(e) => {
424                        eprintln!("{}", crate::messages::askpass::read_failed(&e));
425                        return None;
426                    }
427                };
428                match askpass::bw_unlock(env, &password) {
429                    Ok(token) => return Some(token),
430                    Err(e) => {
431                        if attempt == 0 {
432                            eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
433                        } else {
434                            eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
435                        }
436                    }
437                }
438            }
439            None
440        }
441    }
442}
443
444/// Pre-flight Proton Pass login. If the askpass source is `proton:` and the
445/// CLI is installed but the user is not authenticated, prompt for a Personal
446/// Access Token on stdin and run `pass-cli login`.
447pub fn ensure_proton_login(env: &crate::runtime::env::Env, askpass: Option<&str>) {
448    ensure_proton_login_with(
449        env,
450        askpass,
451        || askpass::proton_status(env),
452        || cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT),
453    );
454}
455
456/// Test seam for `ensure_proton_login`. Inject the status check and the PAT
457/// prompt so the routing logic can be exercised without a real `pass-cli` or a
458/// real stdin tty.
459pub fn ensure_proton_login_with<S, P>(
460    env: &crate::runtime::env::Env,
461    askpass: Option<&str>,
462    status_fn: S,
463    mut prompt_pat: P,
464) where
465    S: FnOnce() -> askpass::ProtonStatus,
466    P: FnMut() -> Result<Option<String>>,
467{
468    let Some(askpass) = askpass else {
469        return;
470    };
471    if !askpass.starts_with("proton:") {
472        return;
473    }
474    match status_fn() {
475        askpass::ProtonStatus::Authenticated => {
476            debug!("[external] Proton Pass pre-flight: already authenticated");
477        }
478        askpass::ProtonStatus::NotInstalled => {
479            debug!("[config] Proton Pass pre-flight: pass-cli not installed");
480            eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
481        }
482        askpass::ProtonStatus::NotAuthenticated => {
483            debug!("[external] Proton Pass pre-flight: not authenticated, prompting for PAT");
484            for attempt in 0..2 {
485                let pat = match prompt_pat() {
486                    Ok(Some(p)) if !p.is_empty() => p,
487                    Ok(Some(_)) => {
488                        debug!("[external] Proton Pass pre-flight: empty PAT, aborting");
489                        eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
490                        return;
491                    }
492                    Ok(None) => {
493                        debug!("[external] Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
494                        return;
495                    }
496                    Err(e) => {
497                        warn!("[external] Proton Pass PAT prompt read failed: {e}");
498                        eprintln!("{}", crate::messages::askpass::read_failed(&e));
499                        return;
500                    }
501                };
502                match askpass::proton_login(env, &pat) {
503                    Ok(()) => {
504                        debug!(
505                            "[external] Proton Pass pre-flight: login succeeded on attempt {attempt}"
506                        );
507                        eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
508                        return;
509                    }
510                    Err(e) => {
511                        debug!(
512                            "[external] Proton Pass pre-flight: login attempt {attempt} failed: {e}"
513                        );
514                        if attempt == 0 {
515                            eprintln!(
516                                "{}",
517                                crate::messages::askpass::proton_login_failed_retry(&e)
518                            );
519                        } else {
520                            warn!("[external] Proton Pass login failed after retries: {e}");
521                            eprintln!(
522                                "{}",
523                                crate::messages::askpass::proton_login_failed_prompt(&e)
524                            );
525                        }
526                    }
527                }
528            }
529        }
530    }
531}
532
533/// Apply saved sort/group/view preferences to a fresh App. Reads
534/// `~/.purple/preferences`, restores sort/group/view modes, clears stale
535/// group keys, and re-runs `apply_sort` plus `select_first_host` so the
536/// first row visible after startup matches the saved sort order.
537pub fn apply_saved_sort(app: &mut App) {
538    let paths = app.env().paths().cloned();
539    let p = paths.as_ref();
540    let saved = crate::preferences::load_sort_mode(p);
541    let group = crate::preferences::load_group_by(p);
542    app.hosts_state.set_sort_mode(saved);
543    app.hosts_state.set_group_by_raw(group);
544    app.hosts_state
545        .set_view_mode(crate::preferences::load_view_mode(p));
546    app.containers_overview.hydrate_from_prefs(p);
547    if app.clear_stale_group_tag()
548        && let Err(e) = crate::preferences::save_group_by(p, app.hosts_state.group_by())
549    {
550        app.notify_error(crate::messages::group_pref_reset_failed(&e));
551    }
552    if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by(), app::GroupBy::None)
553    {
554        app.apply_sort();
555        app.select_first_host();
556    }
557}
558
559/// Pre-flight check for keychain password. If the askpass source is `keychain` and
560/// no password is stored yet, prompts the user to enter one and stores it.
561pub fn ensure_keychain_password(
562    env: &crate::runtime::env::Env,
563    alias: &str,
564    askpass: Option<&str>,
565) {
566    if askpass != Some("keychain") {
567        return;
568    }
569    if askpass::keychain_has_password(env, alias) {
570        return;
571    }
572    let password = match cli::prompt_hidden_input(
573        &crate::messages::askpass::keychain_password_prompt(alias),
574    ) {
575        Ok(Some(p)) if !p.is_empty() => p,
576        Ok(Some(_)) => {
577            eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
578            return;
579        }
580        Ok(None) => return,
581        Err(_) => return,
582    };
583    match askpass::store_in_keychain(env, alias, &password) {
584        Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
585        Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
586    }
587}
588
589#[cfg(test)]
590#[path = "../main_tests.rs"]
591mod tests;