Skip to main content

strop_remote/hosts/
mod.rs

1//! Local SSH host-candidate enumeration (0036 "Completion and homes").
2//!
3//! Completion is a read of candidate data and nothing else: explicit
4//! ssh_config files (with bounded `Include` expansion), explicit
5//! known_hosts files, and caller-supplied history endpoints. Nothing
6//! here runs `ssh` — `ssh -G` stays a resolver for an already chosen
7//! host (0033), `Match exec` commands never run merely because
8//! completion asked, and no connection or authentication is attempted.
9//! All inputs are explicit paths: this module never reads ambient
10//! state, so enumeration is pure, bounded and testable against fixture
11//! directories. A candidate endpoint's admissibility is delegated to
12//! the one address grammar (`RemoteFile::parse`); no second notion of
13//! a valid host exists here. `HostName` values are deliberately not
14//! candidates: users type aliases, and resolving an alias is OpenSSH's
15//! job at open time — never enumeration's.
16
17use 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
28/// Bounds that keep one Tab cheap against pathological configs.
29/// Every bound hit is a note, never a silent truncation.
30const 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/// Where a candidate came from — shown so users can tell config data
39/// from hosts they actually connected to.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum CandidateOrigin {
42    /// A literal `Host` alias in a read ssh_config file.
43    Config,
44    /// A literal host pattern in a read known_hosts file.
45    KnownHosts,
46    /// An endpoint the caller already opened (editor documents).
47    History,
48}
49
50/// One completion candidate: the endpoint local data knows about.
51/// `token()` renders exactly what can be typed after `ssh://`; the
52/// constructor is pure and `admissible()` is judged by the address
53/// grammar alone.
54#[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    /// The endpoint as typed into an `ssh://` URI (`user@host:port`,
94    /// IPv6 bracketed). Display spelling of identity data.
95    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    /// The `host[:port]` region without a user — what a typed port
104    /// prefix must extend.
105    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    /// True when the one address grammar admits this endpoint. This is
122    /// the only validity notion in the crate — there is no local
123    /// re-implementation of hostname rules.
124    pub fn admissible(&self) -> bool {
125        RemoteFile::parse(&format!("ssh://{}/", self.token())).is_ok()
126    }
127
128    /// The completed endpoint token for a typed prefix, or None when
129    /// this candidate does not extend what the user typed. Typing a
130    /// user wins over a configured one; typing past `:` must extend
131    /// the candidate's port, never reset it.
132    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
152/// The host region of a typed authority: text before an explicit port.
153fn 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/// Explicit enumeration inputs. The caller owns path resolution, so
168/// enumeration itself is isolated from ambient state.
169#[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    /// Relative `Include` arguments resolve against this directory
188    /// (ssh_config(5) resolves them against `~/.ssh`).
189    pub fn set_include_base(&mut self, base: PathBuf) -> &mut Self {
190        self.include_base = Some(base);
191        self
192    }
193
194    /// The standard locations for one home directory. Pure: nothing is
195    /// read here; unreadable or missing files become notes (or
196    /// silence) at enumeration time.
197    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/// Everything enumeration found: candidates plus bounded notes. Notes
214/// are diagnostics, not failures — an unreadable config still completes
215/// from the remaining sources.
216#[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    /// The sorted, deduplicated endpoint tokens extending `typed`
232    /// (`""` lists every candidate).
233    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
245/// Shared state of one enumeration walk.
246struct 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
267/// Enumerate host candidates from explicit local data. Pure and
268/// bounded: reads only the given files, spawns nothing, authenticates
269/// nothing. History candidates are admitted first (the endpoints the
270/// user actually opens), then config files in order, then known_hosts.
271pub 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
302/// Admit one candidate: deduplicated by token, validated by the
303/// address grammar, capped in total.
304fn 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}
323/// Read one file, bounded; a missing file is silence, anything else is
324/// a note. Returns None when the file contributes nothing.
325fn 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
359/// Parse one ssh_config file, following `Include` directives inline
360/// (depth-first like OpenSSH) with cycle and budget guards.
361fn 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
396/// Line-oriented ssh_config reading. Only `Host` contributes names;
397/// `User`/`Port` qualify the open `Host` block (or the file's global
398/// defaults); `Match` opens a conditional block whose commands are
399/// never evaluated; `Include` is expanded inline. An `Include` resets
400/// the includer's block context — an approximation of OpenSSH's
401/// fully-ordered merge, documented here: resolving the final
402/// configuration remains `ssh -G`'s job at open time, never
403/// enumeration's.
404fn 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                    // Patterns and negations are match rules, not names
420                    // a user can type; literal aliases only.
421                    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    // File-level defaults qualify this file's candidates that lack a
476    // block-level value.
477    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
495/// Resolve one `Include` argument to concrete files: absolute paths
496/// pass through; relative paths resolve against the include base
497/// (ssh_config(5) uses `~/.ssh`); glob wildcards (`*` and `?`, one
498/// directory — OpenSSH's cross-component globs are not completion
499/// data) expand sorted and bounded. Without a base, relative includes
500/// are skipped with a note.
501fn 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
522/// Expand a glob with `*` and `?` over one directory, sorted, bounded.
523fn 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
558/// Anchored glob match with `*` and `?` (ssh wildcards minus character
559/// classes, which no completion source relies on).
560fn 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
577/// ssh_config tokenization: whitespace-separated, single- or
578/// double-quoted tokens, `#` starts a comment only before the first
579/// token of a line.
580fn 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
606/// Parse one known_hosts file. Literal hosts become candidates
607/// (`[host]:port` carries its port); hashed entries cannot be reversed
608/// and are counted in a note; markers, negations and wildcard patterns
609/// are not connect targets.
610fn 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
648/// Split one known_hosts pattern: `[host]:port`, `[host]` or `host`.
649fn 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}