Skip to content

Commit cd4ea12

Browse files
committed
Add known hosts file writing feature to cli
- For now, it blows away the local file completely and replaces it with the remote Note: This commit was mostly authored by Claude Code using Claude Sonnet 4
1 parent 7f80f12 commit cd4ea12

File tree

5 files changed

+127
-16
lines changed

5 files changed

+127
-16
lines changed

cli/src/commands/known_hosts.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,17 @@ pub fn pretty_print_known_hosts(response: &KnownHostsResponse) {
146146
);
147147
}
148148

149-
pub fn fetch_known_hosts(server_url: &str) -> Result<()> {
149+
/// Private function to fetch known hosts from the server
150+
///
151+
/// This function handles the HTTP request to the known hosts server,
152+
/// validates the response, and parses the JSON into a KnownHostsResponse.
153+
///
154+
/// # Arguments
155+
/// * `server_url` - The base URL of the keys server
156+
///
157+
/// # Returns
158+
/// * `Result<KnownHostsResponse>` - The parsed known hosts response or an error
159+
fn fetch_known_hosts_from_server(server_url: &str) -> Result<KnownHostsResponse> {
150160
let url = format!("{server_url}/known_hosts");
151161

152162
let client = reqwest::blocking::Client::new();
@@ -166,8 +176,13 @@ pub fn fetch_known_hosts(server_url: &str) -> Result<()> {
166176
));
167177
}
168178

169-
let known_hosts_response: KnownHostsResponse =
170-
response.json().context("Failed to parse JSON response")?;
179+
response
180+
.json::<KnownHostsResponse>()
181+
.context("Failed to parse JSON response")
182+
}
183+
184+
pub fn fetch_known_hosts(server_url: &str) -> Result<()> {
185+
let known_hosts_response = fetch_known_hosts_from_server(server_url)?;
171186

172187
// Check if the output is being piped (not connected to a terminal)
173188
// Use raw/minimal output when piped to another command
@@ -218,6 +233,94 @@ pub fn fetch_known_hosts(server_url: &str) -> Result<()> {
218233
Ok(())
219234
}
220235

236+
/// Helper function to format a host entry in known_hosts format
237+
fn format_known_hosts_line(host: &KnownHost, key: &HostKey) -> String {
238+
let hosts_str = host.hosts.join(",");
239+
let key_type = &key.key_type;
240+
let key_value = &key.key;
241+
242+
// Add flags if present
243+
let mut flags = Vec::new();
244+
if key.revoked.unwrap_or(false) {
245+
flags.push("@revoked");
246+
}
247+
if key.cert_authority.unwrap_or(false) {
248+
flags.push("@cert-authority");
249+
}
250+
251+
// Format comment if present
252+
let comment_str = if let Some(comment) = &key.comment {
253+
format!(" # {comment}")
254+
} else {
255+
String::new()
256+
};
257+
258+
// Output in OpenSSH known_hosts format with flags and optional comment
259+
if flags.is_empty() {
260+
format!("{hosts_str} {key_type} {key_value}{comment_str}")
261+
} else {
262+
format!(
263+
"{} {} {} {}{}",
264+
flags.join(" "),
265+
hosts_str,
266+
key_type,
267+
key_value,
268+
comment_str
269+
)
270+
}
271+
}
272+
273+
pub fn write_known_hosts(server_url: &str, file_path: &str) -> Result<()> {
274+
// Fetch known hosts from the server
275+
let known_hosts_response = fetch_known_hosts_from_server(server_url)?;
276+
277+
// Expand ~ to home directory if present
278+
let expanded_path = shellexpand::tilde(file_path);
279+
let path = std::path::Path::new(expanded_path.as_ref());
280+
281+
// Create directory if it doesn't exist
282+
if let Some(parent) = path.parent()
283+
&& !parent.exists()
284+
{
285+
std::fs::create_dir_all(parent)
286+
.with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;
287+
}
288+
289+
// Generate the file content (always replace completely)
290+
let mut lines = Vec::new();
291+
for host in &known_hosts_response.hosts {
292+
for key in &host.keys {
293+
lines.push(format_known_hosts_line(host, key));
294+
}
295+
}
296+
297+
let file_content = lines.join("\n");
298+
if !file_content.is_empty() {
299+
let file_content = format!("{}\n", file_content);
300+
std::fs::write(path, file_content)
301+
.with_context(|| format!("Failed to write to file: {}", path.display()))?;
302+
} else {
303+
// Write empty file if no hosts
304+
std::fs::write(path, "")
305+
.with_context(|| format!("Failed to write to file: {}", path.display()))?;
306+
}
307+
308+
// Count the total number of entries written
309+
let total_entries: usize = known_hosts_response
310+
.hosts
311+
.iter()
312+
.map(|h| h.keys.len())
313+
.sum();
314+
315+
println!(
316+
"✅ Wrote {} known host entries to {}",
317+
total_entries,
318+
path.display()
319+
);
320+
321+
Ok(())
322+
}
323+
221324
#[cfg(test)]
222325
mod tests {
223326
use super::*;

cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod ssh_keys;
44

55
// Re-export the main command functions for easier imports
66
pub use known_hosts::fetch_known_hosts;
7+
pub use known_hosts::write_known_hosts;
78
pub use pgp_keys::fetch_pgp_keys;
89
pub use ssh_keys::fetch_ssh_keys;
910
pub use ssh_keys::write_ssh_keys;

cli/src/commands/ssh_keys.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,11 @@ pub fn write_ssh_keys(server_url: &str, file_path: &str, force: bool) -> Result<
214214
let num_local_only = local_only_keys.len();
215215

216216
// Create directory if it doesn't exist
217-
if let Some(parent) = path.parent() {
218-
if !parent.exists() {
219-
std::fs::create_dir_all(parent).with_context(|| {
220-
format!("Failed to create parent directory: {}", parent.display())
221-
})?;
222-
}
217+
if let Some(parent) = path.parent()
218+
&& !parent.exists()
219+
{
220+
std::fs::create_dir_all(parent)
221+
.with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;
223222
}
224223

225224
let mut updated_keys_count = 0;

cli/src/config/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ pub fn load_config(config_path: Option<&str>) -> Result<Config> {
4040
}
4141

4242
// Try to load from default locations
43-
if let Some(config_path) = get_default_config_path() {
44-
if config_path.exists() {
45-
return load_config_from_path(&config_path);
46-
}
43+
if let Some(config_path) = get_default_config_path()
44+
&& config_path.exists()
45+
{
46+
return load_config_from_path(&config_path);
4747
}
4848

4949
// If no config file found, return default config

cli/src/main.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ enum Commands {
3535
Pgp {},
3636

3737
/// Fetch known hosts from the server
38-
KnownHosts {},
38+
KnownHosts {
39+
/// Write known hosts to file (replaces entire file)
40+
#[arg(short, long)]
41+
write: Option<String>,
42+
},
3943

4044
/// Initialize a default config file
4145
Init {},
@@ -61,8 +65,12 @@ fn main() -> Result<()> {
6165
Commands::Pgp {} => {
6266
commands::pgp_keys::fetch_pgp_keys(&server_url)?;
6367
}
64-
Commands::KnownHosts {} => {
65-
commands::known_hosts::fetch_known_hosts(&server_url)?;
68+
Commands::KnownHosts { write } => {
69+
if let Some(path) = write {
70+
commands::known_hosts::write_known_hosts(&server_url, path)?;
71+
} else {
72+
commands::known_hosts::fetch_known_hosts(&server_url)?;
73+
}
6674
}
6775
Commands::Init {} => {
6876
// Create a default config file

0 commit comments

Comments
 (0)