diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index ce838b746..5c6901a49 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -5,6 +5,7 @@ getInput = "input to get for sshd_config or default shell settings" exportCompare = "compare exported sshd_config setting to provided input" exportInput = "input to export from sshd_config" setInput = "input to set in sshd_config" +setWhatIf = "validate and return the expected state without applying changes" [canonical_properties] inputMustBeBoolean = "value of '%{input}' must be true or false" diff --git a/resources/sshdconfig/src/args.rs b/resources/sshdconfig/src/args.rs index 643711e36..ad1d0a443 100644 --- a/resources/sshdconfig/src/args.rs +++ b/resources/sshdconfig/src/args.rs @@ -19,7 +19,7 @@ pub enum TraceLevel { Warn, Info, Debug, - Trace + Trace, } #[derive(Parser)] @@ -28,7 +28,13 @@ pub struct Args { pub command: Command, #[clap(short = 'l', long, help = "Trace level to use", value_enum)] pub trace_level: Option, - #[clap(short = 'f', long, help = "Trace format to use", value_enum, default_value = "json")] + #[clap( + short = 'f', + long, + help = "Trace format to use", + value_enum, + default_value = "json" + )] pub trace_format: TraceFormat, } @@ -47,6 +53,8 @@ pub enum Command { input: String, #[clap(short = 's', long, hide = true)] setting: Setting, + #[clap(short = 'w', long = "what-if", help = t!("args.setWhatIf").to_string())] + what_if: bool, }, /// Export `sshd_config`, eventually to be used for repeatable keywords Export { diff --git a/resources/sshdconfig/src/main.rs b/resources/sshdconfig/src/main.rs index 90fd93ded..b5d1f82f2 100644 --- a/resources/sshdconfig/src/main.rs +++ b/resources/sshdconfig/src/main.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use clap::{Parser}; +use clap::Parser; use rust_i18n::{i18n, t}; use schemars::schema_for; use serde_json::Map; @@ -43,33 +43,35 @@ fn main() { Command::Export { input, compare } => { debug!("{}: {:?}", t!("main.export").to_string(), input); invoke_export(input.as_ref(), *compare) - }, - Command::Get { input, setting } => { - invoke_get(input.as_ref(), setting) - }, + } + Command::Get { input, setting } => invoke_get(input.as_ref(), setting), Command::Schema { setting } => { debug!("{}; {:?}", t!("main.schema").to_string(), setting); let schema = match setting { Setting::SshdConfig => { schema_for!(SshdConfigParser) - }, + } Setting::SshdConfigRepeat => { schema_for!(RepeatInput) - }, + } Setting::SshdConfigRepeatList => { schema_for!(RepeatListInput) - }, + } Setting::WindowsGlobal => { schema_for!(DefaultShell) } }; println!("{}", serde_json::to_string(&schema).unwrap()); Ok(Map::new()) - }, - Command::Set { input, setting } => { + } + Command::Set { + input, + setting, + what_if, + } => { debug!("{}", t!("main.set", input = input).to_string()); - invoke_set(input, setting) - }, + invoke_set(input, setting, *what_if) + } }; match result { @@ -84,7 +86,7 @@ fn main() { } } exit(EXIT_SUCCESS); - }, + } Err(e) => { error!("{}", e); exit(EXIT_FAILURE); diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 47d4e9f47..43ebe34cd 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -3,14 +3,18 @@ #[cfg(windows)] use { - std::path::Path, - dsc_lib_registry::{config::RegistryValueData, RegistryHelper}, - crate::metadata::windows::{DEFAULT_SHELL, DEFAULT_SHELL_CMD_OPTION, DEFAULT_SHELL_ESCAPE_ARGS, REGISTRY_PATH}, + crate::metadata::windows::{ + DEFAULT_SHELL, DEFAULT_SHELL_CMD_OPTION, DEFAULT_SHELL_ESCAPE_ARGS, REGISTRY_PATH, + }, + dsc_lib_registry::{RegistryHelper, config::RegistryValueData}, }; use rust_i18n::t; use serde_json::{Map, Value}; -use std::{path::PathBuf, string::String}; +use std::{ + path::{Path, PathBuf}, + string::String, +}; use tracing::{debug, info, warn}; use crate::args::{DefaultShell, Setting}; @@ -21,57 +25,81 @@ use crate::get::get_sshd_settings; use crate::inputs::{CommandInfo, SshdCommandArgs}; use crate::metadata::{SSHD_CONFIG_HEADER, SSHD_CONFIG_HEADER_VERSION, SSHD_CONFIG_HEADER_WARNING}; use crate::repeat_keyword::{ - RepeatInput, RepeatListInput, NameValueEntry, - add_or_update_entry, extract_single_keyword, remove_entry, parse_and_validate_entries + NameValueEntry, RepeatInput, RepeatListInput, add_or_update_entry, extract_single_keyword, + parse_and_validate_entries, remove_entry, +}; +use crate::util::{ + build_command_info, ensure_sshd_config_exists, get_default_sshd_config_path, + invoke_sshd_config_validation, }; -use crate::util::{build_command_info, ensure_sshd_config_exists, 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, setting: &Setting) -> Result, SshdConfigError> { +pub fn invoke_set( + input: &str, + setting: &Setting, + what_if: bool, +) -> Result, SshdConfigError> { match setting { Setting::SshdConfig => { debug!("{} {:?}", t!("set.settingSshdConfig").to_string(), setting); 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), - } - }, + let state = set_sshd_config(&mut cmd_info, what_if)?; + if what_if { Ok(state) } else { Ok(Map::new()) } + } Setting::SshdConfigRepeat => { debug!("{} {:?}", t!("set.settingSshdConfig").to_string(), setting); let cmd_info = build_command_info(Some(&input.to_string()), false)?; - set_sshd_config_repeat(input, &cmd_info) - }, + set_sshd_config_repeat(input, &cmd_info, what_if) + } Setting::SshdConfigRepeatList => { debug!("{} {:?}", t!("set.settingSshdConfig").to_string(), setting); let cmd_info = build_command_info(Some(&input.to_string()), false)?; - set_sshd_config_repeat_list(input, &cmd_info) - }, + set_sshd_config_repeat_list(input, &cmd_info, what_if) + } Setting::WindowsGlobal => { - debug!("{} {:?}", t!("set.settingDefaultShell").to_string(), setting); + 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())), + debug!( + "{}", + t!( + "set.defaultShellDebug", + shell = format!("{:?}", default_shell) + ) + ); + let desired_state = get_default_shell_desired_state(default_shell)?; + if what_if { + default_shell_to_map(&desired_state) + } else { + set_default_shell(&desired_state)?; + Ok(Map::new()) + } + } + Err(e) => Err(SshdConfigError::InvalidInput( + t!("set.failedToParseDefaultShell", error = e).to_string(), + )), } } } } /// Handle single name-value keyword entry operations (add or remove). -fn set_sshd_config_repeat(input: &str, cmd_info: &CommandInfo) -> Result, SshdConfigError> { - let keyword_input: RepeatInput = serde_json::from_str(input) - .map_err(|e| SshdConfigError::InvalidInput(t!("set.failedToParse", input = e.to_string()).to_string()))?; +fn set_sshd_config_repeat( + input: &str, + cmd_info: &CommandInfo, + what_if: bool, +) -> Result, SshdConfigError> { + let keyword_input: RepeatInput = serde_json::from_str(input).map_err(|e| { + SshdConfigError::InvalidInput(t!("set.failedToParse", input = e.to_string()).to_string()) + })?; let (keyword, entry_value) = extract_single_keyword(keyword_input.additional_properties)?; @@ -80,8 +108,9 @@ fn set_sshd_config_repeat(input: &str, cmd_info: &CommandInfo) -> Result Result Result, SshdConfigError> { - let list_input: RepeatListInput = serde_json::from_str(input) - .map_err(|e| SshdConfigError::InvalidInput(t!("set.failedToParse", input = e.to_string()).to_string()))?; +fn set_sshd_config_repeat_list( + input: &str, + cmd_info: &CommandInfo, + what_if: bool, +) -> Result, SshdConfigError> { + let list_input: RepeatListInput = serde_json::from_str(input).map_err(|e| { + SshdConfigError::InvalidInput(t!("set.failedToParse", input = e.to_string()).to_string()) + })?; let (keyword, entries_value) = extract_single_keyword(list_input.additional_properties)?; let mut existing_config = get_existing_config(cmd_info)?; // Ensure it's an array let Value::Array(ref entries_array) = entries_value else { return Err(SshdConfigError::InvalidInput( - t!("set.expectedArrayForKeyword", keyword = keyword).to_string() + t!("set.expectedArrayForKeyword", keyword = keyword).to_string(), )); }; @@ -120,39 +162,85 @@ fn set_sshd_config_repeat_list(input: &str, cmd_info: &CommandInfo) -> Result, 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) { - return Err(SshdConfigError::InvalidInput(t!("set.shellPathMustNotBeRelative").to_string())); +fn get_default_shell_desired_state( + default_shell: DefaultShell, +) -> Result { + if let Some(shell) = default_shell.shell.as_deref() + && !shell.is_empty() + { + let shell_path = Path::new(shell); + if shell_path.is_relative() + && shell_path + .components() + .any(|c| c == std::path::Component::ParentDir) + { + return Err(SshdConfigError::InvalidInput( + t!("set.shellPathMustNotBeRelative").to_string(), + )); } if !shell_path.exists() { - return Err(SshdConfigError::InvalidInput(t!("set.shellPathDoesNotExist", shell = shell).to_string())); + return Err(SshdConfigError::InvalidInput( + t!("set.shellPathDoesNotExist", shell = shell).to_string(), + )); } - set_registry(DEFAULT_SHELL, RegistryValueData::String(shell))?; } - if let Some(cmd_option) = cmd_option { - set_registry(DEFAULT_SHELL_CMD_OPTION, RegistryValueData::String(cmd_option.clone()))?; + Ok(DefaultShell { + shell: default_shell.shell.filter(|shell| !shell.is_empty()), + cmd_option: default_shell.cmd_option, + escape_arguments: default_shell.escape_arguments, + }) +} + +fn default_shell_to_map( + default_shell: &DefaultShell, +) -> Result, SshdConfigError> { + let value = serde_json::to_value(default_shell)?; + match value { + Value::Object(map) => Ok(map), + _ => Ok(Map::new()), + } +} + +#[cfg(windows)] +fn set_default_shell(default_shell: &DefaultShell) -> Result<(), SshdConfigError> { + debug!("{}", t!("set.settingDefaultShell")); + if let Some(shell) = &default_shell.shell { + set_registry(DEFAULT_SHELL, RegistryValueData::String(shell.clone()))?; + } else { + remove_registry(DEFAULT_SHELL)?; + } + + if let Some(cmd_option) = &default_shell.cmd_option { + set_registry( + DEFAULT_SHELL_CMD_OPTION, + RegistryValueData::String(cmd_option.clone()), + )?; } else { remove_registry(DEFAULT_SHELL_CMD_OPTION)?; } - if let Some(escape_args) = escape_arguments { + if let Some(escape_args) = default_shell.escape_arguments { let mut escape_data = 0; if escape_args { escape_data = 1; } - set_registry(DEFAULT_SHELL_ESCAPE_ARGS, RegistryValueData::DWord(escape_data))?; + set_registry( + DEFAULT_SHELL_ESCAPE_ARGS, + RegistryValueData::DWord(escape_data), + )?; } else { remove_registry(DEFAULT_SHELL_ESCAPE_ARGS)?; } @@ -161,8 +249,10 @@ fn set_default_shell(shell: String, cmd_option: Option, escape_arguments } #[cfg(not(windows))] -fn set_default_shell(_shell: String, _cmd_option: Option, _escape_arguments: Option) -> Result<(), SshdConfigError> { - Err(SshdConfigError::InvalidInput(t!("get.windowsOnly").to_string())) +fn set_default_shell(_default_shell: &DefaultShell) -> Result<(), SshdConfigError> { + Err(SshdConfigError::InvalidInput( + t!("get.windowsOnly").to_string(), + )) } #[cfg(windows)] @@ -179,7 +269,10 @@ fn remove_registry(name: &str) -> Result<(), SshdConfigError> { Ok(()) } -fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { +fn set_sshd_config( + cmd_info: &mut CommandInfo, + what_if: bool, +) -> 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) @@ -202,14 +295,28 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { existing_config }; - write_and_validate_config(&mut config_to_write, cmd_info.metadata.filepath.as_ref()) + write_and_validate_config( + &mut config_to_write, + cmd_info.metadata.filepath.as_ref(), + what_if, + )?; + Ok(config_to_write) } /// Write configuration to file after validation. -fn write_and_validate_config(config: &mut Map, filepath: Option<&PathBuf>) -> Result<(), SshdConfigError> { +fn write_and_validate_config( + config: &mut Map, + filepath: Option<&PathBuf>, + what_if: bool, +) -> Result<(), SshdConfigError> { debug!("{}", t!("set.writingTempConfig")); CanonicalProperties::remove_all(config); - let mut config_text = SSHD_CONFIG_HEADER.to_string() + "\n" + SSHD_CONFIG_HEADER_VERSION + "\n" + SSHD_CONFIG_HEADER_WARNING + "\n"; + let mut config_text = SSHD_CONFIG_HEADER.to_string() + + "\n" + + SSHD_CONFIG_HEADER_VERSION + + "\n" + + SSHD_CONFIG_HEADER_WARNING + + "\n"; config_text.push_str(&write_config_map_to_text(config)?); // Write input to a temporary file and validate it with SSHD -T @@ -224,32 +331,46 @@ fn write_and_validate_config(config: &mut Map, filepath: Option<& .map_err(|e| SshdConfigError::CommandError(e.to_string()))?; drop(file); - let args = Some( - SshdCommandArgs { - filepath: Some(temp_path), - additional_args: None, - } - ); + 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) { - warn!("{}", t!("set.cleanupFailed", path = path.display(), error = e)); + warn!( + "{}", + t!("set.cleanupFailed", path = path.display(), error = e) + ); } // Propagate failure, if any result?; + if what_if { + return Ok(()); + } + let sshd_config_path = get_default_sshd_config_path(filepath.cloned())?; 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) { + 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())); + 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 diff --git a/resources/sshdconfig/sshd-subsystem.dsc.resource.json b/resources/sshdconfig/sshd-subsystem.dsc.resource.json index c11850730..ecae08653 100644 --- a/resources/sshdconfig/sshd-subsystem.dsc.resource.json +++ b/resources/sshdconfig/sshd-subsystem.dsc.resource.json @@ -24,8 +24,12 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } ], + "whatIfReturns": "state", "handlesExist": true }, "schema": { diff --git a/resources/sshdconfig/sshd-subsystemList.dsc.resource.json b/resources/sshdconfig/sshd-subsystemList.dsc.resource.json index 6fe9a6c9a..27047b0e3 100644 --- a/resources/sshdconfig/sshd-subsystemList.dsc.resource.json +++ b/resources/sshdconfig/sshd-subsystemList.dsc.resource.json @@ -23,8 +23,12 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } - ] + ], + "whatIfReturns": "state" }, "schema": { "embedded": { diff --git a/resources/sshdconfig/sshd-windows.dsc.resource.json b/resources/sshdconfig/sshd-windows.dsc.resource.json index 814b9d1df..114919e53 100644 --- a/resources/sshdconfig/sshd-windows.dsc.resource.json +++ b/resources/sshdconfig/sshd-windows.dsc.resource.json @@ -24,8 +24,12 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } - ] + ], + "whatIfReturns": "state" }, "schema": { "command": { diff --git a/resources/sshdconfig/sshd_config.dsc.resource.json b/resources/sshdconfig/sshd_config.dsc.resource.json index 223f4f05c..64785a2a3 100644 --- a/resources/sshdconfig/sshd_config.dsc.resource.json +++ b/resources/sshdconfig/sshd_config.dsc.resource.json @@ -25,8 +25,12 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } - ] + ], + "whatIfReturns": "state" }, "export": { "executable": "sshdconfig", diff --git a/resources/sshdconfig/tests/sshdconfig.whatif.tests.ps1 b/resources/sshdconfig/tests/sshdconfig.whatif.tests.ps1 new file mode 100644 index 000000000..913c5df42 --- /dev/null +++ b/resources/sshdconfig/tests/sshdconfig.whatif.tests.ps1 @@ -0,0 +1,175 @@ +# 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) + } + else { + $isElevated = (id -u) -eq 0 + } + + $sshdExists = ($null -ne (Get-Command sshd -CommandType Application -ErrorAction Ignore)) + $skipSshdTest = !$isElevated -or !$sshdExists +} + +Describe 'sshdconfig manifest what-if definitions' { + It 'Defines what-if for ' -ForEach @( + @{ ManifestName = 'sshd_config.dsc.resource.json' } + @{ ManifestName = 'sshd-windows.dsc.resource.json' } + @{ ManifestName = 'sshd-subsystem.dsc.resource.json' } + @{ ManifestName = 'sshd-subsystemList.dsc.resource.json' } + ) { + $manifest = Get-Content -Raw -Path (Join-Path $PSScriptRoot '..' $ManifestName) | ConvertFrom-Json + + $manifest.set.whatIfReturns | Should -BeExactly 'state' + $whatIfArg = $manifest.set.args | Where-Object { $_.whatIfArg } + $whatIfArg.whatIfArg | Should -BeExactly '--what-if' + } +} + +Describe 'sshdconfig what-if set tests' -Skip:($skipSshdTest) { + BeforeAll { + $TestDir = Join-Path $TestDrive 'sshd_whatif_test' + New-Item -Path $TestDir -ItemType Directory -Force | Out-Null + $TestConfigPath = Join-Path $TestDir 'sshd_config' + + if ($IsWindows) { + $script:DefaultSftpPath = 'sftp-server.exe' + $script:AlternatePath = "$env:SystemDrive\OpenSSH\bin\sftp.exe" + } + else { + $script:DefaultSftpPath = '/usr/lib/openssh/sftp-server' + $script:AlternatePath = '/usr/libexec/sftp-server' + } + } + + AfterEach { + if (Test-Path $TestConfigPath) { + Remove-Item -Path $TestConfigPath -Force -ErrorAction SilentlyContinue + } + if (Test-Path "${TestConfigPath}_backup") { + Remove-Item -Path "${TestConfigPath}_backup" -Force -ErrorAction SilentlyContinue + } + } + + It 'Returns predicted sshd_config state without writing the target file' { + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _purge = $true + Port = '1234' + PasswordAuthentication = $false + } | ConvertTo-Json + + $output = sshdconfig set --what-if --input $inputConfig -s sshd-config 2>$null + $LASTEXITCODE | Should -Be 0 + + $result = $output | ConvertFrom-Json + $result.port | Should -Be '1234' + $result.passwordauthentication | Should -BeFalse + Test-Path $TestConfigPath | Should -BeFalse + } + + It 'Returns predicted single subsystem state without updating the file' { + @" +Port 22 +Subsystem sftp $script:DefaultSftpPath +"@ | Set-Content -Path $TestConfigPath + + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _exist = $true + subsystem = @{ + name = 'newsubsystem' + value = $script:AlternatePath + } + } | ConvertTo-Json + + $output = sshdconfig set --what-if --input $inputConfig -s sshd-config-repeat 2>$null + $LASTEXITCODE | Should -Be 0 + + $result = $output | ConvertFrom-Json + $result.subsystem.Count | Should -Be 2 + ($result.subsystem | Where-Object { $_.name -ceq 'newsubsystem' }).value | Should -Be $script:AlternatePath + Get-Content -Raw -Path $TestConfigPath | Should -Not -Match 'newsubsystem' + } + + It 'Returns predicted subsystem list state without updating the file' { + @" +Port 22 +Subsystem sftp $script:DefaultSftpPath +Subsystem test2 /path/to/test2 +"@ | Set-Content -Path $TestConfigPath + + $inputConfig = @{ + _metadata = @{ + filepath = $TestConfigPath + } + _purge = $true + subsystem = @( + @{ + name = 'powershell' + value = $script:AlternatePath + } + ) + } | ConvertTo-Json -Depth 10 + + $output = sshdconfig set --what-if --input $inputConfig -s sshd-config-repeat-list 2>$null + $LASTEXITCODE | Should -Be 0 + + $result = $output | ConvertFrom-Json + $result.subsystem.Count | Should -Be 1 + $result.subsystem[0].name | Should -BeExactly 'powershell' + Get-Content -Raw -Path $TestConfigPath | Should -Match 'test2' + Get-Content -Raw -Path $TestConfigPath | Should -Not -Match 'powershell' + } +} + +Describe 'sshdconfig Windows global what-if tests' -Skip:(!$IsWindows) { + BeforeAll { + $RegistryPath = 'HKLM:\SOFTWARE\OpenSSH' + $ValueNames = @('DefaultShell', 'DefaultShellCommandOption', 'DefaultShellEscapeArguments') + $OriginalValues = @{} + + if (Test-Path $RegistryPath) { + foreach ($valueName in $ValueNames) { + $value = Get-ItemProperty -Path $RegistryPath -Name $valueName -ErrorAction SilentlyContinue + if ($null -ne $value) { + $OriginalValues[$valueName] = $value.$valueName + } + } + } + } + + It 'Returns predicted default shell state without updating registry values' { + $inputConfig = @{ + shell = 'C:\Windows\System32\cmd.exe' + cmdOption = '/c' + escapeArguments = $false + } | ConvertTo-Json + + $output = sshdconfig set --what-if --input $inputConfig -s windows-global 2>$null + $LASTEXITCODE | Should -Be 0 + + $result = $output | ConvertFrom-Json + $result.shell | Should -Be 'C:\Windows\System32\cmd.exe' + $result.cmdOption | Should -Be '/c' + $result.escapeArguments | Should -BeFalse + + foreach ($valueName in $ValueNames) { + $value = Get-ItemProperty -Path $RegistryPath -Name $valueName -ErrorAction SilentlyContinue + if ($OriginalValues.ContainsKey($valueName)) { + $value.$valueName | Should -Be $OriginalValues[$valueName] + } + else { + $value | Should -BeNullOrEmpty + } + } + } +} \ No newline at end of file