diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index 91215af71..b4421521c 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" invalidInput = "Invalid Input" +fmt = "Format" io = "IO" json = "JSON" language = "Language" @@ -49,14 +50,29 @@ unknownNode = "unknown node: '%{kind}'" unknownNodeType = "unknown node type: '%{node}'" [set] -failedToParseInput = "failed to parse input as DefaultShell with error: '%{error}'" +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}'" +settingDefaultShell = "Setting default shell" +settingSshdConfig = "Setting sshd_config" shellPathDoesNotExist = "shell path does not exist: '%{shell}'" shellPathMustNotBeRelative = "shell path must not be relative" +sshdConfigReadFailed = "failed to read existing sshd_config file at path: '%{path}'" +tempFileCreated = "temporary file created at: %{path}" +validatingTempConfig = "Validating temporary sshd_config file" +valueMustBeString = "value for key '%{key}' must be a string" +writingTempConfig = "Writing temporary sshd_config file" [util] -includeDefaultsMustBeBoolean = "_includeDefaults must be true or false" +cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" +inputMustBeBoolean = "value of '%{input}' must be true or false" inputMustBeEmpty = "get command does not support filtering based on input settings" 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}" tracingInitError = "Failed to initialize tracing" diff --git a/resources/sshdconfig/src/args.rs b/resources/sshdconfig/src/args.rs index 616ec4073..d1d012141 100644 --- a/resources/sshdconfig/src/args.rs +++ b/resources/sshdconfig/src/args.rs @@ -24,7 +24,9 @@ pub enum Command { /// Set default shell, eventually to be used for `sshd_config` and repeatable keywords Set { #[clap(short = 'i', long, help = t!("args.setInput").to_string())] - input: String + input: String, + #[clap(short = 's', long, hide = true)] + setting: Setting, }, /// Export `sshd_config`, eventually to be used for repeatable keywords Export { diff --git a/resources/sshdconfig/src/error.rs b/resources/sshdconfig/src/error.rs index 74ea0ab18..65b422a6e 100644 --- a/resources/sshdconfig/src/error.rs +++ b/resources/sshdconfig/src/error.rs @@ -9,6 +9,8 @@ use thiserror::Error; pub enum SshdConfigError { #[error("{t}: {0}", t = t!("error.command"))] CommandError(String), + #[error("{t}: {0}", t = t!("error.fmt"))] + FmtError(#[from] std::fmt::Error), #[error("{t}: {0}", t = t!("error.invalidInput"))] InvalidInput(String), #[error("{t}: {0}", t = t!("error.io"))] diff --git a/resources/sshdconfig/src/inputs.rs b/resources/sshdconfig/src/inputs.rs index 97507d196..01fd9f1b8 100644 --- a/resources/sshdconfig/src/inputs.rs +++ b/resources/sshdconfig/src/inputs.rs @@ -3,9 +3,12 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +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, @@ -21,6 +24,7 @@ impl CommandInfo { /// Create a new `CommandInfo` instance. pub fn new(include_defaults: bool) -> Self { Self { + clobber: false, include_defaults, input: Map::new(), metadata: Metadata::new(), @@ -33,7 +37,7 @@ impl CommandInfo { pub struct Metadata { /// Filepath for the `sshd_config` file to be processed #[serde(skip_serializing_if = "Option::is_none")] - pub filepath: Option + pub filepath: Option } impl Metadata { @@ -49,7 +53,7 @@ impl Metadata { pub struct SshdCommandArgs { /// the path to the `sshd_config` file to be processed #[serde(skip_serializing_if = "Option::is_none")] - pub filepath: Option, + pub filepath: Option, /// additional arguments to pass to the sshd -T command #[serde(rename = "additionalArgs", skip_serializing_if = "Option::is_none")] pub additional_args: Option>, diff --git a/resources/sshdconfig/src/main.rs b/resources/sshdconfig/src/main.rs index bf6440ee5..f473a47d3 100644 --- a/resources/sshdconfig/src/main.rs +++ b/resources/sshdconfig/src/main.rs @@ -57,9 +57,9 @@ fn main() { println!("{}", serde_json::to_string(&schema).unwrap()); Ok(Map::new()) }, - Command::Set { input } => { + Command::Set { input, setting } => { debug!("{}", t!("main.set", input = input).to_string()); - invoke_set(input) + invoke_set(input, setting) }, }; diff --git a/resources/sshdconfig/src/metadata.rs b/resources/sshdconfig/src/metadata.rs index 7a94c5109..2b34d2382 100644 --- a/resources/sshdconfig/src/metadata.rs +++ b/resources/sshdconfig/src/metadata.rs @@ -39,6 +39,12 @@ pub const REPEATABLE_KEYWORDS: [&str; 12] = [ "subsystem" ]; + +pub const SSHD_CONFIG_HEADER: &str = "# This file is managed by the Microsoft DSC sshdconfig resource."; +pub const SSHD_CONFIG_DEFAULT_PATH_UNIX: &str = "/etc/ssh/sshd_config"; +// For Windows, full path is constructed at runtime using ProgramData environment variable +pub const SSHD_CONFIG_DEFAULT_PATH_WINDOWS: &str = "\\ssh\\sshd_config"; + #[cfg(windows)] pub mod windows { pub const REGISTRY_PATH: &str = "HKLM\\SOFTWARE\\OpenSSH"; diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 1a7724164..50a2f901b 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -10,30 +10,53 @@ use { use rust_i18n::t; use serde_json::{Map, Value}; +use std::{fmt::Write, string::String}; +use tracing::debug; -use crate::args::DefaultShell; +use crate::args::{DefaultShell, Setting}; use crate::error::SshdConfigError; +use crate::inputs::{CommandInfo, SshdCommandArgs}; +use crate::metadata::SSHD_CONFIG_HEADER; +use crate::util::{build_command_info, get_default_sshd_config_path, invoke_sshd_config_validation}; /// Invoke the set command. /// /// # Errors /// /// This function will return an error if the desired settings cannot be applied. -pub fn invoke_set(input: &str) -> Result, SshdConfigError> { - match serde_json::from_str::(input) { - Ok(default_shell) => { - set_default_shell(default_shell.shell, default_shell.cmd_option, default_shell.escape_arguments)?; - Ok(Map::new()) +pub fn invoke_set(input: &str, setting: &Setting) -> Result, SshdConfigError> { + 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) { + Ok(()) => Ok(Map::new()), + Err(e) => Err(e), + } }, - Err(e) => { - Err(SshdConfigError::InvalidInput(t!("set.failedToParseInput", error = e).to_string())) + Setting::WindowsGlobal => { + debug!("{} {:?}", t!("set.settingDefaultShell").to_string(), setting); + match serde_json::from_str::(input) { + Ok(default_shell) => { + debug!("{}", t!("set.defaultShellDebug", shell = format!("{:?}", default_shell))); + // if default_shell.shell is Some, we should pass that into set default shell + // otherwise pass in an empty string + let shell: String = default_shell.shell.clone().unwrap_or_default(); + set_default_shell(shell, default_shell.cmd_option, default_shell.escape_arguments)?; + Ok(Map::new()) + }, + Err(e) => Err(SshdConfigError::InvalidInput(t!("set.failedToParseDefaultShell", error = e).to_string())), + } } } } #[cfg(windows)] -fn set_default_shell(shell: Option, cmd_option: Option, escape_arguments: Option) -> Result<(), SshdConfigError> { - if let Some(shell) = shell { +fn set_default_shell(shell: String, cmd_option: Option, escape_arguments: Option) -> Result<(), SshdConfigError> { + debug!("{}", t!("set.settingDefaultShell")); + if shell.is_empty() { + remove_registry(DEFAULT_SHELL)?; + } else { // TODO: if shell contains quotes, we need to remove them let shell_path = Path::new(&shell); if shell_path.is_relative() && shell_path.components().any(|c| c == std::path::Component::ParentDir) { @@ -42,13 +65,9 @@ fn set_default_shell(shell: Option, cmd_option: Option, escape_a if !shell_path.exists() { return Err(SshdConfigError::InvalidInput(t!("set.shellPathDoesNotExist", shell = shell).to_string())); } - set_registry(DEFAULT_SHELL, RegistryValueData::String(shell))?; - } else { - remove_registry(DEFAULT_SHELL)?; } - if let Some(cmd_option) = cmd_option { set_registry(DEFAULT_SHELL_CMD_OPTION, RegistryValueData::String(cmd_option.clone()))?; } else { @@ -69,7 +88,7 @@ fn set_default_shell(shell: Option, cmd_option: Option, escape_a } #[cfg(not(windows))] -fn set_default_shell(_shell: Option, _cmd_option: Option, _escape_arguments: Option) -> Result<(), SshdConfigError> { +fn set_default_shell(_shell: String, _cmd_option: Option, _escape_arguments: Option) -> Result<(), SshdConfigError> { Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) } @@ -86,3 +105,79 @@ fn remove_registry(name: &str) -> Result<(), SshdConfigError> { registry_helper.remove()?; Ok(()) } + +fn set_sshd_config(cmd_info: &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"; + if cmd_info.clobber { + for (key, value) in &cmd_info.input { + if let Some(value_str) = value.as_str() { + writeln!(&mut config_text, "{key} {value_str}")?; + } else { + return Err(SshdConfigError::InvalidInput(t!("set.valueMustBeString", key = key).to_string())); + } + } + } else { + /* TODO: preserve existing settings that are not in input, probably need to call get */ + return Err(SshdConfigError::InvalidInput(t!("set.clobberFalseUnsupported").to_string())); + } + + // Write input to a temporary file and validate it with SSHD -T + let temp_file = tempfile::Builder::new() + .prefix("sshd_config_temp_") + .suffix(".tmp") + .tempfile()?; + let temp_path = temp_file.path().to_path_buf(); + let (file, path) = temp_file.keep()?; + debug!("{}", t!("set.tempFileCreated", path = temp_path.display())); + std::fs::write(&temp_path, &config_text) + .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; + drop(file); + + let args = Some( + SshdCommandArgs { + filepath: Some(temp_path), + additional_args: None, + } + ); + + debug!("{}", t!("set.validatingTempConfig")); + let result = invoke_sshd_config_validation(args); + // Always cleanup temp file, regardless of result success or failure + if let Err(e) = std::fs::remove_file(&path) { + debug!("{}", t!("set.cleanupFailed", path = path.display(), error = e)); + } + // Propagate failure, if any + result?; + + let sshd_config_path = get_default_sshd_config_path(cmd_info.metadata.filepath.clone()); + + if sshd_config_path.exists() { + let mut sshd_config_content = String::new(); + if let Ok(mut file) = std::fs::OpenOptions::new().read(true).open(&sshd_config_path) { + use std::io::Read; + file.read_to_string(&mut sshd_config_content) + .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; + } else { + return Err(SshdConfigError::CommandError(t!("set.sshdConfigReadFailed", path = sshd_config_path.display()).to_string())); + } + if !sshd_config_content.starts_with(SSHD_CONFIG_HEADER) { + // If config file is not already managed by this resource, create a backup of the existing file + debug!("{}", t!("set.backingUpConfig")); + let backup_path = format!("{}.bak", sshd_config_path.display()); + std::fs::write(&backup_path, &sshd_config_content) + .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; + debug!("{}", t!("set.backupCreated", path = backup_path)); + } + } else { + debug!("{}", t!("set.configDoesNotExist")); + } + + std::fs::write(&sshd_config_path, &config_text) + .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; + + Ok(()) +} diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index d095f3bb8..5877ff218 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -3,12 +3,13 @@ use rust_i18n::t; use serde_json::{Map, Value}; -use std::{path::Path, process::Command}; +use std::{path::PathBuf, process::Command}; use tracing::debug; use tracing_subscriber::{EnvFilter, filter::LevelFilter, Layer, prelude::__tracing_subscriber_SubscriberExt}; use crate::error::SshdConfigError; use crate::inputs::{CommandInfo, Metadata, SshdCommandArgs}; +use crate::metadata::{SSHD_CONFIG_DEFAULT_PATH_UNIX, SSHD_CONFIG_DEFAULT_PATH_WINDOWS}; use crate::parser::parse_text_to_map; /// Enable tracing. @@ -34,6 +35,22 @@ pub fn enable_tracing() { } } +/// Get the `sshd_config` path +/// Uses the input value, if provided. +/// If input value not provided, get default path for the OS. +/// On Windows, uses the `ProgramData` environment variable and standard path. +/// On Unix-like systems, uses the standard path. +pub fn get_default_sshd_config_path(input: Option) -> PathBuf { + if let Some(path) = input { + path + } else if cfg!(windows) { + let program_data = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".into()); + PathBuf::from(format!("{program_data}{SSHD_CONFIG_DEFAULT_PATH_WINDOWS}")) + } else { + PathBuf::from(SSHD_CONFIG_DEFAULT_PATH_UNIX) + } +} + /// Invoke sshd -T. /// /// # Errors @@ -45,7 +62,7 @@ pub fn invoke_sshd_config_validation(args: Option) -> Result Result, SshdConfigError> { .tempfile()?; // on Windows, sshd cannot read from the file if it is still open - let temp_path = temp_file.path().to_string_lossy().into_owned(); + let temp_path = temp_file.path().to_path_buf(); // do not automatically delete the file when it goes out of scope let (file, path) = temp_file.keep()?; // close the file handle to allow sshd to read it drop(file); - debug!("temporary file created at: {}", temp_path); + debug!("{}", t!("util.tempFileCreated", path = temp_path.display())); let args = Some( SshdCommandArgs { - filepath: Some(temp_path.clone()), + filepath: Some(temp_path), additional_args: None, } ); @@ -100,7 +117,7 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { // Clean up the temporary file regardless of success or failure let output = invoke_sshd_config_validation(args); if let Err(e) = std::fs::remove_file(&path) { - debug!("Failed to clean up temporary file {}: {}", path.display(), e); + debug!("{}", t!("util.cleanupFailed", path = path.display(), error = e)); } let result = output?; let sshd_config: Map = parse_text_to_map(&result)?; @@ -115,30 +132,24 @@ pub fn extract_sshd_defaults() -> Result, SshdConfigError> { pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result { 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") { serde_json::from_value(value)? } else { Metadata::new() }; - let sshd_args = metadata.filepath.as_ref().map(|filepath| { + let sshd_args = metadata.filepath.clone().map(|filepath| { SshdCommandArgs { - filepath: Some(filepath.clone()), + filepath: Some(filepath), additional_args: None, } }); - let include_defaults: bool = if let Some(value) = sshd_config.remove("_includeDefaults") { - if let Value::Bool(b) = value { - b - } else { - return Err(SshdConfigError::InvalidInput(t!("util.includeDefaultsMustBeBoolean").to_string())); - } - } else { - is_get - }; if is_get && !sshd_config.is_empty() { return Err(SshdConfigError::InvalidInput(t!("util.inputMustBeEmpty").to_string())); } return Ok(CommandInfo { + clobber, include_defaults, input: sshd_config, metadata, @@ -152,25 +163,17 @@ pub fn build_command_info(input: Option<&String>, is_get: bool) -> Result) -> Result { - let sshd_config_path = if let Some(input) = input { - input - } else if cfg!(windows) { - let program_data = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".into()); - format!("{program_data}\\ssh\\sshd_config") - } else { - "/etc/ssh/sshd_config".to_string() - }; - let filepath = Path::new(&sshd_config_path); +pub fn read_sshd_config(input: Option) -> 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) { + if let Ok(mut file) = std::fs::OpenOptions::new().read(true).open(&filepath) { use std::io::Read; file.read_to_string(&mut sshd_config_content) .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; @@ -182,3 +185,26 @@ pub fn read_sshd_config(input: Option) -> Result, key: &str, default: bool) -> Result { + if let Some(value) = map.remove(key) { + if let Value::Bool(b) = value { + Ok(b) + } else { + Err(SshdConfigError::InvalidInput(t!("util.inputMustBeBoolean", input = key).to_string())) + } + } else { + Ok(default) + } +} diff --git a/resources/sshdconfig/sshd-windows.dsc.resource.json b/resources/sshdconfig/sshd-windows.dsc.resource.json index 574dc28c6..6a92d0a12 100644 --- a/resources/sshdconfig/sshd-windows.dsc.resource.json +++ b/resources/sshdconfig/sshd-windows.dsc.resource.json @@ -18,6 +18,8 @@ "executable": "sshdconfig", "args": [ "set", + "-s", + "windows-global", { "jsonInputArg": "--input", "mandatory": true diff --git a/resources/sshdconfig/tests/defaultshell.tests.ps1 b/resources/sshdconfig/tests/defaultshell.tests.ps1 index 913d46118..2135057d7 100644 --- a/resources/sshdconfig/tests/defaultshell.tests.ps1 +++ b/resources/sshdconfig/tests/defaultshell.tests.ps1 @@ -116,7 +116,7 @@ Describe 'Default Shell Configuration Tests' -Skip:(!$IsWindows -or !$isElevated escapeArguments = $false } | ConvertTo-Json - sshdconfig set --input $inputConfig 2>$null + sshdconfig set --input $inputConfig -s windows-global 2>$null $LASTEXITCODE | Should -Be 0 $defaultShell = Get-ItemProperty -Path $RegistryPath -Name "DefaultShell" -ErrorAction SilentlyContinue @@ -134,7 +134,7 @@ Describe 'Default Shell Configuration Tests' -Skip:(!$IsWindows -or !$isElevated shell = "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" } | ConvertTo-Json - sshdconfig set --input $inputConfig 2>$null + sshdconfig set --input $inputConfig -s windows-global 2>$null $LASTEXITCODE | Should -Be 0 $defaultShell = Get-ItemProperty -Path $RegistryPath -Name "DefaultShell" -ErrorAction SilentlyContinue @@ -144,16 +144,16 @@ Describe 'Default Shell Configuration Tests' -Skip:(!$IsWindows -or !$isElevated It 'Should handle invalid JSON input gracefully' { $invalidJson = "{ invalid json }" - sshdconfig set --input $invalidJson 2>$null + sshdconfig set --input $invalidJson -s windows-global 2>$null $LASTEXITCODE | Should -Not -Be 0 } - It 'Should clear default shell when set to null' { + It 'Should clear default shell when set to empty string' { Set-ItemProperty -Path $RegistryPath -Name "DefaultShell" -Value "C:\Windows\System32\cmd.exe" - $inputConfig = @{ shell = $null } | ConvertTo-Json + $inputConfig = @{ shell = "" } | ConvertTo-Json - sshdconfig set --input $inputConfig 2>$null + sshdconfig set --input $inputConfig -s windows-global 2>$null $LASTEXITCODE | Should -Be 0 $result = Get-ItemProperty -Path $RegistryPath -Name "DefaultShell" -ErrorAction SilentlyContinue @@ -170,7 +170,7 @@ Describe 'Default Shell Configuration Tests' -Skip:(!$IsWindows -or !$isElevated } $inputJson = $originalConfig | ConvertTo-Json - sshdconfig set --input $inputJson 2>$null + sshdconfig set --input $inputJson -s windows-global 2>$null $LASTEXITCODE | Should -Be 0 $getOutput = sshdconfig get -s windows-global 2>$null @@ -191,7 +191,7 @@ Describe 'Default Shell Configuration Tests' -Skip:(!$IsWindows -or !$isElevated $inputConfig = @{ shell = $null } | ConvertTo-Json - sshdconfig set --input $inputConfig 2>$null + sshdconfig set --input $inputConfig -s windows-global 2>$null $LASTEXITCODE | Should -Be 0 $result = Get-ItemProperty -Path $RegistryPath -Name "DefaultShell" -ErrorAction SilentlyContinue @@ -204,7 +204,7 @@ Describe 'Default Shell Configuration Error Handling on Non-Windows Platforms' - It 'Should return error for set command' { $inputConfig = @{ shell = $null } | ConvertTo-Json - $out = sshdconfig set --input $inputConfig 2>&1 + $out = sshdconfig set --input $inputConfig -s windows-global 2>&1 $LASTEXITCODE | Should -Not -Be 0 $result = $out | ConvertFrom-Json $found = $false diff --git a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 new file mode 100644 index 000000000..ee716522d --- /dev/null +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +BeforeDiscovery { + if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + $isElevated = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) + $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) + $skipTest = !$isElevated -or !$sshdExists + } +} + +Describe 'sshd_config Set Tests' -Skip:(!$IsWindows -or $skipTest) { + BeforeAll { + # Create a temporary test directory for sshd_config files + $TestDir = Join-Path $TestDrive "sshd_test" + New-Item -Path $TestDir -ItemType Directory -Force | Out-Null + $TestConfigPath = Join-Path $TestDir "sshd_config" + } + + AfterEach { + # Clean up test config file after each test + if (Test-Path $TestConfigPath) { + Remove-Item -Path $TestConfigPath -Force -ErrorAction SilentlyContinue + } + if (Test-Path "$TestConfigPath.bak") { + Remove-Item -Path "$TestConfigPath.bak" -Force -ErrorAction SilentlyContinue + } + } + + Context 'Set with valid keyword and value' { + It 'Should set a valid keyword with valid value' { + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + Port = "1234" + } | ConvertTo-Json + + $output = sshdconfig set --input $inputConfig -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + + # Verify file was created + Test-Path $TestConfigPath | Should -Be $true + + # Verify content using get + $getInput = @{ + _metadata = @{ + filepath = $TestConfigPath + } + } | ConvertTo-Json + $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + $result.Port | Should -Be "1234" + } + + It 'Should create backup when file exists and is not managed by DSC' { + # Create a non-DSC managed file + "Port 22`nPermitRootLogin yes" | Set-Content $TestConfigPath + + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + Port = "5555" + } | ConvertTo-Json + + sshdconfig set --input $inputConfig -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + + # Verify backup was created + Test-Path "$TestConfigPath.bak" | Should -Be $true + + # Verify backup content + $backupContent = Get-Content "$TestConfigPath.bak" -Raw + $backupContent | Should -Match "Port 22" + $backupContent | Should -Match "PermitRootLogin yes" + + # Verify new content using get + $getInput = @{ + _metadata = @{ + filepath = $TestConfigPath + } + } | ConvertTo-Json + $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + $result.Port | Should -Be "5555" + } + + It 'Should not create backup when file is already managed by DSC' { + # Create a DSC-managed file + $initialConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + Port = "6789" + } | ConvertTo-Json + + sshdconfig set --input $initialConfig -s sshd-config 2>$null + + # Update the file + $newConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + Port = "7777" + } | ConvertTo-Json + + sshdconfig set --input $newConfig -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + + # Verify no backup was created + Test-Path "$TestConfigPath.bak" | Should -Be $false + + # Verify content using get + $getInput = @{ + _metadata = @{ + filepath = $TestConfigPath + } + } | ConvertTo-Json + $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + $result.Port | Should -Be "7777" + } + } + + Context 'Set with invalid configuration' { + It 'Should fail with clobber set to false' { + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $false + Port = "8888" + } | ConvertTo-Json + + $logFile = Join-Path $TestDrive "clobber_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 yet supported" + } + + 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 = @{ + filepath = $TestConfigPath + } + } | ConvertTo-Json + $originalResult = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + + # Try to set with invalid keyword + $invalidConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _clobber = $true + FakeKeyword = "1234" + } | ConvertTo-Json + + $output = sshdconfig set --input $invalidConfig -s sshd-config 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + + # Verify file content hasn't changed using get + $currentResult = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json + $currentResult.Port | Should -Be "9999" + $currentResult.Port | Should -Be $originalResult.Port + } + } +}