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 diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index f5d1603f6..03df0c91e 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" @@ -19,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" @@ -58,10 +65,11 @@ 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" 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" shellPathDoesNotExist = "shell path does not exist: '%{shell}'" @@ -73,12 +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" -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}" 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/formatter.rs b/resources/sshdconfig/src/formatter.rs new file mode 100644 index 000000000..14c91fb5c --- /dev/null +++ b/resources/sshdconfig/src/formatter.rs @@ -0,0 +1,201 @@ +// 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, + #[serde(flatten)] + contents: Map, +} + +#[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) -> Result { + 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 { + let match_block = match serde_json::from_value::(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) -> Result { + 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/inputs.rs b/resources/sshdconfig/src/inputs.rs index 01fd9f1b8..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 = "_clobber")] - pub clobber: 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, /// 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 } impl CommandInfo { /// Create a new `CommandInfo` instance. - pub fn new(include_defaults: bool) -> Self { + pub fn new( + include_defaults: bool, + input: Map, + metadata: Metadata, + purge: bool, + sshd_args: Option + ) -> Self { + // Lowercase keys for case-insensitive comparison + let input = input.into_iter() + .map(|(k, v)| (k.to_lowercase(), v)) + .collect(); + Self { - clobber: false, include_defaults, - input: Map::new(), - metadata: Metadata::new(), - sshd_args: None + input, + metadata, + purge, + sshd_args } } } 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 333e2a3fa..2950b795d 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -10,14 +10,16 @@ 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::formatter::write_config_map_to_text; +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}; /// Invoke the set command. /// @@ -28,8 +30,8 @@ pub fn invoke_set(input: &str, setting: &Setting) -> Result, 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 +108,48 @@ 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 { + if cmd_info.purge { + 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; + get_cmd_info.input = Map::new(); + + 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(); - - // 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}")?; - } + 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) { + return Err(SshdConfigError::InvalidInput(t!("set.purgeFalseUnsupported").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"); + 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 0d6e3027f..2ee6d3a50 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. use rust_i18n::t; -use serde::Deserialize; use serde_json::{Map, Value}; use std::{path::PathBuf, process::Command}; use tracing::{debug, warn, Level}; @@ -11,16 +10,9 @@ use tracing_subscriber::{EnvFilter, Layer, prelude::__tracing_subscriber_Subscri 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::{SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; -#[derive(Debug, Deserialize)] -struct MatchBlock { - criteria: Map, - #[serde(flatten)] - contents: Map, -} - /// Enable tracing. /// /// # Arguments @@ -98,74 +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. -pub fn format_sshd_value(key: &str, value: &Value) -> Result { - 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()))? - }; - - 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 { - match value { - Value::Array(arr) => { - if arr.is_empty() { - return Ok(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 Ok(String::new()); - } - - let separator = if is_comma_separated { - "," - } else { - " " - }; - - Ok(string_values.join(separator)) - }, - Value::Bool(b) => { - let bool_str = if *b { "yes" } else { "no" }; - Ok(bool_str.to_string()) - }, - Value::Number(n) => Ok(n.to_string()), - Value::String(s) => Ok(s.clone()), - _ => Ok(String::new()) - } -} - /// Get the `sshd_config` path /// Uses the input value, if provided. /// If input value not provided, get default path for the OS. @@ -193,6 +117,9 @@ pub fn invoke_sshd_config_validation(args: Option) -> Result Result, 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 { + let mut include_defaults = is_get; + let mut metadata: Metadata = Metadata::new(); + let mut purge = false; + let mut sshd_args: Option = None; + let mut sshd_config: Map = Map::new(); + if let Some(inputs) = input { - let mut sshd_config: Map = serde_json::from_str(inputs.as_str())?; - let clobber = get_bool_or_default(&mut sshd_config, "_clobber", 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, @@ -282,15 +213,9 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result, is_get: bool) -> Result) -> Result { 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) { @@ -316,7 +240,7 @@ pub fn read_sshd_config(input: Option) -> Result, key: &str, default: bool) - Ok(default) } } - -fn format_match_block(match_obj: &Value) -> Result { - let match_block = match serde_json::from_value::(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")) -} 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 23f49d823..49e80728b 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -39,13 +39,13 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _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' { @@ -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 @@ -168,37 +168,58 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { } Context 'Set with invalid configuration' { - It 'Should fail with clobber set to false' { - $inputConfig = @{ + BeforeEach { + # Create initial file with valid config + $validConfig = @{ _metadata = @{ filepath = $TestConfigPath } - _clobber = $false + _purge = $true + Port = "9999" + } | ConvertTo-Json + sshdconfig set --input $validConfig -s sshd-config + } + + 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 - $logFile = Join-Path $TestDrive "clobber_error.log" - sshdconfig set --input $inputConfig -s sshd-config 2>$logFile + $stderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile.txt" + sshdconfig set --input $inputConfig -s sshd-config 2>$stderrFile $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 yet supported" + $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 invalid keyword and not modify file' { - # Create initial file with valid config - $validConfig = @{ + It 'Should fail with purge set to false for repeatable keywords' { + $inputConfig = @{ _metadata = @{ filepath = $TestConfigPath } - _clobber = $true - Port = "9999" + _purge = $false + Port = "8888" } | ConvertTo-Json - sshdconfig set --input $validConfig -s sshd-config 2>$null - $LASTEXITCODE | Should -Be 0 + $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 "purge=false is not supported for keywords that can have multiple values" + } + + It 'Should fail with invalid keyword and not modify file' { # Get original content $getInput = @{ _metadata = @{ @@ -212,7 +233,7 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { _metadata = @{ filepath = $TestConfigPath } - _clobber = $true + _purge = $true FakeKeyword = "1234" } | ConvertTo-Json @@ -225,4 +246,119 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { $currentResult.Port | Should -Be $originalResult.Port } } + + Context 'Set with _purge=false' { + BeforeEach { + $initialContent = @" +Port 2222 +AddressFamily inet +MaxAuthTries 5 +PermitRootLogin yes +PasswordAuthentication no +Match Group administrators + GSSAPIAuthentication yes +"@ + Set-Content -Path $TestConfigPath -Value $initialContent + } + + It '' -TestCases @( + @{ + Title = 'Should preserve unchanged regular keyword when value is the same' + InputConfig = @{ MaxAuthTries = "5" } + 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 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 } + 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 = @( + @{ Before = "^Port"; Last = "^Match" }, + @{ Before = "^AddressFamily"; Last = "^Match" }, + @{ Before = "^MaxAuthTries"; Last = "^Match" }, + @{ Before = "^PermitRootLogin"; Last = "^Match" }, + @{ Before = "^PasswordAuthentication"; Last = "^Match" } + ) + } + ) { + param($Title, $InputConfig, $ExpectedContains, $ExpectedNotContains, $VerifyOrder) + + $config = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _purge = $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.Before).LineNumber + $afterLine = ($sshdConfigContents | Select-String -Pattern $orderCheck.Last).LineNumber + $beforeLine | Should -BeLessThan $afterLine -Because "Expected '$($orderCheck.Before)' to appear before '$($orderCheck.Last)'" + } + } + } }