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 ~/.purple/ and back up the original SSH config.
155/// Returns `Some(has_backup)` if this was a first launch, or `None` if already initialized.
156pub fn first_launch_init(purple_dir: &Path, config_path: &Path) -> Option<bool> {
157    let markers = [
158        "config.original",
159        "preferences",
160        "history.tsv",
161        "container_cache.jsonl",
162        "last_version_check",
163        "providers",
164        "snippets.toml",
165        "themes",
166    ];
167    if markers.iter().any(|m| purple_dir.join(m).exists()) {
168        return None;
169    }
170    if let Err(e) = std::fs::create_dir_all(purple_dir) {
171        warn!("[config] Failed to create ~/.purple directory: {e}");
172    }
173    #[cfg(unix)]
174    {
175        use std::os::unix::fs::PermissionsExt;
176        if let Err(e) = std::fs::set_permissions(purple_dir, std::fs::Permissions::from_mode(0o700))
177        {
178            warn!("[config] Failed to set ~/.purple directory permissions: {e}");
179        }
180    }
181    let original_backup = purple_dir.join("config.original");
182    if config_path.exists() {
183        if let Err(e) = std::fs::copy(config_path, &original_backup) {
184            warn!(
185                "[config] Failed to backup SSH config to {}: {e}",
186                original_backup.display()
187            );
188        }
189        #[cfg(unix)]
190        {
191            use std::os::unix::fs::PermissionsExt;
192            if let Err(e) =
193                std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
194            {
195                warn!("[config] Failed to set backup permissions: {e}");
196            }
197        }
198    }
199    Some(original_backup.exists())
200}
201
202/// Check and renew Vault SSH certificate if the host has a vault role configured.
203/// Writes the cert file to ~/.purple/certs/ AND sets CertificateFile on the host
204/// block when it is empty, so `ssh` actually uses the freshly signed cert.
205pub fn ensure_vault_ssh_if_needed(
206    env: &crate::runtime::env::Env,
207    alias: &str,
208    host: &ssh_config::model::HostEntry,
209    provider_config: &providers::config::ProviderConfig,
210    config: &mut ssh_config::model::SshConfigFile,
211) -> Option<(String, bool)> {
212    let role = vault_ssh::resolve_vault_role(
213        host.vault_ssh.as_deref(),
214        host.provider.as_deref(),
215        host.provider_label.as_deref(),
216        provider_config,
217    )?;
218
219    let pubkey = match vault_ssh::resolve_pubkey_path(env.paths(), &host.identity_file) {
220        Ok(p) => p,
221        Err(e) => {
222            return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
223        }
224    };
225
226    let check_path =
227        vault_ssh::resolve_cert_path(env.paths(), alias, &host.certificate_file).ok()?;
228    let status = vault_ssh::check_cert_validity(env, &check_path);
229    if !vault_ssh::needs_renewal(&status) {
230        return None;
231    }
232
233    let vault_addr = vault_ssh::resolve_vault_addr(
234        host.vault_addr.as_deref(),
235        host.provider.as_deref(),
236        host.provider_label.as_deref(),
237        provider_config,
238    );
239    match vault_ssh::ensure_cert(
240        env,
241        &role,
242        &pubkey,
243        alias,
244        &host.certificate_file,
245        vault_addr.as_deref(),
246    ) {
247        Ok(cert_path) => {
248            if should_write_certificate_file(&host.certificate_file) {
249                let cert_str = cert_path.to_string_lossy().to_string();
250                let updated = config.set_host_certificate_file(alias, &cert_str);
251                if !updated {
252                    eprintln!(
253                        "{}",
254                        crate::messages::vault_cert_host_block_missing(alias, &cert_path)
255                    );
256                } else if let Err(e) = config.write() {
257                    eprintln!(
258                        "{}",
259                        crate::messages::vault_cert_config_write_failed(alias, &e)
260                    );
261                }
262            }
263            Some((crate::messages::vault_signed_pre_connect(alias), false))
264        }
265        Err(e) => {
266            let msg = e.to_string();
267            eprintln!(
268                "{}",
269                crate::messages::vault_sign_failed_pre_connect(alias, &msg)
270            );
271            Some((
272                crate::messages::vault_sign_failed_pre_connect(alias, &msg),
273                true,
274            ))
275        }
276    }
277}
278
279/// Resolve the effective ProxyJump chain for `target_alias` and run
280/// `ensure_vault_ssh_if_needed` for every host in it.
281pub fn ensure_vault_ssh_chain_if_needed(
282    env: &crate::runtime::env::Env,
283    target_alias: &str,
284    config_path: &Path,
285    provider_config: &providers::config::ProviderConfig,
286    config: &mut ssh_config::model::SshConfigFile,
287) -> Option<(String, bool)> {
288    let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
289    let mut signed_count: usize = 0;
290    let mut last_error: Option<String> = None;
291
292    for hop_alias in &chain {
293        let host_entry = config
294            .host_entries()
295            .into_iter()
296            .find(|h| h.alias == *hop_alias);
297        let Some(host) = host_entry else {
298            continue;
299        };
300        if let Some((msg, is_error)) =
301            ensure_vault_ssh_if_needed(env, hop_alias, &host, provider_config, config)
302        {
303            if is_error {
304                last_error = Some(msg);
305            } else {
306                signed_count += 1;
307            }
308        }
309    }
310
311    if let Some(err) = last_error {
312        return Some((err, true));
313    }
314    if signed_count == 0 {
315        return None;
316    }
317    Some((
318        crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
319        false,
320    ))
321}
322
323/// Per-alias locks serializing concurrent Vault SSH renewals. Container
324/// listing, inspect, logs and action fetches run on separate worker threads
325/// and can target the same host at once. Without this, two threads would
326/// sign the same cert in parallel and both rewrite the sacred ~/.ssh/config.
327static RENEWAL_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
328
329fn renewal_lock(alias: &str) -> Arc<Mutex<()>> {
330    RENEWAL_LOCKS
331        .get_or_init(|| Mutex::new(HashMap::new()))
332        .lock()
333        .unwrap_or_else(|p| p.into_inner())
334        .entry(alias.to_string())
335        .or_default()
336        .clone()
337}
338
339/// Worker-thread-safe Vault SSH renewal for a single host alias.
340///
341/// The connect path renews via `ensure_vault_ssh_chain_if_needed` with the
342/// main-thread App config in hand. The SSH chokepoints (container listing,
343/// exec, logs, actions and the file browser) run on worker threads with no
344/// App access, so this loads the provider and SSH config from disk itself.
345/// Serialized per alias so concurrent fetches never sign the same cert twice.
346/// Returns the same `(message, is_error)` summary as the chain helper;
347/// chokepoints log it and continue, the SSH call surfaces any real failure.
348pub fn ensure_vault_cert_for_alias(
349    env: &crate::runtime::env::Env,
350    alias: &str,
351    config_path: &Path,
352) -> Option<(String, bool)> {
353    let lock = renewal_lock(alias);
354    let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
355
356    let mut config = match ssh_config::model::SshConfigFile::parse_with_env(config_path, env) {
357        Ok(c) => c,
358        Err(e) => {
359            warn!("[config] Vault SSH renewal skipped for '{alias}': {e}");
360            return None;
361        }
362    };
363    let provider_config = providers::config::ProviderConfig::load(env.paths());
364    let result =
365        ensure_vault_ssh_chain_if_needed(env, alias, config_path, &provider_config, &mut config);
366    match &result {
367        Some((msg, true)) => warn!("[external] Vault SSH renewal for '{alias}': {msg}"),
368        Some((msg, false)) => debug!("Vault SSH renewal for '{alias}': {msg}"),
369        None => {}
370    }
371    result
372}
373
374/// Decide whether to write a `CertificateFile` directive after a successful
375/// Vault SSH signing. Only write when the host has no existing
376/// `CertificateFile`. A user-set custom path must never be silently
377/// overwritten with purple's default cert path. Whitespace-only values count
378/// as empty.
379pub fn should_write_certificate_file(existing: &str) -> bool {
380    existing.trim().is_empty()
381}
382
383/// Pre-flight check for Bitwarden vault. If the askpass source uses `bw:` and
384/// no session token is cached, prompts the user to unlock the vault.
385pub fn ensure_bw_session(
386    env: &crate::runtime::env::Env,
387    existing: Option<&str>,
388    askpass: Option<&str>,
389) -> Option<String> {
390    let askpass = askpass?;
391    if !askpass.starts_with("bw:") || existing.is_some() {
392        return None;
393    }
394    let status = askpass::bw_vault_status(env);
395    match status {
396        askpass::BwStatus::Unlocked => None,
397        askpass::BwStatus::NotInstalled => {
398            eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
399            None
400        }
401        askpass::BwStatus::NotAuthenticated => {
402            eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
403            None
404        }
405        askpass::BwStatus::Locked => {
406            for attempt in 0..2 {
407                let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
408                    Ok(Some(p)) if !p.is_empty() => p,
409                    Ok(Some(_)) => {
410                        eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
411                        return None;
412                    }
413                    Ok(None) => return None,
414                    Err(e) => {
415                        eprintln!("{}", crate::messages::askpass::read_failed(&e));
416                        return None;
417                    }
418                };
419                match askpass::bw_unlock(env, &password) {
420                    Ok(token) => return Some(token),
421                    Err(e) => {
422                        if attempt == 0 {
423                            eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
424                        } else {
425                            eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
426                        }
427                    }
428                }
429            }
430            None
431        }
432    }
433}
434
435/// Pre-flight Proton Pass login. If the askpass source is `proton:` and the
436/// CLI is installed but the user is not authenticated, prompt for a Personal
437/// Access Token on stdin and run `pass-cli login`.
438pub fn ensure_proton_login(env: &crate::runtime::env::Env, askpass: Option<&str>) {
439    ensure_proton_login_with(
440        env,
441        askpass,
442        || askpass::proton_status(env),
443        || cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT),
444    );
445}
446
447/// Test seam for `ensure_proton_login`. Inject the status check and the PAT
448/// prompt so the routing logic can be exercised without a real `pass-cli` or a
449/// real stdin tty.
450pub fn ensure_proton_login_with<S, P>(
451    env: &crate::runtime::env::Env,
452    askpass: Option<&str>,
453    status_fn: S,
454    mut prompt_pat: P,
455) where
456    S: FnOnce() -> askpass::ProtonStatus,
457    P: FnMut() -> Result<Option<String>>,
458{
459    let Some(askpass) = askpass else {
460        return;
461    };
462    if !askpass.starts_with("proton:") {
463        return;
464    }
465    match status_fn() {
466        askpass::ProtonStatus::Authenticated => {
467            debug!("Proton Pass pre-flight: already authenticated");
468        }
469        askpass::ProtonStatus::NotInstalled => {
470            debug!("Proton Pass pre-flight: pass-cli not installed");
471            eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
472        }
473        askpass::ProtonStatus::NotAuthenticated => {
474            debug!("Proton Pass pre-flight: not authenticated, prompting for PAT");
475            for attempt in 0..2 {
476                let pat = match prompt_pat() {
477                    Ok(Some(p)) if !p.is_empty() => p,
478                    Ok(Some(_)) => {
479                        debug!("Proton Pass pre-flight: empty PAT, aborting");
480                        eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
481                        return;
482                    }
483                    Ok(None) => {
484                        debug!("Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
485                        return;
486                    }
487                    Err(e) => {
488                        warn!("[config] Proton Pass PAT prompt read failed: {e}");
489                        eprintln!("{}", crate::messages::askpass::read_failed(&e));
490                        return;
491                    }
492                };
493                match askpass::proton_login(env, &pat) {
494                    Ok(()) => {
495                        debug!("Proton Pass pre-flight: login succeeded on attempt {attempt}");
496                        eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
497                        return;
498                    }
499                    Err(e) => {
500                        debug!("Proton Pass pre-flight: login attempt {attempt} failed: {e}");
501                        if attempt == 0 {
502                            eprintln!(
503                                "{}",
504                                crate::messages::askpass::proton_login_failed_retry(&e)
505                            );
506                        } else {
507                            warn!("[external] Proton Pass login failed after retries: {e}");
508                            eprintln!(
509                                "{}",
510                                crate::messages::askpass::proton_login_failed_prompt(&e)
511                            );
512                        }
513                    }
514                }
515            }
516        }
517    }
518}
519
520/// Apply saved sort/group/view preferences to a fresh App. Reads
521/// `~/.purple/preferences`, restores sort/group/view modes, clears stale
522/// group keys, and re-runs `apply_sort` plus `select_first_host` so the
523/// first row visible after startup matches the saved sort order.
524pub fn apply_saved_sort(app: &mut App) {
525    let paths = app.env().paths().cloned();
526    let p = paths.as_ref();
527    let saved = crate::preferences::load_sort_mode(p);
528    let group = crate::preferences::load_group_by(p);
529    app.hosts_state.set_sort_mode(saved);
530    app.hosts_state.set_group_by_raw(group);
531    app.hosts_state
532        .set_view_mode(crate::preferences::load_view_mode(p));
533    app.containers_overview.hydrate_from_prefs(p);
534    if app.clear_stale_group_tag() {
535        if let Err(e) = crate::preferences::save_group_by(p, app.hosts_state.group_by()) {
536            app.notify_error(crate::messages::group_pref_reset_failed(&e));
537        }
538    }
539    if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by(), app::GroupBy::None)
540    {
541        app.apply_sort();
542        app.select_first_host();
543    }
544}
545
546/// Pre-flight check for keychain password. If the askpass source is `keychain` and
547/// no password is stored yet, prompts the user to enter one and stores it.
548pub fn ensure_keychain_password(
549    env: &crate::runtime::env::Env,
550    alias: &str,
551    askpass: Option<&str>,
552) {
553    if askpass != Some("keychain") {
554        return;
555    }
556    if askpass::keychain_has_password(env, alias) {
557        return;
558    }
559    let password = match cli::prompt_hidden_input(
560        &crate::messages::askpass::keychain_password_prompt(alias),
561    ) {
562        Ok(Some(p)) if !p.is_empty() => p,
563        Ok(Some(_)) => {
564            eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
565            return;
566        }
567        Ok(None) => return,
568        Err(_) => return,
569    };
570    match askpass::store_in_keychain(env, alias, &password) {
571        Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
572        Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
573    }
574}
575
576#[cfg(test)]
577#[path = "../main_tests.rs"]
578mod tests;