From 98d1c11e978d95ae38581fc68fcffecbcb886637 Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Thu, 16 Apr 2026 09:16:58 +0200 Subject: [PATCH 1/2] Sync last barnach --- data.build.json | 4 + resources/sshdconfig/locales/en-us.toml | 5 +- resources/sshdconfig/src/error.rs | 2 - resources/sshdconfig/src/set.rs | 23 +++- resources/sshdconfig/src/util.rs | 65 +-------- .../sshdconfig/tests/sshdconfig.get.tests.ps1 | 5 +- .../sshdconfig/tests/sshdconfig.set.tests.ps1 | 109 +++------------- .../tests/sshdconfigRepeat.tests.ps1 | 116 ----------------- .../tests/sshdconfigRepeatList.tests.ps1 | 123 ------------------ 9 files changed, 41 insertions(+), 411 deletions(-) diff --git a/data.build.json b/data.build.json index bd36e3f5d..ea3ada62e 100644 --- a/data.build.json +++ b/data.build.json @@ -22,6 +22,7 @@ "powershell.dsc.extension.json", "powershell.discover.ps1", "powershell.dsc.resource.json", + "PowerShell_adapter.dsc.resource.json", "psDscAdapter/", "psscript.ps1", "psscript.dsc.resource.json", @@ -51,6 +52,7 @@ "powershell.dsc.extension.json", "powershell.discover.ps1", "powershell.dsc.resource.json", + "PowerShell_adapter.dsc.resource.json", "psDscAdapter/", "psscript.ps1", "psscript.dsc.resource.json", @@ -83,6 +85,7 @@ "powershell.dsc.extension.json", "powershell.discover.ps1", "powershell.dsc.resource.json", + "PowerShell_adapter.dsc.resource.json", "psDscAdapter/", "psscript.ps1", "psscript.dsc.resource.json", @@ -99,6 +102,7 @@ "sshd-subsystem.dsc.resource.json", "sshd-subsystemList.dsc.resource.json", "windowspowershell.dsc.resource.json", + "WindowsPowerShell_adapter.dsc.resource.json", "windowsupdate.dsc.resource.json", "wu_dsc.exe", "windows_firewall.dsc.resource.json", diff --git a/resources/sshdconfig/locales/en-us.toml b/resources/sshdconfig/locales/en-us.toml index ce838b746..2b215dc9e 100644 --- a/resources/sshdconfig/locales/en-us.toml +++ b/resources/sshdconfig/locales/en-us.toml @@ -11,7 +11,6 @@ inputMustBeBoolean = "value of '%{input}' must be true or false" [error] command = "Command" -configInitRequired = "Configuration File Initialization Required" envVar = "Environment Variable" fileNotFound = "File not found: %{path}" invalidInput = "Invalid Input" @@ -87,6 +86,7 @@ defaultShellDebug = "default_shell: %{shell}" expectedArrayForKeyword = "Expected array for keyword '%{keyword}'" failedToParse = "failed to parse: '%{input}'" 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." settingDefaultShell = "Setting default shell" settingSshdConfig = "Setting sshd_config" shellPathDoesNotExist = "shell path does not exist: '%{shell}'" @@ -99,9 +99,6 @@ writingTempConfig = "Writing temporary sshd_config file" [util] cleanupFailed = "Failed to clean up temporary file %{path}: %{error}" getIgnoresInputFilters = "get command does not support filtering based on input settings, provided input will be ignored" -seededConfigFromDefault = "Seeded missing sshd_config from '%{source}' to '%{target}'" -sshdConfigDefaultNotFound = "'%{path}' does not exist and no default source could be found. Checked: %{paths}. Start the sshd service to initialize it, then retry." -sshdConfigNotFoundNonWindows = "'%{path}' does not exist. Start the sshd service to initialize it, then retry." 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 94a0edbe3..52d0d62f5 100644 --- a/resources/sshdconfig/src/error.rs +++ b/resources/sshdconfig/src/error.rs @@ -9,8 +9,6 @@ use thiserror::Error; pub enum SshdConfigError { #[error("{t}: {0}", t = t!("error.command"))] CommandError(String), - #[error("{t}: {0}", t = t!("error.configInitRequired"))] - ConfigInitRequired(String), #[error("{t}: {0}", t = t!("error.envVar"))] EnvVarError(#[from] std::env::VarError), #[error("{t}", t = t!("error.fileNotFound", path = .0))] diff --git a/resources/sshdconfig/src/set.rs b/resources/sshdconfig/src/set.rs index 47d4e9f47..14c7564ad 100644 --- a/resources/sshdconfig/src/set.rs +++ b/resources/sshdconfig/src/set.rs @@ -24,7 +24,7 @@ use crate::repeat_keyword::{ RepeatInput, RepeatListInput, NameValueEntry, add_or_update_entry, extract_single_keyword, remove_entry, parse_and_validate_entries }; -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, get_default_sshd_config_path, invoke_sshd_config_validation}; /// Invoke the set command. /// @@ -189,9 +189,16 @@ fn set_sshd_config(cmd_info: &mut CommandInfo) -> Result<(), SshdConfigError> { let mut get_cmd_info = cmd_info.clone(); get_cmd_info.include_defaults = false; get_cmd_info.input = Map::new(); - ensure_sshd_config_exists(get_cmd_info.metadata.filepath.clone())?; - 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 { if value.is_null() { existing_config.remove(key); @@ -274,6 +281,12 @@ fn get_existing_config(cmd_info: &CommandInfo) -> Result, Ssh let mut get_cmd_info = cmd_info.clone(); get_cmd_info.include_defaults = false; get_cmd_info.input = Map::new(); - ensure_sshd_config_exists(get_cmd_info.metadata.filepath.clone())?; - get_sshd_settings(&get_cmd_info, false) + match get_sshd_settings(&get_cmd_info, false) { + Ok(config) => Ok(config), + Err(SshdConfigError::FileNotFound(_)) => { + // If file doesn't exist, create empty config + Ok(Map::new()) + } + Err(e) => Err(e), + } } diff --git a/resources/sshdconfig/src/util.rs b/resources/sshdconfig/src/util.rs index 27dae940e..4031e190b 100644 --- a/resources/sshdconfig/src/util.rs +++ b/resources/sshdconfig/src/util.rs @@ -107,69 +107,6 @@ pub fn get_default_sshd_config_path(input: Option) -> Result Vec { - let mut candidates: Vec = Vec::new(); - - if cfg!(windows) && let Ok(win_dir) = std::env::var("windir") { - candidates.push( - PathBuf::from(win_dir) - .join("System32") - .join("OpenSSH") - .join("sshd_config_default"), - ); - } - - candidates -} - -/// Ensure the target `sshd_config` exists by seeding it from a platform default source. -/// -/// # Errors -/// -/// This function returns an error if the target cannot be created or no source default config is available. -pub fn ensure_sshd_config_exists(input: Option) -> Result { - let target_path = get_default_sshd_config_path(input)?; - if target_path.exists() { - return Ok(target_path); - } - - if !cfg!(windows) { - return Err(SshdConfigError::ConfigInitRequired( - t!("util.sshdConfigNotFoundNonWindows", path = target_path.display()).to_string(), - )); - } - - let candidates = get_sshd_config_default_source_candidates(); - let source_path = candidates - .iter() - .find(|candidate| candidate.is_file()) - .cloned() - .ok_or_else(|| { - let paths = candidates - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", "); - SshdConfigError::ConfigInitRequired( - t!( - "util.sshdConfigDefaultNotFound", - path = target_path.display(), - paths = paths - ) - .to_string(), - ) - })?; - - if let Some(parent) = target_path.parent() { - std::fs::create_dir_all(parent)?; - } - - std::fs::copy(&source_path, &target_path)?; - debug!("{}", t!("util.seededConfigFromDefault", source = source_path.display(), target = target_path.display())); - - Ok(target_path) -} - /// Invoke sshd -T. /// /// # Errors @@ -307,3 +244,5 @@ pub fn read_sshd_config(input: Option) -> Result$stderrFile $LASTEXITCODE | Should -Not -Be 0 - Test-Path $nonExistentPath | Should -Be $false - $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 268e8a055..e90261bad 100644 --- a/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfig.set.tests.ps1 @@ -180,104 +180,25 @@ Describe 'sshd_config Set Tests' -Skip:($skipTest) { sshdconfig set --input $validConfig -s sshd-config } - Context 'Missing target with _purge=false on non-Windows' -Skip:($IsWindows) { - It 'Should fail when the target file does not exist' { - $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config_nonwindows" - $stderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile_nonwindows.txt" - $inputConfig = @{ - _metadata = @{ - filepath = $nonExistentPath - } - _purge = $false - Port = "8888" - } | ConvertTo-Json - - sshdconfig set --input $inputConfig -s sshd-config 2>$stderrFile - - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $nonExistentPath | Should -Be $false - (Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "does not exist" - - Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue - } - } - - Context 'Missing target with _purge=false on Windows' -Skip:(-not $IsWindows) { - BeforeAll { - $script:MockWinDir = Join-Path $TestDrive "mock_windir" - New-Item -Path $script:MockWinDir -ItemType Directory -Force | Out-Null - $script:WindowsDefaultSourcePath = Join-Path $script:MockWinDir "System32\OpenSSH\sshd_config_default" - } - - AfterEach { - Remove-Item -Path $script:CurrentWindowsStderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $script:CurrentWindowsTargetPath -Force -ErrorAction SilentlyContinue - } - - It 'Should create the target file from the default source' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_windows_success" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile_windows_success.txt" + It 'Should fail with purge=false when file does not exist' { + $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config" - $defaultSourceDirectory = Split-Path -Path $script:WindowsDefaultSourcePath -Parent - New-Item -Path $defaultSourceDirectory -ItemType Directory -Force | Out-Null - Set-Content -Path $script:WindowsDefaultSourcePath -Value @( - "Port 22", - "PasswordAuthentication yes" - ) -Encoding ascii - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _purge = $false - Port = "8888" - } | ConvertTo-Json - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir + $inputConfig = @{ + _metadata = @{ + filepath = $nonExistentPath } + _purge = $false + Port = "8888" + } | ConvertTo-Json - $LASTEXITCODE | Should -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $true - $result = sshdconfig get --input $inputConfig -s sshd-config 2>$null | ConvertFrom-Json - $result.Port | Should -Be "8888" - - Remove-Item -Path $script:WindowsDefaultSourcePath -Force -ErrorAction SilentlyContinue - } - - It 'Should fail and leave the target file absent when the default source is unavailable' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_windows_missing_default" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile_windows_missing_default.txt" - - Test-Path -Path $script:WindowsDefaultSourcePath -PathType Leaf -ErrorAction SilentlyContinue | Should -Be $false - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _purge = $false - Port = "8888" - } | ConvertTo-Json - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir - } + $stderrFile = Join-Path $TestDrive "stderr_purgefalse_nofile.txt" + sshdconfig set --input $inputConfig -s sshd-config 2>$stderrFile + $LASTEXITCODE | Should -Not -Be 0 - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $false - (Get-Content -Path $script:CurrentWindowsStderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "no default source could be found" - } + $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' { diff --git a/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 b/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 index 018613248..8a2a34d7b 100644 --- a/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfigRepeat.tests.ps1 @@ -33,7 +33,6 @@ Describe 'sshd-config-repeat Set Tests' -Skip:($skipTest) { $script:DefaultSftpPath = "/usr/lib/openssh/sftp-server" $script:AlternatePath = "/usr/libexec/sftp-server" } - } AfterEach { @@ -232,119 +231,4 @@ PasswordAuthentication yes $subsystems | Should -Contain "subsystem testExistDefault /path/to/subsystem" } } - - Context 'Missing target file on non-Windows' -Skip:($IsWindows) { - It 'Should fail when the target file does not exist' { - $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config_repeat_nonwindows" - $stderrFile = Join-Path $TestDrive "stderr_nofile_repeat_nonwindows.txt" - $inputConfig = @{ - _metadata = @{ - filepath = $nonExistentPath - } - _exist = $true - subsystem = @{ - name = "powershell" - value = "/usr/bin/pwsh -sshs" - } - } | ConvertTo-Json - - sshdconfig set --input $inputConfig -s sshd-config-repeat 2>$stderrFile - - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $nonExistentPath | Should -Be $false - (Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "does not exist" - - Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue - } - } - - Context 'Missing target file on Windows' -Skip:(-not $IsWindows) { - BeforeAll { - $script:MockWinDir = Join-Path $TestDrive "mock_windir_repeat" - New-Item -Path $script:MockWinDir -ItemType Directory -Force | Out-Null - $script:WindowsDefaultSourcePath = Join-Path $script:MockWinDir "System32\OpenSSH\sshd_config_default" - } - - AfterEach { - Remove-Item -Path $script:CurrentWindowsStderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $script:CurrentWindowsTargetPath -Force -ErrorAction SilentlyContinue - } - - It 'Should create the target file from the default source' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_repeat_windows_success" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_nofile_repeat_windows_success.txt" - - $defaultSourceDirectory = Split-Path -Path $script:WindowsDefaultSourcePath -Parent - New-Item -Path $defaultSourceDirectory -ItemType Directory -Force | Out-Null - Set-Content -Path $script:WindowsDefaultSourcePath -Value @( - "Port 22", - "PasswordAuthentication yes" - ) -Encoding ascii - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _exist = $true - subsystem = @{ - name = "powershell" - value = "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - } - } | ConvertTo-Json - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config-repeat 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir - } - - $LASTEXITCODE | Should -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $true - $getInput = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - } | ConvertTo-Json - $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json - $result.subsystem.name | Should -Be "powershell" - $result.subsystem.value | Should -Be "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - - Remove-Item -Path $script:WindowsDefaultSourcePath -Force -ErrorAction SilentlyContinue - } - - It 'Should fail and leave the target file absent when the default source is unavailable' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_repeat_windows_missing_default" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_nofile_repeat_windows_missing_default.txt" - - Test-Path -Path $script:WindowsDefaultSourcePath -PathType Leaf -ErrorAction SilentlyContinue | Should -Be $false - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _exist = $true - subsystem = @{ - name = "powershell" - value = "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - } - } | ConvertTo-Json - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config-repeat 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir - } - - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $false - (Get-Content -Path $script:CurrentWindowsStderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "no default source could be found" - } - } } diff --git a/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 b/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 index f1123ab3b..1869ea60d 100644 --- a/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 +++ b/resources/sshdconfig/tests/sshdconfigRepeatList.tests.ps1 @@ -33,7 +33,6 @@ Describe 'sshd-config-repeat-list Set Tests' -Skip:($skipTest) { $script:DefaultSftpPath = "/usr/lib/openssh/sftp-server" $script:AlternatePath = "/usr/libexec/sftp-server" } - } AfterEach { @@ -322,126 +321,4 @@ PasswordAuthentication yes $subsystems.Count | Should -Be 0 } } - - Context 'Missing target file on non-Windows' -Skip:($IsWindows) { - It 'Should fail when the target file does not exist' { - $nonExistentPath = Join-Path $TestDrive "nonexistent_sshd_config_repeatlist_nonwindows" - $stderrFile = Join-Path $TestDrive "stderr_nofile_repeatlist_nonwindows.txt" - $inputConfig = @{ - _metadata = @{ - filepath = $nonExistentPath - } - _purge = $false - subsystem = @( - @{ - name = "powershell" - value = "/usr/bin/pwsh -sshs" - } - ) - } | ConvertTo-Json -Depth 10 - - sshdconfig set --input $inputConfig -s sshd-config-repeat-list 2>$stderrFile - - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $nonExistentPath | Should -Be $false - (Get-Content -Path $stderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "does not exist" - - Remove-Item -Path $stderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $nonExistentPath -Force -ErrorAction SilentlyContinue - } - } - - Context 'Missing target file on Windows' -Skip:(-not $IsWindows) { - BeforeAll { - $script:MockWinDir = Join-Path $TestDrive "mock_windir_repeatlist" - New-Item -Path $script:MockWinDir -ItemType Directory -Force | Out-Null - $script:WindowsDefaultSourcePath = Join-Path $script:MockWinDir "System32\OpenSSH\sshd_config_default" - } - - AfterEach { - Remove-Item -Path $script:CurrentWindowsStderrFile -Force -ErrorAction SilentlyContinue - Remove-Item -Path $script:CurrentWindowsTargetPath -Force -ErrorAction SilentlyContinue - } - - It 'Should create the target file from the default source' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_repeatlist_windows_success" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_nofile_repeatlist_windows_success.txt" - - $defaultSourceDirectory = Split-Path -Path $script:WindowsDefaultSourcePath -Parent - New-Item -Path $defaultSourceDirectory -ItemType Directory -Force | Out-Null - Set-Content -Path $script:WindowsDefaultSourcePath -Value @( - "Port 22", - "PasswordAuthentication yes" - ) -Encoding ascii - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _purge = $false - subsystem = @( - @{ - name = "powershell" - value = "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - } - ) - } | ConvertTo-Json -Depth 10 - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config-repeat-list 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir - } - - $LASTEXITCODE | Should -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $true - $getInput = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - } | ConvertTo-Json - $result = sshdconfig get --input $getInput -s sshd-config 2>$null | ConvertFrom-Json - $psEntry = $result.subsystem | Where-Object { $_.name -eq "powershell" } - $psEntry | Should -Not -BeNullOrEmpty - $psEntry.value | Should -Be "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - - Remove-Item -Path $script:WindowsDefaultSourcePath -Force -ErrorAction SilentlyContinue - } - - It 'Should fail and leave the target file absent when the default source is unavailable' { - $script:CurrentWindowsTargetPath = Join-Path $TestDrive "nonexistent_sshd_config_repeatlist_windows_missing_default" - $script:CurrentWindowsStderrFile = Join-Path $TestDrive "stderr_nofile_repeatlist_windows_missing_default.txt" - - Test-Path -Path $script:WindowsDefaultSourcePath -PathType Leaf -ErrorAction SilentlyContinue | Should -Be $false - - $inputConfig = @{ - _metadata = @{ - filepath = $script:CurrentWindowsTargetPath - } - _purge = $false - subsystem = @( - @{ - name = "powershell" - value = "$env:ProgramFiles\PowerShell\7\pwsh.exe -sshs -NoLogo -NoProfile" - } - ) - } | ConvertTo-Json -Depth 10 - - $origWinDir = $env:windir - try { - $env:windir = $script:MockWinDir - sshdconfig set --input $inputConfig -s sshd-config-repeat-list 2>$script:CurrentWindowsStderrFile - } - finally { - $env:windir = $origWinDir - } - - $LASTEXITCODE | Should -Not -Be 0 - Test-Path $script:CurrentWindowsTargetPath | Should -Be $false - (Get-Content -Path $script:CurrentWindowsStderrFile -Raw -ErrorAction SilentlyContinue) | Should -Match "no default source could be found" - } - } } From a53677509f244ee553e5e25dbe17ef7d6c3f3a7d Mon Sep 17 00:00:00 2001 From: Michal Machniak Date: Thu, 23 Apr 2026 20:56:48 +0200 Subject: [PATCH 2/2] Add Windows Secret Store DSC resources --- Cargo.toml | 3 + resources/secret_store/Cargo.toml | 18 + resources/secret_store/locales/en-us.toml | 27 ++ .../secretstoresecret.dsc.resource.json | 101 +++++ .../secretstorevaultconfig.dsc.resource.json | 106 +++++ resources/secret_store/src/main.rs | 220 ++++++++++ resources/secret_store/src/secret.rs | 172 ++++++++ resources/secret_store/src/types.rs | 415 ++++++++++++++++++ resources/secret_store/src/vault_config.rs | 175 ++++++++ .../tests/secret_export.tests.ps1 | 91 ++++ .../secret_store/tests/secret_get.tests.ps1 | 84 ++++ .../secret_store/tests/secret_set.tests.ps1 | 103 +++++ .../tests/vault_config_get.tests.ps1 | 66 +++ .../tests/vault_config_set.tests.ps1 | 71 +++ .../tests/vault_config_test.tests.ps1 | 68 +++ 15 files changed, 1720 insertions(+) create mode 100644 resources/secret_store/Cargo.toml create mode 100644 resources/secret_store/locales/en-us.toml create mode 100644 resources/secret_store/secretstoresecret.dsc.resource.json create mode 100644 resources/secret_store/secretstorevaultconfig.dsc.resource.json create mode 100644 resources/secret_store/src/main.rs create mode 100644 resources/secret_store/src/secret.rs create mode 100644 resources/secret_store/src/types.rs create mode 100644 resources/secret_store/src/vault_config.rs create mode 100644 resources/secret_store/tests/secret_export.tests.ps1 create mode 100644 resources/secret_store/tests/secret_get.tests.ps1 create mode 100644 resources/secret_store/tests/secret_set.tests.ps1 create mode 100644 resources/secret_store/tests/vault_config_get.tests.ps1 create mode 100644 resources/secret_store/tests/vault_config_set.tests.ps1 create mode 100644 resources/secret_store/tests/vault_config_test.tests.ps1 diff --git a/Cargo.toml b/Cargo.toml index 7cd0e980f..f4064692c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "resources/WindowsUpdate", "resources/windows_service", "resources/windows_firewall", + "resources/secret_store", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -53,6 +54,7 @@ default-members = [ "resources/WindowsUpdate", "resources/windows_service", "resources/windows_firewall", + "resources/secret_store", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -84,6 +86,7 @@ Windows = [ "resources/WindowsUpdate", "resources/windows_service", "resources/windows_firewall", + "resources/secret_store", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", diff --git a/resources/secret_store/Cargo.toml b/resources/secret_store/Cargo.toml new file mode 100644 index 000000000..20336a5f6 --- /dev/null +++ b/resources/secret_store/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "secret_store" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[[bin]] +name = "secret_store" +path = "src/main.rs" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/resources/secret_store/locales/en-us.toml b/resources/secret_store/locales/en-us.toml new file mode 100644 index 000000000..ce4ccb7a6 --- /dev/null +++ b/resources/secret_store/locales/en-us.toml @@ -0,0 +1,27 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: secret_store [--input ]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, test, or export" +unknownResourceType = "Unknown resource type: '%{resource_type}'. Expected: vault-config or secret" +missingArguments = "Missing required arguments. Expected: secret_store " +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +windowsOnly = "This resource is only supported on Windows" +stdinReadError = "Failed to read from stdin: %{error}" + +[vault_config] +pwshNotFound = "PowerShell (pwsh) not found. Ensure PowerShell 7+ is installed and on the PATH." +moduleNotInstalled = "Required PowerShell module '%{module}' is not installed. Install it with: Install-Module -Name %{module} -Force" +getConfigFailed = "Failed to get SecretStore configuration: %{error}" +setConfigFailed = "Failed to set SecretStore configuration: %{error}" +unlockFailed = "Failed to unlock SecretStore: %{error}" +registerVaultFailed = "Failed to register SecretStore vault: %{error}" + +[secret] +nameRequired = "'name' is required" +getFailed = "Failed to get secret '%{name}': %{error}" +setFailed = "Failed to set secret '%{name}': %{error}" +removeFailed = "Failed to remove secret '%{name}': %{error}" +exportFailed = "Failed to export secrets: %{error}" diff --git a/resources/secret_store/secretstoresecret.dsc.resource.json b/resources/secret_store/secretstoresecret.dsc.resource.json new file mode 100644 index 000000000..9b67c93f1 --- /dev/null +++ b/resources/secret_store/secretstoresecret.dsc.resource.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage individual secrets in the Microsoft.PowerShell.SecretStore vault, including creating, updating, removing, and exporting secrets.", + "tags": [ + "windows", + "secretstore", + "secretmanagement", + "secret", + "credential" + ], + "type": "Microsoft.Windows/SecretStoreSecret", + "version": "0.1.0", + "get": { + "executable": "secret_store", + "args": [ + "get", + "secret" + ], + "input": "stdin" + }, + "set": { + "executable": "secret_store", + "args": [ + "set", + "secret" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "export": { + "executable": "secret_store", + "args": [ + "export", + "secret" + ], + "input": "stdin" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Operation error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/SecretStoreSecret/v0.1.0/schema.json", + "title": "SecretStore Secret", + "description": "Manages individual secrets in the Microsoft.PowerShell.SecretStore vault on Windows. Supports creating, updating, removing, and exporting secrets with optional metadata.", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Secret name", + "description": "The name of the secret. Required for get and set operations. For export, this is optional and wildcards (*) are supported." + }, + "secretType": { + "type": "string", + "title": "Secret type", + "description": "The type of the secret. Read-only; returned by get and export operations.", + "enum": [ + "String", + "SecureString", + "ByteArray", + "PSCredential", + "Hashtable" + ], + "readOnly": true + }, + "value": { + "type": "string", + "title": "Secret value", + "description": "The value to set for the secret. Used only during set operations. Never returned by get or export." + }, + "vaultName": { + "type": "string", + "title": "Vault name", + "description": "The name of the vault to use. Defaults to the SecretStore default vault if not specified." + }, + "metadata": { + "type": "object", + "title": "Metadata", + "description": "Key-value metadata associated with the secret.", + "additionalProperties": { + "type": "string" + } + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the secret exists or should exist. Set to false to remove a secret." + } + } + } + } +} diff --git a/resources/secret_store/secretstorevaultconfig.dsc.resource.json b/resources/secret_store/secretstorevaultconfig.dsc.resource.json new file mode 100644 index 000000000..bd8c02090 --- /dev/null +++ b/resources/secret_store/secretstorevaultconfig.dsc.resource.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage the Microsoft.PowerShell.SecretStore vault configuration, including module installation, vault registration, authentication, password timeout, and interaction settings.", + "tags": [ + "windows", + "secretstore", + "secretmanagement", + "vault", + "configuration" + ], + "type": "Microsoft.Windows/SecretStoreVaultConfig", + "version": "0.1.0", + "get": { + "executable": "secret_store", + "args": [ + "get", + "vault-config" + ], + "input": "stdin" + }, + "set": { + "executable": "secret_store", + "args": [ + "set", + "vault-config" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state" + }, + "test": { + "executable": "secret_store", + "args": [ + "test", + "vault-config" + ], + "input": "stdin" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Operation error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/SecretStoreVaultConfig/v0.1.0/schema.json", + "title": "SecretStore Vault Configuration", + "description": "Manages the Microsoft.PowerShell.SecretStore vault configuration on Windows. Ensures modules are installed, vault is registered, and configuration (authentication, password timeout, interaction) matches desired state.", + "type": "object", + "additionalProperties": false, + "properties": { + "authentication": { + "type": "string", + "title": "Authentication type", + "description": "The authentication type for the SecretStore vault. 'Password' requires a password to unlock the vault. 'None' allows passwordless access.", + "enum": [ + "None", + "Password" + ], + "default": "Password" + }, + "passwordTimeout": { + "type": "integer", + "title": "Password timeout (seconds)", + "description": "The number of seconds the vault remains unlocked after a password is provided. After this period, the vault is automatically locked. Set to -1 for no timeout.", + "default": 900 + }, + "interaction": { + "type": "string", + "title": "Interaction preference", + "description": "Controls whether the SecretStore prompts the user. 'Prompt' allows interactive prompts. 'None' suppresses all prompts (required for automation/DSC).", + "enum": [ + "Prompt", + "None" + ], + "default": "None" + }, + "secretManagementInstalled": { + "type": "boolean", + "title": "SecretManagement module installed", + "description": "Indicates whether the Microsoft.PowerShell.SecretManagement module is installed. Read-only; returned by get operations.", + "readOnly": true + }, + "secretStoreInstalled": { + "type": "boolean", + "title": "SecretStore module installed", + "description": "Indicates whether the Microsoft.PowerShell.SecretStore module is installed. Read-only; returned by get operations.", + "readOnly": true + }, + "vaultRegistered": { + "type": "boolean", + "title": "Vault registered", + "description": "Indicates whether the SecretStore vault is registered with SecretManagement. Read-only; returned by get operations.", + "readOnly": true + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the SecretStore configuration exists on the system (modules installed and configured)." + } + } + } + } +} diff --git a/resources/secret_store/src/main.rs b/resources/secret_store/src/main.rs new file mode 100644 index 000000000..1413decc5 --- /dev/null +++ b/resources/secret_store/src/main.rs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; + +#[cfg(windows)] +mod vault_config; +#[cfg(windows)] +mod secret; + +use rust_i18n::t; +use std::io::{self, Read, IsTerminal}; +use std::process::exit; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_OPERATION_ERROR: i32 = 3; + +/// Write a JSON error object to stderr. +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + +/// Read JSON input from stdin. +fn read_stdin() -> Result { + let mut buffer = String::new(); + if !io::stdin().is_terminal() { + io::stdin() + .read_to_string(&mut buffer) + .map_err(|e| t!("main.stdinReadError", error = e).to_string())?; + } + Ok(buffer) +} + +/// Parse `--input ` from command line args, or fall back to stdin. +fn get_input_json(args: &[String]) -> Option { + // Check for --input arg + for i in 0..args.len() { + if args[i] == "--input" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + } + } + // Fall back to stdin + read_stdin().ok().filter(|s| !s.trim().is_empty()) +} + +/// Serialize a value to JSON and print it to stdout, or exit with an error. +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_OPERATION_ERROR); + } + } +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_OPERATION_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 3 { + write_error(&t!("main.missingArguments")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let resource_type = args[2].as_str(); + let input_json = get_input_json(&args); + + match (operation, resource_type) { + // --- vault-config operations --- + ("get", "vault-config") => { + let input: types::VaultConfig = parse_input_or_default(input_json); + match vault_config::get_config(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + ("set", "vault-config") => { + let input: types::VaultConfig = parse_input_or_default(input_json); + match vault_config::set_config(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + ("test", "vault-config") => { + let input: types::VaultConfig = parse_input_or_default(input_json); + match vault_config::test_config(&input) { + Ok(in_desired_state) => { + let mut result = input.clone(); + // DSC convention: _inDesiredState is communicated via the diff + println!("{}", serde_json::json!({ + "inDesiredState": in_desired_state + })); + let _ = result; + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + + // --- secret operations --- + ("get", "secret") => { + let input: types::Secret = require_input(input_json); + match secret::get_secret(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + ("set", "secret") => { + let input: types::Secret = require_input(input_json); + match secret::set_secret(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + ("export", "secret") => { + let filter: Option = input_json.and_then(|json| { + serde_json::from_str(&json).ok() + }); + match secret::export_secrets(filter.as_ref()) { + Ok(secrets) => { + for s in &secrets { + print_json(s); + } + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_OPERATION_ERROR); + } + } + } + + // --- error handling --- + ("get" | "set" | "test" | "export", _) => { + write_error(&t!("main.unknownResourceType", resource_type = resource_type)); + exit(EXIT_INVALID_ARGS); + } + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + } +} + +/// Parse JSON input into a typed struct, or exit with an error. +#[cfg(windows)] +fn require_input(input_json: Option) -> T { + let json = match input_json { + Some(j) => j, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +/// Parse JSON input into a typed struct, or return Default if no input provided. +#[cfg(windows)] +fn parse_input_or_default(input_json: Option) -> T { + match input_json { + Some(json) if !json.trim().is_empty() => { + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } + } + _ => T::default(), + } +} diff --git a/resources/secret_store/src/secret.rs b/resources/secret_store/src/secret.rs new file mode 100644 index 000000000..8aeeb5d35 --- /dev/null +++ b/resources/secret_store/src/secret.rs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::Secret; +use rust_i18n::t; +use std::process::Command; + +/// Run a PowerShell command and return its stdout, or an error with stderr. +fn run_pwsh(script: &str) -> Result { + let output = Command::new("pwsh") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Get a secret's information from the SecretStore. +pub fn get_secret(input: &Secret) -> Result { + let name = input.name.as_deref() + .ok_or_else(|| t!("secret.nameRequired").to_string())?; + + let vault_arg = input.vault_name.as_deref() + .map(|v| format!(" -Vault '{v}'")) + .unwrap_or_default(); + + let script = format!( + r#" + $info = Get-SecretInfo -Name '{name}'{vault_arg} -ErrorAction SilentlyContinue + if ($null -eq $info) {{ + @{{ name = '{name}'; _exist = $false }} | ConvertTo-Json -Compress + }} else {{ + $meta = $null + if ($info.Metadata.Count -gt 0) {{ + $meta = @{{}} + foreach ($key in $info.Metadata.Keys) {{ + $meta[$key] = $info.Metadata[$key].ToString() + }} + }} + @{{ + name = $info.Name + secretType = $info.Type.ToString() + vaultName = $info.VaultName + metadata = $meta + _exist = $true + }} | ConvertTo-Json -Compress + }} + "# + ); + + let result = run_pwsh(&script) + .map_err(|e| t!("secret.getFailed", name = name, error = e).to_string())?; + + let secret: Secret = serde_json::from_str(&result) + .map_err(|e| t!("secret.getFailed", name = name, error = e.to_string()).to_string())?; + + Ok(secret) +} + +/// Set (create or update) a secret in the SecretStore. +pub fn set_secret(input: &Secret) -> Result { + let name = input.name.as_deref() + .ok_or_else(|| t!("secret.nameRequired").to_string())?; + + let exist = input._exist.unwrap_or(true); + + if !exist { + // Remove the secret + let vault_arg = input.vault_name.as_deref() + .map(|v| format!(" -Vault '{v}'")) + .unwrap_or_default(); + + run_pwsh(&format!( + "Remove-Secret -Name '{name}'{vault_arg} -ErrorAction SilentlyContinue" + )).map_err(|e| t!("secret.removeFailed", name = name, error = e).to_string())?; + + return Ok(Secret { + name: Some(name.to_string()), + _exist: Some(false), + ..Default::default() + }); + } + + let value = input.value.as_deref().unwrap_or(""); + let vault_arg = input.vault_name.as_deref() + .map(|v| format!(" -Vault '{v}'")) + .unwrap_or_default(); + + // Set the secret value + let script = format!( + "Set-Secret -Name '{name}' -Secret '{value}'{vault_arg}" + ); + run_pwsh(&script) + .map_err(|e| t!("secret.setFailed", name = name, error = e).to_string())?; + + // Set metadata if provided + if let Some(ref metadata) = input.metadata { + if let Some(obj) = metadata.as_object() { + let mut hashtable_entries = Vec::new(); + for (key, val) in obj { + let val_str = match val { + serde_json::Value::String(s) => format!("'{s}'"), + other => other.to_string(), + }; + hashtable_entries.push(format!("'{key}' = {val_str}")); + } + if !hashtable_entries.is_empty() { + let ht = hashtable_entries.join("; "); + let meta_script = format!( + "Set-SecretInfo -Name '{name}'{vault_arg} -Metadata @{{ {ht} }}" + ); + run_pwsh(&meta_script) + .map_err(|e| t!("secret.setFailed", name = name, error = e).to_string())?; + } + } + } + + // Return current state + get_secret(input) +} + +/// Export all secrets (metadata only, not values) from the SecretStore. +pub fn export_secrets(filter: Option<&Secret>) -> Result, String> { + let name_filter = filter + .and_then(|f| f.name.as_deref()) + .unwrap_or("*"); + + let vault_arg = filter + .and_then(|f| f.vault_name.as_deref()) + .map(|v| format!(" -Vault '{v}'")) + .unwrap_or_default(); + + let script = format!( + r#" + $secrets = Get-SecretInfo -Name '{name_filter}'{vault_arg} -ErrorAction SilentlyContinue + if ($null -eq $secrets) {{ + '[]' + }} else {{ + $results = @() + foreach ($info in $secrets) {{ + $meta = $null + if ($info.Metadata.Count -gt 0) {{ + $meta = @{{}} + foreach ($key in $info.Metadata.Keys) {{ + $meta[$key] = $info.Metadata[$key].ToString() + }} + }} + $results += @{{ + name = $info.Name + secretType = $info.Type.ToString() + vaultName = $info.VaultName + metadata = $meta + _exist = $true + }} + }} + $results | ConvertTo-Json -Compress -AsArray + }} + "# + ); + + let result = run_pwsh(&script) + .map_err(|e| t!("secret.exportFailed", error = e).to_string())?; + + let secrets: Vec = serde_json::from_str(&result) + .map_err(|e| t!("secret.exportFailed", error = e.to_string()).to_string())?; + + Ok(secrets) +} diff --git a/resources/secret_store/src/types.rs b/resources/secret_store/src/types.rs new file mode 100644 index 000000000..b468bbf46 --- /dev/null +++ b/resources/secret_store/src/types.rs @@ -0,0 +1,415 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +/// Represents the authentication type for the SecretStore vault. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum Authentication { + /// No password is required to access the vault. + None, + /// A password is required to access the vault. + Password, +} + +impl Default for Authentication { + fn default() -> Self { + Self::Password + } +} + +impl std::fmt::Display for Authentication { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "None"), + Self::Password => write!(f, "Password"), + } + } +} + +/// Represents the interaction preference for the SecretStore vault. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum Interaction { + /// The vault will prompt the user for input when needed. + Prompt, + /// The vault will never prompt the user; operations that require interaction will fail. + None, +} + +impl Default for Interaction { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for Interaction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Prompt => write!(f, "Prompt"), + Self::None => write!(f, "None"), + } + } +} + +/// Represents the SecretStore vault configuration. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VaultConfig { + /// The authentication type for the vault. + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication: Option, + + /// The password timeout in seconds. After this time, the vault is locked. + #[serde(skip_serializing_if = "Option::is_none")] + pub password_timeout: Option, + + /// The interaction preference for the vault. + #[serde(skip_serializing_if = "Option::is_none")] + pub interaction: Option, + + /// Whether the SecretManagement module is installed. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_management_installed: Option, + + /// Whether the SecretStore module is installed. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_store_installed: Option, + + /// Whether the SecretStore vault is registered with SecretManagement. + #[serde(skip_serializing_if = "Option::is_none")] + pub vault_registered: Option, + + /// Whether the resource exists / should exist. + #[serde(skip_serializing_if = "Option::is_none")] + pub _exist: Option, +} + +/// Represents the type of a secret stored in the vault. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum SecretType { + String, + SecureString, + ByteArray, + PSCredential, + Hashtable, +} + +impl std::fmt::Display for SecretType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String => write!(f, "String"), + Self::SecureString => write!(f, "SecureString"), + Self::ByteArray => write!(f, "ByteArray"), + Self::PSCredential => write!(f, "PSCredential"), + Self::Hashtable => write!(f, "Hashtable"), + } + } +} + +/// Represents a secret in the SecretStore vault. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Secret { + /// The name of the secret. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// The type of the secret. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_type: Option, + + /// The value to set for the secret. Only used during set operations. + /// This field is never returned in get/export output. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// The vault name to use. Defaults to the SecretStore default vault. + #[serde(skip_serializing_if = "Option::is_none")] + pub vault_name: Option, + + /// Metadata associated with the secret (key-value pairs). + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + + /// Whether the secret exists / should exist. + #[serde(skip_serializing_if = "Option::is_none")] + pub _exist: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // --- Authentication enum --- + + #[test] + fn authentication_default_is_password() { + assert_eq!(Authentication::default(), Authentication::Password); + } + + #[test] + fn authentication_display() { + assert_eq!(Authentication::None.to_string(), "None"); + assert_eq!(Authentication::Password.to_string(), "Password"); + } + + #[test] + fn authentication_serde_roundtrip() { + let json = json!("None"); + let auth: Authentication = serde_json::from_value(json).unwrap(); + assert_eq!(auth, Authentication::None); + assert_eq!(serde_json::to_value(&auth).unwrap(), json!("None")); + + let json = json!("Password"); + let auth: Authentication = serde_json::from_value(json).unwrap(); + assert_eq!(auth, Authentication::Password); + assert_eq!(serde_json::to_value(&auth).unwrap(), json!("Password")); + } + + // --- Interaction enum --- + + #[test] + fn interaction_default_is_none() { + assert_eq!(Interaction::default(), Interaction::None); + } + + #[test] + fn interaction_display() { + assert_eq!(Interaction::Prompt.to_string(), "Prompt"); + assert_eq!(Interaction::None.to_string(), "None"); + } + + #[test] + fn interaction_serde_roundtrip() { + let json = json!("Prompt"); + let interaction: Interaction = serde_json::from_value(json).unwrap(); + assert_eq!(interaction, Interaction::Prompt); + assert_eq!(serde_json::to_value(&interaction).unwrap(), json!("Prompt")); + + let json = json!("None"); + let interaction: Interaction = serde_json::from_value(json).unwrap(); + assert_eq!(interaction, Interaction::None); + assert_eq!(serde_json::to_value(&interaction).unwrap(), json!("None")); + } + + // --- SecretType enum --- + + #[test] + fn secret_type_display() { + assert_eq!(SecretType::String.to_string(), "String"); + assert_eq!(SecretType::SecureString.to_string(), "SecureString"); + assert_eq!(SecretType::ByteArray.to_string(), "ByteArray"); + assert_eq!(SecretType::PSCredential.to_string(), "PSCredential"); + assert_eq!(SecretType::Hashtable.to_string(), "Hashtable"); + } + + #[test] + fn secret_type_serde_roundtrip() { + let variants = vec![ + (SecretType::String, "String"), + (SecretType::SecureString, "SecureString"), + (SecretType::ByteArray, "ByteArray"), + (SecretType::PSCredential, "PSCredential"), + (SecretType::Hashtable, "Hashtable"), + ]; + for (variant, expected) in variants { + let serialized = serde_json::to_value(&variant).unwrap(); + assert_eq!(serialized, json!(expected)); + let deserialized: SecretType = serde_json::from_value(serialized).unwrap(); + assert_eq!(deserialized, variant); + } + } + + // --- VaultConfig struct --- + + #[test] + fn vault_config_default_is_all_none() { + let config = VaultConfig::default(); + assert!(config.authentication.is_none()); + assert!(config.password_timeout.is_none()); + assert!(config.interaction.is_none()); + assert!(config.secret_management_installed.is_none()); + assert!(config.secret_store_installed.is_none()); + assert!(config.vault_registered.is_none()); + assert!(config._exist.is_none()); + } + + #[test] + fn vault_config_serializes_empty_when_all_none() { + let config = VaultConfig::default(); + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json, json!({})); + } + + #[test] + fn vault_config_uses_camel_case_keys() { + let config = VaultConfig { + authentication: Some(Authentication::Password), + password_timeout: Some(900), + interaction: Some(Interaction::None), + secret_management_installed: Some(true), + secret_store_installed: Some(true), + vault_registered: Some(true), + _exist: Some(true), + }; + let json = serde_json::to_value(&config).unwrap(); + let obj = json.as_object().unwrap(); + assert!(obj.contains_key("authentication")); + assert!(obj.contains_key("passwordTimeout")); + assert!(obj.contains_key("interaction")); + assert!(obj.contains_key("secretManagementInstalled")); + assert!(obj.contains_key("secretStoreInstalled")); + assert!(obj.contains_key("vaultRegistered")); + assert!(obj.contains_key("_exist")); + } + + #[test] + fn vault_config_serde_roundtrip() { + let config = VaultConfig { + authentication: Some(Authentication::None), + password_timeout: Some(300), + interaction: Some(Interaction::Prompt), + secret_management_installed: Some(false), + secret_store_installed: Some(true), + vault_registered: Some(false), + _exist: Some(true), + }; + let json_str = serde_json::to_string(&config).unwrap(); + let deserialized: VaultConfig = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized.authentication, Some(Authentication::None)); + assert_eq!(deserialized.password_timeout, Some(300)); + assert_eq!(deserialized.interaction, Some(Interaction::Prompt)); + assert_eq!(deserialized.secret_management_installed, Some(false)); + assert_eq!(deserialized.secret_store_installed, Some(true)); + assert_eq!(deserialized.vault_registered, Some(false)); + assert_eq!(deserialized._exist, Some(true)); + } + + #[test] + fn vault_config_deserializes_from_partial_json() { + let json = json!({"authentication": "Password", "passwordTimeout": 600}); + let config: VaultConfig = serde_json::from_value(json).unwrap(); + assert_eq!(config.authentication, Some(Authentication::Password)); + assert_eq!(config.password_timeout, Some(600)); + assert!(config.interaction.is_none()); + assert!(config.secret_management_installed.is_none()); + assert!(config._exist.is_none()); + } + + #[test] + fn vault_config_skips_none_fields_in_serialization() { + let config = VaultConfig { + authentication: Some(Authentication::Password), + password_timeout: None, + interaction: None, + secret_management_installed: None, + secret_store_installed: None, + vault_registered: None, + _exist: None, + }; + let json = serde_json::to_value(&config).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert!(obj.contains_key("authentication")); + } + + // --- Secret struct --- + + #[test] + fn secret_default_is_all_none() { + let secret = Secret::default(); + assert!(secret.name.is_none()); + assert!(secret.secret_type.is_none()); + assert!(secret.value.is_none()); + assert!(secret.vault_name.is_none()); + assert!(secret.metadata.is_none()); + assert!(secret._exist.is_none()); + } + + #[test] + fn secret_serializes_empty_when_all_none() { + let secret = Secret::default(); + let json = serde_json::to_value(&secret).unwrap(); + assert_eq!(json, json!({})); + } + + #[test] + fn secret_uses_camel_case_keys() { + let secret = Secret { + name: Some("test".to_string()), + secret_type: Some(SecretType::String), + value: Some("val".to_string()), + vault_name: Some("SecretStore".to_string()), + metadata: Some(json!({"key": "value"})), + _exist: Some(true), + }; + let json = serde_json::to_value(&secret).unwrap(); + let obj = json.as_object().unwrap(); + assert!(obj.contains_key("name")); + assert!(obj.contains_key("secretType")); + assert!(obj.contains_key("value")); + assert!(obj.contains_key("vaultName")); + assert!(obj.contains_key("metadata")); + assert!(obj.contains_key("_exist")); + } + + #[test] + fn secret_serde_roundtrip() { + let secret = Secret { + name: Some("MySecret".to_string()), + secret_type: Some(SecretType::SecureString), + value: Some("s3cret!".to_string()), + vault_name: Some("SecretStore".to_string()), + metadata: Some(json!({"env": "prod", "owner": "admin"})), + _exist: Some(true), + }; + let json_str = serde_json::to_string(&secret).unwrap(); + let deserialized: Secret = serde_json::from_str(&json_str).unwrap(); + assert_eq!(deserialized.name, Some("MySecret".to_string())); + assert_eq!(deserialized.secret_type, Some(SecretType::SecureString)); + assert_eq!(deserialized.value, Some("s3cret!".to_string())); + assert_eq!(deserialized.vault_name, Some("SecretStore".to_string())); + assert_eq!(deserialized.metadata, Some(json!({"env": "prod", "owner": "admin"}))); + assert_eq!(deserialized._exist, Some(true)); + } + + #[test] + fn secret_deserializes_from_minimal_json() { + let json = json!({"name": "OnlyName"}); + let secret: Secret = serde_json::from_value(json).unwrap(); + assert_eq!(secret.name, Some("OnlyName".to_string())); + assert!(secret.secret_type.is_none()); + assert!(secret.value.is_none()); + assert!(secret.vault_name.is_none()); + assert!(secret.metadata.is_none()); + assert!(secret._exist.is_none()); + } + + #[test] + fn secret_skips_none_fields_in_serialization() { + let secret = Secret { + name: Some("test".to_string()), + _exist: Some(false), + ..Default::default() + }; + let json = serde_json::to_value(&secret).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(obj.len(), 2); + assert!(obj.contains_key("name")); + assert!(obj.contains_key("_exist")); + } + + #[test] + fn secret_metadata_with_nested_values() { + let secret = Secret { + name: Some("test".to_string()), + metadata: Some(json!({"tag": "test", "count": "42"})), + ..Default::default() + }; + let json = serde_json::to_value(&secret).unwrap(); + let meta = json.get("metadata").unwrap(); + assert_eq!(meta.get("tag").unwrap(), "test"); + assert_eq!(meta.get("count").unwrap(), "42"); + } +} diff --git a/resources/secret_store/src/vault_config.rs b/resources/secret_store/src/vault_config.rs new file mode 100644 index 000000000..06c94e985 --- /dev/null +++ b/resources/secret_store/src/vault_config.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::{VaultConfig, Authentication, Interaction}; +use rust_i18n::t; +use std::process::Command; + +/// Run a PowerShell command and return its stdout, or an error with stderr. +fn run_pwsh(script: &str) -> Result { + let output = Command::new("pwsh") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output() + .map_err(|e| t!("vault_config.pwshNotFound").to_string() + ": " + &e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Check whether a PowerShell module is installed. +fn is_module_installed(module_name: &str) -> Result { + let script = format!( + "if (Get-Module -ListAvailable -Name '{module_name}') {{ 'true' }} else {{ 'false' }}" + ); + let result = run_pwsh(&script)?; + Ok(result.trim().eq_ignore_ascii_case("true")) +} + +/// Get the current SecretStore vault configuration. +pub fn get_config(input: &VaultConfig) -> Result { + let _ = input; // input is accepted for schema consistency but not used for get + + let sm_installed = is_module_installed("Microsoft.PowerShell.SecretManagement") + .unwrap_or(false); + let ss_installed = is_module_installed("Microsoft.PowerShell.SecretStore") + .unwrap_or(false); + + if !sm_installed || !ss_installed { + return Ok(VaultConfig { + secret_management_installed: Some(sm_installed), + secret_store_installed: Some(ss_installed), + _exist: Some(false), + ..Default::default() + }); + } + + // Check if vault is registered + let vault_registered = run_pwsh( + "if (Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue) { 'true' } else { 'false' }" + ).map(|r| r.trim().eq_ignore_ascii_case("true")) + .unwrap_or(false); + + // Get configuration + let config_json = run_pwsh( + "Get-SecretStoreConfiguration | ConvertTo-Json -Compress" + ).map_err(|e| t!("vault_config.getConfigFailed", error = e).to_string())?; + + // Parse the PowerShell JSON output + let ps_config: serde_json::Value = serde_json::from_str(&config_json) + .map_err(|e| t!("vault_config.getConfigFailed", error = e.to_string()).to_string())?; + + let authentication = ps_config.get("Authentication") + .and_then(|v| v.as_str()) + .map(|s| match s { + "None" => Authentication::None, + _ => Authentication::Password, + }); + + let password_timeout = ps_config.get("PasswordTimeout") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + let interaction = ps_config.get("Interaction") + .and_then(|v| v.as_str()) + .map(|s| match s { + "Prompt" => Interaction::Prompt, + _ => Interaction::None, + }); + + Ok(VaultConfig { + authentication, + password_timeout, + interaction, + secret_management_installed: Some(true), + secret_store_installed: Some(true), + vault_registered: Some(vault_registered), + _exist: Some(true), + }) +} + +/// Set the SecretStore vault configuration to the desired state. +pub fn set_config(input: &VaultConfig) -> Result { + // Ensure modules are installed + let sm_installed = is_module_installed("Microsoft.PowerShell.SecretManagement") + .unwrap_or(false); + let ss_installed = is_module_installed("Microsoft.PowerShell.SecretStore") + .unwrap_or(false); + + if !sm_installed { + run_pwsh("Install-Module -Name Microsoft.PowerShell.SecretManagement -Force -Scope CurrentUser") + .map_err(|e| t!("vault_config.moduleNotInstalled", module = "Microsoft.PowerShell.SecretManagement").to_string() + ": " + &e)?; + } + + if !ss_installed { + run_pwsh("Install-Module -Name Microsoft.PowerShell.SecretStore -Force -Scope CurrentUser") + .map_err(|e| t!("vault_config.moduleNotInstalled", module = "Microsoft.PowerShell.SecretStore").to_string() + ": " + &e)?; + } + + // Build the Set-SecretStoreConfiguration command + let mut params = Vec::new(); + + if let Some(ref auth) = input.authentication { + params.push(format!("-Authentication {auth}")); + } + + if let Some(timeout) = input.password_timeout { + params.push(format!("-PasswordTimeout {timeout}")); + } + + if let Some(ref interaction) = input.interaction { + params.push(format!("-Interaction {interaction}")); + } + + if !params.is_empty() { + let script = format!( + "Set-SecretStoreConfiguration {} -Confirm:$false -Force", + params.join(" ") + ); + run_pwsh(&script) + .map_err(|e| t!("vault_config.setConfigFailed", error = e).to_string())?; + } + + // Ensure the vault is registered + let vault_registered = run_pwsh( + "if (Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue) { 'true' } else { 'false' }" + ).map(|r| r.trim().eq_ignore_ascii_case("true")) + .unwrap_or(false); + + if !vault_registered { + run_pwsh( + "Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault" + ).map_err(|e| t!("vault_config.registerVaultFailed", error = e).to_string())?; + } + + // Return current state after applying changes + get_config(input) +} + +/// Test whether the current vault configuration matches the desired state. +pub fn test_config(input: &VaultConfig) -> Result { + let current = get_config(input)?; + + if let Some(ref desired_auth) = input.authentication { + if current.authentication.as_ref() != Some(desired_auth) { + return Ok(false); + } + } + + if let Some(desired_timeout) = input.password_timeout { + if current.password_timeout != Some(desired_timeout) { + return Ok(false); + } + } + + if let Some(ref desired_interaction) = input.interaction { + if current.interaction.as_ref() != Some(desired_interaction) { + return Ok(false); + } + } + + // If we get here, all specified properties match + Ok(true) +} diff --git a/resources/secret_store/tests/secret_export.tests.ps1 b/resources/secret_store/tests/secret_export.tests.ps1 new file mode 100644 index 000000000..9f1ce02e9 --- /dev/null +++ b/resources/secret_store/tests/secret_export.tests.ps1 @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreSecret - export operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreSecret' + $testSecretPrefix = 'DSC-SecretStore-Export-Test' + $testSecretName1 = "${testSecretPrefix}-1" + $testSecretName2 = "${testSecretPrefix}-2" + + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore')) { + # Ensure vault is configured for non-interactive use + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Force -ErrorAction SilentlyContinue + if (-not (Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue)) { + Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault + } + # Create known test secrets + Set-Secret -Name $testSecretName1 -Secret 'ExportValue1' -ErrorAction SilentlyContinue + Set-Secret -Name $testSecretName2 -Secret 'ExportValue2' -ErrorAction SilentlyContinue + } + } + + AfterAll { + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement')) { + Remove-Secret -Name $testSecretName1 -ErrorAction SilentlyContinue + Remove-Secret -Name $testSecretName2 -ErrorAction SilentlyContinue + } + } + + It 'exports all secrets' -Skip:(!$modulesAvailable) { + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $results = $raw | ConvertFrom-Json + $results | Should -Not -BeNullOrEmpty + } + + It 'exports secrets matching a name filter' -Skip:(!$modulesAvailable) { + $input = @{ name = "${testSecretPrefix}*" } | ConvertTo-Json -Compress + $raw = $input | dsc resource export -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $results = $raw | ConvertFrom-Json + $names = @($results | ForEach-Object { + if ($_.resources) { $_.resources[0].properties.name } else { $_.name } + }) + $names | Should -Contain $testSecretName1 + $names | Should -Contain $testSecretName2 + } + + It 'returns empty when no secrets match filter' -Skip:(!$modulesAvailable) { + $input = @{ name = 'DSC-NonExistent-Export-XXXXXX' } | ConvertTo-Json -Compress + $raw = $input | dsc resource export -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + # Should either be empty or return no matching secrets + if ($raw) { + $results = @($raw | ConvertFrom-Json) + $names = @($results | ForEach-Object { + if ($_.resources) { $_.resources[0].properties.name } else { $_.name } + }) + $names | Should -Not -Contain 'DSC-NonExistent-Export-XXXXXX' + } + } + + It 'exported secrets contain expected properties' -Skip:(!$modulesAvailable) { + $input = @{ name = $testSecretName1 } | ConvertTo-Json -Compress + $raw = $input | dsc resource export -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $results = @($raw | ConvertFrom-Json) + $results.Count | Should -BeGreaterOrEqual 1 + $secret = if ($results[0].resources) { $results[0].resources[0].properties } else { $results[0] } + $secret.name | Should -BeExactly $testSecretName1 + $secret._exist | Should -BeTrue + $secret.PSObject.Properties.Name | Should -Contain 'secretType' + $secret.PSObject.Properties.Name | Should -Contain 'vaultName' + } +} diff --git a/resources/secret_store/tests/secret_get.tests.ps1 b/resources/secret_store/tests/secret_get.tests.ps1 new file mode 100644 index 000000000..5b2af181c --- /dev/null +++ b/resources/secret_store/tests/secret_get.tests.ps1 @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreSecret - get operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreSecret' + $testSecretName = 'DSC-SecretStore-Get-Test' + + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore')) { + # Ensure vault is configured for non-interactive use + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Force -ErrorAction SilentlyContinue + if (-not (Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue)) { + Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault + } + # Create a known test secret + Set-Secret -Name $testSecretName -Secret 'TestValue123' -ErrorAction SilentlyContinue + } + } + + AfterAll { + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement')) { + Remove-Secret -Name $testSecretName -ErrorAction SilentlyContinue + } + } + + It 'returns an existing secret with metadata' -Skip:(!$modulesAvailable) { + $input = @{ name = $testSecretName } | ConvertTo-Json -Compress + $json = $input | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result.name | Should -BeExactly $testSecretName + $result._exist | Should -BeTrue + $result.PSObject.Properties.Name | Should -Contain 'secretType' + $result.PSObject.Properties.Name | Should -Contain 'vaultName' + } + + It 'returns _exist false for a non-existent secret' -Skip:(!$modulesAvailable) { + $input = @{ name = 'DSC-NonExistent-Secret-XXXXXX' } | ConvertTo-Json -Compress + $json = $input | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result.name | Should -BeExactly 'DSC-NonExistent-Secret-XXXXXX' + $result._exist | Should -BeFalse + } + + It 'fails when name is not provided' -Skip:(!$modulesAvailable) { + $input = '{}' | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns secret type as a known enum value' -Skip:(!$modulesAvailable) { + $input = @{ name = $testSecretName } | ConvertTo-Json -Compress + $json = $input | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + if ($result._exist) { + $result.secretType | Should -BeIn @('String', 'SecureString', 'ByteArray', 'PSCredential', 'Hashtable') + } + } + + It 'accepts optional vaultName parameter' -Skip:(!$modulesAvailable) { + $input = @{ name = $testSecretName; vaultName = 'SecretStore' } | ConvertTo-Json -Compress + $json = $input | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result.name | Should -BeExactly $testSecretName + } +} diff --git a/resources/secret_store/tests/secret_set.tests.ps1 b/resources/secret_store/tests/secret_set.tests.ps1 new file mode 100644 index 000000000..879b6c3b5 --- /dev/null +++ b/resources/secret_store/tests/secret_set.tests.ps1 @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreSecret - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreSecret' + $testSecretName = 'DSC-SecretStore-Set-Test' + $testSecretName2 = 'DSC-SecretStore-Set-Test-2' + + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore')) { + # Ensure vault is configured for non-interactive use + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false -Force -ErrorAction SilentlyContinue + if (-not (Get-SecretVault -Name 'SecretStore' -ErrorAction SilentlyContinue)) { + Register-SecretVault -Name 'SecretStore' -ModuleName 'Microsoft.PowerShell.SecretStore' -DefaultVault + } + } + } + + AfterAll { + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement')) { + Remove-Secret -Name $testSecretName -ErrorAction SilentlyContinue + Remove-Secret -Name $testSecretName2 -ErrorAction SilentlyContinue + } + } + + It 'creates a new secret' -Skip:(!$modulesAvailable) { + Remove-Secret -Name $testSecretName -ErrorAction SilentlyContinue + + $input = @{ name = $testSecretName; value = 'NewSecretValue' } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testSecretName + $result._exist | Should -BeTrue + } + + It 'updates an existing secret' -Skip:(!$modulesAvailable) { + # Ensure secret exists first + Set-Secret -Name $testSecretName -Secret 'OldValue' -ErrorAction SilentlyContinue + + $input = @{ name = $testSecretName; value = 'UpdatedValue' } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testSecretName + $result._exist | Should -BeTrue + } + + It 'removes a secret when _exist is false' -Skip:(!$modulesAvailable) { + # Ensure secret exists first + Set-Secret -Name $testSecretName2 -Secret 'ToBeRemoved' -ErrorAction SilentlyContinue + + $input = @{ name = $testSecretName2; _exist = $false } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testSecretName2 + $result._exist | Should -BeFalse + + # Verify the secret is actually gone + $info = Get-SecretInfo -Name $testSecretName2 -ErrorAction SilentlyContinue + $info | Should -BeNullOrEmpty + } + + It 'sets metadata on a secret' -Skip:(!$modulesAvailable) { + Remove-Secret -Name $testSecretName -ErrorAction SilentlyContinue + + $input = @{ + name = $testSecretName + value = 'MetadataTest' + metadata = @{ environment = 'test'; owner = 'dsc' } + } | ConvertTo-Json -Compress -Depth 5 + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testSecretName + $result._exist | Should -BeTrue + $result.metadata.environment | Should -BeExactly 'test' + $result.metadata.owner | Should -BeExactly 'dsc' + } + + It 'fails when name is not provided' -Skip:(!$modulesAvailable) { + $input = @{ value = 'NoName' } | ConvertTo-Json -Compress + $input | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } +} diff --git a/resources/secret_store/tests/vault_config_get.tests.ps1 b/resources/secret_store/tests/vault_config_get.tests.ps1 new file mode 100644 index 000000000..5d5c0bdf6 --- /dev/null +++ b/resources/secret_store/tests/vault_config_get.tests.ps1 @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreVaultConfig - get operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreVaultConfig' + } + + It 'returns module installation status' { + $json = '{}' | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result.PSObject.Properties.Name | Should -Contain 'secretManagementInstalled' + $result.PSObject.Properties.Name | Should -Contain 'secretStoreInstalled' + $result.secretManagementInstalled | Should -BeOfType [bool] + $result.secretStoreInstalled | Should -BeOfType [bool] + } + + It 'returns _exist false when modules are not installed' -Skip:($modulesAvailable) { + $json = '{}' | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result._exist | Should -BeFalse + } + + It 'returns vault registration and configuration when modules are installed' -Skip:(!$modulesAvailable) { + $json = '{}' | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result.PSObject.Properties.Name | Should -Contain 'vaultRegistered' + $result.vaultRegistered | Should -BeOfType [bool] + } + + It 'returns authentication and interaction when vault is configured' -Skip:(!$modulesAvailable) { + $json = '{}' | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + if ($result._exist -ne $false) { + $result.authentication | Should -BeIn @('None', 'Password') + $result.interaction | Should -BeIn @('Prompt', 'None') + $result.passwordTimeout | Should -BeOfType [int] + } + } + + It 'accepts input json without error' { + $input = @{ authentication = 'Password' } | ConvertTo-Json -Compress + $json = $input | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).actualState + $result | Should -Not -BeNullOrEmpty + } +} diff --git a/resources/secret_store/tests/vault_config_set.tests.ps1 b/resources/secret_store/tests/vault_config_set.tests.ps1 new file mode 100644 index 000000000..611c3d3ca --- /dev/null +++ b/resources/secret_store/tests/vault_config_set.tests.ps1 @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreVaultConfig - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreVaultConfig' + } + + It 'sets authentication type' -Skip:(!$modulesAvailable) { + $input = @{ authentication = 'None'; interaction = 'None' } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.authentication | Should -BeExactly 'None' + } + + It 'sets password timeout' -Skip:(!$modulesAvailable) { + $input = @{ passwordTimeout = 600 } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.passwordTimeout | Should -Be 600 + } + + It 'sets interaction preference' -Skip:(!$modulesAvailable) { + $input = @{ interaction = 'None' } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.interaction | Should -BeExactly 'None' + } + + It 'returns modules installed and vault registered after set' -Skip:(!$modulesAvailable) { + $input = @{ authentication = 'None'; interaction = 'None' } | ConvertTo-Json -Compress + $json = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = ($json | ConvertFrom-Json).afterState + $result.secretManagementInstalled | Should -BeTrue + $result.secretStoreInstalled | Should -BeTrue + $result.vaultRegistered | Should -BeTrue + } + + It 'is idempotent when setting the same configuration twice' -Skip:(!$modulesAvailable) { + $input = @{ authentication = 'None'; interaction = 'None'; passwordTimeout = 900 } | ConvertTo-Json -Compress + + $json1 = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $json2 = $input | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result1 = ($json1 | ConvertFrom-Json).afterState + $result2 = ($json2 | ConvertFrom-Json).afterState + $result1.authentication | Should -BeExactly $result2.authentication + $result1.passwordTimeout | Should -Be $result2.passwordTimeout + $result1.interaction | Should -BeExactly $result2.interaction + } +} diff --git a/resources/secret_store/tests/vault_config_test.tests.ps1 b/resources/secret_store/tests/vault_config_test.tests.ps1 new file mode 100644 index 000000000..01ed96080 --- /dev/null +++ b/resources/secret_store/tests/vault_config_test.tests.ps1 @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/SecretStoreVaultConfig - test operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $modulesAvailable = if ($IsWindows) { + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore') + } else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/SecretStoreVaultConfig' + + # Get current configuration to use as baseline for test assertions + if ($IsWindows -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretManagement') -and + (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.SecretStore')) { + $getJson = '{}' | dsc resource get -r $resourceType -f - 2>$null + $currentConfig = ($getJson | ConvertFrom-Json).actualState + } + } + + It 'returns true when configuration matches desired state' -Skip:(!$modulesAvailable) { + # Test with current actual values so it should match + $input = @{} | ConvertTo-Json -Compress + if ($currentConfig.authentication) { + $input = @{ authentication = $currentConfig.authentication } | ConvertTo-Json -Compress + } + $json = $input | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = $json | ConvertFrom-Json + $result.inDesiredState | Should -BeTrue + } + + It 'returns false when authentication does not match' -Skip:(!$modulesAvailable) { + # Use the opposite of the current authentication value + $desiredAuth = if ($currentConfig.authentication -eq 'Password') { 'None' } else { 'Password' } + $input = @{ authentication = $desiredAuth } | ConvertTo-Json -Compress + $json = $input | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = $json | ConvertFrom-Json + $result.inDesiredState | Should -BeFalse + } + + It 'returns false when password timeout does not match' -Skip:(!$modulesAvailable) { + # Use a different timeout than current + $desiredTimeout = if ($currentConfig.passwordTimeout -eq 999) { 1000 } else { 999 } + $input = @{ passwordTimeout = $desiredTimeout } | ConvertTo-Json -Compress + $json = $input | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = $json | ConvertFrom-Json + $result.inDesiredState | Should -BeFalse + } + + It 'returns true when no properties are specified' -Skip:(!$modulesAvailable) { + $input = '{}' | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + $result = $input | ConvertFrom-Json + $result.inDesiredState | Should -BeTrue + } +}