From 9523b4c0f195586a36d9e7b1b73f1987e62cf5c2 Mon Sep 17 00:00:00 2001 From: Tess Gauthier Date: Tue, 16 Dec 2025 13:46:09 -0500 Subject: [PATCH 01/15] add _clobber=false tests --- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index d9eab06e4..395c80044 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -221,4 +221,109 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { $currentResult.Port | Should -Be $originalResult.Port } } + + Context 'Set with _clobber=false' { + BeforeEach { + $initialContent = @" + Port 2222 + AddressFamily inet + MaxAuthTries 5 + PermitRootLogin yes + PasswordAuthentication no +"@ + Set-Content -Path $TestConfigPath -Value $initialContent + } + + It '' -TestCases @( + @{ + Title = 'Should preserve unchanged regular keyword when value is the same' + InputConfig = @{ Port = "2222" } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication no") + ExpectedNotContains = @() + VerifyOrder = @() + }, + @{ + Title = 'Should overwrite regular keyword when value is different' + InputConfig = @{ MaxAuthTries = "3" } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 3", "PermitRootLogin yes", "PasswordAuthentication no") + ExpectedNotContains = @("MaxAuthTries 5") + VerifyOrder = @() + }, + @{ + Title = 'Should add regular keyword when it does not exist' + InputConfig = @{ LoginGraceTime = "60" } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication no", "LoginGraceTime 60") + ExpectedNotContains = @() + VerifyOrder = @() + }, + @{ + Title = 'Should preserve unchanged boolean keyword when value is the same' + InputConfig = @{ PasswordAuthentication = $false } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication no") + ExpectedNotContains = @() + VerifyOrder = @() + }, + @{ + Title = 'Should overwrite boolean keyword when value is different' + InputConfig = @{ PasswordAuthentication = $true } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication yes") + ExpectedNotContains = @("PasswordAuthentication no") + VerifyOrder = @() + }, + @{ + Title = 'Should add boolean keyword when it does not exist' + InputConfig = @{ PubkeyAuthentication = $true } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication no", "PubkeyAuthentication yes") + ExpectedNotContains = @() + VerifyOrder = @() + }, + @{ + Title = 'Should handle multiple keyword changes and preserve order' + InputConfig = @{ + PasswordAuthentication = $false + PermitRootLogin = $false + LoginGraceTime = "60" + } + ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin no", "PasswordAuthentication no", "LoginGraceTime 60") + ExpectedNotContains = @("PermitRootLogin yes") + VerifyOrder = @( + @{ Pattern = "^Port"; Before = "^PasswordAuthentication" }, + @{ Pattern = "^PasswordAuthentication"; Before = "^PermitRootLogin" }, + @{ Pattern = "^PermitRootLogin"; Before = "^AddressFamily" }, + @{ Pattern = "^AddressFamily"; Before = "^MaxAuthTries" } + ) + } + ) { + param($Title, $InputConfig, $ExpectedContains, $ExpectedNotContains, $VerifyOrder) + + $config = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $false + } + foreach ($key in $InputConfig.Keys) { + $config[$key] = $InputConfig[$key] + } + $inputJson = $config | ConvertTo-Json + + $output = sshdconfig set --input $inputJson -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + $sshdConfigContents = Get-Content $TestConfigPath + + foreach ($expected in $ExpectedContains) { + $sshdConfigContents | Should -Contain $expected + } + + foreach ($notExpected in $ExpectedNotContains) { + $sshdConfigContents | Should -Not -Contain $notExpected + } + + foreach ($orderCheck in $VerifyOrder) { + $beforeLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Pattern).LineNumber + $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Before).LineNumber + $beforeLine | Should -BeLessThan $afterLine + } + } + } } From 353b1d3412bc0e14b2a7e41a6633941cc0bf859f Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 5 Jan 2026 12:24:45 -0500 Subject: [PATCH 02/15] update tests --- resources/sshdconfig/tests/sshdconfig.set.tests.ps1 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index 395c80044..691632f77 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -237,7 +237,7 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { It '<Title>' -TestCases @( @{ Title = 'Should preserve unchanged regular keyword when value is the same' - InputConfig = @{ Port = "2222" } + InputConfig = @{ MaxAuthTries = "5" } ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin yes", "PasswordAuthentication no") ExpectedNotContains = @() VerifyOrder = @() @@ -256,6 +256,13 @@ Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { ExpectedNotContains = @() VerifyOrder = @() }, + @{ + Title = 'Should remove regular keyword when value is NULL' + InputConfig = @{ MaxAuthTries = $null } + ExpectedContains = @("Port 2222", "AddressFamily inet", "PermitRootLogin yes", "PasswordAuthentication no") + ExpectedNotContains = @("MaxAuthTries 5") + VerifyOrder = @() + }, @{ Title = 'Should preserve unchanged boolean keyword when value is the same' InputConfig = @{ PasswordAuthentication = $false } From 53ca91e1744693a4771aa4999a7c7e916924caf7 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 5 Jan 2026 15:43:25 -0500 Subject: [PATCH 03/15] add support for regular keywords and _clobber=false --- resources/sshdconfig/locales/en-us.toml | 2 +- resources/sshdconfig/src/set.rs | 53 +++++++------ resources/sshdconfig/src/util.rs | 78 ++++++++++++++----- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 41 +++++----- 4 files changed, 110 insertions(+), 64 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index f5d1603f6..757f2f5a1 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -58,7 +58,7 @@ unknownNodeType = "unknown node type: '%{node}'" backingUpConfig = "Backing up existing sshd_config file" backupCreated = "Backup created at: %{path}" cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" -clobberFalseUnsupported = "clobber=false is not yet supported for sshd_config resource" +clobberFalseUnsupported = "clobber=false is not supported for keywords that can have multiple values" configDoesNotExist = "sshd_config file does not exist, no backup created" defaultShellDebug = "default_shell: %{shell}" failedToParseDefaultShell = "failed to parse input for DefaultShell with error: '%{error}'" diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 333e2a3fa..daa8299bb 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -10,14 +10,15 @@ use { use rust_i18n::t; use serde_json::{Map, Value}; -use std::{fmt::Write, string::String}; +use std::string::String; use tracing::{debug, info, warn}; use crate::args::{DefaultShell, Setting}; use crate::error::SshdConfigError; +use crate::get::get_sshd_settings; use crate::inputs::{CommandInfo, SshdCommandArgs}; -use crate::metadata::{REPEATABLE_KEYWORDS, SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; -use crate::util::{build_command_info, format_sshd_value, get_default_sshd_config_path, invoke_sshd_config_validation}; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, REPEATABLE_KEYWORDS, SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; +use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation, write_config_map_to_text}; /// Invoke the set command. /// @@ -28,8 +29,8 @@ pub fn invoke_set(input: &str, setting: &Setting) -> Result<Map<String, Value>, match setting { Setting::SshdConfig => { debug!("{} {:?}", t!("set.settingSshdConfig").to_string(), setting); - let cmd_info = build_command_info(Some(&input.to_string()), false)?; - match set_sshd_config(&cmd_info) { + let mut cmd_info = build_command_info(Some(&input.to_string()), false)?; + match set_sshd_config(&mut cmd_info) { Ok(()) => Ok(Map::new()), Err(e) => Err(e), } @@ -106,37 +107,41 @@ fn remove_registry(name: &str) -> Result<(), SshdConfigError> { Ok(()) } -fn set_sshd_config(cmd_info: &CommandInfo) -> Result<(), SshdConfigError> { +fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { // this should be its own helper function that checks that the value makes sense for the key type // i.e. if the key can be repeated or have multiple values, etc. // or if the value is something besides a string (like an object to convert back into a comma-separated list) debug!("{}", t!("set.writingTempConfig")); let mut config_text = SSHD_CONFIG_HEADER.to_string() + "\n" + SSHD_CONFIG_HEADER_VERSION + "\n" + SSHD_CONFIG_HEADER_WARNING + "\n"; if cmd_info.clobber { + let match_map = cmd_info.input.remove("match"); + config_text.push_str(&write_config_map_to_text(&cmd_info.input, match_map)?); + } else { + let mut get_cmd_info = cmd_info.clone(); + get_cmd_info.include_defaults = false; + get_cmd_info.input = Map::new(); + + let mut existing_config = get_sshd_settings(&get_cmd_info, true)?; for (key, value) in &cmd_info.input { let key_lower = key.to_lowercase(); + let key_contains = key_lower.as_str(); - // Handle repeatable keywords - write multiple lines - if REPEATABLE_KEYWORDS.contains(&key_lower.as_str()) { - if let Value::Array(arr) = value { - for item in arr { - let formatted = format_sshd_value(key, item)?; - writeln!(&mut config_text, "{key} {formatted}")?; - } - } else { - // Single value for repeatable keyword, write as-is - let formatted = format_sshd_value(key, value)?; - writeln!(&mut config_text, "{key} {formatted}")?; - } + if REPEATABLE_KEYWORDS.contains(&key_contains) + || MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_contains) + || MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&key_contains) { + return Err(SshdConfigError::InvalidInput(t!("set.clobberFalseUnsupported").to_string())); + } + + if value.is_null() { + existing_config.remove(key); } else { - // Handle non-repeatable keywords - format and write single line - let formatted = format_sshd_value(key, value)?; - writeln!(&mut config_text, "{key} {formatted}")?; + existing_config.insert(key.clone(), value.clone()); } } - } else { - /* TODO: preserve existing settings that are not in input, probably need to call get */ - return Err(SshdConfigError::InvalidInput(t!("set.clobberFalseUnsupported").to_string())); + existing_config.remove("_metadata"); + existing_config.remove("_inheritedDefaults"); + let match_map = existing_config.remove("match"); + config_text.push_str(&write_config_map_to_text(&existing_config, match_map)?); } // Write input to a temporary file and validate it with SSHD -T diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 0d6e3027f..75b99ce72 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -4,14 +4,14 @@ use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; -use std::{path::PathBuf, process::Command}; +use std::{fmt::Write, path::PathBuf, process::Command}; use tracing::{debug, warn, Level}; use tracing_subscriber::{EnvFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::args::{TraceFormat, TraceLevel}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; -use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, REPEATABLE_KEYWORDS, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; #[derive(Debug, Deserialize)] @@ -114,38 +114,33 @@ pub fn enable_tracing(trace_level: Option<&TraceLevel>, trace_format: &TraceForm /// # Errors /// /// Returns an error if the value type is not supported or if formatting fails. -pub fn format_sshd_value(key: &str, value: &Value) -> Result<String, SshdConfigError> { +fn format_sshd_value(key: &str, value: &Value) -> Result<String, SshdConfigError> { let key_lower = key.to_lowercase(); - let result = if key_lower == "match" { - format_match_block(value)? - } else { - format_value_as_string(value, MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str()))? - }; - + let result = format_value_as_string(value, MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str())); if result.is_empty() { return Err(SshdConfigError::ParserError(t!("util.invalidValue", key = key).to_string())) } Ok(result) } -fn format_value_as_string(value: &Value, is_comma_separated: bool) -> Result<String, SshdConfigError> { +fn format_value_as_string(value: &Value, is_comma_separated: bool) -> String { match value { Value::Array(arr) => { if arr.is_empty() { - return Ok(String::new()); + return String::new(); } // Convert array elements to strings let mut string_values = Vec::new(); for item in arr { - let result = format_value_as_string(item, false)?; + let result = format_value_as_string(item, false); if !result.is_empty() { string_values.push(result); } } if string_values.is_empty() { - return Ok(String::new()); + return String::new(); } let separator = if is_comma_separated { @@ -154,15 +149,15 @@ fn format_value_as_string(value: &Value, is_comma_separated: bool) -> Result<Str " " }; - Ok(string_values.join(separator)) + string_values.join(separator) }, Value::Bool(b) => { let bool_str = if *b { "yes" } else { "no" }; - Ok(bool_str.to_string()) + bool_str.to_string() }, - Value::Number(n) => Ok(n.to_string()), - Value::String(s) => Ok(s.clone()), - _ => Ok(String::new()) + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => String::new() } } @@ -364,7 +359,7 @@ fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> { for (key, value) in &match_block.criteria { // all match criteria values are comma-separated - let value_formatted = format_value_as_string(value, true)?; + let value_formatted = format_value_as_string(value, true); match_parts.push(format!("{key} {value_formatted}")); } @@ -379,3 +374,48 @@ fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> { Ok(result.join("\n")) } + +/// Write configuration map to config text string +/// +/// # Errors +/// +/// This function will return an error if formatting fails. +pub fn write_config_map_to_text(global_map: &Map<String, Value>, match_map: Option<Value>) -> Result<String, SshdConfigError> { + let mut config_text = String::new(); + + for (key, value) in global_map { + let key_lower = key.to_lowercase(); + + // Handle repeatable keywords - write multiple lines + if REPEATABLE_KEYWORDS.contains(&key_lower.as_str()) { + if let Value::Array(arr) = value { + for item in arr { + let formatted = format_sshd_value(key, item)?; + writeln!(&mut config_text, "{key} {formatted}")?; + } + } else { + // Single value for repeatable keyword, write as-is + let formatted = format_sshd_value(key, value)?; + writeln!(&mut config_text, "{key} {formatted}")?; + } + } else { + // Handle non-repeatable keywords - format and write single line + let formatted = format_sshd_value(key, value)?; + writeln!(&mut config_text, "{key} {formatted}")?; + } + } + + if let Some(match_map) = match_map { + if let Value::Array(arr) = match_map { + for item in arr { + let formatted = format_match_block(&item)?; + writeln!(&mut config_text, "match {formatted}")?; + } + } else { + let formatted = format_match_block(&match_map)?; + writeln!(&mut config_text, "match {formatted}")?; + } + } + + Ok(config_text) +} diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index 6f5952a3e..df460c646 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -168,6 +168,18 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { } Context 'Set with invalid configuration' { + BeforeEach { + # Create initial file with valid config + $validConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + Port = "9999" + } | ConvertTo-Json + sshdconfig set --input $validConfig -s sshd-config + } + It 'Should fail with clobber set to false' { $inputConfig = @{ _metadata = @{ @@ -183,22 +195,10 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { # Read log file and check for error message $logContent = Get-Content $logFile -Raw - $logContent | Should -Match "clobber=false is not yet supported" + $logContent | Should -Match "clobber=false is not supported for keywords that can have multiple values" } It 'Should fail with invalid keyword and not modify file' { - # Create initial file with valid config - $validConfig = @{ - _metadata = @{ - filepath = $TestConfigPath - } - _clobber = $true - Port = "9999" - } | ConvertTo-Json - - sshdconfig set --input $validConfig -s sshd-config 2>$null - $LASTEXITCODE | Should -Be 0 - # Get original content $getInput = @{ _metadata = @{ @@ -298,10 +298,11 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin no", "PasswordAuthentication no", "LoginGraceTime 60") ExpectedNotContains = @("PermitRootLogin yes") VerifyOrder = @( - @{ Pattern = "^Port"; Before = "^PasswordAuthentication" }, - @{ Pattern = "^PasswordAuthentication"; Before = "^PermitRootLogin" }, - @{ Pattern = "^PermitRootLogin"; Before = "^AddressFamily" }, - @{ Pattern = "^AddressFamily"; Before = "^MaxAuthTries" } + @{ First = "^Port"; Next = "^AddressFamily" }, + @{ First = "^AddressFamily"; Next = "^MaxAuthTries" }, + @{ First = "^MaxAuthTries"; Next = "^PermitRootLogin" }, + @{ First = "^PermitRootLogin"; Next = "^PasswordAuthentication" }, + @{ First = "^PasswordAuthentication"; Next = "^LoginGraceTime" } ) } ) { @@ -331,9 +332,9 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { } foreach ($orderCheck in $VerifyOrder) { - $beforeLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Pattern).LineNumber - $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Before).LineNumber - $beforeLine | Should -BeLessThan $afterLine + $beforeLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.First).LineNumber + $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Next).LineNumber + $beforeLine | Should -BeLessThan $afterLine -Because "Expected '$($orderCheck.First)' to appear before '$($orderCheck.Next)'" } } } From 5e8daabe41abb7a7346ba9f3d4379d2bfb8cf594 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 5 Jan 2026 15:59:56 -0500 Subject: [PATCH 04/15] rename _clobber to _purge --- resources/sshdconfig/locales/en-us.toml | 2 +- resources/sshdconfig/src/inputs.rs | 6 ++--- resources/sshdconfig/src/set.rs | 4 +-- resources/sshdconfig/src/util.rs | 4 +-- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 26 +++++++++---------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 757f2f5a1..33125d150 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -58,10 +58,10 @@ unknownNodeType = "unknown node type: '%{node}'" backingUpConfig = "Backing up existing sshd_config file" backupCreated = "Backup created at: %{path}" cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" -clobberFalseUnsupported = "clobber=false is not supported for keywords that can have multiple values" configDoesNotExist = "sshd_config file does not exist, no backup created" defaultShellDebug = "default_shell: %{shell}" failedToParseDefaultShell = "failed to parse input for DefaultShell with error: '%{error}'" +purgeFalseUnsupported = "_purge=false is not supported for keywords that can have multiple values" settingDefaultShell = "Setting default shell" settingSshdConfig = "Setting sshd_config" shellPathDoesNotExist = "shell path does not exist: '%{shell}'" diff --git a/resources/sshdconfig/src/inputs.rs b/resources/sshdconfig/src/inputs.rs index 01fd9f1b8..a373d97cf 100644 --- a/resources/sshdconfig/src/inputs.rs +++ b/resources/sshdconfig/src/inputs.rs @@ -7,8 +7,8 @@ use std::path::PathBuf; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CommandInfo { - #[serde(rename = "_clobber")] - pub clobber: bool, + #[serde(rename = "_purge")] + pub purge: bool, /// Switch to include defaults in the output #[serde(rename = "_includeDefaults")] pub include_defaults: bool, @@ -24,7 +24,7 @@ impl CommandInfo { /// Create a new `CommandInfo` instance. pub fn new(include_defaults: bool) -> Self { Self { - clobber: false, + purge: false, include_defaults, input: Map::new(), metadata: Metadata::new(), diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index daa8299bb..895180f62 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -113,7 +113,7 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { // or if the value is something besides a string (like an object to convert back into a comma-separated list) debug!("{}", t!("set.writingTempConfig")); let mut config_text = SSHD_CONFIG_HEADER.to_string() + "\n" + SSHD_CONFIG_HEADER_VERSION + "\n" + SSHD_CONFIG_HEADER_WARNING + "\n"; - if cmd_info.clobber { + if cmd_info.purge { let match_map = cmd_info.input.remove("match"); config_text.push_str(&write_config_map_to_text(&cmd_info.input, match_map)?); } else { @@ -129,7 +129,7 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { if REPEATABLE_KEYWORDS.contains(&key_contains) || MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_contains) || MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&key_contains) { - return Err(SshdConfigError::InvalidInput(t!("set.clobberFalseUnsupported").to_string())); + return Err(SshdConfigError::InvalidInput(t!("set.purgeFalseUnsupported").to_string())); } if value.is_null() { diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 75b99ce72..85047248d 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -258,7 +258,7 @@ pub fn extract_sshd_defaults() -> Result<Map<String, Value>, SshdConfigError> { pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result<CommandInfo, SshdConfigError> { if let Some(inputs) = input { let mut sshd_config: Map<String, Value> = serde_json::from_str(inputs.as_str())?; - let clobber = get_bool_or_default(&mut sshd_config, "_clobber", false)?; + let purge = get_bool_or_default(&mut sshd_config, "_purge", false)?; let include_defaults = get_bool_or_default(&mut sshd_config, "_includeDefaults", is_get)?; let metadata: Metadata = if let Some(value) = sshd_config.remove("_metadata") { serde_json::from_value(value)? @@ -278,7 +278,7 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result<Comman sshd_config.clear(); } return Ok(CommandInfo { - clobber, + purge, include_defaults, input: sshd_config, metadata, diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index df460c646..f334d1c65 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -39,7 +39,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true Port = "1234" passwordauthentication = $false allowusers = @("user1", "user2") @@ -68,7 +68,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true match = @( @{ criteria = @{ @@ -103,7 +103,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true Port = "5555" } | ConvertTo-Json @@ -135,7 +135,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true Port = "6789" } | ConvertTo-Json @@ -146,7 +146,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true Port = "7777" } | ConvertTo-Json @@ -174,28 +174,28 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true Port = "9999" } | ConvertTo-Json sshdconfig set --input $validConfig -s sshd-config } - It 'Should fail with clobber set to false' { + It 'Should fail with purge set to false' { $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath } - _clobber = $false + _purge = $false Port = "8888" } | ConvertTo-Json - $logFile = Join-Path $TestDrive "clobber_error.log" + $logFile = Join-Path $TestDrive "purge_error.log" sshdconfig set --input $inputConfig -s sshd-config 2>$logFile $LASTEXITCODE | Should -Not -Be 0 # Read log file and check for error message $logContent = Get-Content $logFile -Raw - $logContent | Should -Match "clobber=false is not supported for keywords that can have multiple values" + $logContent | Should -Match "purge=false is not supported for keywords that can have multiple values" } It 'Should fail with invalid keyword and not modify file' { @@ -212,7 +212,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true FakeKeyword = "1234" } | ConvertTo-Json @@ -226,7 +226,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { } } - Context 'Set with _clobber=false' { + Context 'Set with _purge=false' { BeforeEach { $initialContent = @" Port 2222 @@ -312,7 +312,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $false + _purge = $false } foreach ($key in $InputConfig.Keys) { $config[$key] = $InputConfig[$key] From 964ce3d3d290abfbd7a04efeca51da6d93d0bfa0 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 5 Jan 2026 16:27:18 -0500 Subject: [PATCH 05/15] display error message when file does not exist but _purge=false --- resources/sshdconfig/locales/en-us.toml | 2 ++ resources/sshdconfig/src/error.rs | 2 ++ resources/sshdconfig/src/set.rs | 10 +++++++- resources/sshdconfig/src/util.rs | 7 ++++-- .../sshdconfig/tests/sshdconfig.get.tests.ps1 | 18 +++++++++++++++ .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 23 ++++++++++++++++++- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 33125d150..6e580dcf8 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -8,6 +8,7 @@ setInput = "input to set in sshd_config" [error] command = "Command" envVar = "Environment Variable" +fileNotFound = "File not found: %{path}" invalidInput = "Invalid Input" fmt = "Format" io = "IO" @@ -61,6 +62,7 @@ cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" configDoesNotExist = "sshd_config file does not exist, no backup created" defaultShellDebug = "default_shell: %{shell}" failedToParseDefaultShell = "failed to parse input for DefaultShell with error: '%{error}'" +purgeFalseRequiresExistingFile = "_purge=false requires an existing sshd_config file. Use _purge=true to create a new configuration file." purgeFalseUnsupported = "_purge=false is not supported for keywords that can have multiple values" settingDefaultShell = "Setting default shell" settingSshdConfig = "Setting sshd_config" diff --git a/resources/sshdconfig/src/error.rs b/resources/sshdconfig/src/error.rs index 30164e74b..52d0d62f5 100644 --- a/resources/sshdconfig/src/error.rs +++ b/resources/sshdconfig/src/error.rs @@ -11,6 +11,8 @@ pub enum SshdConfigError { CommandError(String), #[error("{t}: {0}", t = t!("error.envVar"))] EnvVarError(#[from] std::env::VarError), + #[error("{t}", t = t!("error.fileNotFound", path = .0))] + FileNotFound(String), #[error("{t}: {0}", t = t!("error.fmt"))] FmtError(#[from] std::fmt::Error), #[error("{t}: {0}", t = t!("error.invalidInput"))] diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 895180f62..beefcdbd4 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -121,7 +121,15 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { get_cmd_info.include_defaults = false; get_cmd_info.input = Map::new(); - let mut existing_config = get_sshd_settings(&get_cmd_info, true)?; + let mut existing_config = match get_sshd_settings(&get_cmd_info, true) { + Ok(config) => config, + Err(SshdConfigError::FileNotFound(_)) => { + return Err(SshdConfigError::InvalidInput( + t!("set.purgeFalseRequiresExistingFile").to_string() + )); + } + Err(e) => return Err(e), + }; for (key, value) in &cmd_info.input { let key_lower = key.to_lowercase(); let key_contains = key_lower.as_str(); diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 85047248d..9dc91dca2 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -188,6 +188,9 @@ pub fn invoke_sshd_config_validation(args: Option<SshdCommandArgs>) -> Result<St if let Some(args) = args { if let Some(filepath) = args.filepath { + if !filepath.exists() { + return Err(SshdConfigError::FileNotFound(filepath.display().to_string())); + } command.arg("-f").arg(&filepath); } if let Some(additional_args) = args.additional_args { @@ -195,6 +198,7 @@ pub fn invoke_sshd_config_validation(args: Option<SshdCommandArgs>) -> Result<St } } + debug!("here10"); let output = command.output() .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; @@ -299,7 +303,6 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result<Comman /// This function will return an error if the file cannot be found or read. pub fn read_sshd_config(input: Option<PathBuf>) -> Result<String, SshdConfigError> { let filepath = get_default_sshd_config_path(input)?; - if filepath.exists() { let mut sshd_config_content = String::new(); if let Ok(mut file) = std::fs::OpenOptions::new().read(true).open(&filepath) { @@ -311,7 +314,7 @@ pub fn read_sshd_config(input: Option<PathBuf>) -> Result<String, SshdConfigErro } Ok(sshd_config_content) } else { - Err(SshdConfigError::CommandError(t!("util.sshdConfigNotFound", path = filepath.display()).to_string())) + Err(SshdConfigError::FileNotFound(filepath.display().to_string())) } } diff --git a/resources/sshdconfig/tests/sshdconfig.get.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.get.tests.ps1 index 9427f7c69..d6581b3bd 100644 --- a/resources/sshdconfig/tests/sshdconfig.get.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.get.tests.ps1 @@ -147,4 +147,22 @@ PasswordAuthentication no $stderr | Should -BeLike "*WARN*Include directive found in sshd_config*" Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue } + + It 'Should fail when config file does not exist' { + $nonExistentPath = Join-Path $TestDrive 'nonexistent_sshd_config' + + $inputData = @{ + _metadata = @{ + filepath = $nonExistentPath + } + } | ConvertTo-Json + + $stderrFile = Join-Path $TestDrive "stderr_filenotfound.txt" + sshdconfig get --input $inputData -s sshd-config 2>$stderrFile + $LASTEXITCODE | Should -Not -Be 0 + + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "File not found" + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue + } } diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index f334d1c65..b8da06ba8 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -180,7 +180,28 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { sshdconfig set --input $validConfig -s sshd-config } - It 'Should fail with purge set to false' { + It 'Should fail with purge=false when file does not exist' { + $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config" + + $inputConfig = @{ + _metadata = @{ + filepath = $nonExistentPath + } + _purge = $false + Port = "8888" + } | ConvertTo-Json + + $stderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile.txt" + sshdconfig set --input $inputConfig -s sshd-config 2>$stderrFile + $LASTEXITCODE | Should -Not -Be 0 + + $stderr = Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue + $stderr | Should -Match "_purge=false requires an existing sshd_config file" + $stderr | Should -Match "Use _purge=true to create a new configuration file" + Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue + } + + It 'Should fail with purge set to false for multi-value keywords' { $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath From 92396c727ef6a6acfb745f052e28f975c19d7007 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 5 Jan 2026 16:27:51 -0500 Subject: [PATCH 06/15] remove debug statement --- resources/sshdconfig/src/util.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 9dc91dca2..7180b24bd 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -198,7 +198,6 @@ pub fn invoke_sshd_config_validation(args: Option<SshdCommandArgs>) -> Result<St } } - debug!("here10"); let output = command.output() .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; From 4329686a3ae957e55f6f4c52a883b5317d10aa02 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Tue, 6 Jan 2026 10:09:50 -0500 Subject: [PATCH 07/15] modify order preservation test --- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index b8da06ba8..fac882696 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -255,6 +255,8 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { MaxAuthTries 5 PermitRootLogin yes PasswordAuthentication no + Match Group administrators + GSSAPIAuthentication yes "@ Set-Content -Path $TestConfigPath -Value $initialContent } @@ -319,11 +321,11 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { ExpectedContains = @("Port 2222", "AddressFamily inet", "MaxAuthTries 5", "PermitRootLogin no", "PasswordAuthentication no", "LoginGraceTime 60") ExpectedNotContains = @("PermitRootLogin yes") VerifyOrder = @( - @{ First = "^Port"; Next = "^AddressFamily" }, - @{ First = "^AddressFamily"; Next = "^MaxAuthTries" }, - @{ First = "^MaxAuthTries"; Next = "^PermitRootLogin" }, - @{ First = "^PermitRootLogin"; Next = "^PasswordAuthentication" }, - @{ First = "^PasswordAuthentication"; Next = "^LoginGraceTime" } + @{ Before = "^Port"; Last = "^Match" }, + @{ Before = "^AddressFamily"; Last = "^Match" }, + @{ Before = "^MaxAuthTries"; Last = "^Match" }, + @{ Before = "^PermitRootLogin"; Last = "^Match" }, + @{ Before = "^PasswordAuthentication"; Last = "^Match" } ) } ) { @@ -353,9 +355,9 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { } foreach ($orderCheck in $VerifyOrder) { - $beforeLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.First).LineNumber - $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Next).LineNumber - $beforeLine | Should -BeLessThan $afterLine -Because "Expected '$($orderCheck.First)' to appear before '$($orderCheck.Next)'" + $beforeLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Before).LineNumber + $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Last).LineNumber + $beforeLine | Should -BeLessThan $afterLine -Because "Expected '$($orderCheck.Before)' to appear before '$($orderCheck.Last)'" } } } From 44817c8a72a646cee0539252ffc9b5ed311711c0 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 12 Jan 2026 14:08:40 -0500 Subject: [PATCH 08/15] fix i8n --- resources/sshdconfig/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 6e580dcf8..60e7131f3 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -80,7 +80,6 @@ getIgnoresInputFilters = "get command does not support filtering based on input inputMustBeBoolean = "value of '%{input}' must be true or false" invalidValue = "Key: '%{key}' cannot have empty value" matchBlockMissingCriteria = "Match block must contain 'criteria' field" -sshdConfigNotFound = "sshd_config not found at path: '%{path}'" sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" sshdElevation = "elevated security context required" tempFileCreated = "temporary file created at: %{path}" From a2a86f08878d7f7e5b11f0952bd97027ba2193a6 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 12 Jan 2026 14:23:35 -0500 Subject: [PATCH 09/15] Apply suggestions from Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index fac882696..3e3cf046c 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -201,7 +201,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue } - It 'Should fail with purge set to false for multi-value keywords' { + It 'Should fail with purge set to false for repeatable keywords' { $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath @@ -250,13 +250,13 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { Context 'Set with _purge=false' { BeforeEach { $initialContent = @" - Port 2222 - AddressFamily inet - MaxAuthTries 5 - PermitRootLogin yes - PasswordAuthentication no - Match Group administrators - GSSAPIAuthentication yes +Port 2222 +AddressFamily inet +MaxAuthTries 5 +PermitRootLogin yes +PasswordAuthentication no +Match Group administrators + GSSAPIAuthentication yes "@ Set-Content -Path $TestConfigPath -Value $initialContent } From 384ca7aac0b9f1bf26617d2d9f4d2f32ed87a519 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Mon, 12 Jan 2026 15:50:21 -0500 Subject: [PATCH 10/15] fix test --- dsc/tests/dsc_sshdconfig.tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dsc/tests/dsc_sshdconfig.tests.ps1 b/dsc/tests/dsc_sshdconfig.tests.ps1 index ffc9b6389..ccb5564f7 100644 --- a/dsc/tests/dsc_sshdconfig.tests.ps1 +++ b/dsc/tests/dsc_sshdconfig.tests.ps1 @@ -139,7 +139,7 @@ resources: } Context 'Set Commands' { - It 'Set works with _clobber: true' { + It 'Set works with _purge: true' { $set_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json metadata: @@ -151,7 +151,7 @@ resources: metadata: filepath: $filepath properties: - _clobber: true + _purge: true port: 1234 allowUsers: - user1 From 459c6ee57b6991d7095bfbfd1ecace9f512aaa2e Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Tue, 13 Jan 2026 14:05:11 -0500 Subject: [PATCH 11/15] lowercase input keys as part of CommandInfo initialization --- resources/sshdconfig/src/inputs.rs | 25 ++++++++++++++++++------- resources/sshdconfig/src/set.rs | 3 +-- resources/sshdconfig/src/util.rs | 28 +++++++++++++--------------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/resources/sshdconfig/src/inputs.rs b/resources/sshdconfig/src/inputs.rs index a373d97cf..8c7bc8909 100644 --- a/resources/sshdconfig/src/inputs.rs +++ b/resources/sshdconfig/src/inputs.rs @@ -7,8 +7,6 @@ use std::path::PathBuf; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct CommandInfo { - #[serde(rename = "_purge")] - pub purge: bool, /// Switch to include defaults in the output #[serde(rename = "_includeDefaults")] pub include_defaults: bool, @@ -16,19 +14,32 @@ pub struct CommandInfo { pub input: Map<String, Value>, /// metadata provided with the command pub metadata: Metadata, + #[serde(rename = "_purge")] + pub purge: bool, /// additional arguments for the call to sshd -T pub sshd_args: Option<SshdCommandArgs> } impl CommandInfo { /// Create a new `CommandInfo` instance. - pub fn new(include_defaults: bool) -> Self { + pub fn new( + include_defaults: bool, + input: Map<String, Value>, + metadata: Metadata, + purge: bool, + sshd_args: Option<SshdCommandArgs> + ) -> Self { + // Lowercase keys for case-insensitive comparison + let input = input.into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(); + Self { - purge: false, include_defaults, - input: Map::new(), - metadata: Metadata::new(), - sshd_args: None + input, + metadata, + purge, + sshd_args } } } diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index beefcdbd4..2c5aa6575 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -131,8 +131,7 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { Err(e) => return Err(e), }; for (key, value) in &cmd_info.input { - let key_lower = key.to_lowercase(); - let key_contains = key_lower.as_str(); + let key_contains = key.as_str(); if REPEATABLE_KEYWORDS.contains(&key_contains) || MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_contains) diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 7180b24bd..79431f750 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -259,18 +259,22 @@ pub fn extract_sshd_defaults() -> Result<Map<String, Value>, SshdConfigError> { /// /// This function will return an error if it fails to parse the input string and if the _metadata field exists, extract it. pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result<CommandInfo, SshdConfigError> { + let mut include_defaults = is_get; + let mut metadata: Metadata = Metadata::new(); + let mut purge = false; + let mut sshd_args: Option<SshdCommandArgs> = None; + let mut sshd_config: Map<String, Value> = Map::new(); + if let Some(inputs) = input { - let mut sshd_config: Map<String, Value> = serde_json::from_str(inputs.as_str())?; - let purge = get_bool_or_default(&mut sshd_config, "_purge", false)?; - let include_defaults = get_bool_or_default(&mut sshd_config, "_includeDefaults", is_get)?; - let metadata: Metadata = if let Some(value) = sshd_config.remove("_metadata") { + sshd_config = serde_json::from_str(inputs.as_str())?; + purge = get_bool_or_default(&mut sshd_config, "_purge", false)?; + include_defaults = get_bool_or_default(&mut sshd_config, "_includeDefaults", is_get)?; + metadata = if let Some(value) = sshd_config.remove("_metadata") { serde_json::from_value(value)? } else { Metadata::new() }; - // lowercase keys for case-insensitive comparison later of SSHD -T output - sshd_config = sshd_config.into_iter().map(|(k, v)| (k.to_lowercase(), v)).collect(); - let sshd_args = metadata.filepath.clone().map(|filepath| { + sshd_args = metadata.filepath.clone().map(|filepath| { SshdCommandArgs { filepath: Some(filepath), additional_args: None, @@ -280,15 +284,9 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result<Comman warn!("{}", t!("util.getIgnoresInputFilters")); sshd_config.clear(); } - return Ok(CommandInfo { - purge, - include_defaults, - input: sshd_config, - metadata, - sshd_args - }) } - Ok(CommandInfo::new(is_get)) + + Ok(CommandInfo::new(include_defaults, sshd_config, metadata, purge, sshd_args)) } /// Reads `sshd_config` file. From 402fde02f251d2c73af178c3afba9d7ede86ee1b Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Tue, 13 Jan 2026 14:13:35 -0500 Subject: [PATCH 12/15] tweak match block writing --- resources/sshdconfig/src/set.rs | 6 ++---- resources/sshdconfig/src/util.rs | 7 ++++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 2c5aa6575..07e85e6b0 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -114,8 +114,7 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { debug!("{}", t!("set.writingTempConfig")); let mut config_text = SSHD_CONFIG_HEADER.to_string() + "\n" + SSHD_CONFIG_HEADER_VERSION + "\n" + SSHD_CONFIG_HEADER_WARNING + "\n"; if cmd_info.purge { - let match_map = cmd_info.input.remove("match"); - config_text.push_str(&write_config_map_to_text(&cmd_info.input, match_map)?); + config_text.push_str(&write_config_map_to_text(&cmd_info.input)?); } else { let mut get_cmd_info = cmd_info.clone(); get_cmd_info.include_defaults = false; @@ -147,8 +146,7 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { } existing_config.remove("_metadata"); existing_config.remove("_inheritedDefaults"); - let match_map = existing_config.remove("match"); - config_text.push_str(&write_config_map_to_text(&existing_config, match_map)?); + config_text.push_str(&write_config_map_to_text(&existing_config)?); } // Write input to a temporary file and validate it with SSHD -T diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 79431f750..224b09a4e 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -380,12 +380,17 @@ fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> { /// # Errors /// /// This function will return an error if formatting fails. -pub fn write_config_map_to_text(global_map: &Map<String, Value>, match_map: Option<Value>) -> Result<String, SshdConfigError> { +pub fn write_config_map_to_text(global_map: &Map<String, Value>) -> Result<String, SshdConfigError> { + let match_map = global_map.get("match"); let mut config_text = String::new(); for (key, value) in global_map { let key_lower = key.to_lowercase(); + if key_lower == "match" { + continue; // match blocks are handled after global settings + } + // Handle repeatable keywords - write multiple lines if REPEATABLE_KEYWORDS.contains(&key_lower.as_str()) { if let Value::Array(arr) = value { From 74a0915f1fe8f04ad5924ccd7de5398dbee396f6 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Tue, 13 Jan 2026 16:06:01 -0500 Subject: [PATCH 13/15] add formatter mod with struct and display implemented --- resources/sshdconfig/locales/en-us.toml | 9 +- resources/sshdconfig/src/formatter.rs | 202 ++++++++++++++++++ resources/sshdconfig/src/main.rs | 1 + resources/sshdconfig/src/set.rs | 3 +- resources/sshdconfig/src/util.rs | 162 +------------- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 10 +- 6 files changed, 218 insertions(+), 169 deletions(-) create mode 100644 resources/sshdconfig/src/formatter.rs diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 60e7131f3..03df0c91e 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -20,6 +20,12 @@ persist = "Persist" registry = "Registry" stringUtf8 = "String UTF-8" +[formatter] +deserializeFailed = "Failed to deserialize match input: %{error}" +invalidArrayItem = "Array item '%{item}' for key '%{key}' is not valid" +invalidValue = "Key: '%{key}' cannot have empty value" +matchBlockMissingCriteria = "Match block must contain 'criteria' field" + [get] debugSetting = "Get setting:" defaultShellCmdOptionMustBeString = "cmdOption must be a string" @@ -75,11 +81,8 @@ writingTempConfig = "Writing temporary sshd_config file" [util] cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" -deserializeFailed = "Failed to deserialize match input: %{error}" getIgnoresInputFilters = "get command does not support filtering based on input settings, provided input will be ignored" inputMustBeBoolean = "value of '%{input}' must be true or false" -invalidValue = "Key: '%{key}' cannot have empty value" -matchBlockMissingCriteria = "Match block must contain 'criteria' field" sshdConfigReadFailed = "failed to read sshd_config at path: '%{path}'" sshdElevation = "elevated security context required" tempFileCreated = "temporary file created at: %{path}" diff --git a/resources/sshdconfig/src/formatter.rs b/resources/sshdconfig/src/formatter.rs new file mode 100644 index 000000000..6cac3b9ec --- /dev/null +++ b/resources/sshdconfig/src/formatter.rs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::{fmt, fmt::Write}; +use tracing::warn; + +use crate::error::SshdConfigError; +use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, REPEATABLE_KEYWORDS}; + +#[derive(Debug, Deserialize)] +struct MatchBlock { + criteria: Map<String, Value>, + #[serde(flatten)] + contents: Map<String, Value>, +} + +#[derive(Clone, Debug)] +pub struct SshdConfigValue<'a> { + is_repeatable: bool, + key: &'a str, + separator: ValueSeparator, + value: &'a Value, + +} + +#[derive(Clone, Copy, Debug)] +pub enum ValueSeparator { + Comma, + Space, +} + +impl<'a> SshdConfigValue<'a> { + /// Create a new SSHD config value, returning an error if the value is empty/invalid + pub fn try_new(key: &'a str, value: &'a Value, override_separator: Option<ValueSeparator>) -> Result<Self, SshdConfigError> { + if matches!(value, Value::Null | Value::Object(_)) { + return Err(SshdConfigError::ParserError( + t!("formatter.invalidValue", key = key).to_string() + )); + } + + if let Value::Array(arr) = value { + if arr.is_empty() { + return Err(SshdConfigError::ParserError( + t!("formatter.invalidValue", key = key).to_string() + )); + } + } + + let separator = match override_separator { + Some(separator) => separator, + None => { + if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key) { + ValueSeparator::Comma + } else { + ValueSeparator::Space + } + } + }; + + let is_repeatable = REPEATABLE_KEYWORDS.contains(&key); + + Ok(Self { + is_repeatable, + key, + separator, + value, + }) + } + + pub fn write_to_config(&self, config_text: &mut String) -> Result<(), SshdConfigError> { + if self.is_repeatable { + if let Value::Array(arr) = self.value { + for item in arr { + let item = SshdConfigValue::try_new(self.key, item, Some(self.separator))?; + writeln!(config_text, "{} {item}", self.key)?; + } + } else { + writeln!(config_text, "{} {self}", self.key)?; + } + } else { + writeln!(config_text, "{} {self}", self.key)?; + } + Ok(()) + } +} + +impl fmt::Display for SshdConfigValue<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.value { + Value::Array(arr) => { + if arr.is_empty() { + return Ok(()); + } + + let separator = match self.separator { + ValueSeparator::Comma => ",", + ValueSeparator::Space => " ", + }; + + let mut first = true; + for item in arr { + if let Ok(sshd_config_value) = SshdConfigValue::try_new(self.key, item, Some(self.separator)) { + let formatted = sshd_config_value.to_string(); + if !formatted.is_empty() { + if !first { + write!(f, "{separator}")?; + } + write!(f, "{formatted}")?; + first = false; + } + } else { + warn!("{}", t!("formatter.invalidArrayItem", key = self.key, item = item).to_string()); + } + } + Ok(()) + }, + Value::Bool(b) => write!(f, "{}", if *b { "yes" } else { "no" }), + Value::Number(n) => write!(f, "{n}"), + Value::String(s) => { + if s.contains(char::is_whitespace) { + write!(f, "\"{s}\"") + } else { + write!(f, "{s}") + } + }, + Value::Null | Value::Object(_) => Ok(()), + } + } +} + +fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> { + let match_block = match serde_json::from_value::<MatchBlock>(match_obj.clone()) { + Ok(result) => { + result + } + Err(e) => { + return Err(SshdConfigError::ParserError(t!("formatter.deserializeFailed", error = e).to_string())); + } + }; + + if match_block.criteria.is_empty() { + return Err(SshdConfigError::InvalidInput(t!("formatter.matchBlockMissingCriteria").to_string())); + } + + let mut match_parts = vec![]; + let mut result = vec![]; + + for (key, value) in &match_block.criteria { + // all match criteria values are comma-separated + let sshd_config_value = SshdConfigValue::try_new(key, value, Some(ValueSeparator::Comma))?; + match_parts.push(format!("{key} {sshd_config_value}")); + } + + // Write the Match line with the formatted criteria(s) + result.push(match_parts.join(" ")); + + // Format other keywords in the match block + for (key, value) in &match_block.contents { + let sshd_config_value = SshdConfigValue::try_new(key, value, None)?; + result.push(format!("\t{key} {sshd_config_value}")); + } + + Ok(result.join("\n")) +} + +/// Write configuration map to config text string +/// +/// # Errors +/// +/// This function will return an error if formatting fails. +pub fn write_config_map_to_text(global_map: &Map<String, Value>) -> Result<String, SshdConfigError> { + let match_map = global_map.get("match"); + let mut config_text = String::new(); + + for (key, value) in global_map { + let key_lower = key.to_lowercase(); + + if key_lower == "match" { + continue; // match blocks are handled after global settings + } + + let sshd_config_value = SshdConfigValue::try_new(key, value, None)?; + sshd_config_value.write_to_config(&mut config_text)?; + } + + if let Some(match_map) = match_map { + if let Value::Array(arr) = match_map { + for item in arr { + let formatted = format_match_block(item)?; + writeln!(&mut config_text, "match {formatted}")?; + } + } else { + let formatted = format_match_block(match_map)?; + writeln!(&mut config_text, "match {formatted}")?; + } + } + + Ok(config_text) +} diff --git a/resources/sshdconfig/src/main.rs b/resources/sshdconfig/src/main.rs index 443d2ec62..0ca12da5c 100644 --- a/resources/sshdconfig/src/main.rs +++ b/resources/sshdconfig/src/main.rs @@ -16,6 +16,7 @@ use util::{build_command_info, enable_tracing}; mod args; mod error; +mod formatter; mod get; mod inputs; mod metadata; diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 07e85e6b0..17fd5ba3a 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -15,10 +15,11 @@ use tracing::{debug, info, warn}; use crate::args::{DefaultShell, Setting}; use crate::error::SshdConfigError; +use crate::formatter::write_config_map_to_text; use crate::get::get_sshd_settings; use crate::inputs::{CommandInfo, SshdCommandArgs}; use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, MULTI_ARG_KEYWORDS_SPACE_SEP, REPEATABLE_KEYWORDS, SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; -use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation, write_config_map_to_text}; +use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation}; /// Invoke the set command. /// diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 224b09a4e..2ee6d3a50 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -2,25 +2,17 @@ // Licensed under the MIT License. use rust_i18n::t; -use serde::Deserialize; use serde_json::{Map, Value}; -use std::{fmt::Write, path::PathBuf, process::Command}; +use std::{path::PathBuf, process::Command}; use tracing::{debug, warn, Level}; use tracing_subscriber::{EnvFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::args::{TraceFormat, TraceLevel}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; -use crate::metadata::{MULTI_ARG_KEYWORDS_COMMA_SEP, REPEATABLE_KEYWORDS, SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; +use crate::metadata::{SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; -#[derive(Debug, Deserialize)] -struct MatchBlock { - criteria: Map<String, Value>, - #[serde(flatten)] - contents: Map<String, Value>, -} - /// Enable tracing. /// /// # Arguments @@ -98,69 +90,6 @@ pub fn enable_tracing(trace_level: Option<&TraceLevel>, trace_format: &TraceForm } } -/// Format a JSON value for writing to `sshd_config`. -/// -/// # Arguments -/// -/// * `key` - The configuration key name (used to determine formatting rules) -/// * `value` - The JSON value to format -/// -/// # Returns -/// -/// * `Ok(Some(String))` - Formatted value string -/// * `Ok(None)` - Value is null and should be skipped -/// * `Err(SshdConfigError)` - Invalid value type or formatting error -/// -/// # Errors -/// -/// Returns an error if the value type is not supported or if formatting fails. -fn format_sshd_value(key: &str, value: &Value) -> Result<String, SshdConfigError> { - let key_lower = key.to_lowercase(); - let result = format_value_as_string(value, MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_lower.as_str())); - if result.is_empty() { - return Err(SshdConfigError::ParserError(t!("util.invalidValue", key = key).to_string())) - } - Ok(result) -} - -fn format_value_as_string(value: &Value, is_comma_separated: bool) -> String { - match value { - Value::Array(arr) => { - if arr.is_empty() { - return String::new(); - } - - // Convert array elements to strings - let mut string_values = Vec::new(); - for item in arr { - let result = format_value_as_string(item, false); - if !result.is_empty() { - string_values.push(result); - } - } - - if string_values.is_empty() { - return String::new(); - } - - let separator = if is_comma_separated { - "," - } else { - " " - }; - - string_values.join(separator) - }, - Value::Bool(b) => { - let bool_str = if *b { "yes" } else { "no" }; - bool_str.to_string() - }, - Value::Number(n) => n.to_string(), - Value::String(s) => s.clone(), - _ => String::new() - } -} - /// Get the `sshd_config` path /// Uses the input value, if provided. /// If input value not provided, get default path for the OS. @@ -337,90 +266,3 @@ fn get_bool_or_default(map: &mut Map<String, Value>, key: &str, default: bool) - Ok(default) } } - -fn format_match_block(match_obj: &Value) -> Result<String, SshdConfigError> { - let match_block = match serde_json::from_value::<MatchBlock>(match_obj.clone()) { - Ok(result) => { - result - } - Err(e) => { - return Err(SshdConfigError::ParserError(t!("util.deserializeFailed", error = e).to_string())); - } - }; - - if match_block.criteria.is_empty() { - return Err(SshdConfigError::InvalidInput( - t!("util.matchBlockMissingCriteria").to_string() - )); - } - - let mut match_parts = vec![]; - let mut result = vec![]; - - for (key, value) in &match_block.criteria { - // all match criteria values are comma-separated - let value_formatted = format_value_as_string(value, true); - match_parts.push(format!("{key} {value_formatted}")); - } - - // Write the Match line with the formatted criteria(s) - result.push(match_parts.join(" ")); - - // Format other keywords in the match block - for (key, value) in &match_block.contents { - let formatted_value = format_sshd_value(key, value)?; - result.push(format!(" {key} {formatted_value}")); - } - - Ok(result.join("\n")) -} - -/// Write configuration map to config text string -/// -/// # Errors -/// -/// This function will return an error if formatting fails. -pub fn write_config_map_to_text(global_map: &Map<String, Value>) -> Result<String, SshdConfigError> { - let match_map = global_map.get("match"); - let mut config_text = String::new(); - - for (key, value) in global_map { - let key_lower = key.to_lowercase(); - - if key_lower == "match" { - continue; // match blocks are handled after global settings - } - - // Handle repeatable keywords - write multiple lines - if REPEATABLE_KEYWORDS.contains(&key_lower.as_str()) { - if let Value::Array(arr) = value { - for item in arr { - let formatted = format_sshd_value(key, item)?; - writeln!(&mut config_text, "{key} {formatted}")?; - } - } else { - // Single value for repeatable keyword, write as-is - let formatted = format_sshd_value(key, value)?; - writeln!(&mut config_text, "{key} {formatted}")?; - } - } else { - // Handle non-repeatable keywords - format and write single line - let formatted = format_sshd_value(key, value)?; - writeln!(&mut config_text, "{key} {formatted}")?; - } - } - - if let Some(match_map) = match_map { - if let Value::Array(arr) = match_map { - for item in arr { - let formatted = format_match_block(&item)?; - writeln!(&mut config_text, "match {formatted}")?; - } - } else { - let formatted = format_match_block(&match_map)?; - writeln!(&mut config_text, "match {formatted}")?; - } - } - - Ok(config_text) -} diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 index 3e3cf046c..49e80728b 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -42,10 +42,10 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _purge = $true Port = "1234" passwordauthentication = $false - allowusers = @("user1", "user2") + allowgroups = @("openssh users", "group2") ciphers = @("aes128-ctr", "aes192-ctr", "aes256-ctr") addressfamily = "inet6" - authorizedkeysfile = @(".ssh/authorized_keys", ".ssh/authorized_keys2") + authorizedkeysfile = @(".ssh/authorized_keys", ".ssh//authorized keys with spaces") } | ConvertTo-Json $output = sshdconfig set --input $inputConfig -s sshd-config 2>$null @@ -56,11 +56,11 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { $sshdConfigContents = Get-Content $TestConfigPath $sshdConfigContents | Should -Contain "Port 1234" $sshdConfigContents | Should -Contain "PasswordAuthentication no" - $sshdConfigContents | Should -Contain "AllowUsers user1" - $sshdConfigContents | Should -Contain "AllowUsers user2" + $sshdConfigContents | Should -Contain "AllowGroups `"openssh users`"" + $sshdConfigContents | Should -Contain "AllowGroups group2" $sshdConfigContents | Should -Contain "Ciphers aes128-ctr,aes192-ctr,aes256-ctr" $sshdConfigContents | Should -Contain "AddressFamily inet6" - $sshdConfigContents | Should -Contain "AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2" + $sshdConfigContents | Should -Contain "AuthorizedKeysFile .ssh/authorized_keys `".ssh//authorized keys with spaces`"" } It 'Should set with valid match blocks' { From f39a38e8d3200196972832da89935439a495c1f1 Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Tue, 13 Jan 2026 16:35:38 -0500 Subject: [PATCH 14/15] address copilot feedback --- resources/sshdconfig/src/formatter.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/sshdconfig/src/formatter.rs b/resources/sshdconfig/src/formatter.rs index 6cac3b9ec..14c91fb5c 100644 --- a/resources/sshdconfig/src/formatter.rs +++ b/resources/sshdconfig/src/formatter.rs @@ -23,7 +23,6 @@ pub struct SshdConfigValue<'a> { key: &'a str, separator: ValueSeparator, value: &'a Value, - } #[derive(Clone, Copy, Debug)] @@ -52,7 +51,7 @@ impl<'a> SshdConfigValue<'a> { let separator = match override_separator { Some(separator) => separator, None => { - if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key) { + if MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key) { ValueSeparator::Comma } else { ValueSeparator::Space @@ -190,11 +189,11 @@ pub fn write_config_map_to_text(global_map: &Map<String, Value>) -> Result<Strin if let Value::Array(arr) = match_map { for item in arr { let formatted = format_match_block(item)?; - writeln!(&mut config_text, "match {formatted}")?; + writeln!(&mut config_text, "Match {formatted}")?; } } else { let formatted = format_match_block(match_map)?; - writeln!(&mut config_text, "match {formatted}")?; + writeln!(&mut config_text, "Match {formatted}")?; } } From 51a909b60dfc24427ead5a07b86b770d390b862c Mon Sep 17 00:00:00 2001 From: Tess Gauthier <tessgauthier@microsoft.com> Date: Wed, 14 Jan 2026 10:47:20 -0500 Subject: [PATCH 15/15] Add TODO comment --- resources/sshdconfig/src/set.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 17fd5ba3a..2950b795d 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -133,6 +133,8 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { for (key, value) in &cmd_info.input { let key_contains = key.as_str(); + // TODO: remove when design for handling repeatable and multi-arg keywords is finalized + // and consider using SshdConfigValue instead of any remaining contains() checks if REPEATABLE_KEYWORDS.contains(&key_contains) || MULTI_ARG_KEYWORDS_COMMA_SEP.contains(&key_contains) || MULTI_ARG_KEYWORDS_SPACE_SEP.contains(&key_contains) {