Skip to main content

purple_ssh/ssh_config/
pattern.rs

1//! OpenSSH host-pattern matching.
2//!
3//! Implements `Host` keyword wildcard semantics: `*`, `?`, `[charset]`,
4//! `[!charset]`/`[^charset]`, `[a-z]` ranges and `!pattern` negation.
5//! All functions here are pure; they own no state and depend only on
6//! `PatternEntry` from the model module.
7
8use super::model::PatternEntry;
9
10/// Does this pattern contain any SSH wildcard metacharacters?
11pub fn is_host_pattern(pattern: &str) -> bool {
12    pattern.contains('*')
13        || pattern.contains('?')
14        || pattern.contains('[')
15        || pattern.starts_with('!')
16        || pattern.contains(' ')
17        || pattern.contains('\t')
18}
19
20/// Match a text string against an SSH host pattern.
21/// Supports `*` (any sequence), `?` (single char), `[charset]` (character class),
22/// `[!charset]`/`[^charset]` (negated class), `[a-z]` (ranges) and `!pattern` (negation).
23pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
24    if let Some(rest) = pattern.strip_prefix('!') {
25        return !match_glob(rest, text);
26    }
27    match_glob(pattern, text)
28}
29
30/// Core glob matcher without negation prefix handling.
31/// Empty text only matches empty pattern.
32fn match_glob(pattern: &str, text: &str) -> bool {
33    if text.is_empty() {
34        return pattern.is_empty();
35    }
36    if pattern.is_empty() {
37        return false;
38    }
39    let pat: Vec<char> = pattern.chars().collect();
40    let txt: Vec<char> = text.chars().collect();
41    glob_match(&pat, &txt)
42}
43
44/// Iterative glob matching with star-backtracking.
45fn glob_match(pat: &[char], txt: &[char]) -> bool {
46    let mut pi = 0;
47    let mut ti = 0;
48    let mut star: Option<(usize, usize)> = None; // (pattern_pos, text_pos)
49
50    while ti < txt.len() {
51        if pi < pat.len() && pat[pi] == '?' {
52            pi += 1;
53            ti += 1;
54        } else if pi < pat.len() && pat[pi] == '*' {
55            star = Some((pi + 1, ti));
56            pi += 1;
57        } else if pi < pat.len() && pat[pi] == '[' {
58            if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
59                if matches {
60                    pi = end;
61                    ti += 1;
62                } else if let Some((spi, sti)) = star {
63                    let sti = sti + 1;
64                    star = Some((spi, sti));
65                    pi = spi;
66                    ti = sti;
67                } else {
68                    return false;
69                }
70            } else if let Some((spi, sti)) = star {
71                // Malformed class: backtrack
72                let sti = sti + 1;
73                star = Some((spi, sti));
74                pi = spi;
75                ti = sti;
76            } else {
77                return false;
78            }
79        } else if pi < pat.len() && pat[pi] == txt[ti] {
80            pi += 1;
81            ti += 1;
82        } else if let Some((spi, sti)) = star {
83            let sti = sti + 1;
84            star = Some((spi, sti));
85            pi = spi;
86            ti = sti;
87        } else {
88            return false;
89        }
90    }
91
92    while pi < pat.len() && pat[pi] == '*' {
93        pi += 1;
94    }
95    pi == pat.len()
96}
97
98/// Parse and match a `[...]` character class starting at `pat[start]`.
99/// Returns `Some((matched, end_index))` where `end_index` is past `]`.
100/// Returns `None` if no closing `]` is found.
101fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
102    let mut i = start + 1;
103    if i >= pat.len() {
104        return None;
105    }
106
107    let negate = pat[i] == '!' || pat[i] == '^';
108    if negate {
109        i += 1;
110    }
111
112    let mut matched = false;
113    while i < pat.len() && pat[i] != ']' {
114        if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
115            let lo = pat[i];
116            let hi = pat[i + 2];
117            if ch >= lo && ch <= hi {
118                matched = true;
119            }
120            i += 3;
121        } else {
122            matched |= pat[i] == ch;
123            i += 1;
124        }
125    }
126
127    if i >= pat.len() {
128        return None;
129    }
130
131    let result = if negate { !matched } else { matched };
132    Some((result, i + 1))
133}
134
135/// Check whether a `Host` pattern matches a given alias.
136/// OpenSSH `Host` keyword matches only against the target alias typed on the
137/// command line, never against the resolved HostName.
138pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
139    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
140    if patterns.is_empty() {
141        return false;
142    }
143
144    let mut any_positive_match = false;
145    for pat in &patterns {
146        if let Some(neg) = pat.strip_prefix('!') {
147            if match_glob(neg, alias) {
148                return false;
149            }
150        } else if ssh_pattern_match(pat, alias) {
151            any_positive_match = true;
152        }
153    }
154
155    any_positive_match
156}
157
158/// Returns true if any hop in a (possibly comma-separated) ProxyJump value
159/// matches the given alias. Strips optional `user@` prefix and `:port`
160/// suffix from each hop before comparing. Handles IPv6 bracket notation
161/// `[addr]:port`. Used to detect self-referencing loops.
162pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
163    proxy_jump.split(',').any(|hop| {
164        let h = hop.trim();
165        // Strip optional user@ prefix (take everything after the first @).
166        let h = h.split_once('@').map_or(h, |(_, host)| host);
167        // Strip optional :port suffix. Handle [IPv6]:port bracket notation.
168        let h = if let Some(bracketed) = h.strip_prefix('[') {
169            bracketed.split_once(']').map_or(h, |(host, _)| host)
170        } else {
171            h.rsplit_once(':').map_or(h, |(host, _)| host)
172        };
173        h == alias
174    })
175}
176
177/// Apply first-match-wins inheritance from a pattern to mutable field refs.
178/// Only fills fields that are still empty. Self-referencing ProxyJump values
179/// are assigned (SSH would do the same) so the UI can warn about the loop.
180pub(super) fn apply_first_match_fields(
181    proxy_jump: &mut String,
182    user: &mut String,
183    identity_file: &mut String,
184    p: &PatternEntry,
185) {
186    if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
187        proxy_jump.clone_from(&p.proxy_jump);
188    }
189    if user.is_empty() && !p.user.is_empty() {
190        user.clone_from(&p.user);
191    }
192    if identity_file.is_empty() && !p.identity_file.is_empty() {
193        identity_file.clone_from(&p.identity_file);
194    }
195}