Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 121 additions & 3 deletions command-signatures/src/generators/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ use warp_completion_metadata::{
};

/// Shell command that reads ~/.ssh/config and all files referenced by Include directives.
/// Include paths are resolved by replacing ~ with $HOME and treating relative paths as
/// relative to ~/.ssh/. Glob patterns in Include paths are expanded by the shell.
pub const SSH_CONFIG_CMD: &str = "cat ~/.ssh/config $(awk 'tolower($1)==\"include\"{for(i=2;i<=NF;i++){gsub(\"~\",ENVIRON[\"HOME\"],$i);if($i!~/^\\//)$i=ENVIRON[\"HOME\"]\"/.ssh/\"$i;print $i}}' ~/.ssh/config 2>/dev/null) 2>/dev/null";
/// Include paths are resolved by replacing ~ with $HOME, normalizing Windows drive-letter
/// paths (`C:\Users\..` / `C:/Users/..` -> `/c/Users/..`) so they resolve under Git Bash /
/// MSYS, and treating other relative paths as relative to ~/.ssh/. Glob patterns in Include
/// paths are expanded by the shell. Only rooted drive paths (`C:\..`, `C:/..`) are normalized;
/// drive-relative (`C:foo`) and UNC (`\\server\share`) forms are not (ssh does not accept them
/// in Include either).
pub const SSH_CONFIG_CMD: &str = "cat ~/.ssh/config $(awk 'tolower($1)==\"include\"{for(i=2;i<=NF;i++){gsub(\"~\",ENVIRON[\"HOME\"],$i);if($i~/^[A-Za-z]:/){d=tolower(substr($i,1,1));r=substr($i,3);gsub(/\\\\/,\"/\",r);$i=\"/\"d r}else if($i!~/^\\//)$i=ENVIRON[\"HOME\"]\"/.ssh/\"$i;print $i}}' ~/.ssh/config 2>/dev/null) 2>/dev/null";

/// Parses SSH config output to extract Host entries as suggestions.
pub fn ssh_hosts(output: &str) -> GeneratorResults {
Expand Down Expand Up @@ -136,3 +140,117 @@ pub fn users_generator() -> Generator {
},
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ssh_hosts_parses_host_entries_and_skips_wildcards() {
let config = "Host main-host\n HostName 10.0.0.1\nHost *.internal\nHost included-host\n HostName 10.0.0.9\n";
let hosts: Vec<String> = ssh_hosts(config)
.suggestions
.into_iter()
.map(|s| s.exact_string)
.collect();
assert_eq!(hosts, vec!["main-host", "included-host"]);
}

// The Include-path expansion lives in an awk program embedded in `SSH_CONFIG_CMD`.
// These tests run that exact program (extracted between the single quotes, so there is
// a single source of truth) over a synthetic config and assert how each Include style is
// resolved. They are gated to unix because they shell out to `awk`.
#[cfg(unix)]
mod include_expansion {
use super::*;
use std::io::Write;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

/// The awk program shipped inside `SSH_CONFIG_CMD` (the text between its single quotes).
/// This relies on the command embedding exactly one single-quoted awk program and the
/// program itself containing no single quote -- both true today (awk uses double quotes
/// internally). It keeps the test in lockstep with whatever the generator actually ships.
fn include_awk() -> &'static str {
SSH_CONFIG_CMD
.split('\'')
.nth(1)
.expect("SSH_CONFIG_CMD embeds an awk program in single quotes")
}

/// Runs the shipped awk over `config_body` with `$HOME=home` and returns the
/// Include paths it resolves (one per emitted line).
fn resolved_includes(config_body: &str, home: &str) -> Vec<String> {
// Cargo runs tests as threads in one process, so a pid-only filename would be
// shared across the concurrent cases; a per-call counter keeps each unique.
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut tmp = std::env::temp_dir();
tmp.push(format!(
"cs_ssh_include_{}_{}.cfg",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
{
let mut f = std::fs::File::create(&tmp).expect("create temp config");
f.write_all(config_body.as_bytes())
.expect("write temp config");
}
let out = Command::new("awk")
.arg(include_awk())
.arg(&tmp)
.env("HOME", home)
.output()
.expect("run awk");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"awk failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.to_string())
.collect()
}

#[test]
fn normalizes_windows_backslash_drive_path() {
assert_eq!(
resolved_includes("Include C:\\Users\\me\\.ssh\\extra\n", "/home/me"),
vec!["/c/Users/me/.ssh/extra"]
);
}

#[test]
fn normalizes_windows_forward_slash_drive_path() {
assert_eq!(
resolved_includes("Include D:/data/ssh_extra\n", "/home/me"),
vec!["/d/data/ssh_extra"]
);
}

#[test]
fn leaves_posix_absolute_path_unchanged() {
assert_eq!(
resolved_includes("Include /etc/ssh/extra\n", "/home/me"),
vec!["/etc/ssh/extra"]
);
}

#[test]
fn resolves_relative_path_under_ssh_dir() {
assert_eq!(
resolved_includes("Include work_config\n", "/home/me"),
vec!["/home/me/.ssh/work_config"]
);
}

#[test]
fn expands_tilde_to_home() {
assert_eq!(
resolved_includes("Include ~/.ssh/extra\n", "/home/me"),
vec!["/home/me/.ssh/extra"]
);
}
}
}