#![allow(clippy::unreadable_literal)]
use super::*;
#[test]
fn parse_simple_host_block() {
let input = "\
Host example
HostName example.com
User alice
IdentityFile ~/.ssh/id_ed25519
Host *
ServerAliveInterval 60
";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 3);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns, &["example"]);
assert_eq!(b.nodes.len(), 3);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn round_trip_preserves_content() {
let input = "\
# My SSH config
Host example
HostName=example.com
User alice
Host *
ServerAliveInterval 60
";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn parse_equals_separator() {
let input = "Host=myserver\n HostName=192.168.1.1\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.header, "Host=myserver");
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn split_trailing_comment_works() {
let (val, comment) = split_trailing_comment("hello # a comment");
assert_eq!(val, "hello");
assert_eq!(comment, Some("a comment".to_owned()));
let (val, comment) = split_trailing_comment("no comment");
assert_eq!(val, "no comment");
assert_eq!(comment, None);
let (val, comment) = split_trailing_comment("\"path # here\" # real comment");
assert_eq!(val, "\"path # here\"");
assert_eq!(comment, Some("real comment".to_owned()));
}
#[test]
fn parse_directive_with_equals_in_value() {
let (keyword, sep, rest) = parse_directive_parts("SetEnv FOO=bar");
assert_eq!(keyword, "SetEnv");
assert_eq!(sep, Separator::Space);
assert_eq!(rest, "FOO=bar");
}
#[test]
fn parse_directive_equals_separator() {
let (keyword, sep, rest) = parse_directive_parts("HostName=example.com");
assert_eq!(keyword, "HostName");
assert_eq!(sep, Separator::Equals);
assert_eq!(rest, "example.com");
}
#[test]
fn round_trip_preserves_tab_indentation() {
let input = "Host example\n\tHostName example.com\n\tUser alice\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn round_trip_with_comment_in_block() {
let input = "Host example\n # inline comment\n HostName example.com\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn parse_empty_input() {
let ast = parse("");
assert!(ast.nodes.is_empty());
}
#[test]
fn parse_only_whitespace() {
let ast = parse(" \n \n");
assert!(ast.nodes.iter().all(|n| matches!(n, ConfigNode::BlankLine)));
}
#[test]
fn parse_only_comments() {
let input = "# comment 1\n# comment 2\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 2);
assert!(
ast.nodes
.iter()
.all(|n| matches!(n, ConfigNode::Comment { .. }))
);
}
#[test]
fn parse_host_with_no_directives() {
let input = "Host empty\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 1);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns, &["empty"]);
assert!(b.nodes.is_empty());
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_host_with_multiple_patterns() {
let input = "Host web1 web2 *.example.com\n User admin\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns, &["web1", "web2", "*.example.com"]);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_directive_keyword_only() {
let (keyword, sep, rest) = parse_directive_parts("ForwardAgent");
assert_eq!(keyword, "ForwardAgent");
assert_eq!(sep, Separator::Space);
assert_eq!(rest, "");
}
#[test]
fn parse_directive_with_tab_separator() {
let input = "Host example\n\tHostName example.com\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => match &b.nodes[0] {
ConfigNode::Directive(d) => {
assert_eq!(d.keyword, "HostName");
assert_eq!(d.indent, "\t");
}
_ => panic!("expected Directive"),
},
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_multiple_host_blocks() {
let input = "\
Host web
HostName web.example.com
Host db
HostName db.example.com
";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 3); match &ast.nodes[0] {
ConfigNode::HostBlock(b) => assert_eq!(b.patterns, &["web"]),
_ => panic!("expected HostBlock"),
}
match &ast.nodes[2] {
ConfigNode::HostBlock(b) => assert_eq!(b.patterns, &["db"]),
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_match_block() {
let input = "Match host web*\n User admin\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 1);
match &ast.nodes[0] {
ConfigNode::MatchBlock(b) => {
assert_eq!(b.criteria, "host web*");
assert_eq!(b.nodes.len(), 1);
}
_ => panic!("expected MatchBlock"),
}
}
#[test]
fn round_trip_empty_input() {
let ast = parse("");
assert_eq!(ast.to_string_lossless(), "");
}
#[test]
fn line_indent_empty_string() {
assert_eq!(line_indent(""), "");
}
#[test]
fn line_indent_no_indent() {
assert_eq!(line_indent("hello"), "");
}
#[test]
fn line_indent_spaces() {
assert_eq!(line_indent(" hello"), " ");
}
#[test]
fn line_indent_tabs() {
assert_eq!(line_indent("\thello"), "\t");
}
#[test]
fn line_indent_mixed() {
assert_eq!(line_indent(" \t hello"), " \t ");
}
#[test]
fn parse_config_with_crlf_line_endings() {
let input = "Host example\r\n HostName example.com\r\n User alice\r\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert!(b.patterns[0].contains("example") || header_contains_r(&ast.nodes[0]));
assert!(!b.nodes.is_empty());
}
_ => panic!("expected HostBlock"),
}
}
fn header_contains_r(node: &ConfigNode) -> bool {
match node {
ConfigNode::HostBlock(b) => b.header.contains('\r'),
_ => false,
}
}
#[test]
fn parse_config_with_trailing_whitespace() {
let input = "Host example \n HostName example.com \n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns[0], "example");
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_config_with_only_blank_lines() {
let input = "\n\n\n\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 4);
assert!(ast.nodes.iter().all(|n| matches!(n, ConfigNode::BlankLine)));
}
#[test]
fn parse_config_with_mixed_blank_and_comments() {
let input = "# comment 1\n\n# comment 2\n\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 4);
assert!(matches!(&ast.nodes[0], ConfigNode::Comment { .. }));
assert!(matches!(&ast.nodes[1], ConfigNode::BlankLine));
assert!(matches!(&ast.nodes[2], ConfigNode::Comment { .. }));
assert!(matches!(&ast.nodes[3], ConfigNode::BlankLine));
}
#[test]
fn parse_host_with_very_long_name() {
let long_name = "a".repeat(256);
let input = format!("Host {long_name}\n User alice\n");
let ast = parse(&input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns[0], long_name);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_host_with_special_characters() {
let input = "Host my-host.example.com\n User alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns[0], "my-host.example.com");
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_directive_with_equals_no_spaces() {
let input = "Host=example\nHostName=example.com\nUser=alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.header, "Host=example");
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn round_trip_normalizes_crlf() {
let input = "Host example\r\n User alice\r\n";
let ast = parse(input);
let output = ast.to_string_lossless();
let _ = output;
}
#[test]
fn parse_comment_with_leading_whitespace() {
let input = " # indented comment\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::Comment { text, indent } => {
assert_eq!(text, "# indented comment");
assert_eq!(indent, " ");
}
_ => panic!("expected Comment"),
}
}
#[test]
fn parse_multiple_consecutive_blank_lines() {
let input = "Host example\n\n\n\n User alice\n";
let ast = parse(input);
assert!(!ast.nodes.is_empty());
}
#[test]
fn parse_host_block_followed_immediately_by_another() {
let input = "Host a\n User alice\nHost b\n User bob\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 2); match &ast.nodes[0] {
ConfigNode::HostBlock(b) => assert_eq!(b.patterns[0], "a"),
_ => panic!("expected HostBlock"),
}
match &ast.nodes[1] {
ConfigNode::HostBlock(b) => assert_eq!(b.patterns[0], "b"),
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_config_with_no_trailing_newline() {
let input = "Host example\n User alice";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.nodes.len(), 1);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_config_with_only_newline() {
let ast = parse("\n");
assert_eq!(ast.nodes.len(), 1);
assert!(matches!(&ast.nodes[0], ConfigNode::BlankLine));
}
#[test]
fn parse_config_with_bom() {
let input = "\u{FEFF}Host example\n User alice\n";
let ast = parse(input);
assert!(!ast.nodes.is_empty());
}
#[test]
fn parse_config_with_tabs_and_spaces_mixed() {
let input = "Host example\n\t User alice\n \tPort 22\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.nodes.len(), 2);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_config_with_carriage_return_only() {
let input = "Host example\r User alice\r";
let ast = parse(input);
let _ = ast;
}
#[test]
fn parse_host_with_empty_pattern() {
let input = "Host \n User alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert!(b.patterns.is_empty() || b.patterns.contains(&String::new()));
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_host_with_only_negation_pattern() {
let input = "Host !badhost\n User alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns[0], "!badhost");
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_match_with_exec_criteria() {
let input = "Match exec \"/bin/true\"\n User alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::MatchBlock(b) => {
assert!(b.criteria.contains("exec"));
}
_ => panic!("expected MatchBlock"),
}
}
#[test]
fn parse_directive_with_very_long_value() {
let long_value = "x".repeat(100000);
let input = format!("Host example\n HostName {long_value}\n");
let ast = parse(&input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => match &b.nodes[0] {
ConfigNode::Directive(d) => {
assert_eq!(d.value.len(), 100000);
}
_ => panic!("expected Directive"),
},
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_config_with_control_characters() {
let input = "Host example\n\tUser alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.nodes.len(), 1);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn round_trip_no_trailing_newline() {
let input = "Host example\n User alice";
let ast = parse(input);
let output = ast.to_string_lossless();
assert!(output.starts_with("Host example"));
assert!(output.contains("User alice"));
}
#[test]
fn parse_host_with_multiple_negation_patterns() {
let input = "Host * !bad1 !bad2\n User alice\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
assert_eq!(b.patterns, &["*", "!bad1", "!bad2"]);
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_comment_with_hash_in_value() {
let input = "Host example\n # This is a comment with # inside\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => match &b.nodes[0] {
ConfigNode::Comment { text, .. } => {
assert!(text.contains('#'));
}
_ => panic!("expected Comment"),
},
_ => panic!("expected HostBlock"),
}
}
#[test]
fn parse_directive_with_hash_in_quoted_value() {
let input = "Host example\n IdentityFile \"path#with#hash\"\n";
let ast = parse(input);
match &ast.nodes[0] {
ConfigNode::HostBlock(b) => {
match &b.nodes[0] {
ConfigNode::Directive(d) => {
assert!(d.value.contains('#') || d.comment.is_some());
}
_ => panic!("expected Directive"),
}
}
_ => panic!("expected HostBlock"),
}
}
#[test]
fn round_trip_preserves_equals_separator() {
let input = "Host=myserver\n HostName=example.com\n User=alice\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn round_trip_preserves_mixed_separators() {
let input = "Host example\n HostName=example.com\n User alice\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn round_trip_preserves_multiple_blank_lines() {
let input =
"Host example\n HostName example.com\n\n\n\nHost other\n HostName other.com\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn round_trip_preserves_trailing_newline() {
let input = "Host example\n HostName example.com\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
assert!(output.ends_with('\n'));
}
#[test]
fn round_trip_preserves_inline_comment_with_equals() {
let input = "Host example\n HostName=example.com #my server\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
#[test]
fn parse_empty_config() {
let ast = parse("");
assert!(ast.nodes.is_empty());
assert_eq!(ast.to_string_lossless(), "");
}
#[test]
fn parse_only_comments_roundtrip() {
let input = "# comment 1\n# comment 2\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 2);
assert!(
ast.nodes
.iter()
.all(|n| matches!(n, ConfigNode::Comment { .. }))
);
assert_eq!(ast.to_string_lossless(), input);
}
#[test]
fn parse_only_blank_lines() {
let input = "\n\n\n";
let ast = parse(input);
assert_eq!(ast.nodes.len(), 3);
assert!(ast.nodes.iter().all(|n| matches!(n, ConfigNode::BlankLine)));
assert_eq!(ast.to_string_lossless(), input);
}
#[test]
fn round_trip_preserves_deeply_nested_indent() {
let input = "Host example\n HostName example.com\n";
let ast = parse(input);
let output = ast.to_string_lossless();
assert_eq!(output, input);
}
use proptest::prelude::*;
fn safe_token() -> impl Strategy<Value = String> {
"[A-Za-z0-9._\\-*/?]{1,12}"
}
fn safe_value() -> impl Strategy<Value = String> {
"[A-Za-z0-9._\\-/:+]{1,16}"
}
fn indent_str() -> impl Strategy<Value = String> {
prop_oneof![Just(String::new()), "[ ]{1,8}", Just("\t".to_owned())]
}
fn top_level_line() -> impl Strategy<Value = String> {
prop_oneof![
Just("\n".to_owned()),
safe_token().prop_map(|t| format!("#{t}\n")),
(safe_token(), safe_value()).prop_map(|(k, v)| format!("{k} {v}\n")),
(safe_token(), indent_str(), safe_token(), safe_value())
.prop_map(|(pat, ind, dk, dval)| { format!("Host {pat}\n{ind}{dk} {dval}\n") }),
(safe_token(), indent_str(), safe_token(), safe_value())
.prop_map(|(crit, ind, dk, dval)| { format!("Match host {crit}\n{ind}{dk} {dval}\n") }),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn prop_parse_to_string_lossless_roundtrip(lines in prop::collection::vec(top_level_line(), 0..16)) {
let input: String = lines.concat();
let rendered = parse(&input).to_string_lossless();
prop_assert_eq!(rendered, input);
}
}