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/// Strip a single pair of surrounding double quotes from a Host pattern
136/// token. OpenSSH accepts `Host "alpha"` as equivalent to `Host alpha`; without
137/// this strip purple's stored pattern would contain literal quote characters
138/// and never match the user-typed alias.
139fn unquote_pattern_token(token: &str) -> &str {
140    if token.len() >= 2 && token.starts_with('"') && token.ends_with('"') {
141        &token[1..token.len() - 1]
142    } else {
143        token
144    }
145}
146
147/// Check whether a `Host` pattern matches a given alias.
148/// OpenSSH `Host` keyword matches only against the target alias typed on the
149/// command line, never against the resolved HostName.
150pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
151    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
152    if patterns.is_empty() {
153        return false;
154    }
155
156    let mut any_positive_match = false;
157    for pat in &patterns {
158        let pat = unquote_pattern_token(pat);
159        if let Some(neg) = pat.strip_prefix('!') {
160            if match_glob(neg, alias) {
161                return false;
162            }
163        } else if ssh_pattern_match(pat, alias) {
164            any_positive_match = true;
165        }
166    }
167
168    any_positive_match
169}
170
171/// Returns true if any hop in a (possibly comma-separated) ProxyJump value
172/// matches the given alias. Strips optional `user@` prefix and `:port`
173/// suffix from each hop before comparing. Handles IPv6 bracket notation
174/// `[addr]:port`. Used to detect self-referencing loops.
175pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
176    proxy_jump.split(',').any(|hop| {
177        let h = hop.trim();
178        // Strip optional user@ prefix (take everything after the first @).
179        let h = h.split_once('@').map_or(h, |(_, host)| host);
180        // Strip optional :port suffix. Handle [IPv6]:port bracket notation.
181        let h = if let Some(bracketed) = h.strip_prefix('[') {
182            bracketed.split_once(']').map_or(h, |(host, _)| host)
183        } else {
184            h.rsplit_once(':').map_or(h, |(host, _)| host)
185        };
186        h == alias
187    })
188}
189
190/// Apply first-match-wins inheritance from a pattern to mutable field refs.
191/// Only fills fields that are still empty. Self-referencing ProxyJump values
192/// are assigned (SSH would do the same) so the UI can warn about the loop.
193pub(super) fn apply_first_match_fields(
194    proxy_jump: &mut String,
195    user: &mut String,
196    identity_file: &mut String,
197    p: &PatternEntry,
198) {
199    if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
200        proxy_jump.clone_from(&p.proxy_jump);
201    }
202    if user.is_empty() && !p.user.is_empty() {
203        user.clone_from(&p.user);
204    }
205    if identity_file.is_empty() && !p.identity_file.is_empty() {
206        identity_file.clone_from(&p.identity_file);
207    }
208}