1use std::collections::HashSet;
18use std::fs::File;
19use std::io::Read;
20use std::num::NonZeroU16;
21use std::path::{Path, PathBuf};
22
23use crate::address::RemoteFile;
24
25#[cfg(test)]
26mod tests;
27
28const MAX_INCLUDE_DEPTH: usize = 16;
31const MAX_CONFIG_FILES: usize = 128;
32const MAX_FILE_BYTES: u64 = 1 << 20;
33const MAX_TOTAL_CONFIG_BYTES: usize = 4 << 20;
34const MAX_GLOB_MATCHES: usize = 256;
35const MAX_CANDIDATES: usize = 1000;
36const MAX_NOTES: usize = 16;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CandidateOrigin {
42 Config,
44 KnownHosts,
46 History,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct HostCandidate {
56 host: String,
57 user: Option<String>,
58 port: Option<NonZeroU16>,
59 origin: CandidateOrigin,
60}
61
62impl HostCandidate {
63 pub fn new(
64 host: String,
65 user: Option<String>,
66 port: Option<NonZeroU16>,
67 origin: CandidateOrigin,
68 ) -> Self {
69 Self {
70 host,
71 user,
72 port,
73 origin,
74 }
75 }
76
77 pub fn host(&self) -> &str {
78 &self.host
79 }
80
81 pub fn user(&self) -> Option<&str> {
82 self.user.as_deref()
83 }
84
85 pub fn port(&self) -> Option<NonZeroU16> {
86 self.port
87 }
88
89 pub fn origin(&self) -> CandidateOrigin {
90 self.origin
91 }
92
93 pub fn token(&self) -> String {
96 let mut out = self.host_port_token();
97 if let Some(user) = &self.user {
98 out.insert_str(0, &format!("{user}@"));
99 }
100 out
101 }
102
103 fn host_port_token(&self) -> String {
106 use std::fmt::Write as _;
107 let mut out = String::with_capacity(self.host.len() + 8);
108 if self.host.contains(':') {
109 out.push('[');
110 out.push_str(&self.host);
111 out.push(']');
112 } else {
113 out.push_str(&self.host);
114 }
115 if let Some(port) = self.port {
116 let _ = write!(out, ":{port}");
117 }
118 out
119 }
120
121 pub fn admissible(&self) -> bool {
125 RemoteFile::parse(&format!("ssh://{}/", self.token())).is_ok()
126 }
127
128 pub fn matches_typed(&self, typed: &str) -> Option<String> {
133 if let Some((typed_user, typed_rest)) = typed.split_once('@') {
134 if !self.host.starts_with(host_prefix_of(typed_rest)) {
135 return None;
136 }
137 let mut token = format!("{typed_user}@");
138 token.push_str(&self.host_port_token());
139 return Some(token);
140 }
141 if !self.host.starts_with(host_prefix_of(typed)) {
142 return None;
143 }
144 if typed.contains(':') {
145 let bare = self.host_port_token();
146 return bare.starts_with(typed).then(|| self.token());
147 }
148 Some(self.token())
149 }
150}
151
152fn host_prefix_of(typed: &str) -> &str {
154 match typed.rsplit_once(':') {
155 Some((host, digits))
156 if !digits.is_empty()
157 && digits.bytes().all(|b| b.is_ascii_digit())
158 && !host.contains(':')
159 && !host.is_empty() =>
160 {
161 host
162 }
163 _ => typed,
164 }
165}
166
167#[derive(Debug, Clone, Default)]
170pub struct HostSources {
171 config: Vec<PathBuf>,
172 known_hosts: Vec<PathBuf>,
173 include_base: Option<PathBuf>,
174}
175
176impl HostSources {
177 pub fn push_config(&mut self, path: PathBuf) -> &mut Self {
178 self.config.push(path);
179 self
180 }
181
182 pub fn push_known_hosts(&mut self, path: PathBuf) -> &mut Self {
183 self.known_hosts.push(path);
184 self
185 }
186
187 pub fn set_include_base(&mut self, base: PathBuf) -> &mut Self {
190 self.include_base = Some(base);
191 self
192 }
193
194 pub fn discover(home: Option<&Path>) -> Self {
198 let mut sources = Self::default();
199 if let Some(home) = home {
200 let ssh = home.join(".ssh");
201 sources.config.push(ssh.join("config"));
202 sources.known_hosts.push(ssh.join("known_hosts"));
203 sources.include_base = Some(ssh);
204 }
205 sources.config.push(PathBuf::from("/etc/ssh/ssh_config"));
206 sources
207 .known_hosts
208 .push(PathBuf::from("/etc/ssh/ssh_known_hosts"));
209 sources
210 }
211}
212
213#[derive(Debug, Clone, Default)]
217pub struct HostEnumeration {
218 candidates: Vec<HostCandidate>,
219 notes: Vec<String>,
220}
221
222impl HostEnumeration {
223 pub fn candidates(&self) -> &[HostCandidate] {
224 &self.candidates
225 }
226
227 pub fn notes(&self) -> &[String] {
228 &self.notes
229 }
230
231 pub fn complete(&self, typed: &str) -> Vec<String> {
234 let mut tokens: Vec<String> = self
235 .candidates
236 .iter()
237 .filter_map(|candidate| candidate.matches_typed(typed))
238 .collect();
239 tokens.sort();
240 tokens.dedup();
241 tokens
242 }
243}
244
245struct Walk<'a> {
247 out: HostEnumeration,
248 sources: &'a HostSources,
249 seen_files: HashSet<PathBuf>,
250 seen_tokens: HashSet<String>,
251 files_read: usize,
252 bytes_read: usize,
253 skipped_names: usize,
254 capped: bool,
255 depth_noted: bool,
256 budget_noted: bool,
257}
258
259impl Walk<'_> {
260 fn note(&mut self, message: String) {
261 if self.out.notes.len() < MAX_NOTES {
262 self.out.notes.push(message);
263 }
264 }
265}
266
267pub fn enumerate_hosts(sources: &HostSources, history: &[HostCandidate]) -> HostEnumeration {
272 let mut walk = Walk {
273 out: HostEnumeration::default(),
274 sources,
275 seen_files: HashSet::new(),
276 seen_tokens: HashSet::new(),
277 files_read: 0,
278 bytes_read: 0,
279 skipped_names: 0,
280 capped: false,
281 depth_noted: false,
282 budget_noted: false,
283 };
284 for candidate in history {
285 admit(&mut walk, candidate.clone());
286 }
287 for path in &sources.config {
288 parse_config(&mut walk, path, 0);
289 }
290 for path in &sources.known_hosts {
291 parse_known_hosts(&mut walk, path);
292 }
293 if walk.skipped_names > 0 {
294 walk.note(format!(
295 "{} host names are not valid endpoints and were skipped",
296 walk.skipped_names
297 ));
298 }
299 walk.out
300}
301
302fn admit(walk: &mut Walk, candidate: HostCandidate) -> Option<usize> {
305 if !candidate.admissible() {
306 walk.skipped_names += 1;
307 return None;
308 }
309 let token = candidate.token();
310 if !walk.seen_tokens.insert(token) {
311 return None;
312 }
313 if walk.out.candidates.len() >= MAX_CANDIDATES {
314 if !walk.capped {
315 walk.capped = true;
316 walk.note(format!("candidate list capped at {MAX_CANDIDATES}"));
317 }
318 return None;
319 }
320 walk.out.candidates.push(candidate);
321 Some(walk.out.candidates.len() - 1)
322}
323fn read_bounded(walk: &mut Walk, path: &Path) -> Option<String> {
326 let file = match File::open(path) {
327 Ok(file) => file,
328 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
329 Err(error) => {
330 walk.note(format!("{}: {error}", path.display()));
331 return None;
332 }
333 };
334 let mut bytes = Vec::new();
335 if let Err(error) = file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes) {
336 walk.note(format!("{}: {error}", path.display()));
337 return None;
338 }
339 if bytes.len() as u64 > MAX_FILE_BYTES {
340 walk.note(format!(
341 "{} larger than {} bytes; truncated",
342 path.display(),
343 MAX_FILE_BYTES
344 ));
345 bytes.truncate(MAX_FILE_BYTES as usize);
346 }
347 match String::from_utf8(bytes) {
348 Ok(text) => {
349 walk.bytes_read = walk.bytes_read.saturating_add(text.len());
350 Some(text)
351 }
352 Err(_) => {
353 walk.note(format!("{}: not UTF-8; skipped", path.display()));
354 None
355 }
356 }
357}
358
359fn parse_config(walk: &mut Walk, path: &Path, depth: usize) {
362 if depth >= MAX_INCLUDE_DEPTH {
363 if !walk.depth_noted {
364 walk.depth_noted = true;
365 walk.note(format!("Include deeper than {MAX_INCLUDE_DEPTH} skipped"));
366 }
367 return;
368 }
369 if walk.files_read >= MAX_CONFIG_FILES {
370 walk.note("config file limit reached; further includes skipped".into());
371 return;
372 }
373 if walk.bytes_read >= MAX_TOTAL_CONFIG_BYTES {
374 if !walk.budget_noted {
375 walk.budget_noted = true;
376 walk.note(format!(
377 "config budget of {MAX_TOTAL_CONFIG_BYTES} bytes reached; further files skipped"
378 ));
379 }
380 return;
381 }
382 let identity = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
383 if !walk.seen_files.insert(identity) {
384 if depth > 0 {
385 walk.note(format!("Include cycle skipped: {}", path.display()));
386 }
387 return;
388 }
389 let Some(text) = read_bounded(walk, path) else {
390 return;
391 };
392 walk.files_read += 1;
393 parse_config_text(walk, &text, depth);
394}
395
396fn parse_config_text(walk: &mut Walk, text: &str, depth: usize) {
405 let mut global_user: Option<String> = None;
406 let mut global_port: Option<NonZeroU16> = None;
407 let mut open_block: Vec<usize> = Vec::new();
408 let mut file_candidates: Vec<usize> = Vec::new();
409
410 for line in text.lines() {
411 let tokens = tokenize(line);
412 let Some(keyword) = tokens.first().map(|token| token.to_ascii_lowercase()) else {
413 continue;
414 };
415 match keyword.as_str() {
416 "host" => {
417 open_block.clear();
418 for pattern in &tokens[1..] {
419 if pattern.contains(['*', '?', '!']) {
422 continue;
423 }
424 let candidate =
425 HostCandidate::new(pattern.clone(), None, None, CandidateOrigin::Config);
426 if let Some(index) = admit(walk, candidate) {
427 open_block.push(index);
428 file_candidates.push(index);
429 }
430 }
431 }
432 "match" => open_block.clear(),
433 "user" => {
434 let Some(value) = tokens.get(1) else {
435 continue;
436 };
437 if open_block.is_empty() {
438 global_user.get_or_insert_with(|| value.clone());
439 } else {
440 for &index in &open_block {
441 let candidate = &mut walk.out.candidates[index];
442 candidate.user.get_or_insert_with(|| value.clone());
443 }
444 }
445 }
446 "port" => {
447 let Some(value) = tokens.get(1) else {
448 continue;
449 };
450 let Some(port) = parse_port(value) else {
451 walk.note(format!("port {value}: not a port; ignored"));
452 continue;
453 };
454 if open_block.is_empty() {
455 global_port.get_or_insert(port);
456 } else {
457 for &index in &open_block {
458 let candidate = &mut walk.out.candidates[index];
459 candidate.port.get_or_insert(port);
460 }
461 }
462 }
463 "include" => {
464 for argument in &tokens[1..] {
465 for path in expand_include(walk, argument) {
466 parse_config(walk, &path, depth + 1);
467 }
468 }
469 open_block.clear();
470 }
471 _ => {}
472 }
473 }
474
475 for index in file_candidates {
478 let candidate = &mut walk.out.candidates[index];
479 if candidate.user.is_none() {
480 candidate.user.clone_from(&global_user);
481 }
482 if candidate.port.is_none() {
483 candidate.port = global_port;
484 }
485 }
486}
487
488fn parse_port(value: &str) -> Option<NonZeroU16> {
489 if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
490 return None;
491 }
492 value.parse::<u16>().ok().and_then(NonZeroU16::new)
493}
494
495fn expand_include(walk: &mut Walk, argument: &str) -> Vec<PathBuf> {
502 let literal = Path::new(argument);
503 let resolved = if literal.is_absolute() {
504 literal.to_path_buf()
505 } else {
506 match &walk.sources.include_base {
507 Some(base) => base.join(literal),
508 None => {
509 walk.note(format!(
510 "Include {argument}: relative without an include base; skipped"
511 ));
512 return Vec::new();
513 }
514 }
515 };
516 if !argument.contains(['*', '?']) {
517 return vec![resolved];
518 }
519 glob_expand(walk, &resolved)
520}
521
522fn glob_expand(walk: &mut Walk, pattern: &Path) -> Vec<PathBuf> {
524 let text = pattern.to_string_lossy().into_owned();
525 let Some(wildcard) = text.find(['*', '?']) else {
526 return vec![pattern.to_path_buf()];
527 };
528 let split = text[..wildcard].rfind('/').map_or(0, |at| at + 1);
529 let directory = match std::fs::canonicalize(Path::new(&text[..split])) {
530 Ok(absolute) => absolute,
531 Err(_) => return Vec::new(),
532 };
533 let pattern = directory
534 .join(&text[split..])
535 .to_string_lossy()
536 .into_owned();
537 let entries = match std::fs::read_dir(&directory) {
538 Ok(entries) => entries,
539 Err(_) => return Vec::new(),
540 };
541 let mut matches = Vec::new();
542 for entry in entries.flatten() {
543 let path = entry.path();
544 if glob_match(&pattern, &path.to_string_lossy()) {
545 matches.push(path);
546 if matches.len() > MAX_GLOB_MATCHES {
547 walk.note(format!(
548 "Include glob matched more than {MAX_GLOB_MATCHES} files; truncated"
549 ));
550 break;
551 }
552 }
553 }
554 matches.sort();
555 matches
556}
557
558fn glob_match(pattern: &str, text: &str) -> bool {
561 fn inner(pattern: &[u8], text: &[u8]) -> bool {
562 match (pattern.first(), text.first()) {
563 (None, None) => true,
564 (Some(b'*'), _) => {
565 inner(&pattern[1..], text) || (!text.is_empty() && inner(pattern, &text[1..]))
566 }
567 (Some(b'?'), Some(_)) => inner(&pattern[1..], &text[1..]),
568 (Some(expected), Some(actual)) if expected == actual => {
569 inner(&pattern[1..], &text[1..])
570 }
571 _ => false,
572 }
573 }
574 inner(pattern.as_bytes(), text.as_bytes())
575}
576
577fn tokenize(line: &str) -> Vec<String> {
581 let mut tokens = Vec::new();
582 let mut current = String::new();
583 let mut quote: Option<char> = None;
584 for character in line.chars() {
585 match quote {
586 Some(open) if character == open => quote = None,
587 Some(_) => current.push(character),
588 None => match character {
589 '#' if current.is_empty() && tokens.is_empty() => break,
590 '"' | '\'' if current.is_empty() => quote = Some(character),
591 character if character.is_whitespace() => {
592 if !current.is_empty() {
593 tokens.push(std::mem::take(&mut current));
594 }
595 }
596 character => current.push(character),
597 },
598 }
599 }
600 if !current.is_empty() {
601 tokens.push(current);
602 }
603 tokens
604}
605
606fn parse_known_hosts(walk: &mut Walk, path: &Path) {
611 let Some(text) = read_bounded(walk, path) else {
612 return;
613 };
614 let mut hashed = 0;
615 for line in text.lines() {
616 let line = line.trim();
617 if line.is_empty() || line.starts_with('#') || line.starts_with('@') {
618 continue;
619 }
620 let Some(patterns) = line.split_whitespace().next() else {
621 continue;
622 };
623 for pattern in patterns.split(',') {
624 if pattern.is_empty() {
625 continue;
626 }
627 if pattern.starts_with('|') {
628 hashed += 1;
629 continue;
630 }
631 if pattern.starts_with('!') || pattern.contains(['*', '?']) {
632 continue;
633 }
634 let Some((host, port)) = split_known_host(pattern) else {
635 continue;
636 };
637 let candidate = HostCandidate::new(host, None, port, CandidateOrigin::KnownHosts);
638 admit(walk, candidate);
639 }
640 }
641 if hashed > 0 {
642 walk.note(format!(
643 "{hashed} hashed known_hosts entries cannot be completed"
644 ));
645 }
646}
647
648fn split_known_host(pattern: &str) -> Option<(String, Option<NonZeroU16>)> {
650 if let Some(bracketed) = pattern.strip_prefix('[') {
651 let (host, tail) = bracketed.split_once(']')?;
652 let port = match tail.strip_prefix(':') {
653 Some(digits) => Some(parse_port(digits)?),
654 None if tail.is_empty() => None,
655 None => return None,
656 };
657 return Some((host.to_owned(), port));
658 }
659 Some((pattern.to_owned(), None))
660}