ssh-portkey 0.1.3

Secure SSH credential manager with a fast ratatui-based TUI, fuzzy search, and encrypted vault
Documentation
use anyhow::{anyhow, Result};

use crate::models::Server;

pub const BEGIN_MARKER: &str = "# BEGIN Portkey managed entries";
pub const END_MARKER: &str = "# END Portkey managed entries";

fn validate_non_empty_single_line(label: &str, value: &str) -> Result<()> {
    if value.trim().is_empty() {
        return Err(anyhow!("{label} cannot be empty"));
    }

    if value.chars().any(|c| c == '\n' || c == '\r') {
        return Err(anyhow!("{label} cannot contain newlines"));
    }

    Ok(())
}

fn validate_host_alias(alias: &str) -> Result<()> {
    validate_non_empty_single_line("Host alias", alias)?;

    if alias.chars().any(char::is_whitespace) {
        return Err(anyhow!("Host alias cannot contain whitespace"));
    }

    Ok(())
}

fn validate_server(server: &Server) -> Result<()> {
    validate_host_alias(&server.name)?;
    validate_non_empty_single_line("HostName", &server.host)?;
    validate_non_empty_single_line("User", &server.username)?;

    if let Some(identity_file) = server.identity_file.as_deref() {
        if !identity_file.is_empty() {
            validate_non_empty_single_line("IdentityFile", identity_file)?;
        }
    }

    Ok(())
}

pub fn render_ssh_config(servers: &[Server]) -> Result<String> {
    let mut output = String::new();

    for server in servers {
        validate_server(server)?;
        output.push_str(&format!(
            "Host {}\n  HostName {}\n  User {}\n  Port {}\n",
            server.name, server.host, server.username, server.port
        ));

        if let Some(identity_file) = server
            .identity_file
            .as_deref()
            .filter(|path| !path.is_empty())
        {
            output.push_str(&format!("  IdentityFile {identity_file}\n"));
        }

        if server.forward_agent {
            output.push_str("  ForwardAgent yes\n");
        }

        output.push('\n');
    }

    Ok(output)
}

pub fn render_managed_block(servers: &[Server]) -> Result<String> {
    let config = render_ssh_config(servers)?;
    Ok(format!("{BEGIN_MARKER}\n{config}{END_MARKER}\n"))
}

pub fn upsert_managed_block(existing: &str, managed_block: &str) -> String {
    if let Some(begin) = existing.find(BEGIN_MARKER) {
        if let Some(relative_end) = existing[begin..].find(END_MARKER) {
            let end = begin + relative_end + END_MARKER.len();
            let before = existing[..begin].trim_end();
            let after = existing[end..].trim_start_matches(['\r', '\n']);

            return match (before.is_empty(), after.is_empty()) {
                (true, true) => managed_block.to_string(),
                (true, false) => format!("{managed_block}\n{after}"),
                (false, true) => format!("{before}\n\n{managed_block}"),
                (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
            };
        }
    }

    let existing = existing.trim_end();
    if existing.is_empty() {
        managed_block.to_string()
    } else {
        format!("{existing}\n\n{managed_block}")
    }
}