Skip to main content

portkey/
ssh_config.rs

1use anyhow::{anyhow, Result};
2
3use crate::models::Server;
4
5pub const BEGIN_MARKER: &str = "# BEGIN Portkey managed entries";
6pub const END_MARKER: &str = "# END Portkey managed entries";
7
8fn validate_non_empty_single_line(label: &str, value: &str) -> Result<()> {
9    if value.trim().is_empty() {
10        return Err(anyhow!("{label} cannot be empty"));
11    }
12
13    if value.chars().any(|c| c == '\n' || c == '\r') {
14        return Err(anyhow!("{label} cannot contain newlines"));
15    }
16
17    Ok(())
18}
19
20fn validate_host_alias(alias: &str) -> Result<()> {
21    validate_non_empty_single_line("Host alias", alias)?;
22
23    if alias.chars().any(char::is_whitespace) {
24        return Err(anyhow!("Host alias cannot contain whitespace"));
25    }
26
27    Ok(())
28}
29
30fn validate_server(server: &Server) -> Result<()> {
31    validate_host_alias(&server.name)?;
32    validate_non_empty_single_line("HostName", &server.host)?;
33    validate_non_empty_single_line("User", &server.username)?;
34
35    if let Some(identity_file) = server.identity_file.as_deref() {
36        if !identity_file.is_empty() {
37            validate_non_empty_single_line("IdentityFile", identity_file)?;
38        }
39    }
40
41    Ok(())
42}
43
44pub fn render_ssh_config(servers: &[Server]) -> Result<String> {
45    let mut output = String::new();
46
47    for server in servers {
48        validate_server(server)?;
49        output.push_str(&format!(
50            "Host {}\n  HostName {}\n  User {}\n  Port {}\n",
51            server.name, server.host, server.username, server.port
52        ));
53
54        if let Some(identity_file) = server
55            .identity_file
56            .as_deref()
57            .filter(|path| !path.is_empty())
58        {
59            output.push_str(&format!("  IdentityFile {identity_file}\n"));
60        }
61
62        if server.forward_agent {
63            output.push_str("  ForwardAgent yes\n");
64        }
65
66        output.push('\n');
67    }
68
69    Ok(output)
70}
71
72pub fn render_managed_block(servers: &[Server]) -> Result<String> {
73    let config = render_ssh_config(servers)?;
74    Ok(format!("{BEGIN_MARKER}\n{config}{END_MARKER}\n"))
75}
76
77pub fn upsert_managed_block(existing: &str, managed_block: &str) -> String {
78    if let Some(begin) = existing.find(BEGIN_MARKER) {
79        if let Some(relative_end) = existing[begin..].find(END_MARKER) {
80            let end = begin + relative_end + END_MARKER.len();
81            let before = existing[..begin].trim_end();
82            let after = existing[end..].trim_start_matches(['\r', '\n']);
83
84            return match (before.is_empty(), after.is_empty()) {
85                (true, true) => managed_block.to_string(),
86                (true, false) => format!("{managed_block}\n{after}"),
87                (false, true) => format!("{before}\n\n{managed_block}"),
88                (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
89            };
90        }
91    }
92
93    let existing = existing.trim_end();
94    if existing.is_empty() {
95        managed_block.to_string()
96    } else {
97        format!("{existing}\n\n{managed_block}")
98    }
99}