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