use std::collections::HashSet;
use std::fs::File;
use std::io::Read;
use std::num::NonZeroU16;
use std::path::{Path, PathBuf};
use crate::address::RemoteFile;
#[cfg(test)]
mod tests;
const MAX_INCLUDE_DEPTH: usize = 16;
const MAX_CONFIG_FILES: usize = 128;
const MAX_FILE_BYTES: u64 = 1 << 20;
const MAX_TOTAL_CONFIG_BYTES: usize = 4 << 20;
const MAX_GLOB_MATCHES: usize = 256;
const MAX_CANDIDATES: usize = 1000;
const MAX_NOTES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateOrigin {
Config,
KnownHosts,
History,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostCandidate {
host: String,
user: Option<String>,
port: Option<NonZeroU16>,
origin: CandidateOrigin,
}
impl HostCandidate {
pub fn new(
host: String,
user: Option<String>,
port: Option<NonZeroU16>,
origin: CandidateOrigin,
) -> Self {
Self {
host,
user,
port,
origin,
}
}
pub fn host(&self) -> &str {
&self.host
}
pub fn user(&self) -> Option<&str> {
self.user.as_deref()
}
pub fn port(&self) -> Option<NonZeroU16> {
self.port
}
pub fn origin(&self) -> CandidateOrigin {
self.origin
}
pub fn token(&self) -> String {
let mut out = self.host_port_token();
if let Some(user) = &self.user {
out.insert_str(0, &format!("{user}@"));
}
out
}
fn host_port_token(&self) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(self.host.len() + 8);
if self.host.contains(':') {
out.push('[');
out.push_str(&self.host);
out.push(']');
} else {
out.push_str(&self.host);
}
if let Some(port) = self.port {
let _ = write!(out, ":{port}");
}
out
}
pub fn admissible(&self) -> bool {
RemoteFile::parse(&format!("ssh://{}/", self.token())).is_ok()
}
pub fn matches_typed(&self, typed: &str) -> Option<String> {
if let Some((typed_user, typed_rest)) = typed.split_once('@') {
if !self.host.starts_with(host_prefix_of(typed_rest)) {
return None;
}
let mut token = format!("{typed_user}@");
token.push_str(&self.host_port_token());
return Some(token);
}
if !self.host.starts_with(host_prefix_of(typed)) {
return None;
}
if typed.contains(':') {
let bare = self.host_port_token();
return bare.starts_with(typed).then(|| self.token());
}
Some(self.token())
}
}
fn host_prefix_of(typed: &str) -> &str {
match typed.rsplit_once(':') {
Some((host, digits))
if !digits.is_empty()
&& digits.bytes().all(|b| b.is_ascii_digit())
&& !host.contains(':')
&& !host.is_empty() =>
{
host
}
_ => typed,
}
}
#[derive(Debug, Clone, Default)]
pub struct HostSources {
config: Vec<PathBuf>,
known_hosts: Vec<PathBuf>,
include_base: Option<PathBuf>,
}
impl HostSources {
pub fn push_config(&mut self, path: PathBuf) -> &mut Self {
self.config.push(path);
self
}
pub fn push_known_hosts(&mut self, path: PathBuf) -> &mut Self {
self.known_hosts.push(path);
self
}
pub fn set_include_base(&mut self, base: PathBuf) -> &mut Self {
self.include_base = Some(base);
self
}
pub fn discover(home: Option<&Path>) -> Self {
let mut sources = Self::default();
if let Some(home) = home {
let ssh = home.join(".ssh");
sources.config.push(ssh.join("config"));
sources.known_hosts.push(ssh.join("known_hosts"));
sources.include_base = Some(ssh);
}
sources.config.push(PathBuf::from("/etc/ssh/ssh_config"));
sources
.known_hosts
.push(PathBuf::from("/etc/ssh/ssh_known_hosts"));
sources
}
}
#[derive(Debug, Clone, Default)]
pub struct HostEnumeration {
candidates: Vec<HostCandidate>,
notes: Vec<String>,
}
impl HostEnumeration {
pub fn candidates(&self) -> &[HostCandidate] {
&self.candidates
}
pub fn notes(&self) -> &[String] {
&self.notes
}
pub fn complete(&self, typed: &str) -> Vec<String> {
let mut tokens: Vec<String> = self
.candidates
.iter()
.filter_map(|candidate| candidate.matches_typed(typed))
.collect();
tokens.sort();
tokens.dedup();
tokens
}
}
struct Walk<'a> {
out: HostEnumeration,
sources: &'a HostSources,
seen_files: HashSet<PathBuf>,
seen_tokens: HashSet<String>,
files_read: usize,
bytes_read: usize,
skipped_names: usize,
capped: bool,
depth_noted: bool,
budget_noted: bool,
}
impl Walk<'_> {
fn note(&mut self, message: String) {
if self.out.notes.len() < MAX_NOTES {
self.out.notes.push(message);
}
}
}
pub fn enumerate_hosts(sources: &HostSources, history: &[HostCandidate]) -> HostEnumeration {
let mut walk = Walk {
out: HostEnumeration::default(),
sources,
seen_files: HashSet::new(),
seen_tokens: HashSet::new(),
files_read: 0,
bytes_read: 0,
skipped_names: 0,
capped: false,
depth_noted: false,
budget_noted: false,
};
for candidate in history {
admit(&mut walk, candidate.clone());
}
for path in &sources.config {
parse_config(&mut walk, path, 0);
}
for path in &sources.known_hosts {
parse_known_hosts(&mut walk, path);
}
if walk.skipped_names > 0 {
walk.note(format!(
"{} host names are not valid endpoints and were skipped",
walk.skipped_names
));
}
walk.out
}
fn admit(walk: &mut Walk, candidate: HostCandidate) -> Option<usize> {
if !candidate.admissible() {
walk.skipped_names += 1;
return None;
}
let token = candidate.token();
if !walk.seen_tokens.insert(token) {
return None;
}
if walk.out.candidates.len() >= MAX_CANDIDATES {
if !walk.capped {
walk.capped = true;
walk.note(format!("candidate list capped at {MAX_CANDIDATES}"));
}
return None;
}
walk.out.candidates.push(candidate);
Some(walk.out.candidates.len() - 1)
}
fn read_bounded(walk: &mut Walk, path: &Path) -> Option<String> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => {
walk.note(format!("{}: {error}", path.display()));
return None;
}
};
let mut bytes = Vec::new();
if let Err(error) = file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes) {
walk.note(format!("{}: {error}", path.display()));
return None;
}
if bytes.len() as u64 > MAX_FILE_BYTES {
walk.note(format!(
"{} larger than {} bytes; truncated",
path.display(),
MAX_FILE_BYTES
));
bytes.truncate(MAX_FILE_BYTES as usize);
}
match String::from_utf8(bytes) {
Ok(text) => {
walk.bytes_read = walk.bytes_read.saturating_add(text.len());
Some(text)
}
Err(_) => {
walk.note(format!("{}: not UTF-8; skipped", path.display()));
None
}
}
}
fn parse_config(walk: &mut Walk, path: &Path, depth: usize) {
if depth >= MAX_INCLUDE_DEPTH {
if !walk.depth_noted {
walk.depth_noted = true;
walk.note(format!("Include deeper than {MAX_INCLUDE_DEPTH} skipped"));
}
return;
}
if walk.files_read >= MAX_CONFIG_FILES {
walk.note("config file limit reached; further includes skipped".into());
return;
}
if walk.bytes_read >= MAX_TOTAL_CONFIG_BYTES {
if !walk.budget_noted {
walk.budget_noted = true;
walk.note(format!(
"config budget of {MAX_TOTAL_CONFIG_BYTES} bytes reached; further files skipped"
));
}
return;
}
let identity = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if !walk.seen_files.insert(identity) {
if depth > 0 {
walk.note(format!("Include cycle skipped: {}", path.display()));
}
return;
}
let Some(text) = read_bounded(walk, path) else {
return;
};
walk.files_read += 1;
parse_config_text(walk, &text, depth);
}
fn parse_config_text(walk: &mut Walk, text: &str, depth: usize) {
let mut global_user: Option<String> = None;
let mut global_port: Option<NonZeroU16> = None;
let mut open_block: Vec<usize> = Vec::new();
let mut file_candidates: Vec<usize> = Vec::new();
for line in text.lines() {
let tokens = tokenize(line);
let Some(keyword) = tokens.first().map(|token| token.to_ascii_lowercase()) else {
continue;
};
match keyword.as_str() {
"host" => {
open_block.clear();
for pattern in &tokens[1..] {
if pattern.contains(['*', '?', '!']) {
continue;
}
let candidate =
HostCandidate::new(pattern.clone(), None, None, CandidateOrigin::Config);
if let Some(index) = admit(walk, candidate) {
open_block.push(index);
file_candidates.push(index);
}
}
}
"match" => open_block.clear(),
"user" => {
let Some(value) = tokens.get(1) else {
continue;
};
if open_block.is_empty() {
global_user.get_or_insert_with(|| value.clone());
} else {
for &index in &open_block {
let candidate = &mut walk.out.candidates[index];
candidate.user.get_or_insert_with(|| value.clone());
}
}
}
"port" => {
let Some(value) = tokens.get(1) else {
continue;
};
let Some(port) = parse_port(value) else {
walk.note(format!("port {value}: not a port; ignored"));
continue;
};
if open_block.is_empty() {
global_port.get_or_insert(port);
} else {
for &index in &open_block {
let candidate = &mut walk.out.candidates[index];
candidate.port.get_or_insert(port);
}
}
}
"include" => {
for argument in &tokens[1..] {
for path in expand_include(walk, argument) {
parse_config(walk, &path, depth + 1);
}
}
open_block.clear();
}
_ => {}
}
}
for index in file_candidates {
let candidate = &mut walk.out.candidates[index];
if candidate.user.is_none() {
candidate.user.clone_from(&global_user);
}
if candidate.port.is_none() {
candidate.port = global_port;
}
}
}
fn parse_port(value: &str) -> Option<NonZeroU16> {
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
value.parse::<u16>().ok().and_then(NonZeroU16::new)
}
fn expand_include(walk: &mut Walk, argument: &str) -> Vec<PathBuf> {
let literal = Path::new(argument);
let resolved = if literal.is_absolute() {
literal.to_path_buf()
} else {
match &walk.sources.include_base {
Some(base) => base.join(literal),
None => {
walk.note(format!(
"Include {argument}: relative without an include base; skipped"
));
return Vec::new();
}
}
};
if !argument.contains(['*', '?']) {
return vec![resolved];
}
glob_expand(walk, &resolved)
}
fn glob_expand(walk: &mut Walk, pattern: &Path) -> Vec<PathBuf> {
let text = pattern.to_string_lossy().into_owned();
let Some(wildcard) = text.find(['*', '?']) else {
return vec![pattern.to_path_buf()];
};
let split = text[..wildcard].rfind('/').map_or(0, |at| at + 1);
let directory = match std::fs::canonicalize(Path::new(&text[..split])) {
Ok(absolute) => absolute,
Err(_) => return Vec::new(),
};
let pattern = directory
.join(&text[split..])
.to_string_lossy()
.into_owned();
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut matches = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if glob_match(&pattern, &path.to_string_lossy()) {
matches.push(path);
if matches.len() > MAX_GLOB_MATCHES {
walk.note(format!(
"Include glob matched more than {MAX_GLOB_MATCHES} files; truncated"
));
break;
}
}
}
matches.sort();
matches
}
fn glob_match(pattern: &str, text: &str) -> bool {
fn inner(pattern: &[u8], text: &[u8]) -> bool {
match (pattern.first(), text.first()) {
(None, None) => true,
(Some(b'*'), _) => {
inner(&pattern[1..], text) || (!text.is_empty() && inner(pattern, &text[1..]))
}
(Some(b'?'), Some(_)) => inner(&pattern[1..], &text[1..]),
(Some(expected), Some(actual)) if expected == actual => {
inner(&pattern[1..], &text[1..])
}
_ => false,
}
}
inner(pattern.as_bytes(), text.as_bytes())
}
fn tokenize(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for character in line.chars() {
match quote {
Some(open) if character == open => quote = None,
Some(_) => current.push(character),
None => match character {
'#' if current.is_empty() && tokens.is_empty() => break,
'"' | '\'' if current.is_empty() => quote = Some(character),
character if character.is_whitespace() => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
character => current.push(character),
},
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn parse_known_hosts(walk: &mut Walk, path: &Path) {
let Some(text) = read_bounded(walk, path) else {
return;
};
let mut hashed = 0;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('@') {
continue;
}
let Some(patterns) = line.split_whitespace().next() else {
continue;
};
for pattern in patterns.split(',') {
if pattern.is_empty() {
continue;
}
if pattern.starts_with('|') {
hashed += 1;
continue;
}
if pattern.starts_with('!') || pattern.contains(['*', '?']) {
continue;
}
let Some((host, port)) = split_known_host(pattern) else {
continue;
};
let candidate = HostCandidate::new(host, None, port, CandidateOrigin::KnownHosts);
admit(walk, candidate);
}
}
if hashed > 0 {
walk.note(format!(
"{hashed} hashed known_hosts entries cannot be completed"
));
}
}
fn split_known_host(pattern: &str) -> Option<(String, Option<NonZeroU16>)> {
if let Some(bracketed) = pattern.strip_prefix('[') {
let (host, tail) = bracketed.split_once(']')?;
let port = match tail.strip_prefix(':') {
Some(digits) => Some(parse_port(digits)?),
None if tail.is_empty() => None,
None => return None,
};
return Some((host.to_owned(), port));
}
Some((pattern.to_owned(), None))
}