Skip to main content

purple_ssh/runtime/
helpers.rs

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
13/// Expand `~/`, `${HOME}/` and `$HOME/` prefixes against the user's home
14/// directory. MCPB clients (e.g. Claude Desktop) do not always substitute
15/// `${HOME}` before passing CLI args, so the binary must handle it.
16pub 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
45/// Replace the spinner frame prefix in a status text. Returns None if the
46/// text does not start with a known spinner frame.
47///
48/// Animated statuses MUST start with a character from
49/// [`crate::animation::SPINNER_FRAMES`] followed by a space, otherwise
50/// `event_loop::handle_tick` cannot rotate the frame and the animation
51/// silently stops.
52pub 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
63/// Thin re-export. The real implementation lives in `crate::messages` so
64/// every user-facing string funnels through one module.
65pub 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
87/// Footer status that surfaces in-flight providers as the batch progresses.
88/// While a sync is running the line is `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`,
89/// where the leading char is a braille spinner frame rotated by
90/// `event_loop::handle_tick` and the names are the providers that have not yet
91/// reported back. Once every provider in the batch has resolved the line
92/// becomes `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)` and the
93/// batch state resets. Persists `sync_history.tsv` on completion.
94pub 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
146/// First-launch initialization: create ~/.purple/ and back up the original SSH config.
147/// Returns `Some(has_backup)` if this was a first launch, or `None` if already initialized.
148pub 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
194/// Check and renew Vault SSH certificate if the host has a vault role configured.
195/// Writes the cert file to ~/.purple/certs/ AND sets CertificateFile on the host
196/// block when it is empty, so `ssh` actually uses the freshly signed cert.
197pub 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
268/// Resolve the effective ProxyJump chain for `target_alias` and run
269/// `ensure_vault_ssh_if_needed` for every host in it.
270pub 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
311/// Decide whether to write a `CertificateFile` directive after a successful
312/// Vault SSH signing. Only write when the host has no existing
313/// `CertificateFile`. A user-set custom path must never be silently
314/// overwritten with purple's default cert path. Whitespace-only values count
315/// as empty.
316pub fn should_write_certificate_file(existing: &str) -> bool {
317    existing.trim().is_empty()
318}
319
320/// Pre-flight check for Bitwarden vault. If the askpass source uses `bw:` and
321/// no session token is cached, prompts the user to unlock the vault.
322pub 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
368/// Pre-flight Proton Pass login. If the askpass source is `proton:` and the
369/// CLI is installed but the user is not authenticated, prompt for a Personal
370/// Access Token on stdin and run `pass-cli login`.
371pub 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
377/// Test seam for `ensure_proton_login`. Inject the status check and the PAT
378/// prompt so the routing logic can be exercised without a real `pass-cli` or a
379/// real stdin tty.
380pub 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
446/// Apply saved sort/group/view preferences to a fresh App. Reads
447/// `~/.purple/preferences`, restores sort/group/view modes, clears stale
448/// group keys, and re-runs `apply_sort` plus `select_first_host` so the
449/// first row visible after startup matches the saved sort order.
450pub 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
468/// Pre-flight check for keychain password. If the askpass source is `keychain` and
469/// no password is stored yet, prompts the user to enter one and stores it.
470pub 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;