Skip to content

Commit 0161afd

Browse files
committed
Update with remarks Tess
1 parent 5d110c6 commit 0161afd

4 files changed

Lines changed: 175 additions & 18 deletions

File tree

resources/sshdconfig/locales/en-us.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ schema = "Schema command:"
5151
set = "Set command: '%{input}'"
5252

5353
[parser]
54+
combinedMultiArgValue = "combined multiple arguments into a single value for keyword '%{keyword}'"
5455
failedToParse = "failed to parse: '%{input}'"
5556
failedToParseAsArray = "value is not an array"
5657
failedToParseNode = "failed to parse '%{input}'"
5758
failedToParseRoot = "failed to parse root: '%{input}'"
5859
invalidConfig = "invalid config: '%{input}'"
59-
invalidMultiArgNode = "multi-arg node '%{input}' is not valid"
6060
keyNotFound = "key '%{key}' not found"
6161
keyNotRepeatable = "key '%{key}' is not repeatable"
6262
missingCriteriaInMatch = "missing criteria field in match block: '%{input}'"

resources/sshdconfig/src/get.rs

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ use crate::canonical_properties::CanonicalProperty;
1717
use crate::error::SshdConfigError;
1818
use crate::inputs::{CommandInfo, SSHD_CONFIG_FILEPATH};
1919
use crate::parser::parse_text_to_map;
20-
use crate::repeat_keyword::MULTI_ARG_KEYWORDS_SPACE_SEP;
2120
use crate::util::{
2221
build_command_info,
2322
extract_sshd_defaults,
@@ -134,11 +133,11 @@ pub fn get_sshd_settings(cmd_info: &CommandInfo, is_get: bool) -> Result<Map<Str
134133
result.insert("match".to_string(), match_value.clone());
135134
}
136135

137-
// sshd -T normalizes space-separated list keywords by stripping the quotes that preserve
138-
// values containing spaces (e.g. Windows group names like "openssh users"), splitting them
139-
// into separate entries. Prefer the value parsed directly from the config file, which retains
140-
// the quoting, for these keywords when they are explicitly set.
141-
prefer_explicit_space_sep_lists(&mut result, &explicit_settings);
136+
// sshd -T strips the quotes that preserve values containing spaces (e.g. Windows group names
137+
// like "openssh users" or paths like "C:\Program Files\ssh\banner.txt") and normalizes their
138+
// casing. Prefer the value parsed directly from the config file, which retains the quoting and
139+
// the original casing, for any keyword whose explicit value contains whitespace.
140+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
142141

143142
if cmd_info.include_defaults {
144143
// get default from SSHD -T with empty config
@@ -179,14 +178,24 @@ pub fn get_sshd_settings(cmd_info: &CommandInfo, is_get: bool) -> Result<Map<Str
179178
Ok(result)
180179
}
181180

182-
fn prefer_explicit_space_sep_lists(result: &mut Map<String, Value>, explicit_settings: &Map<String, Value>) {
183-
for keyword in MULTI_ARG_KEYWORDS_SPACE_SEP {
184-
if let Some(value) = explicit_settings.get(keyword) {
185-
result.insert((*keyword).to_string(), value.clone());
181+
fn prefer_explicit_values_with_spaces(result: &mut Map<String, Value>, explicit_settings: &Map<String, Value>) {
182+
for (keyword, value) in explicit_settings {
183+
if contains_whitespace(value) {
184+
result.insert(keyword.clone(), value.clone());
186185
}
187186
}
188187
}
189188

189+
/// Whether the value, or any value nested within it, is a string containing whitespace.
190+
fn contains_whitespace(value: &Value) -> bool {
191+
match value {
192+
Value::String(s) => s.contains(char::is_whitespace),
193+
Value::Array(items) => items.iter().any(contains_whitespace),
194+
Value::Object(map) => map.values().any(contains_whitespace),
195+
_ => false
196+
}
197+
}
198+
190199
#[cfg(test)]
191200
mod tests {
192201
use super::*;
@@ -202,14 +211,46 @@ mod tests {
202211
let mut explicit_settings = Map::new();
203212
explicit_settings.insert("allowgroups".to_string(), json!(["administrators", "openssh users"]));
204213

205-
prefer_explicit_space_sep_lists(&mut result, &explicit_settings);
214+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
206215

207216
assert_eq!(
208217
result.get("allowgroups").unwrap(),
209218
&json!(["administrators", "openssh users"])
210219
);
211220
}
212221

222+
#[test]
223+
fn overrides_single_value_keyword_with_quoted_file_value() {
224+
let mut result = Map::new();
225+
result.insert("banner".to_string(), json!("c:\\program files\\ssh\\sample_banner.txt"));
226+
227+
let mut explicit_settings = Map::new();
228+
explicit_settings.insert("banner".to_string(), json!("C:\\Program Files\\ssh\\sample_banner.txt"));
229+
230+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
231+
232+
assert_eq!(
233+
result.get("banner").unwrap(),
234+
&json!("C:\\Program Files\\ssh\\sample_banner.txt")
235+
);
236+
}
237+
238+
#[test]
239+
fn overrides_nested_value_with_spaces() {
240+
let mut result = Map::new();
241+
result.insert("subsystem".to_string(), json!([{"name": "sftp", "value": "c:/program files/openssh/sftp-server.exe"}]));
242+
243+
let mut explicit_settings = Map::new();
244+
explicit_settings.insert("subsystem".to_string(), json!([{"name": "sftp", "value": "C:/Program Files/OpenSSH/sftp-server.exe"}]));
245+
246+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
247+
248+
assert_eq!(
249+
result.get("subsystem").unwrap(),
250+
&json!([{"name": "sftp", "value": "C:/Program Files/OpenSSH/sftp-server.exe"}])
251+
);
252+
}
253+
213254
#[test]
214255
fn leaves_keyword_absent_from_file_untouched() {
215256
// allowgroups is present in sshd -T output but not explicitly set in the config file.
@@ -218,7 +259,7 @@ mod tests {
218259

219260
let explicit_settings = Map::new();
220261

221-
prefer_explicit_space_sep_lists(&mut result, &explicit_settings);
262+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
222263

223264
assert_eq!(
224265
result.get("allowgroups").unwrap(),
@@ -227,16 +268,18 @@ mod tests {
227268
}
228269

229270
#[test]
230-
fn leaves_non_space_sep_keyword_untouched() {
231-
// port is not a space-separated list keyword and must not be overridden.
271+
fn leaves_value_without_spaces_untouched() {
232272
let mut result = Map::new();
233273
result.insert("port".to_string(), json!([22]));
274+
result.insert("allowgroups".to_string(), json!(["administrators"]));
234275

235276
let mut explicit_settings = Map::new();
236277
explicit_settings.insert("port".to_string(), json!([2222]));
278+
explicit_settings.insert("allowgroups".to_string(), json!(["openssh"]));
237279

238-
prefer_explicit_space_sep_lists(&mut result, &explicit_settings);
280+
prefer_explicit_values_with_spaces(&mut result, &explicit_settings);
239281

240282
assert_eq!(result.get("port").unwrap(), &json!([22]));
283+
assert_eq!(result.get("allowgroups").unwrap(), &json!(["administrators"]));
241284
}
242285
}

resources/sshdconfig/src/parser.rs

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,10 +266,11 @@ fn parse_arguments_node(arg_node: tree_sitter::Node, input: &str, input_bytes: &
266266
let mut vec: Vec<Value> = Vec::new();
267267
let is_vec = keyword_info.is_multi_arg();
268268

269-
// if there is more than one argument, but a vector is not expected for the keyword, throw an error
270269
let children: Vec<_> = arg_node.named_children(&mut cursor).collect();
270+
271271
if children.len() > 1 && !is_vec {
272-
return Err(SshdConfigError::ParserError(t!("parser.invalidMultiArgNode", input = input).to_string()));
272+
debug!("{}", t!("parser.combinedMultiArgValue", keyword = &keyword_info.name).to_string());
273+
return Ok(Value::String(combine_args(&children, input, input_bytes)?));
273274
}
274275

275276
for node in &children {
@@ -333,6 +334,33 @@ fn parse_arguments_node(arg_node: tree_sitter::Node, input: &str, input_bytes: &
333334
}
334335
}
335336

337+
/// Combine multiple argument nodes into a single string value.
338+
///
339+
/// The grammar cannot include spaces in a `string` token, so an unquoted value containing spaces is
340+
/// split into several argument nodes. The separator that appeared between two arguments in the
341+
/// source text is preserved, so a comma-separated value is not silently rewritten as a
342+
/// space-separated one. Quote characters sit between the argument nodes and are dropped, matching
343+
/// how a quoted value that parsed as a single argument is handled.
344+
fn combine_args(children: &[tree_sitter::Node], input: &str, input_bytes: &[u8]) -> Result<String, SshdConfigError> {
345+
let mut combined = String::new();
346+
let mut previous_end: Option<usize> = None;
347+
348+
for node in children {
349+
if node.is_error() {
350+
return Err(SshdConfigError::ParserError(t!("parser.failedToParseNode", input = input).to_string()));
351+
}
352+
if let Some(end) = previous_end {
353+
let between = input.get(end..node.start_byte()).unwrap_or_default();
354+
combined.push_str(if between.contains(',') { "," } else { " " });
355+
}
356+
combined.push_str(node.utf8_text(input_bytes)?.trim());
357+
previous_end = Some(node.end_byte());
358+
}
359+
360+
// Unescape backslashes (sshd -T escapes them on Windows)
361+
Ok(unescape_backslashes(&combined))
362+
}
363+
336364
/// Parse arguments node for match criteria, always returning an array.
337365
/// Match criteria are always comma-separated and should be arrays.
338366
fn parse_arguments_node_as_array(arg_node: tree_sitter::Node, input: &str, input_bytes: &[u8], _keyword_info: &KeywordInfo) -> Result<Value, SshdConfigError> {
@@ -487,6 +515,57 @@ mod tests {
487515
assert_eq!(allowgroups[1], Value::String("developers".to_string()));
488516
}
489517

518+
#[test]
519+
fn single_value_keyword_with_spaces() {
520+
// sshd -T prints Banner "C:\Program Files\ssh\sample_banner.txt" without the quotes.
521+
let input = "banner c:\\program files\\ssh\\sample_banner.txt\r\n";
522+
let result: Map<String, Value> = parse_text_to_map(input).unwrap();
523+
assert_eq!(
524+
result.get("banner").unwrap(),
525+
&Value::String("c:\\program files\\ssh\\sample_banner.txt".to_string())
526+
);
527+
}
528+
529+
#[test]
530+
fn single_value_keyword_with_quotes_matches_unquoted() {
531+
let quoted = parse_text_to_map("banner \"c:\\program files\\ssh\\sample_banner.txt\"\r\n").unwrap();
532+
let unquoted = parse_text_to_map("banner c:\\program files\\ssh\\sample_banner.txt\r\n").unwrap();
533+
assert_eq!(quoted.get("banner").unwrap(), unquoted.get("banner").unwrap());
534+
}
535+
536+
#[test]
537+
fn single_value_keyword_preserves_comma_separator() {
538+
// logverbose is not a known multi-arg keyword, but its value is comma-separated.
539+
let input = "logverbose kex.c:*:1,monitor.c:*\r\n";
540+
let result: Map<String, Value> = parse_text_to_map(input).unwrap();
541+
assert_eq!(
542+
result.get("logverbose").unwrap(),
543+
&Value::String("kex.c:*:1,monitor.c:*".to_string())
544+
);
545+
}
546+
547+
#[test]
548+
fn single_value_keyword_preserves_mixed_separators() {
549+
let input = "forcecommand /usr/bin/cmd -o a,b -x\r\n";
550+
let result: Map<String, Value> = parse_text_to_map(input).unwrap();
551+
assert_eq!(
552+
result.get("forcecommand").unwrap(),
553+
&Value::String("/usr/bin/cmd -o a,b -x".to_string())
554+
);
555+
}
556+
557+
#[test]
558+
fn single_value_keyword_with_spaces_roundtrip() {
559+
use crate::formatter::write_config_map_to_text;
560+
561+
let input = "banner \"/etc/ssh/sample banner.txt\"\n";
562+
let parsed = parse_text_to_map(input).unwrap();
563+
let formatted = write_config_map_to_text(&parsed).unwrap();
564+
let reparsed = parse_text_to_map(&formatted).unwrap();
565+
566+
assert_eq!(parsed.get("banner").unwrap(), reparsed.get("banner").unwrap());
567+
}
568+
490569
#[test]
491570
fn err_multiarg_repeated_keyword() {
492571
let input = "hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com\r\n hostkeyalgorithms ecdsa-sha2-nistp256-cert-v01@openssh.com\r\n";

resources/sshdconfig/tests/sshdconfig.get.tests.ps1

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ AllowGroups administrators "openssh users"
4747
"@
4848
$TestConfigPathWithQuotedGroups = Join-Path $TestDrive 'test_sshd_config_quoted_groups'
4949
$configWithQuotedGroups | Set-Content -Path $TestConfigPathWithQuotedGroups
50+
$BannerPath = Join-Path $TestDrive 'Sample Banner.txt'
51+
'welcome' | Set-Content -Path $BannerPath
52+
$configWithQuotedPath = @"
53+
PasswordAuthentication no
54+
Banner "$BannerPath"
55+
"@
56+
$TestConfigPathWithQuotedPath = Join-Path $TestDrive 'test_sshd_config_quoted_path'
57+
$configWithQuotedPath | Set-Content -Path $TestConfigPathWithQuotedPath
5058
}
5159

5260
AfterAll {
@@ -62,6 +70,12 @@ AllowGroups administrators "openssh users"
6270
if (Test-Path $TestConfigPathWithQuotedGroups) {
6371
Remove-Item -Path $TestConfigPathWithQuotedGroups -Force -ErrorAction SilentlyContinue
6472
}
73+
if (Test-Path $TestConfigPathWithQuotedPath) {
74+
Remove-Item -Path $TestConfigPathWithQuotedPath -Force -ErrorAction SilentlyContinue
75+
}
76+
if (Test-Path $BannerPath) {
77+
Remove-Item -Path $BannerPath -Force -ErrorAction SilentlyContinue
78+
}
6579
}
6680

6781
It '<Command> command <Description>' -TestCases @(
@@ -174,6 +188,27 @@ AllowGroups administrators "openssh users"
174188
$result.AllowGroups[1] | Should -Be "openssh users"
175189
}
176190

191+
It '<Command> command preserves a single-value keyword whose path contains spaces' -TestCases @(
192+
@{ Command = 'get' }
193+
@{ Command = 'export' }
194+
) {
195+
param($Command)
196+
197+
$inputData = @{
198+
sshd_config_filepath = $TestConfigPathWithQuotedPath
199+
} | ConvertTo-Json
200+
201+
if ($Command -eq 'get') {
202+
$result = sshdconfig $Command --input $inputData -s sshd-config 2>$null | ConvertFrom-Json
203+
}
204+
else {
205+
$result = sshdconfig $Command --input $inputData 2>$null | ConvertFrom-Json
206+
}
207+
208+
$LASTEXITCODE | Should -Be 0
209+
$result.Banner | Should -Be $BannerPath
210+
}
211+
177212
It 'Should fail without creating target config when file does not exist' {
178213
$nonExistentPath = Join-Path $TestDrive 'nonexistent_sshd_config'
179214

0 commit comments

Comments
 (0)