Skip to main content

toride_ssh_config/
ast.rs

1//! Lossless parse tree for SSH config files.
2//!
3//! Preserves whitespace, `=` separators, comments, and blank lines.
4//! Every byte of the original file is representable.
5
6use serde::{Deserialize, Serialize};
7
8/// Top-level SSH config AST.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ConfigAst {
11    /// Top-level nodes in the config file.
12    pub nodes: Vec<ConfigNode>,
13}
14
15/// Separator between a keyword and its value.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum Separator {
18    /// Space or tab separator: `Host example.com`
19    Space,
20    /// Equals sign separator: `Host=example.com`
21    Equals,
22}
23
24impl Separator {
25    /// Render the separator as a string.
26    #[must_use]
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Self::Space => " ",
30            Self::Equals => "=",
31        }
32    }
33}
34
35/// The default indentation string used when creating new blocks
36/// (4 spaces, matching the OpenSSH convention).
37const DEFAULT_INDENT: &str = "    ";
38
39/// Data carried by a [`ConfigNode::Directive`].
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DirectiveData {
42    /// The directive keyword (e.g. `HostName`, `User`, `Include`).
43    pub keyword: String,
44    /// Separator between keyword and value.
45    pub separator: Separator,
46    /// The raw value string.
47    pub value: String,
48    /// Optional trailing inline comment (without the `#`).
49    pub comment: Option<String>,
50    /// The leading whitespace/indentation before this directive.
51    pub indent: String,
52}
53
54/// Data carried by a [`ConfigNode::HostBlock`].
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HostBlockData {
57    /// The full raw `Host` header line (e.g. `"Host example.com *.example.com"`).
58    pub header: String,
59    /// Parsed host patterns (e.g. `["example.com", "*.example.com"]`).
60    pub patterns: Vec<String>,
61    /// Nodes inside this Host block.
62    pub nodes: Vec<ConfigNode>,
63}
64
65/// Data carried by a [`ConfigNode::MatchBlock`].
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MatchBlockData {
68    /// The full raw `Match` header line.
69    pub header: String,
70    /// The raw criteria string (e.g. `"host *.example.com user alice"`).
71    pub criteria: String,
72    /// Nodes inside this Match block.
73    pub nodes: Vec<ConfigNode>,
74}
75
76/// A single node in the SSH config AST.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub enum ConfigNode {
79    /// An empty / blank line.
80    BlankLine,
81    /// A comment line (including the leading `#`).
82    Comment {
83        /// The comment text (including the leading `#`).
84        text: String,
85        /// The leading whitespace before this comment (preserved for round-trip).
86        indent: String,
87    },
88    /// A standalone directive (not inside a Host/Match block).
89    Directive(Box<DirectiveData>),
90    /// A `Host` block containing nested directives.
91    HostBlock(Box<HostBlockData>),
92    /// A `Match` block containing nested directives.
93    MatchBlock(Box<MatchBlockData>),
94}
95
96impl ConfigAst {
97    /// Render the AST back to a string suitable for writing to disk.
98    #[must_use]
99    pub fn to_string_lossless(&self) -> String {
100        let mut out = String::new();
101        for node in &self.nodes {
102            node.render(&mut out, 0);
103        }
104        out
105    }
106}
107
108impl ConfigNode {
109    /// Render this node (and children) into `out` at the given indent level.
110    ///
111    /// For nodes that carry their own `indent` string (parsed from the original
112    /// file), that string is used instead of the computed prefix, preserving the
113    /// original whitespace exactly.
114    fn render(&self, out: &mut String, indent_level: usize) {
115        let computed_prefix = DEFAULT_INDENT.repeat(indent_level);
116        match self {
117            Self::BlankLine => {
118                out.push('\n');
119            }
120            Self::Comment { text, indent } => {
121                if indent.is_empty() && indent_level > 0 {
122                    out.push_str(&computed_prefix);
123                } else {
124                    out.push_str(indent);
125                }
126                out.push_str(text);
127                out.push('\n');
128            }
129            Self::Directive(d) => {
130                if d.indent.is_empty() && indent_level > 0 {
131                    out.push_str(&computed_prefix);
132                } else {
133                    out.push_str(&d.indent);
134                }
135                out.push_str(&d.keyword);
136                out.push_str(d.separator.as_str());
137                out.push_str(&d.value);
138                if let Some(ref c) = d.comment {
139                    out.push_str(" #");
140                    out.push_str(c);
141                }
142                out.push('\n');
143            }
144            Self::HostBlock(b) => {
145                out.push_str(&computed_prefix);
146                out.push_str(&b.header);
147                out.push('\n');
148                for child in &b.nodes {
149                    child.render(out, indent_level + 1);
150                }
151            }
152            Self::MatchBlock(b) => {
153                out.push_str(&computed_prefix);
154                out.push_str(&b.header);
155                out.push('\n');
156                for child in &b.nodes {
157                    child.render(out, indent_level + 1);
158                }
159            }
160        }
161    }
162
163    /// If this is a `HostBlock`, return its patterns and inner nodes.
164    #[must_use]
165    pub fn as_host_block(&self) -> Option<(&[String], &[Self])> {
166        match self {
167            Self::HostBlock(b) => Some((&b.patterns, &b.nodes)),
168            _ => None,
169        }
170    }
171
172    /// If this is a `HostBlock`, return mutable access to its nodes.
173    pub fn as_host_block_mut(&mut self) -> Option<&mut Vec<Self>> {
174        match self {
175            Self::HostBlock(b) => Some(&mut b.nodes),
176            _ => None,
177        }
178    }
179
180    /// If this is a `Directive`, return its keyword and value.
181    #[must_use]
182    pub fn as_directive(&self) -> Option<(&str, &str)> {
183        match self {
184            Self::Directive(d) => Some((&d.keyword, &d.value)),
185            _ => None,
186        }
187    }
188
189    /// If this is a `Directive`, return mutable access to its fields.
190    pub fn as_directive_mut(&mut self) -> Option<(&mut String, &mut String)> {
191        match self {
192            Self::Directive(d) => Some((&mut d.keyword, &mut d.value)),
193            _ => None,
194        }
195    }
196}
197
198/// Parse an SSH config file string into a lossless AST.
199///
200/// Handles `Host` and `Match` blocks with proper nesting, preserves
201/// whitespace, comments, blank lines, and both `=` and space separators.
202#[must_use]
203pub fn parse(input: &str) -> ConfigAst {
204    let mut nodes = Vec::new();
205    let mut lines = input.lines().peekable();
206
207    while let Some(line) = lines.next() {
208        let trimmed = line.trim();
209        let indent = line_indent(line);
210
211        // Blank line
212        if trimmed.is_empty() {
213            nodes.push(ConfigNode::BlankLine);
214            continue;
215        }
216
217        // Comment
218        if trimmed.starts_with('#') {
219            nodes.push(ConfigNode::Comment {
220                text: trimmed.to_owned(),
221                indent: indent.to_owned(),
222            });
223            continue;
224        }
225
226        // Parse keyword and value
227        let (keyword, separator, rest) = parse_directive_parts(trimmed);
228        if keyword.eq_ignore_ascii_case("host") {
229            let patterns = parse_patterns(rest);
230            let header = line.trim().to_owned();
231            let inner = parse_block_body(&mut lines);
232            nodes.push(ConfigNode::HostBlock(Box::new(HostBlockData {
233                header,
234                patterns,
235                nodes: inner,
236            })));
237        } else if keyword.eq_ignore_ascii_case("match") {
238            let header = line.trim().to_owned();
239            let criteria = rest.to_owned();
240            let inner = parse_block_body(&mut lines);
241            nodes.push(ConfigNode::MatchBlock(Box::new(MatchBlockData {
242                header,
243                criteria,
244                nodes: inner,
245            })));
246        } else {
247            // Regular directive — check for trailing inline comment
248            let (value, comment) = split_trailing_comment(rest);
249            nodes.push(ConfigNode::Directive(Box::new(DirectiveData {
250                keyword: keyword.to_owned(),
251                separator,
252                value,
253                comment,
254                indent: indent.to_owned(),
255            })));
256        }
257    }
258
259    ConfigAst { nodes }
260}
261
262/// Extract the leading whitespace from a line.
263fn line_indent(line: &str) -> &str {
264    let end = line
265        .find(|c: char| !c.is_whitespace())
266        .unwrap_or(line.len());
267    &line[..end]
268}
269
270/// Parse the body of a Host/Match block, consuming indented lines.
271///
272/// # Known limitation (tracked followup)
273///
274/// Membership is gated on indentation: a line belongs to the block only if it
275/// starts with whitespace. OpenSSH does **not** require body indentation — a
276/// `Host`/`Match` block actually extends until the next `Host`/`Match` header
277/// — so an *unindented* block-scoped directive (e.g. a real `sshd_config`
278/// `Match User sftpuser` block written as
279/// `Match User sftpuser\nChrootDirectory /sftp`) leaks into the global view.
280/// In the Security tab this can misreport rare unindented Match-scoped
281/// `AllowUsers`/`DenyUsers` as global.
282///
283/// The obvious fix — header-based membership — is correct but breaks the
284/// managed-block feature (`managed.rs`): a `# >>> toride …` managed section
285/// placed after a `Host` line would be absorbed into that Host block, and
286/// `upsert_managed_block` (which appends at top level) would lose it on
287/// re-parse. Reconciling the two needs a design decision (managed blocks as
288/// self-contained blocks, or an explicit parser mode), so it is left as a
289/// scoped followup rather than risked here.
290///
291/// IMPORTANT: this is **not** a display-only misreport. Because the leaked
292/// directive is reified as a top-level `ConfigNode::Directive`, an editor that
293/// scans `ast.nodes` (as `sshd::upsert_user_in_directive` does) would mutate
294/// the leaked node and re-render it at indent 0 — permanently relocating a
295/// `Match`/`Host`-scoped directive to global scope on disk. `sshd -t` passes
296/// (the file is still syntactically valid), so the validation gate does not
297/// catch it. The `sshd.rs` editor therefore **fail-closes**: if a directive it
298/// is asked to edit is immediately preceded (in document order) by a
299/// `Match`/`Host` block, it refuses the edit with `Error::SshdConfigInvalid`
300/// rather than risk writing to the wrong scope.
301fn parse_block_body<'a, I>(lines: &mut std::iter::Peekable<I>) -> Vec<ConfigNode>
302where
303    I: Iterator<Item = &'a str>,
304{
305    let mut body = Vec::new();
306
307    while let Some(line) = lines.peek() {
308        // A line that starts with whitespace is inside the block.
309        if !line.starts_with(' ') && !line.starts_with('\t') {
310            break;
311        }
312
313        let Some(line) = lines.next() else {
314            break;
315        };
316        let trimmed = line.trim();
317        let indent = line_indent(line);
318
319        if trimmed.is_empty() {
320            // A blank indented line is still part of the block.
321            body.push(ConfigNode::BlankLine);
322            continue;
323        }
324
325        if trimmed.starts_with('#') {
326            body.push(ConfigNode::Comment {
327                text: trimmed.to_owned(),
328                indent: indent.to_owned(),
329            });
330            continue;
331        }
332
333        let (keyword, separator, rest) = parse_directive_parts(trimmed);
334
335        // Nested Host/Match inside a block is not standard, but we handle it
336        // gracefully by treating it as a directive.
337        let (value, comment) = split_trailing_comment(rest);
338        body.push(ConfigNode::Directive(Box::new(DirectiveData {
339            keyword: keyword.to_owned(),
340            separator,
341            value,
342            comment,
343            indent: indent.to_owned(),
344        })));
345    }
346
347    body
348}
349
350/// Split a directive line into (keyword, separator, rest-of-line).
351///
352/// An `=` is only treated as the separator when it appears immediately after
353/// the keyword token with **no intervening whitespace** — i.e. `Key=Value`.
354/// If there is whitespace before the `=` (e.g. `SetEnv FOO=bar`) the `=` is
355/// part of the value, not the separator.
356pub(crate) fn parse_directive_parts(line: &str) -> (&str, Separator, &str) {
357    // Find the first whitespace boundary.
358    let ws_pos = line.find(|c: char| c.is_whitespace());
359
360    // Check for `=` separator: only valid if it appears before any whitespace
361    // (i.e. `Keyword=Value`, not `Keyword Value=thing`).
362    if let Some(eq_pos) = line.find('=') {
363        let before_eq_has_space = line[..eq_pos].contains(' ') || line[..eq_pos].contains('\t');
364        if !before_eq_has_space {
365            let keyword = line[..eq_pos].trim();
366            let rest = line[eq_pos + 1..].trim();
367            return (keyword, Separator::Equals, rest);
368        }
369    }
370
371    // Fall back to whitespace separator.
372    if let Some(ws) = ws_pos {
373        let keyword = &line[..ws];
374        let rest = line[ws..].trim_start();
375        return (keyword, Separator::Space, rest);
376    }
377
378    // Keyword only, no value.
379    (line, Separator::Space, "")
380}
381
382/// Parse space-separated host patterns from a `Host` value string.
383fn parse_patterns(value: &str) -> Vec<String> {
384    value.split_whitespace().map(str::to_owned).collect()
385}
386
387/// Split a value string into (value, `optional_trailing_comment`).
388///
389/// Handles `# comment` at the end of a line. Quotes are respected so that
390/// `#` inside quotes is not treated as a comment.
391pub(crate) fn split_trailing_comment(value: &str) -> (String, Option<String>) {
392    let mut in_double = false;
393    let mut in_single = false;
394    let mut comment_start = None;
395
396    for (i, ch) in value.char_indices() {
397        if ch == '"' && !in_single {
398            in_double = !in_double;
399        } else if ch == '\'' && !in_double {
400            in_single = !in_single;
401        } else if ch == '#' && !in_double && !in_single {
402            comment_start = Some(i);
403            break;
404        }
405    }
406
407    match comment_start {
408        Some(pos) => {
409            let val = value[..pos].trim_end().to_owned();
410            let comment = value[pos + 1..].trim().to_owned();
411            (val, Some(comment))
412        }
413        None => (value.to_owned(), None),
414    }
415}
416
417#[cfg(test)]
418#[path = "ast.test.rs"]
419mod tests;