puressh 0.2.4

A pure-Rust SSH (Secure Shell) protocol library, in the spirit of libssh, built on purecrypto.
Documentation
//! Low-level tokenizer for OpenSSH-style config files.
//!
//! `ssh_config(5)` and `sshd_config(5)` share the same line-oriented syntax:
//! one keyword per line, optional `=` between keyword and value, values may be
//! quoted, `#` starts a comment, and the keyword is case-insensitive. `Host`
//! and `Match` open new blocks; everything before the first block belongs to
//! an implicit "global" block.
//!
//! This module produces a flat stream of [`ParsedLine`] events; turning those
//! into [`SshClientConfig`](super::SshClientConfig) or
//! [`SshServerConfig`](super::SshServerConfig) is the caller's job.

use alloc::string::{String, ToString};
use alloc::vec::Vec;

use super::ConfigError;

/// One parsed non-empty, non-comment line.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ParsedLine {
    /// 1-based source line number (for diagnostics).
    pub line_no: usize,
    /// Lowercased keyword (e.g. `"hostname"`, `"port"`).
    pub keyword: String,
    /// Whitespace-separated value tokens with quoting/escaping resolved.
    pub args: Vec<String>,
    /// The value as written: the rest of the line after the keyword and
    /// its separating whitespace / `=`, quotes kept — what OpenSSH takes for
    /// `ProxyCommand`, `LocalCommand` and `RemoteCommand`.
    pub rest: String,
    /// The file the line came from when an `Include` brought it in (`None`
    /// for the file or string being parsed itself).
    pub file: Option<String>,
}

impl ParsedLine {
    /// Where the line is, for messages: `line 3`, or `<file> line 3` for an
    /// included file (as OpenSSH words it).
    pub fn location(&self) -> String {
        match &self.file {
            Some(f) => alloc::format!("{f} line {}", self.line_no),
            None => alloc::format!("line {}", self.line_no),
        }
    }
}

/// Keyword of the marker line [`expand_includes`](super::include) puts
/// before an included file's lines. The tokenizer cuts keywords at `=`, so
/// no config line can produce it.
pub(crate) const INCLUDE_BEGIN: &str = "=include-begin";
/// Keyword of the marker line after an included file's lines: the block
/// (`Host` / `Match` / global) open at the `Include` is open again, as in
/// OpenSSH, so a `Match` ending a drop-in does not swallow what follows.
pub(crate) const INCLUDE_END: &str = "=include-end";

/// Tokenize `src` into a vector of [`ParsedLine`] events.
///
/// Skips blank lines and `#` comments. Quoted (`"..."`) substrings preserve
/// internal whitespace and honour backslash escapes (`\"`, `\\`). An optional
/// `=` between keyword and value is accepted (matching OpenSSH).
pub fn tokenize(src: &str) -> Result<Vec<ParsedLine>, ConfigError> {
    let mut out = Vec::new();
    for (idx, raw) in src.lines().enumerate() {
        let line_no = idx + 1;
        let stripped = strip_comment(raw);
        let trimmed = stripped.trim();
        if trimmed.is_empty() {
            continue;
        }
        let mut toks = tokenize_line(trimmed, line_no)?;
        if toks.is_empty() {
            continue;
        }
        // The value as written (OpenSSH's parse_command): past the keyword,
        // which ends at whitespace or `=`, and the separators after it.
        let kw_end = trimmed
            .find(|c: char| c.is_whitespace() || c == '=')
            .unwrap_or(trimmed.len());
        let rest = trimmed[kw_end..]
            .trim_start_matches(|c: char| c.is_whitespace() || c == '=')
            .to_string();
        let mut keyword = toks.remove(0);
        // OpenSSH accepts `Keyword=value`, `Keyword =value`, `Keyword= value`,
        // and `Keyword = value`. Resolve each into "keyword + clean args".
        if let Some((kw, rest)) = keyword.split_once('=') {
            // `Keyword=value` or `Keyword=` → the value (if any) becomes the
            // first arg.
            let rest_str = rest.to_string();
            keyword = kw.to_string();
            if !rest_str.is_empty() {
                toks.insert(0, rest_str);
            }
        }
        keyword.make_ascii_lowercase();
        // `Keyword =value` was tokenized as ["=value"]; `Keyword = value`
        // becomes ["=", "value"]; `Keyword= value` becomes ["", "value"]
        // (the split_once above already left an empty kw side discarded).
        if !toks.is_empty() {
            if toks[0] == "=" {
                toks.remove(0);
            } else if let Some(stripped_arg) = toks[0].strip_prefix('=') {
                let s = stripped_arg.to_string();
                if s.is_empty() {
                    toks.remove(0);
                } else {
                    toks[0] = s;
                }
            }
        }
        out.push(ParsedLine {
            line_no,
            keyword,
            args: toks,
            rest,
            file: None,
        });
    }
    Ok(out)
}

/// Cut the line at its comment: a `#` that starts a token (outside quotes),
/// as OpenSSH's argv_split ends a line there. A `#` inside a token
/// (`Host foo#bar`) or quotes is kept.
fn strip_comment(line: &str) -> &str {
    let b = line.as_bytes();
    let mut i = 0;
    while i < b.len() {
        // Between tokens.
        if b[i] == b' ' || b[i] == b'\t' {
            i += 1;
            continue;
        }
        if b[i] == b'#' {
            return &line[..i];
        }
        // A token: to its end, honouring quotes and escapes.
        let mut quote = 0u8;
        while i < b.len() {
            match b[i] {
                b'\\'
                    if i + 1 < b.len()
                        && (matches!(b[i + 1], b'\'' | b'"' | b'\\')
                            || (quote == 0 && b[i + 1] == b' ')) =>
                {
                    i += 1;
                }
                b' ' | b'\t' if quote == 0 => break,
                b'"' | b'\'' if quote == 0 => quote = b[i],
                c if quote != 0 && c == quote => quote = 0,
                _ => {}
            }
            i += 1;
        }
    }
    line
}

/// Split a trimmed, comment-free line into tokens as OpenSSH's argv_split:
/// whitespace separates them; `"…"` and `'…'` quote; a backslash escapes a
/// quote, a backslash, or (outside quotes) a space, and is kept before
/// anything else.
fn tokenize_line(line: &str, line_no: usize) -> Result<Vec<String>, ConfigError> {
    let chars: Vec<char> = line.chars().collect();
    let mut out = Vec::new();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == ' ' || chars[i] == '\t' {
            i += 1;
            continue;
        }
        let mut cur = String::new();
        let mut quote: Option<char> = None;
        while i < chars.len() {
            let c = chars[i];
            match c {
                '\\' => match chars.get(i + 1) {
                    Some(&n)
                        if n == '\'' || n == '"' || n == '\\' || (quote.is_none() && n == ' ') =>
                    {
                        cur.push(n);
                        i += 1;
                    }
                    _ => cur.push(c),
                },
                ' ' | '\t' if quote.is_none() => break,
                '"' | '\'' if quote.is_none() => quote = Some(c),
                _ if quote == Some(c) => quote = None,
                _ => cur.push(c),
            }
            i += 1;
        }
        if quote.is_some() {
            return Err(ConfigError::Syntax {
                line: line_no,
                msg: "unterminated quoted string".into(),
            });
        }
        out.push(cur);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn skips_blank_and_comment_lines() {
        let p = tokenize("# comment\n\n   \n# another\n").unwrap();
        assert!(p.is_empty());
    }

    #[test]
    fn lowercases_keyword() {
        let p = tokenize("HostName example.com").unwrap();
        assert_eq!(p.len(), 1);
        assert_eq!(p[0].keyword, "hostname");
        assert_eq!(p[0].args, vec!["example.com"]);
    }

    #[test]
    fn tokenizes_like_openssh_argv_split() {
        // Escaped quotes (the regress suite's `SSH_SK_HELPER=\"\"`), single
        // quotes, an escaped space, and a `#` inside a token.
        let p = tokenize(
            r#"ProxyCommand env X=\"\" 'a b' c\ d e#f # comment
Host foo#bar
"#,
        )
        .unwrap();
        assert_eq!(p[0].args, vec!["env", r#"X="""#, "a b", "c d", "e#f"]);
        assert_eq!(p[0].rest, r#"env X=\"\" 'a b' c\ d e#f"#);
        assert_eq!(p[1].args, vec!["foo#bar"]);
        assert!(tokenize("Host 'unterminated\n").is_err());
    }

    #[test]
    fn accepts_equals_separator() {
        let cases = ["Port=2222", "Port =2222", "Port= 2222", "Port = 2222"];
        for src in cases {
            let p = tokenize(src).unwrap();
            assert_eq!(p.len(), 1, "case {src:?}");
            assert_eq!(p[0].keyword, "port", "case {src:?}");
            assert_eq!(p[0].args, vec!["2222"], "case {src:?}");
        }
    }

    #[test]
    fn quoted_value_preserves_whitespace() {
        let p = tokenize(r#"User "alice bob""#).unwrap();
        assert_eq!(p[0].args, vec!["alice bob"]);
    }

    #[test]
    fn backslash_escape_in_quotes() {
        let p = tokenize(r#"User "a\"b\\c""#).unwrap();
        assert_eq!(p[0].args, vec!["a\"b\\c"]);
    }

    #[test]
    fn trailing_comment_after_value() {
        let p = tokenize("Port 22 # default").unwrap();
        assert_eq!(p[0].args, vec!["22"]);
    }

    #[test]
    fn quoted_hash_is_not_comment() {
        let p = tokenize(r#"Host "weird#name""#).unwrap();
        assert_eq!(p[0].args, vec!["weird#name"]);
    }

    #[test]
    fn multiple_args() {
        let p = tokenize("LocalForward 8080 example.com:80").unwrap();
        assert_eq!(p[0].args, vec!["8080", "example.com:80"]);
    }

    #[test]
    fn unterminated_quote_errors() {
        let err = tokenize(r#"Host "unterminated"#).unwrap_err();
        match err {
            ConfigError::Syntax { line, .. } => assert_eq!(line, 1),
            _ => panic!("wrong error: {err:?}"),
        }
    }
}