use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use log::{debug, warn};
use crate::app::{self, App};
use crate::{askpass, cli, providers, ssh_config, vault_ssh};
pub fn resolve_config_path(path: &str) -> Result<PathBuf> {
expand_user_path(path)
}
pub fn expand_user_path(path: &str) -> Result<PathBuf> {
let home_prefixes = ["~/", "${HOME}/", "$HOME/"];
for prefix in home_prefixes {
if let Some(rest) = path.strip_prefix(prefix) {
let home = dirs::home_dir().context("Could not determine home directory")?;
return Ok(home.join(rest));
}
}
if path == "~" || path == "${HOME}" || path == "$HOME" {
return dirs::home_dir().context("Could not determine home directory");
}
Ok(PathBuf::from(path))
}
pub fn resolve_token(explicit: Option<String>, from_stdin: bool) -> Result<String> {
if let Some(t) = explicit {
return Ok(t);
}
if from_stdin {
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
return Ok(buf.trim().to_string());
}
if let Ok(t) = std::env::var("PURPLE_TOKEN") {
return Ok(t);
}
anyhow::bail!("{}", crate::messages::cli::NO_TOKEN)
}
pub fn replace_spinner_frame(text: &str, new_frame: &str) -> Option<String> {
let starts_with_spinner = crate::animation::SPINNER_FRAMES
.iter()
.any(|f| text.starts_with(f));
if !starts_with_spinner {
return None;
}
text.split_once(' ')
.map(|(_, rest)| format!("{} {}", new_frame, rest))
}
pub fn format_vault_sign_summary(
signed: u32,
failed: u32,
skipped: u32,
first_error: Option<&str>,
) -> String {
crate::messages::vault_sign_summary(signed, failed, skipped, first_error)
}
pub fn format_sync_diff(added: usize, updated: usize, stale: usize) -> String {
let diff_parts: Vec<String> = [(added, "+"), (updated, "~"), (stale, "-")]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, prefix)| format!("{}{}", prefix, n))
.collect();
if diff_parts.is_empty() {
String::new()
} else {
format!(" ({})", diff_parts.join(" "))
}
}
pub fn set_sync_summary(app: &mut App) {
let still_syncing = !app.providers.syncing().is_empty();
let done = app.providers.sync_done().len();
let total = app
.providers
.batch_total()
.max(done + app.providers.syncing().len());
let added = app.providers.batch_added();
let updated = app.providers.batch_updated();
let stale = app.providers.batch_stale();
if still_syncing {
let mut active: Vec<String> = app
.providers
.syncing()
.keys()
.map(|name| crate::providers::provider_display_name(name).to_string())
.collect();
active.sort();
let active_names = active.join(", ");
let spinner = crate::animation::SPINNER_FRAMES[0];
let text = crate::messages::synced_progress(
spinner,
&active_names,
done,
total,
added,
updated,
stale,
);
if app.providers.sync_had_errors() {
app.notify_background_error(text);
} else {
app.notify_background(text);
}
} else {
let names = app.providers.sync_done().join(", ");
let text = crate::messages::synced_done(done, total, &names, added, updated, stale);
if app.providers.sync_had_errors() {
app.notify_background_error(text);
} else {
app.notify_background(text);
}
app::SyncRecord::save_all(app.providers.sync_history());
app.providers.finish_batch();
}
}
pub fn first_launch_init(purple_dir: &Path, config_path: &Path) -> Option<bool> {
let markers = [
"config.original",
"preferences",
"history.tsv",
"container_cache.jsonl",
"last_version_check",
"providers",
"snippets.toml",
"themes",
];
if markers.iter().any(|m| purple_dir.join(m).exists()) {
return None;
}
if let Err(e) = std::fs::create_dir_all(purple_dir) {
warn!("[config] Failed to create ~/.purple directory: {e}");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(purple_dir, std::fs::Permissions::from_mode(0o700))
{
warn!("[config] Failed to set ~/.purple directory permissions: {e}");
}
}
let original_backup = purple_dir.join("config.original");
if config_path.exists() {
if let Err(e) = std::fs::copy(config_path, &original_backup) {
warn!(
"[config] Failed to backup SSH config to {}: {e}",
original_backup.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
{
warn!("[config] Failed to set backup permissions: {e}");
}
}
}
Some(original_backup.exists())
}
pub fn ensure_vault_ssh_if_needed(
alias: &str,
host: &ssh_config::model::HostEntry,
provider_config: &providers::config::ProviderConfig,
config: &mut ssh_config::model::SshConfigFile,
) -> Option<(String, bool)> {
let role = vault_ssh::resolve_vault_role(
host.vault_ssh.as_deref(),
host.provider.as_deref(),
host.provider_label.as_deref(),
provider_config,
)?;
let pubkey = match vault_ssh::resolve_pubkey_path(&host.identity_file) {
Ok(p) => p,
Err(e) => {
return Some((crate::messages::vault_cert_pubkey_resolve_failed(&e), true));
}
};
let check_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
let status = vault_ssh::check_cert_validity(&check_path);
if !vault_ssh::needs_renewal(&status) {
return None;
}
let vault_addr = vault_ssh::resolve_vault_addr(
host.vault_addr.as_deref(),
host.provider.as_deref(),
host.provider_label.as_deref(),
provider_config,
);
match vault_ssh::ensure_cert(
&role,
&pubkey,
alias,
&host.certificate_file,
vault_addr.as_deref(),
) {
Ok(cert_path) => {
if should_write_certificate_file(&host.certificate_file) {
let cert_str = cert_path.to_string_lossy().to_string();
let updated = config.set_host_certificate_file(alias, &cert_str);
if !updated {
eprintln!(
"{}",
crate::messages::vault_cert_host_block_missing(alias, &cert_path)
);
} else if let Err(e) = config.write() {
eprintln!(
"{}",
crate::messages::vault_cert_config_write_failed(alias, &e)
);
}
}
Some((crate::messages::vault_signed_pre_connect(alias), false))
}
Err(e) => {
let msg = e.to_string();
eprintln!(
"{}",
crate::messages::vault_sign_failed_pre_connect(alias, &msg)
);
Some((
crate::messages::vault_sign_failed_pre_connect(alias, &msg),
true,
))
}
}
}
pub fn ensure_vault_ssh_chain_if_needed(
target_alias: &str,
config_path: &Path,
provider_config: &providers::config::ProviderConfig,
config: &mut ssh_config::model::SshConfigFile,
) -> Option<(String, bool)> {
let chain = vault_ssh::resolve_proxy_chain(config_path, target_alias);
let mut signed_count: usize = 0;
let mut last_error: Option<String> = None;
for hop_alias in &chain {
let host_entry = config
.host_entries()
.into_iter()
.find(|h| h.alias == *hop_alias);
let Some(host) = host_entry else {
continue;
};
if let Some((msg, is_error)) =
ensure_vault_ssh_if_needed(hop_alias, &host, provider_config, config)
{
if is_error {
last_error = Some(msg);
} else {
signed_count += 1;
}
}
}
if let Some(err) = last_error {
return Some((err, true));
}
if signed_count == 0 {
return None;
}
Some((
crate::messages::vault_signed_pre_connect_chain(target_alias, signed_count),
false,
))
}
pub fn should_write_certificate_file(existing: &str) -> bool {
existing.trim().is_empty()
}
pub fn ensure_bw_session(existing: Option<&str>, askpass: Option<&str>) -> Option<String> {
let askpass = askpass?;
if !askpass.starts_with("bw:") || existing.is_some() {
return None;
}
let status = askpass::bw_vault_status();
match status {
askpass::BwStatus::Unlocked => None,
askpass::BwStatus::NotInstalled => {
eprintln!("{}", crate::messages::askpass::BW_NOT_FOUND);
None
}
askpass::BwStatus::NotAuthenticated => {
eprintln!("{}", crate::messages::askpass::BW_NOT_LOGGED_IN);
None
}
askpass::BwStatus::Locked => {
for attempt in 0..2 {
let password = match cli::prompt_hidden_input("Bitwarden master password: ") {
Ok(Some(p)) if !p.is_empty() => p,
Ok(Some(_)) => {
eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
return None;
}
Ok(None) => return None,
Err(e) => {
eprintln!("{}", crate::messages::askpass::read_failed(&e));
return None;
}
};
match askpass::bw_unlock(&password) {
Ok(token) => return Some(token),
Err(e) => {
if attempt == 0 {
eprintln!("{}", crate::messages::askpass::unlock_failed_retry(&e));
} else {
eprintln!("{}", crate::messages::askpass::unlock_failed_prompt(&e));
}
}
}
}
None
}
}
}
pub fn ensure_proton_login(askpass: Option<&str>) {
ensure_proton_login_with(askpass, askpass::proton_status, || {
cli::prompt_hidden_input(crate::messages::askpass::PROTON_LOGIN_PROMPT)
});
}
pub fn ensure_proton_login_with<S, P>(askpass: Option<&str>, status_fn: S, mut prompt_pat: P)
where
S: FnOnce() -> askpass::ProtonStatus,
P: FnMut() -> Result<Option<String>>,
{
let Some(askpass) = askpass else {
return;
};
if !askpass.starts_with("proton:") {
return;
}
match status_fn() {
askpass::ProtonStatus::Authenticated => {
debug!("Proton Pass pre-flight: already authenticated");
}
askpass::ProtonStatus::NotInstalled => {
debug!("Proton Pass pre-flight: pass-cli not installed");
eprintln!("{}", crate::messages::askpass::PROTON_NOT_FOUND);
}
askpass::ProtonStatus::NotAuthenticated => {
debug!("Proton Pass pre-flight: not authenticated, prompting for PAT");
for attempt in 0..2 {
let pat = match prompt_pat() {
Ok(Some(p)) if !p.is_empty() => p,
Ok(Some(_)) => {
debug!("Proton Pass pre-flight: empty PAT, aborting");
eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
return;
}
Ok(None) => {
debug!("Proton Pass pre-flight: PAT prompt dismissed (Esc/EOF)");
return;
}
Err(e) => {
warn!("[config] Proton Pass PAT prompt read failed: {e}");
eprintln!("{}", crate::messages::askpass::read_failed(&e));
return;
}
};
match askpass::proton_login(&pat) {
Ok(()) => {
debug!("Proton Pass pre-flight: login succeeded on attempt {attempt}");
eprintln!("{}", crate::messages::askpass::PROTON_LOGIN_SUCCESS);
return;
}
Err(e) => {
debug!("Proton Pass pre-flight: login attempt {attempt} failed: {e}");
if attempt == 0 {
eprintln!(
"{}",
crate::messages::askpass::proton_login_failed_retry(&e)
);
} else {
warn!("[external] Proton Pass login failed after retries: {e}");
eprintln!(
"{}",
crate::messages::askpass::proton_login_failed_prompt(&e)
);
}
}
}
}
}
}
}
pub fn apply_saved_sort(app: &mut App) {
let saved = crate::preferences::load_sort_mode();
let group = crate::preferences::load_group_by();
app.hosts_state.set_sort_mode(saved);
app.hosts_state.set_group_by_raw(group);
app.hosts_state
.set_view_mode(crate::preferences::load_view_mode());
app.containers_overview.hydrate_from_prefs();
if app.clear_stale_group_tag() {
if let Err(e) = crate::preferences::save_group_by(app.hosts_state.group_by()) {
app.notify_error(crate::messages::group_pref_reset_failed(&e));
}
}
if saved != app::SortMode::Original || !matches!(app.hosts_state.group_by(), app::GroupBy::None)
{
app.apply_sort();
app.select_first_host();
}
}
pub fn ensure_keychain_password(alias: &str, askpass: Option<&str>) {
if askpass != Some("keychain") {
return;
}
if askpass::keychain_has_password(alias) {
return;
}
let password = match cli::prompt_hidden_input(
&crate::messages::askpass::keychain_password_prompt(alias),
) {
Ok(Some(p)) if !p.is_empty() => p,
Ok(Some(_)) => {
eprintln!("{}", crate::messages::askpass::EMPTY_PASSWORD);
return;
}
Ok(None) => return,
Err(_) => return,
};
match askpass::store_in_keychain(alias, &password) {
Ok(()) => eprintln!("{}", crate::messages::askpass::PASSWORD_IN_KEYCHAIN),
Err(e) => eprintln!("{}", crate::messages::askpass::keychain_store_failed(&e)),
}
}
#[cfg(test)]
#[path = "../main_tests.rs"]
mod tests;