diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index ce58f85b4..a3dbf4058 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -40,13 +40,13 @@ resources: $out.resources.count | Should -Be 1 $out.resources[0].properties | Should -Not -BeNullOrEmpty $out.resources[0].properties.port | Should -BeNullOrEmpty - $out.resources[0].properties.passwordAuthentication | Should -Be 'no' + $out.resources[0].properties.passwordAuthentication | Should -Be $false $out.resources[0].properties._inheritedDefaults | Should -BeNullOrEmpty } else { $out.results.count | Should -Be 1 $out.results.result.actualState | Should -Not -BeNullOrEmpty $out.results.result.actualState.port[0] | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'no' + $out.results.result.actualState.passwordAuthentication | Should -Be $false $out.results.result.actualState._inheritedDefaults | Should -Contain 'port' } } @@ -69,7 +69,7 @@ resources: $LASTEXITCODE | Should -Be 0 $out.resources.count | Should -Be 1 ($out.resources[0].properties.psobject.properties | Measure-Object).count | Should -Be 1 - $out.resources[0].properties.passwordAuthentication | Should -Be 'no' + $out.resources[0].properties.passwordAuthentication | Should -Be $false } It ' with _includeDefaults specified works' -TestCases @( @@ -128,7 +128,7 @@ resources: $out.results.count | Should -Be 1 $out.results.result.actualState | Should -Not -BeNullOrEmpty $out.results.result.actualState.port | Should -Be 22 - $out.results.result.actualState.passwordAuthentication | Should -Be 'yes' + $out.results.result.actualState.passwordAuthentication | Should -Be $true $out.results.result.actualState._inheritedDefaults | Should -Not -Contain 'port' } } diff --git a/grammars/tree-sitter-ssh-server-config/grammar.js b/grammars/tree-sitter-ssh-server-config/grammar.js index cad4a1a88..f15ac84e8 100644 --- a/grammars/tree-sitter-ssh-server-config/grammar.js +++ b/grammars/tree-sitter-ssh-server-config/grammar.js @@ -35,9 +35,9 @@ module.exports = grammar({ ), criteria: $ => seq( - field('criteria', $.alpha), + field('keyword', $.alpha), choice(seq(/[ \t]/, optional('=')), '='), - field('argument', $._argument) + field('argument', alias($._argument, $.argument)) ), _argument: $ => choice($.boolean, $.number, $.string, $._commaSeparatedString, $._doublequotedString, $._singlequotedString), diff --git a/grammars/tree-sitter-ssh-server-config/test/corpus/valid_expressions.txt b/grammars/tree-sitter-ssh-server-config/test/corpus/valid_expressions.txt index 3d7352e37..e178f04dd 100644 --- a/grammars/tree-sitter-ssh-server-config/test/corpus/valid_expressions.txt +++ b/grammars/tree-sitter-ssh-server-config/test/corpus/valid_expressions.txt @@ -31,7 +31,8 @@ authorizedkeysfile "path to authorized keys file" (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -53,7 +54,8 @@ authorizedkeysfile "path to authorized keys file" (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -229,7 +231,8 @@ passwordauthentication yes (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -237,7 +240,8 @@ passwordauthentication yes (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -307,7 +311,8 @@ passwordauthentication yes (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -320,7 +325,8 @@ passwordauthentication yes (match (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments @@ -412,11 +418,13 @@ passwordauthentication no (match (criteria (alpha) + (argument (string) - (string)) + (string))) (criteria (alpha) - (string)) + (argument + (string))) (keyword (alphanumeric) (arguments diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 5cf74214b..6572c2b90 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -17,6 +17,7 @@ parser = "Parser" parseInt = "Parse Integer" persist = "Persist" registry = "Registry" +stringUtf8 = "String UTF-8" [get] debugSetting = "Get setting:" @@ -35,17 +36,18 @@ set = "Set command: '%{input}'" [parser] failedToParse = "failed to parse: '%{input}'" failedToParseAsArray = "value is not an array" -failedToParseChildNode = "failed to parse child node: '%{input}'" failedToParseNode = "failed to parse '%{input}'" failedToParseRoot = "failed to parse root: '%{input}'" invalidConfig = "invalid config: '%{input}'" invalidMultiArgNode = "multi-arg node '%{input}' is not valid" -invalidValue = "operator is an invalid value for node" keyNotFound = "key '%{key}' not found" keyNotRepeatable = "key '%{key}' is not repeatable" -keywordDebug = "Parsing keyword: '%{text}'" -missingValueInChildNode = "missing value in child node: '%{input}'" +missingCriteriaInMatch = "missing criteria field in match block: '%{input}'" missingKeyInChildNode = "missing key in child node: '%{input}'" +missingKeyInCriteria = "missing key in criteria node: '%{input}'" +missingValueInCriteria = "missing value in criteria node: '%{input}'" +missingValueInChildNode = "missing value in child node: '%{input}'" +noArgumentsFound = "no arguments found in node: '%{input}'" valueDebug = "Parsed argument value:" unknownNode = "unknown node: '%{kind}'" unknownNodeType = "unknown node type: '%{node}'" diff --git a/resources/sshdconfig/src/error.rs b/resources/sshdconfig/src/error.rs index 1cf384072..30164e74b 100644 --- a/resources/sshdconfig/src/error.rs +++ b/resources/sshdconfig/src/error.rs @@ -9,6 +9,8 @@ use thiserror::Error; pub enum SshdConfigError { #[error("{t}: {0}", t = t!("error.command"))] CommandError(String), + #[error("{t}: {0}", t = t!("error.envVar"))] + EnvVarError(#[from] std::env::VarError), #[error("{t}: {0}", t = t!("error.fmt"))] FmtError(#[from] std::fmt::Error), #[error("{t}: {0}", t = t!("error.invalidInput"))] @@ -28,6 +30,6 @@ pub enum SshdConfigError { #[cfg(windows)] #[error("{t}: {0}", t = t!("error.registry"))] RegistryError(#[from] dsc_lib_registry::error::RegistryError), - #[error("{t}: {0}", t = t!("error.envVar"))] - EnvVarError(#[from] std::env::VarError), + #[error("{t}: {0}", t = t!("error.stringUtf8"))] + StringUtf8Error(#[from] std::str::Utf8Error), } diff --git a/resources/sshdconfig/src/metadata.rs b/resources/sshdconfig/src/metadata.rs index ff39bf964..fc0517f21 100644 --- a/resources/sshdconfig/src/metadata.rs +++ b/resources/sshdconfig/src/metadata.rs @@ -1,14 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// keywords that can have multiple arguments per line but cannot be repeated over multiple lines, -// as subsequent entries are ignored, should be represented as arrays -pub const MULTI_ARG_KEYWORDS: [&str; 17] = [ +// note that it is possible for a keyword to be in one, neither, or both of the multi-arg and repeatable lists below. + +// keywords that can have multiple comma-separated arguments per line and should be represented as arrays. +pub const MULTI_ARG_KEYWORDS: [&str; 22] = [ + "acceptenv", + "allowgroups", + "allowusers", "authenticationmethods", "authorizedkeysfile", "casignaturealgorithms", "channeltimeout", "ciphers", + "denygroups", + "denyusers", "hostbasedacceptedalgorithms", "hostkeyalgorithms", "ipqos", @@ -24,7 +30,6 @@ pub const MULTI_ARG_KEYWORDS: [&str; 17] = [ ]; // keywords that can be repeated over multiple lines and should be represented as arrays. -// note that some keywords can be both multi-arg and repeatable, in which case they only need to be listed here pub const REPEATABLE_KEYWORDS: [&str; 12] = [ "acceptenv", "allowgroups", diff --git a/resources/sshdconfig/src/parser.rs b/resources/sshdconfig/src/parser.rs index 53447ced1..fae4c6070 100644 --- a/resources/sshdconfig/src/parser.rs +++ b/resources/sshdconfig/src/parser.rs @@ -59,57 +59,150 @@ impl SshdConfigParser { return Err(SshdConfigError::ParserError(t!("parser.failedToParse", input = input).to_string())); } match node.kind() { - "keyword" => self.parse_keyword_node(node, input, input_bytes), - "comment" | "empty_line" | "match" => Ok(()), // TODO: do not ignore match nodes when parsing + "comment" => Ok(()), + "keyword" => { + Self::parse_and_insert_keyword(node, input, input_bytes, Some(&mut self.map))?; + Ok(()) + } + "match" => self.parse_match_node(node, input, input_bytes), _ => Err(SshdConfigError::ParserError(t!("parser.unknownNodeType", node = node.kind()).to_string())), } } - fn parse_keyword_node(&mut self, keyword_node: tree_sitter::Node, input: &str, input_bytes: &[u8]) -> Result<(), SshdConfigError> { + fn parse_match_node(&mut self, match_node: tree_sitter::Node, input: &str, input_bytes: &[u8]) -> Result<(), SshdConfigError> { + let mut criteria_map = Map::new(); + let mut cursor = match_node.walk(); + let mut match_object = Map::new(); + + for child_node in match_node.named_children(&mut cursor) { + if child_node.is_error() { + return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); + } + + match child_node.kind() { + "comment" => {} + "criteria" => { + Self::parse_match_criteria(child_node, input, input_bytes, &mut criteria_map)?; + } + "keyword" => { + Self::parse_and_insert_keyword(child_node, input, input_bytes, Some(&mut match_object))?; + } + _ => { + return Err(SshdConfigError::ParserError(t!("parser.unknownNodeType", node = child_node.kind()).to_string())); + } + } + } + + // Add the match object to the main map + if criteria_map.is_empty() { + return Err(SshdConfigError::ParserError(t!("parser.missingCriteriaInMatch", input = input).to_string())); + } + match_object.insert("criteria".to_string(), Value::Object(criteria_map)); + Self::insert_into_map(&mut self.map, "match", Value::Object(match_object), true)?; + Ok(()) + } + + /// Parse a single match criteria node and insert into the provided criteria map. + /// Example criteria node: "user alice,bob" or "address *.*.0.1" + /// Inserts the criterion as a key with an array value into the `criteria_map`. + fn parse_match_criteria(criteria_node: tree_sitter::Node, input: &str, input_bytes: &[u8], criteria_map: &mut Map) -> Result<(), SshdConfigError> { + if let Some(key_node) = criteria_node.child_by_field_name("keyword") { + let Ok(key_text) = key_node.utf8_text(input_bytes) else { + return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); + }; + let key = key_text.to_string(); + + let values: Value; + if let Some(value_node) = criteria_node.child_by_field_name("argument") { + values = parse_arguments_node(value_node, input, input_bytes, true)?; + } + else { + return Err(SshdConfigError::ParserError(t!("parser.missingValueInCriteria", input = input).to_string())); + } + + criteria_map.insert(key.to_lowercase(), values); + Ok(()) + } else { + Err(SshdConfigError::ParserError(t!("parser.missingKeyInCriteria", input = input).to_string())) + } + } + + /// Parse a keyword node and optionally insert it into a map. + /// If `target_map` is provided, the keyword will be inserted into that map with repeatability handling. + /// If `target_map` is None, returns the key-value pair without inserting. + fn parse_and_insert_keyword( + keyword_node: tree_sitter::Node, + input: &str, + input_bytes: &[u8], + target_map: Option<&mut Map> + ) -> Result<(String, Value), SshdConfigError> { let mut cursor = keyword_node.walk(); let mut key = None; let mut value = Value::Null; let mut is_repeatable = false; let mut is_vec = false; + let mut operator: Option = None; if let Some(keyword) = keyword_node.child_by_field_name("keyword") { let Ok(text) = keyword.utf8_text(input_bytes) else { - return Err(SshdConfigError::ParserError( - t!("parser.failedToParseChildNode", input = input).to_string() - )); + return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); }; - debug!("{}", t!("parser.keywordDebug", text = text).to_string()); - if REPEATABLE_KEYWORDS.contains(&text) { - is_repeatable = true; - is_vec = true; - } else if MULTI_ARG_KEYWORDS.contains(&text) { - is_vec = true; - } + + is_repeatable = REPEATABLE_KEYWORDS.contains(&text); + is_vec = is_repeatable || MULTI_ARG_KEYWORDS.contains(&text); key = Some(text.to_string()); } + // Check for operator field + if let Some(operator_node) = keyword_node.child_by_field_name("operator") { + let Ok(op_text) = operator_node.utf8_text(input_bytes) else { + return Err( + SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string()) + ); + }; + operator = Some(op_text.to_string()); + } + for node in keyword_node.named_children(&mut cursor) { if node.is_error() { - return Err(SshdConfigError::ParserError(t!("parser.failedToParseChildNode", input = input).to_string())); + return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); } if node.kind() == "arguments" { value = parse_arguments_node(node, input, input_bytes, is_vec)?; debug!("{}: {:?}", t!("parser.valueDebug").to_string(), value); } } + + // If operator is present, wrap value in a nested map + if let Some(op) = operator { + let mut operator_map = Map::new(); + operator_map.insert("value".to_string(), value); + operator_map.insert("operator".to_string(), Value::String(op)); + value = Value::Object(operator_map); + } + if let Some(key) = key { if value.is_null() { return Err(SshdConfigError::ParserError(t!("parser.missingValueInChildNode", input = input).to_string())); } - return self.update_map(&key, value, is_repeatable); + + // If target_map is provided, insert the value with repeatability handling + if let Some(map) = target_map { + Self::insert_into_map(map, &key, value.clone(), is_repeatable)?; + } + + return Ok((key, value)); } Err(SshdConfigError::ParserError(t!("parser.missingKeyInChildNode", input = input).to_string())) } - fn update_map(&mut self, key: &str, value: Value, is_repeatable: bool) -> Result<(), SshdConfigError> { - if self.map.contains_key(key) { + /// Insert a key-value pair into a map with repeatability handling. + /// If the key is repeatable and already exists, append to the array. + /// If the key is not repeatable and already exists, return an error. + fn insert_into_map(map: &mut Map, key: &str, value: Value, is_repeatable: bool) -> Result<(), SshdConfigError> { + if map.contains_key(key) { if is_repeatable { - let existing_value = self.map.get_mut(key); + let existing_value = map.get_mut(key); if let Some(existing_value) = existing_value { if let Value::Array(ref mut arr) = existing_value { if let Value::Array(vector) = value { @@ -117,9 +210,7 @@ impl SshdConfigParser { arr.push(v); } } else { - return Err(SshdConfigError::ParserError( - t!("parser.failedToParseAsArray").to_string() - )); + arr.push(value); } } else { return Err(SshdConfigError::ParserError( @@ -132,8 +223,15 @@ impl SshdConfigParser { } else { return Err(SshdConfigError::ParserError(t!("parser.keyNotRepeatable", key = key).to_string())); } + } else if is_repeatable { + // Initialize repeatable keywords as arrays + if value.is_array() { + map.insert(key.to_string(), value); + } else { + map.insert(key.to_string(), Value::Array(vec![value])); + } } else { - self.map.insert(key.to_string(), value); + map.insert(key.to_string(), value); } Ok(()) } @@ -142,53 +240,38 @@ impl SshdConfigParser { fn parse_arguments_node(arg_node: tree_sitter::Node, input: &str, input_bytes: &[u8], is_vec: bool) -> Result { let mut cursor = arg_node.walk(); let mut vec: Vec = Vec::new(); - let mut value = Value::Null; - // if there is more than one argument, but a vector is not expected for the keyword, throw an error let children: Vec<_> = arg_node.named_children(&mut cursor).collect(); if children.len() > 1 && !is_vec { return Err(SshdConfigError::ParserError(t!("parser.invalidMultiArgNode", input = input).to_string())); } - - for node in children { + for node in &children { if node.is_error() { - return Err(SshdConfigError::ParserError(t!("parser.failedToParseChildNode", input = input).to_string())); + return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string())); } - let argument: Value = match node.kind() { - "boolean" | "string" => { - let Ok(arg) = node.utf8_text(input_bytes) else { - return Err(SshdConfigError::ParserError( - t!("parser.failedToParseNode", input = input).to_string() - )); - }; - Value::String(arg.trim().to_string()) + let arg = node.utf8_text(input_bytes)?; + match node.kind() { + "boolean" => { + let arg_str = arg.trim(); + vec.push(Value::Bool(arg_str.eq_ignore_ascii_case("yes"))); } + "string" => { + let arg_str = arg.trim(); + vec.push(Value::String(arg_str.to_string())); + }, "number" => { - let Ok(arg) = node.utf8_text(input_bytes) else { - return Err(SshdConfigError::ParserError( - t!("parser.failedToParseNode", input = input).to_string() - )); - }; - Value::Number(arg.parse::()?.into()) - } - "operator" => { - // TODO: handle operator if not parsing from SSHD -T - return Err(SshdConfigError::ParserError( - t!("parser.invalidValue").to_string() - )); - } + vec.push(Value::Number(arg.parse::()?.into())); + }, _ => return Err(SshdConfigError::ParserError(t!("parser.unknownNode", kind = node.kind()).to_string())) - }; - if is_vec { - vec.push(argument); - } else { - value = argument; } } + // Always return array if is_vec is true (for MULTI_ARG_KEYWORDS, and REPEATABLE_KEYWORDS) if is_vec { Ok(Value::Array(vec)) - } else{ - Ok(value) + } else if !vec.is_empty() { + Ok(vec[0].clone()) + } else { /* shouldn't happen */ + Err(SshdConfigError::ParserError(t!("parser.noArgumentsFound", input = input).to_string())) } } @@ -251,7 +334,17 @@ mod tests { fn bool_keyword() { let input = "printmotd yes\r\n"; let result: Map = parse_text_to_map(input).unwrap(); - assert_eq!(result.get("printmotd").unwrap(), &Value::String("yes".to_string())); + assert_eq!(result.get("printmotd").unwrap(), &Value::Bool(true)); + } + + #[test] + fn multiarg_string_with_spaces_no_quotes_keyword() { + let input = "allowgroups administrators developers\n"; + let result: Map = parse_text_to_map(input).unwrap(); + let allowgroups = result.get("allowgroups").unwrap().as_array().unwrap(); + assert_eq!(allowgroups.len(), 2); + assert_eq!(allowgroups[0], Value::String("administrators".to_string())); + assert_eq!(allowgroups[1], Value::String("developers".to_string())); } #[test] @@ -268,4 +361,219 @@ mod tests { let result = parse_text_to_map(code); assert!(result.is_ok()); } + + #[test] + fn keyword_with_operator_variations() { + let input = r#" +ciphers +aes256-ctr +macs -hmac-md5 +kexalgorithms ^ecdh-sha2-nistp256 +"#; + let result: Map = parse_text_to_map(input).unwrap(); + + let ciphers = result.get("ciphers").unwrap().as_object().unwrap(); + assert_eq!(ciphers.get("operator").unwrap(), &Value::String("+".to_string())); + assert!(ciphers.get("value").unwrap().is_array()); + + let macs = result.get("macs").unwrap().as_object().unwrap(); + assert_eq!(macs.get("operator").unwrap(), &Value::String("-".to_string())); + assert!(macs.get("value").unwrap().is_array()); + + let kex = result.get("kexalgorithms").unwrap().as_object().unwrap(); + assert_eq!(kex.get("operator").unwrap(), &Value::String("^".to_string())); + assert!(kex.get("value").unwrap().is_array()); + } + + #[test] + fn keyword_with_operator_multiple_values() { + let input = r#" +ciphers +aes256-ctr,aes128-ctr +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let ciphers = result.get("ciphers").unwrap().as_object().unwrap(); + let value_array = ciphers.get("value").unwrap().as_array().unwrap(); + assert_eq!(value_array.len(), 2); + assert_eq!(value_array[0], Value::String("aes256-ctr".to_string())); + assert_eq!(value_array[1], Value::String("aes128-ctr".to_string())); + assert_eq!(ciphers.get("operator").unwrap(), &Value::String("+".to_string())); + } + + #[test] + fn single_match_block() { + let input = r#" +port 22 +match user bob + gssapiauthentication yes + allowtcpforwarding yes +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + assert_eq!(match_array.len(), 1); + let match_obj = match_array[0].as_object().unwrap(); + println!("match_obj: {:?}", match_obj); + let criteria = match_obj.get("criteria").unwrap().as_object().unwrap(); + let user_array = criteria.get("user").unwrap().as_array().unwrap(); + assert_eq!(user_array[0], Value::String("bob".to_string())); + assert_eq!(match_obj.get("gssapiauthentication").unwrap(), &Value::Bool(true)); + assert_eq!(match_obj.get("allowtcpforwarding").unwrap(), &Value::Bool(true)); + } + + #[test] + fn multiple_match_blocks() { + let input = r#" +match user alice + passwordauthentication yes +match group administrators + permitrootlogin yes +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + assert_eq!(match_array.len(), 2); + let match_obj1 = match_array[0].as_object().unwrap(); + let criteria1 = match_obj1.get("criteria").unwrap().as_object().unwrap(); + let user_array1 = criteria1.get("user").unwrap().as_array().unwrap(); + assert_eq!(user_array1[0], Value::String("alice".to_string())); + assert_eq!(match_obj1.get("passwordauthentication").unwrap(), &Value::Bool(true)); + let match_obj2 = match_array[1].as_object().unwrap(); + let criteria2 = match_obj2.get("criteria").unwrap().as_object().unwrap(); + let group_array2 = criteria2.get("group").unwrap().as_array().unwrap(); + assert_eq!(group_array2[0], Value::String("administrators".to_string())); + assert_eq!(match_obj2.get("permitrootlogin").unwrap(), &Value::Bool(true)); + } + + #[test] + fn match_with_comma_separated_criteria() { + let input = r#" +match user alice,bob + passwordauthentication yes +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + let criteria = match_obj.get("criteria").unwrap().as_object().unwrap(); + let user_array = criteria.get("user").unwrap().as_array().unwrap(); + assert_eq!(user_array.len(), 2); + assert_eq!(user_array[0], Value::String("alice".to_string())); + assert_eq!(user_array[1], Value::String("bob".to_string())); + } + + #[test] + fn match_with_multiarg_keyword() { + let input = r#" +match user testuser + passwordauthentication yes + allowgroups administrators developers +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + for (k, v) in match_obj.iter() { + eprintln!(" {}: {:?}", k, v); + } + + // allowgroups is both MULTI_ARG and REPEATABLE + // Space-separated values should be parsed as array + let allowgroups = match_obj.get("allowgroups").unwrap().as_array().unwrap(); + assert_eq!(allowgroups.len(), 2); + assert_eq!(allowgroups[0], Value::String("administrators".to_string())); + assert_eq!(allowgroups[1], Value::String("developers".to_string())); + } + + #[test] + fn match_with_repeated_multiarg_keyword() { + let input = r#" +match user testuser + allowgroups administrators developers + allowgroups guests users +"#; + let result: Map = parse_text_to_map(input).unwrap(); + + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + + // allowgroups is both MULTI_ARG and REPEATABLE + // Multiple occurrences should append all values to a flat array + let allowgroups = match_obj.get("allowgroups").unwrap().as_array().unwrap(); + assert_eq!(allowgroups.len(), 4); + assert_eq!(allowgroups[0], Value::String("administrators".to_string())); + assert_eq!(allowgroups[1], Value::String("developers".to_string())); + assert_eq!(allowgroups[2], Value::String("guests".to_string())); + assert_eq!(allowgroups[3], Value::String("users".to_string())); + } + + #[test] + fn match_with_repeated_single_value_keyword() { + let input = r#" +match user testuser + port 2222 + port 3333 +"#; + let result: Map = parse_text_to_map(input).unwrap(); + + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + + // port is REPEATABLE - values should be in a flat array + let ports = match_obj.get("port").unwrap().as_array().unwrap(); + assert_eq!(ports.len(), 2); + assert_eq!(ports[0], Value::Number(2222.into())); + assert_eq!(ports[1], Value::Number(3333.into())); + } + + #[test] + fn match_with_comments() { + let input = r#" +match user developer + # Enable password authentication for developers - comment ignored + passwordauthentication yes +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + assert_eq!(match_obj.get("passwordauthentication").unwrap(), &Value::Bool(true)); + assert_eq!(match_obj.len(), 2); + } + + #[test] + fn match_with_multiple_criteria_types() { + let input = r#" +match user alice,bob address 1.2.3.4/56 + passwordauthentication yes + allowtcpforwarding no +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + assert_eq!(match_array.len(), 1); + let match_obj = match_array[0].as_object().unwrap(); + + let criteria = match_obj.get("criteria").unwrap().as_object().unwrap(); + + let user_array = criteria.get("user").unwrap().as_array().unwrap(); + assert_eq!(user_array.len(), 2); + assert_eq!(user_array[0], Value::String("alice".to_string())); + assert_eq!(user_array[1], Value::String("bob".to_string())); + + let address_array = criteria.get("address").unwrap().as_array().unwrap(); + assert_eq!(address_array.len(), 1); + assert_eq!(address_array[0], Value::String("1.2.3.4/56".to_string())); + + assert_eq!(match_obj.get("passwordauthentication").unwrap(), &Value::Bool(true)); + assert_eq!(match_obj.get("allowtcpforwarding").unwrap(), &Value::Bool(false)); + } + + #[test] + fn match_with_operator_argument() { + let input = r#" +match group administrators + pubkeyacceptedalgorithms +ssh-rsa +"#; + let result: Map = parse_text_to_map(input).unwrap(); + let match_array = result.get("match").unwrap().as_array().unwrap(); + let match_obj = match_array[0].as_object().unwrap(); + let pubkey = match_obj.get("pubkeyacceptedalgorithms").unwrap().as_object().unwrap(); + let value_array = pubkey.get("value").unwrap().as_array().unwrap(); + assert_eq!(pubkey.get("operator").unwrap(), &Value::String("+".to_string())); + assert_eq!(value_array.len(), 1); + assert_eq!(value_array[0], Value::String("ssh-rsa".to_string())); + } }