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;