From 16ee747082b42df8c81b43c8ef46b23769f7dd98 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Wed, 21 Oct 2026 09:52:40 -0500 Subject: [PATCH 1/2] (GH-1081) Integrate settings through CLI and lib This change continues the work for using structured settings for DSC. This change: - Defines a globally accessible static for performantly accessing and using the resolved settings. - Replaces ad-hoc settings access with accessing the global static. - Updates the `--ignore-settings-file` argument so users can negate the flag and override the environment variable. --- dsc/src/args.rs | 26 +- dsc/src/main.rs | 31 +- dsc/src/util.rs | 105 +- dsc/tests/discovery/dsc_adapter.tests.ps1 | 11 +- dsc/tests/discovery/dsc_discovery.tests.ps1 | 23 +- dsc/tests/other/dsc_settings.tests.ps1 | 3092 ++++++++++++++++- .../dsc_settings_policy_security.tests.ps1 | 7 +- lib/dsc-lib/locales/en-us.toml | 19 +- .../src/discovery/command_discovery.rs | 399 ++- lib/dsc-lib/src/dscerror.rs | 14 +- .../src/dscresources/command_resource.rs | 51 +- lib/dsc-lib/src/settings/dsc_settings.rs | 8 +- lib/dsc-lib/src/settings/fields/tracing.rs | 24 + lib/dsc-lib/src/settings/global.rs | 50 + lib/dsc-lib/src/settings/mod.rs | 1 + lib/dsc-lib/src/util.rs | 359 +- 16 files changed, 3464 insertions(+), 756 deletions(-) create mode 100644 lib/dsc-lib/src/settings/global.rs diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 69e6dba72..b0a7cc829 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -44,6 +44,18 @@ pub enum TraceFormat { PassThrough, } +impl From for dsc_lib::settings::fields::TracingFormatField { + fn from(value: TraceFormat) -> Self { + match value { + TraceFormat::Default => dsc_lib::settings::fields::TracingFormatField::Default, + TraceFormat::Plaintext => dsc_lib::settings::fields::TracingFormatField::Plaintext, + TraceFormat::Json => dsc_lib::settings::fields::TracingFormatField::Json, + // PassThrough is treated as Json for settings purposes + TraceFormat::PassThrough => dsc_lib::settings::fields::TracingFormatField::Json, + } + } +} + #[derive(Debug, Parser)] #[clap(name = "dsc", version = env!("CARGO_PKG_VERSION"), about = t!("args.about").to_string(), long_about = None)] pub struct Args { @@ -56,8 +68,18 @@ pub struct Args { pub trace_format: Option, #[clap(short = 'p', long, help = t!("args.progressFormat").to_string(), value_enum)] pub progress_format: Option, - #[clap(long, help = t!("args.ignoreSettingsFile").to_string())] - pub ignore_settings_file: bool, + #[clap(long, help = t!("args.ignoreSettingsFile").to_string(), num_args=0..=1, default_missing_value="true", require_equals = true)] + pub ignore_settings_file: Option, +} + +impl From<&Args> for dsc_lib::settings::sources::CommandLineData { + fn from(value: &Args) -> Self { + Self { + trace_level: value.trace_level.clone().map(|v| v.into()), + trace_format: value.trace_format.clone().map(|v| v.into()), + ignore_settings_file: value.ignore_settings_file.map(|v| v.into()), + } + } } #[derive(Debug, PartialEq, Eq, Subcommand)] diff --git a/dsc/src/main.rs b/dsc/src/main.rs index b2c83c020..02f10d8fa 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -4,7 +4,7 @@ use args::{Args, SubCommand}; use clap::{CommandFactory, Parser}; use clap_complete::generate; -use dsc_lib::{progress::ProgressFormat, util::DSC_IGNORE_SETTINGS_FILE}; +use dsc_lib::{progress::ProgressFormat, settings::{DscSettings, DscSettingsScope, global as global_settings, sources::EnvironmentData}}; use server::start_server; use rust_i18n::{i18n, t}; use std::{env::set_var, io, process::ExitCode}; @@ -78,14 +78,31 @@ fn dsc_main() -> Result<(), ExitCode> { let args = Args::parse(); - // if args.ignore_settings_file is set, we create an env var called `DSC_IGNORE_SETTINGS_FILE` set to value 1 - if args.ignore_settings_file { - unsafe { - set_var(DSC_IGNORE_SETTINGS_FILE, "1"); + // Initialize with local settings; reload for global after tracing init + let mut settings = DscSettings::new_with_command_line((&args).into()); + settings.load(); + + util::enable_tracing(settings.resolved()); + + let mut global_settings = global_settings::get_or_init_with_command_line((&args).into()); + global_settings.load(); + global_settings.resolve_all(); + // if ignoring settings files is set from CLI, we create an env var called + // `DSC_IGNORE_SETTINGS_FILE` set to value 1 for recursive invocations + if global_settings.ignoring_settings_files() + && global_settings.resolved().ignore_settings_file.scope == DscSettingsScope::CommandLine { + unsafe { + set_var(EnvironmentData::DSC_IGNORE_SETTINGS_FILE_ENV_VAR, "1"); + } } + // Set the DSC_TRACE_LEVEL environment variable for child processes + unsafe { + env::set_var( + EnvironmentData::DSC_TRACE_LEVEL_ENV_VAR, + global_settings.resolved().tracing.level.value.clone().to_string() + ); } - - util::enable_tracing(args.trace_level.as_ref(), args.trace_format.as_ref()); + drop(global_settings); debug!("{}: {}", t!("main.usingDscVersion"), env!("CARGO_PKG_VERSION")); diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 7323cce51..7b8201c4b 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::args::{SchemaType, OutputFormat, TraceFormat}; +use crate::args::{SchemaType, OutputFormat}; use crate::resolve::Include; use dsc_lib::configure::config_result::{ConfigurationExportResult, ResourceGetResult, ResourceSetResult}; use dsc_lib::dscresources::adapted_resource_manifest::AdaptedDscResourceManifest; +use dsc_lib::settings::DscSettingsResolved; +use dsc_lib::settings::fields::TracingFormatField; use dsc_lib::{ configure::{ config_doc::{ @@ -25,7 +27,6 @@ use dsc_lib::{ }, dscerror::DscError, dscresources::{ - command_resource::TraceLevel, dscresource::DscResource, invoke_result::{ GetResult, @@ -44,7 +45,6 @@ use dsc_lib::{ schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, types::SemanticVersion, util::{ - get_setting, parse_input_to_json, }, }; @@ -52,7 +52,6 @@ use dsc_lib_telemetry::{StderrFormat, basic::{BasicTracingOptions}}; use path_absolutize::Absolutize; use rust_i18n::t; use schemars::{Schema, schema_for}; -use serde::Deserialize; use std::collections::HashMap; use std::env; use std::io::{IsTerminal, Read, stdout, Write}; @@ -65,7 +64,7 @@ use syntect::{ parsing::SyntaxSet, util::{as_24_bit_terminal_escaped, LinesWithEndings} }; -use tracing::{Level, debug, error, info, warn, trace}; +use tracing::{debug, error, info, warn, trace}; pub const EXIT_SUCCESS: u8 = 0; pub const EXIT_INVALID_ARGS: u8 = 1; @@ -80,28 +79,6 @@ pub const EXIT_SERVER_FAILED: u8 = 9; pub const EXIT_BICEP_FAILED: u8 = 10; pub const DSC_CONFIG_ROOT: &str = "DSC_CONFIG_ROOT"; -pub const DSC_TRACE_LEVEL: &str = "DSC_TRACE_LEVEL"; - -#[derive(Deserialize)] -pub struct TracingSetting { - /// Trace level to use - see pub enum `TraceLevel` in `dsc_lib\src\dscresources\command_resource.rs` - level: TraceLevel, - /// Trace format to use - see pub enum `TraceFormat` in `dsc\src\args.rs` - format: TraceFormat, - /// Whether the 'level' can be overrridden by `DSC_TRACE_LEVEL` environment variable - #[serde(rename = "allowOverride")] - allow_override: bool -} - -impl Default for TracingSetting { - fn default() -> TracingSetting { - TracingSetting { - level: TraceLevel::Warn, - format: TraceFormat::Default, - allow_override: true, - } - } -} /// Returns the current version of DSC as a semantic version. /// @@ -369,72 +346,18 @@ pub fn write_object(json: &str, format: Option<&OutputFormat>, include_separator } #[allow(clippy::too_many_lines)] -pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Option<&TraceFormat>) { - let mut policy_is_used = false; - let mut tracing_setting = TracingSetting::default(); +pub fn enable_tracing(resolved_settings: &DscSettingsResolved) { + let tracing_setting = &resolved_settings.tracing; let default_guard = dsc_lib_telemetry::basic::BasicTracingOptions::init_default_guard(); - // read setting/policy from files - if let Ok(v) = get_setting("tracing") { - if v.policy != serde_json::Value::Null { - match serde_json::from_value::(v.policy) { - Ok(v) => { - tracing_setting = v; - policy_is_used = true; - }, - Err(e) => { error!("{e}"); } - } - } else if v.setting != serde_json::Value::Null { - match serde_json::from_value::(v.setting) { - Ok(v) => { - tracing_setting = v; - }, - Err(e) => { error!("{e}"); } - } - } - } else { - error!("{}", t!("util.failedToReadTracingSetting")); - } - - // override with DSC_TRACE_LEVEL env var if permitted - if tracing_setting.allow_override && let Ok(level) = env::var(DSC_TRACE_LEVEL) { - tracing_setting.level = match level.to_ascii_uppercase().as_str() { - "ERROR" => TraceLevel::Error, - "WARN" => TraceLevel::Warn, - "INFO" => TraceLevel::Info, - "DEBUG" => TraceLevel::Debug, - "TRACE" => TraceLevel::Trace, - _ => { - warn!("{}: '{level}'", t!("util.invalidTraceLevel")); - TraceLevel::Warn - } - } - } - - // command-line args override setting value, but not policy - if !policy_is_used { - if let Some(v) = trace_level_arg { - tracing_setting.level = v.clone(); - } - if let Some(v) = trace_format_arg { - tracing_setting.format = v.clone(); - } - } - // convert to 'tracing' crate type - let tracing_level = match tracing_setting.level { - TraceLevel::Error => Level::ERROR, - TraceLevel::Warn => Level::WARN, - TraceLevel::Info => Level::INFO, - TraceLevel::Debug => Level::DEBUG, - TraceLevel::Trace => Level::TRACE, - }; + let tracing_level = tracing_setting.level.value.clone().into(); // convert to 'dsc-lib-telemetry' crate type - let stderr_format = match tracing_setting.format { - TraceFormat::Default => StderrFormat::Default, - TraceFormat::Plaintext => StderrFormat::Plaintext, - TraceFormat::Json | TraceFormat::PassThrough => StderrFormat::Json, + let stderr_format = match tracing_setting.format.value { + TracingFormatField::Default => StderrFormat::Default, + TracingFormatField::Plaintext => StderrFormat::Plaintext, + TracingFormatField::Json => StderrFormat::Json, }; // enable tracing @@ -448,11 +371,7 @@ pub fn enable_tracing(trace_level_arg: Option<&TraceLevel>, trace_format_arg: Op eprintln!("{}", t!("util.failedToSetTracing")); } - // set DSC_TRACE_LEVEL for child processes - unsafe { - env::set_var(DSC_TRACE_LEVEL, tracing_level.to_string().to_ascii_lowercase()); - } - info!("Trace-level is {:?}", tracing_setting.level); + info!("Trace-level is {:?}", tracing_setting.level.value); } pub fn get_input(input: Option<&String>, file: Option<&String>) -> Result { diff --git a/dsc/tests/discovery/dsc_adapter.tests.ps1 b/dsc/tests/discovery/dsc_adapter.tests.ps1 index 694cff549..d8c1bcf0c 100644 --- a/dsc/tests/discovery/dsc_adapter.tests.ps1 +++ b/dsc/tests/discovery/dsc_adapter.tests.ps1 @@ -244,8 +244,17 @@ Describe 'Tests for adapter support' { $out = dsc -l trace resource set -r Adapted/Two -i '{"two":"2"}' 2>$TestDrive/error.log | ConvertFrom-Json -Depth 10 $errorLog = Get-Content $TestDrive/error.log -Raw $LASTEXITCODE | Should -Be 0 -Because $errorLog - $errorLog | Should -BeLike '*Invoking command ''dsctest'' with args Some(`["adapter", "--operation", "set", "--input", "{\"two\":\"2\"}", "--resource-type", "Adapted/Two", "--resource-path", "\"*adaptedTest.dsc.adaptedResource.json\"", "--resource-version", "2.0.0"`])*' -Because $errorLog $out.afterState.two | Should -BeExactly 'value2' -Because $errorLog + $expectedMessage = @( + "Invoking command '.+dsctest(\.exe)?' with args Some\(`\[" + '"adapter", "--operation", "set", ' + '"--input", "\{\\"two\\":\\"2\\"\}", ' + '"--resource-type", "Adapted/Two", ' + '"--resource-path", "\\".+adaptedTest\.dsc\.adaptedResource\.json\\"", ' + '"--resource-version", "2\.0\.0"' + '\]\)' + ) -join '' + "$TestDrive/error.log" | Should -FileContentMatch $expectedMessage -Because $errorLog } It 'Adapted resource with condition false should not be returned' { diff --git a/dsc/tests/discovery/dsc_discovery.tests.ps1 b/dsc/tests/discovery/dsc_discovery.tests.ps1 index 029d69e0a..319d3d0fa 100644 --- a/dsc/tests/discovery/dsc_discovery.tests.ps1 +++ b/dsc/tests/discovery/dsc_discovery.tests.ps1 @@ -240,11 +240,12 @@ Describe 'tests for resource discovery' { $oldPath = $env:DSC_RESTRICTED_PATH try { $env:DSC_RESTRICTED_PATH = $testdrive - $out = dsc resource get -r 'Test/MyEcho' -i '{"output":"Custom"}' 2> "$testdrive/error.txt" | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $out.actualState.output | Should -BeExactly 'Custom' - dsc resource get -r 'Microsoft.DSC.Debug/Echo' -i '{"output":"Custom"}' 2> "$testdrive/error.txt" | ConvertFrom-Json - $LASTEXITCODE | Should -Be 7 + $out = dsc --trace-level info resource get -r 'Test/MyEcho' -i '{"output":"Custom"}' 2> "$testdrive/error.txt" | ConvertFrom-Json + $traceMessages = Get-Content -Raw -Path "$testdrive/error.txt" + $LASTEXITCODE | Should -Be 0 -Because $traceMessages + $out.actualState.output | Should -BeExactly 'Custom' -Because $traceMessages + dsc --trace-level info resource get -r 'Microsoft.DSC.Debug/Echo' -i '{"output":"Custom"}' 2> "$testdrive/error.txt" | ConvertFrom-Json + $LASTEXITCODE | Should -Be 7 -Because $traceMessages Get-Content -Raw -Path "$testdrive/error.txt" | Should -Match "ERROR.*?Resource not found" } finally { @@ -253,8 +254,8 @@ Describe 'tests for resource discovery' { } It 'Resource manifest using relative path to exe: ' -TestCases @( - @{ path = '../dscecho'; success = $true } - @{ path = '../foo/dscecho'; success = $false } + @{ path = '../dscecho'; success = $false } + @{ path = '../foo/dscecho'; success = $true } ) { param($path, $success) $manifest = @" @@ -281,21 +282,23 @@ Describe 'tests for resource discovery' { "@ $dscEcho = Get-Command dscecho -ErrorAction Stop # copy to testdrive - Copy-Item -Path "$($dscEcho.Source)" -Destination $testdrive + $fooFolder = Join-Path -Path $testdrive -ChildPath 'foo' + New-Item -Path $fooFolder -ItemType Directory -Force | Out-Null + Copy-Item -Path "$($dscEcho.Source)" -Destination $fooFolder -Force # create manifest in subfolder $subfolder = Join-Path $testdrive 'subfolder' New-Item -Path $subfolder -ItemType Directory -Force | Out-Null Set-Content -Path (Join-Path $subfolder 'test.dsc.resource.json') -Value $manifest try { - $env:DSC_RESTRICTED_PATH = $subfolder + $env:DSC_RESTRICTED_PATH = @($subfolder, $fooFolder) -join [System.IO.Path]::PathSeparator $out = dsc resource get -r 'Microsoft.DSC.Debug/Echo' -i '{"output":"RelativePathTest"}' 2> "$testdrive/error.txt" | ConvertFrom-Json if ($success) { $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw -Path "$testdrive/error.txt") $out.actualState.output | Should -BeExactly 'RelativePathTest' } else { $LASTEXITCODE | Should -Be 2 -Because (Get-Content -Raw -Path "$testdrive/error.txt") - (Get-Content -Raw -Path "$testdrive/error.txt") | Should -Match "ERROR.*?Executable '\.\./foo/dscecho(\.exe)?' not found" + (Get-Content -Raw -Path "$testdrive/error.txt") | Should -Match "ERROR.*?Executable '\.\./dscecho(\.exe)?' not found" } } finally { diff --git a/dsc/tests/other/dsc_settings.tests.ps1 b/dsc/tests/other/dsc_settings.tests.ps1 index af86d1bdd..674f3dfd7 100644 --- a/dsc/tests/other/dsc_settings.tests.ps1 +++ b/dsc/tests/other/dsc_settings.tests.ps1 @@ -1,149 +1,3037 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'tests for dsc settings' { - BeforeAll { +BeforeDiscovery { + $script:isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + (id -u) -eq 0 + } + $script:skipSystem = -not $IsWindows -or -not $script:isElevated +} + +BeforeAll { + $script:isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + (id -u) -eq 0 + } + $script:skipSystem = -not $IsWindows -or -not $script:isElevated + Write-Verbose -Message "IsElevated: $script:isElevated" + Write-Verbose -Message "SkipSystem: $script:skipSystem" + $script:dscHome = (Get-Command dsc).Path | Split-Path + Write-Verbose -Message "DSC Home: $script:dscHome" + $script:dscBuiltInResources = dsc resource list | ConvertFrom-Json -Depth 20 + + function Get-SettingsFolderPath { + <# + .SYNOPSIS + Gets the folder path for the specified DSC scope. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope + ) + + process { + switch ($Scope) { + "User" { + if (-not [string]::IsNullOrEmpty($env:XDG_CONFIG_HOME)) { + Join-Path -Path $env:XDG_CONFIG_HOME -ChildPath "dsc" + } elseif ($IsWindows) { + Join-Path -Path $env:APPDATA -ChildPath "dsc" + } elseif ($IsMacOS) { + "$HOME/Library/Application Support/dsc" + } else { + "$HOME/.config/dsc" + } + } + "Workspace" { + Get-Location | Select-Object -ExpandProperty Path + } + default { + if ($IsWindows) { + Join-Path -Path $env:ProgramData -ChildPath "dsc" + } elseif ($IsMacOS) { + "/Library/Application Support/dsc" + } else { + "/etc/dsc" + } + } + } + } + } + + function Get-SettingsFilePath { + <# + .SYNOPSIS + Gets the full path for the settings file in the specified scope. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope + ) + + process { + $folder = Get-SettingsFolderPath @PSBoundParameters + if ($Scope -eq 'Policy') { + Join-Path -Path $folder -ChildPath 'dsc.policy.json' + } else { + Join-Path -Path $folder -ChildPath 'dsc.settings.json' + } + } + } + + function Test-SettingsFilePath { + <# + .SYNOPSIS + Tests whether the settings file exists in the specified scope. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope + ) + + process { + Test-Path -Path (Get-SettingsFilePath @PSBoundParameters) + } + } + + function ConvertTo-OrderedDictionary { + <# + .SYNOPSIS + Converts output for `ConvertFrom-Json` into an OrderedDictionary. + .DESCRIPTION + This function is for converting output from `ConvertFrom-Json` into + an ordered dictionary. It recursively converts PSCustomObjects or + Hashtables into an OrderedDictionary, preserving the original + property order from JSON. + #> + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline, Mandatory)] + $InputObject + ) + + begin { + function Test-ShouldRecurse { + param($InputObject) + + $InputObject -is [PSCustomObject] -or + $InputObject -is [System.Collections.IDictionary] + } + function Test-IsCollection { + param($InputObject) + + $InputObject -is [System.Collections.IEnumerable] -and + -not ($InputObject -is [string]) + } + } + + process { + # Create an ordered dictionary + $dict = [System.Collections.Specialized.OrderedDictionary]::new() + Write-Verbose "Converting input object to ordered dictionary..." + Write-Verbose "Input object type: [$($InputObject.GetType().FullName)]" + Write-Verbose "Input object: $($InputObject | ConvertTo-Json -Depth 10)" + + # Get keys to iterate over + $keys = @() + if ($InputObject -is [System.Collections.IDictionary]) { + $keys = $InputObject.Keys + } elseif ($InputObject -is [PSCustomObject]) { + $keys = $InputObject.PSObject.Properties.Name + } else { + return $InputObject # Return non-object types unchanged + } + Write-Verbose "Processing keys for input object: $($keys | ConvertTo-Json)" + + foreach ($key in $keys) { + Write-Verbose "Processing key: $key" + $value = if ($InputObject -is [System.Collections.IDictionary]) { + $InputObject[$key] + } elseif ($InputObject -is [PSCustomObject]) { + $InputObject.$key + } + Write-Verbose "Value for key '$key': $($value | ConvertTo-Json -Depth 10)" + + # Recursively convert nested objects + if (Test-ShouldRecurse $value) { + Write-Verbose "Recursively converting value for key '$key' to ordered dictionary..." + $dict[$key] = ConvertTo-OrderedDictionary $value + } elseif (Test-IsCollection $value) { + Write-Verbose "Processing collection value for key '$key'..." + $dict[$key] = @( + foreach ($item in $value) { + Write-Verbose "Processing '$key' collection item: $($item | ConvertTo-Json -Depth 10)" + if (Test-ShouldRecurse $item) { + Write-Verbose "Recursively converting collection item for key '$key' to ordered dictionary..." + ConvertTo-OrderedDictionary $item + } else { + Write-Verbose "Adding collection item for key '$key' without conversion: $($item | ConvertTo-Json -Depth 10)" + $item + } + } + ) + } else { + Write-Verbose "Adding value for key '$key' without conversion: $($value | ConvertTo-Json -Depth 10)" + $dict[$key] = $value + } + } + + Write-Verbose "Returning ordered dictionary: $($dict | ConvertTo-Json -Depth 10)" + $dict + } + } + + function Get-SettingsFile { + <# + .SYNOPSIS + Retrieves the settings from the specified scope settings file. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope + ) + + process { + $filePath = Get-SettingsFilePath @PSBoundParameters + if (Test-Path -Path $filePath) { + Get-Content -Path $filePath -Raw + | ConvertFrom-Json -Depth 10 + | ConvertTo-OrderedDictionary + } + } + } + + function Remove-SettingsFile { + <# + .SYNOPSIS + Removes the settings file for the specified scope. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope + ) + + process { + $filePath = Get-SettingsFilePath @PSBoundParameters + if (Test-Path -Path $filePath) { + Remove-Item -Path $filePath -Force + } + } + } + + function Set-SettingsFile { + <# + .SYNOPSIS + Ensures the settings file for a scope exists with the defined + settings. + + .DESCRIPTION + This function ensures that the settings file for the specified scope + exists and contains the provided settings. If the file doesn't + exist, the function creates it. It overwrites any previous content. + + .PARAMETER Scope + Specify the scope to define a settings file for. Valid values are: + + - `Machine` - The machine scope settings preference file. + - `User` - The user scope settings preference file. + - `Workspace` - The workspace scope settings preference file. + - `Policy` - The system-wide policy settings file. + + .PARAMETER Settings + An ordered dictionary containing the settings to write to the file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet("Machine", "User", "Workspace", "Policy")] + [string]$Scope, + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary]$Settings + ) + + process { + $folder = Get-SettingsFolderPath -Scope $Scope + if (-not (Test-Path -Path $folder)) { + $null = New-Item -Path $folder -ItemType Directory -Force + } + + $params = @{ + Path = Get-SettingsFilePath -Scope $Scope + Value = $Settings | ConvertTo-Json -Depth 10 + Force = $true + } + Set-Content @params + } + } + + function Initialize-SettingsFileContext { + <# + .SYNOPSIS + Ensures no settings files exist for any scope and XDG_CONFIG_HOME + isn't set. + + .DESCRIPTION + This function checks for existing settings files in all file-source + scopes (Machine, User, Workspace, Policy) and removes them if they + exist after caching their contents. + + When processing the User scope, the function checks whether the + `XDG_CONFIG_HOME` environment variable is set. If it is, the + function caches the value before unsetting the variable. + #> + [CmdletBinding()] + param ( + ) + + begin { + #region Machine scope + if (Test-SettingsFilePath -Scope 'Machine') { + $filePath = Get-SettingsFilePath -Scope 'Machine' + $script:MachineSettingsContent = Get-Content -Path $filePath -Raw + Remove-Item -Path $filePath -Force + } + #endregion Machine scope + #region User scope + if (Test-SettingsFilePath -Scope 'User') { + $filePath = Get-SettingsFilePath -Scope 'User' + if (-not [string]::IsNullOrEmpty($env:XDG_CONFIG_HOME)) { + $script:XDG_CONFIG_HOME = $env:XDG_CONFIG_HOME + Remove-Item Env:\XDG_CONFIG_HOME + } else { + $script:UserSettingsContent = Get-Content -Path $filePath -Raw + Remove-Item -Path $filePath -Force + $parentFolder = Split-Path -Path $filePath -Parent + if (-not (Test-Path $parentFolder)) { + $script:RemoveUserSettingsParentFolder = $true + New-Item -Path $parentFolder -ItemType Directory -Force + } + } + } + #endregion User scope + #region Workspace scope + if (Test-SettingsFilePath -Scope 'Workspace') { + $filePath = Get-SettingsFilePath -Scope 'Workspace' + $script:WorkspaceSettingsContent = Get-Content -Path $filePath -Raw + Remove-Item -Path $filePath -Force + } + #endregion Workspace scope + #region Policy scope + if (-not $script:skipSystem -and (Test-SettingsFilePath -Scope 'Policy')) { + $filePath = Get-SettingsFilePath -Scope 'Policy' + $script:PolicySettingsContent = Get-Content -Path $filePath -Raw + Remove-Item -Path $filePath -Force + } + #endregion Policy scope + } + } + + function Reset-SettingsFileContext { + <# + .SYNOPSIS + Removes the settings files for every scope. + + .DESCRIPTION + This function resets the settings file context by removing the + settings files for all scopes. This should be used between tests. + #> + [CmdletBinding()] + param() + + process { + Remove-SettingsFile -Scope 'Machine' + Remove-SettingsFile -Scope 'User' + Remove-SettingsFile -Scope 'Workspace' + if (-not $script:skipSystem) { + Remove-SettingsFile -Scope 'Policy' + } + } + } + + function Restore-SettingsFileContext { + <# + .SYNOPSIS + Restores the settings file context to before testing. + + .DESCRIPTION + This function restores the settings file context by recreating the + settings files for all scopes using the cached data from the + `Initialize-SettingsFileContext` function. + + It restores any settings files and content and returns the ACL for + the system settings folder to its original state if it existed. + #> + [CmdletBinding()] + param() + + process { + #region Machine scope + if (-not [string]::IsNullOrEmpty($script:MachineSettingsContent)) { + $filePath = Get-SettingsFilePath -Scope 'Machine' + Set-Content -Path $filePath -Value $script:MachineSettingsContent + } else { + Remove-SettingsFile -Scope 'Machine' + } + #endregion Machine scope + #region User scope + if (-not [string]::IsNullOrEmpty($script:XDG_CONFIG_HOME)) { + $env:XDG_CONFIG_HOME = $script:XDG_CONFIG_HOME + } elseif (-not [string]::IsNullOrEmpty($script:UserSettingsContent)) { + $filePath = Get-SettingsFilePath -Scope 'User' + Set-Content -Path $filePath -Value $script:UserSettingsContent + } else { + Remove-SettingsFile -Scope 'User' + } + #endregion User scope + #region Workspace scope + if (-not [string]::IsNullOrEmpty($script:WorkspaceSettingsContent)) { + $filePath = Get-SettingsFilePath -Scope 'Workspace' + Set-Content -Path $filePath -Value $script:WorkspaceSettingsContent + } else { + Remove-SettingsFile -Scope 'Workspace' + } + #endregion Workspace scope + #region Policy scope + if (-not $script:skipSystem) { + if (-not [string]::IsNullOrEmpty($script:PolicySettingsContent)) { + $filePath = Get-SettingsFilePath -Scope 'Policy' + Set-Content -Path $filePath -Value $script:PolicySettingsContent + } else { + Remove-SettingsFile -Scope 'Policy' + } + } + #endregion Policy scope + } + } + + function Initialize-PolicyFolder { + <# + .SYNOPSIS + Initializes the policy folder so it exists with secure ACL settings. + + .DESCRIPTION + This function checks whether the policy folder exists. + + If the policy folder exists, the function caches the original ACL + so it can be restored later with the `Reset-PolicyFolder` function. + If the folder doesn't exist, the function creates it. + + In either case, the function then sets the ACL for the folder to + the expected secure state. + #> + [CmdletBinding()] + param() + + process { + if ($script:skipSystem) { + return + } + + $folder = Get-SettingsFolderPath -Scope 'Policy' + $script:existedBefore = Test-Path -Path $folder + + if ($script:existedBefore) { + $script:originalAcl = Get-Acl -Path $folder + } else { + $null = New-Item -ItemType Directory -Path $folder -Force + } + + $null = Set-PolicyFolderAcl -Secure + } + } + + function Restore-PolicyFolder { + <# + .SYNOPSIS + Resets the policy folder to its original state. - $script:policyFilePath = if ($IsWindows) { - Join-Path $env:ProgramData "dsc" "dsc.settings.json" - } else { - "/etc/dsc/dsc.settings.json" + .DESCRIPTION + This function restores the original ACL for the policy folder if it + existed before initialization. If `Initialize-PolicyFolder` created + the folder, this function removes it. + #> + [CmdletBinding()] + param() + + process { + if ($script:skipSystem) { + return + } + $folder = Get-SettingsFolderPath -Scope 'Policy' + if ($script:existedBefore) { + # Restore the original ACL for the policy folder + Set-Acl -Path $folder -AclObject $script:originalAcl + } else { + Remove-Item -Path $folder -Recurse -Force + } + } + } + + function Get-PolicyFolderAcl { + [CmdletBinding()] + [OutputType([System.Security.AccessControl.DirectorySecurity])] + param() + + process { + $folder = Get-SettingsFolderPath -Scope 'Policy' + Get-Acl -Path $folder } + } - $script:dscHome = (Get-Command dsc).Path | Split-Path - $script:dscSettingsFilePath = Join-Path $script:dscHome "dsc.settings.json" - $script:dscDefaultSettingsFilePath = Join-Path $script:dscHome "dsc_default.settings.json" + function Set-PolicyFolderAcl { + [CmdletBinding(DefaultParameterSetName='Default')] + param( + [Parameter(Mandatory, ParameterSetName='Secure')] + [switch]$Secure, + [Parameter(Mandatory, ParameterSetName='Insecure')] + [switch]$Insecure, + [Parameter(Mandatory, ParameterSetName='AccessRule')] + [System.Security.AccessControl.FileSystemAccessRule[]]$AccessRule + ) - if ($IsWindows) { #"Setting policy on Linux requires sudo" - $script:policyDirPath = $script:policyFilePath | Split-Path - New-Item -ItemType Directory -Path $script:policyDirPath | Out-Null - # Set secure ACLs: only SYSTEM and Administrators have write access - $acl = Get-Acl -Path $script:policyDirPath + begin { + $folder = Get-SettingsFolderPath -Scope 'Policy' + $acl = Get-Acl -Path $folder $acl.SetAccessRuleProtection($true, $false) $acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) } | Out-Null - $systemRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "NT AUTHORITY\SYSTEM", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow") - $adminsRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "BUILTIN\Administrators", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow") - $usersReadRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "BUILTIN\Users", "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") - $acl.AddAccessRule($systemRule) - $acl.AddAccessRule($adminsRule) - $acl.AddAccessRule($usersReadRule) - Set-Acl -Path $script:policyDirPath -AclObject $acl } - #create backups of settings files - $script:dscSettingsFilePath_backup = Join-Path $script:dscHome "dsc.settings.json.backup" - $script:dscDefaultSettingsFilePath_backup = Join-Path $script:dscHome "dsc_default.settings.json.backup" - Copy-Item -Force -Path $script:dscSettingsFilePath -Destination $script:dscSettingsFilePath_backup - Copy-Item -Force -Path $script:dscDefaultSettingsFilePath -Destination $script:dscDefaultSettingsFilePath_backup + process { + if ($script:skipSystem) { + return + } + + if ($Secure) { + $systemRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "NT AUTHORITY\SYSTEM", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow" + ) + $adminsRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "BUILTIN\Administrators", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow" + ) + $usersReadRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "BUILTIN\Users", "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow" + ) + $acl.AddAccessRule($systemRule) + $acl.AddAccessRule($adminsRule) + $acl.AddAccessRule($usersReadRule) + } elseif ($Insecure) { + # todo + } elseif ($AccessRule.Count -gt 0) { + $AccessRule | ForEach-Object { $acl.AddAccessRule($_) } + } + + Set-Acl -Path $folder -AclObject $acl -Passthru + } } - AfterAll { - Remove-Item -Force -Path $script:dscSettingsFilePath_backup - Remove-Item -Force -Path $script:dscDefaultSettingsFilePath_backup - if ($IsWindows) { #"Setting policy on Linux requires sudo" - Remove-Item -Recurse -Force -Path $script:policyDirPath + function Initialize-SettingsEnvironmentContext { + [CmdletBinding()] + param() + + process { + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_LEVEL)) { + $script:dscTraceLevel = $env:DSC_TRACE_LEVEL + Remove-Item env:DSC_TRACE_LEVEL + } + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_FORMAT)) { + $script:dscTraceFormat = $env:DSC_TRACE_FORMAT + Remove-Item env:DSC_TRACE_FORMAT + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESOURCE_PATH)) { + $script:dscResourcePath = $env:DSC_RESOURCE_PATH + Remove-Item env:DSC_RESOURCE_PATH + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESTRICTED_PATH)) { + $script:dscRestrictedPath = $env:DSC_RESTRICTED_PATH + Remove-Item env:DSC_RESTRICTED_PATH + } + if (-not [string]::IsNullOrEmpty($env:DSC_IGNORE_SETTING_FILE)) { + $script:dscIgnoreSettingFile = $env:DSC_IGNORE_SETTING_FILE + Remove-Item env:DSC_IGNORE_SETTING_FILE + } } } - BeforeEach { - $script:dscDefaultSettings = Get-Content -Raw -Path $script:dscDefaultSettingsFilePath_backup | ConvertFrom-Json - $script:dscDefaultv1Settings = (Get-Content -Raw -Path $script:dscDefaultSettingsFilePath_backup | ConvertFrom-Json)."1" + function Restore-SettingsEnvironmentContext { + [CmdletBinding()] + param() + + process { + if (-not [string]::IsNullOrEmpty($script:dscTraceLevel)) { + $env:DSC_TRACE_LEVEL = $script:dscTraceLevel + } + if (-not [string]::IsNullOrEmpty($script:dscTraceFormat)) { + $env:DSC_TRACE_FORMAT = $script:dscTraceFormat + } + if (-not [string]::IsNullOrEmpty($script:dscResourcePath)) { + $env:DSC_RESOURCE_PATH = $script:dscResourcePath + } + if (-not [string]::IsNullOrEmpty($script:dscRestrictedPath)) { + $env:DSC_RESTRICTED_PATH = $script:dscRestrictedPath + } + if (-not [string]::IsNullOrEmpty($script:dscIgnoreSettingFile)) { + $env:DSC_IGNORE_SETTING_FILE = $script:dscIgnoreSettingFile + } + } } - AfterEach { - Copy-Item -Force -Path $script:dscSettingsFilePath_backup -Destination $script:dscSettingsFilePath - Copy-Item -Force -Path $script:dscDefaultSettingsFilePath_backup -Destination $script:dscDefaultSettingsFilePath - if ($IsWindows) { #"Setting policy on Linux requires sudo" - Remove-Item -Path $script:policyFilePath -ErrorAction SilentlyContinue + function Reset-SettingsEnvironment { + [CmdletBinding()] + param() + + process { + Write-Verbose "Resetting settings environment variables..." + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_LEVEL)) { + Write-Verbose "Resetting DSC_TRACE_LEVEL..." + $env:DSC_TRACE_LEVEL = $null + } + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_FORMAT)) { + Write-Verbose "Resetting DSC_TRACE_FORMAT..." + $env:DSC_TRACE_FORMAT = $null + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESOURCE_PATH)) { + Write-Verbose "Resetting DSC_RESOURCE_PATH..." + $env:DSC_RESOURCE_PATH = $null + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESTRICTED_PATH)) { + Write-Verbose "Resetting DSC_RESTRICTED_PATH..." + $env:DSC_RESTRICTED_PATH = $null + } + if (-not [string]::IsNullOrEmpty($env:DSC_IGNORE_SETTINGS_FILE)) { + Write-Verbose "Resetting DSC_IGNORE_SETTINGS_FILE..." + $env:DSC_IGNORE_SETTINGS_FILE = $null + } } } - It 'ensure a new tracing value in settings has effect' { + function Get-SettingsEnvironment { + [CmdletBinding()] + param() - $script:dscDefaultv1Settings."tracing"."level" = "TRACE" - $script:dscDefaultv1Settings | ConvertTo-Json -Depth 90 | Set-Content -Force -Path $script:dscSettingsFilePath + begin { + $settings = [ordered]@{} + } + + process { + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_LEVEL)) { + $settings['DSC_TRACE_LEVEL'] = $env:DSC_TRACE_LEVEL + } + if (-not [string]::IsNullOrEmpty($env:DSC_TRACE_FORMAT)) { + $settings['DSC_TRACE_FORMAT'] = $env:DSC_TRACE_FORMAT + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESOURCE_PATH)) { + $settings['DSC_RESOURCE_PATH'] = $env:DSC_RESOURCE_PATH + } + if (-not [string]::IsNullOrEmpty($env:DSC_RESTRICTED_PATH)) { + $settings['DSC_RESTRICTED_PATH'] = $env:DSC_RESTRICTED_PATH + } + if (-not [string]::IsNullOrEmpty($env:DSC_IGNORE_SETTING_FILE)) { + $settings['DSC_IGNORE_SETTING_FILE'] = $env:DSC_IGNORE_SETTING_FILE + } + } - dsc resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Trace-level is Trace" + end { + $settings + } + } + + function Set-SettingsEnvironment { + [CmdletBinding(DefaultParameterSetName='SeparateSettings')] + param( + [Parameter(ParameterSetName='SeparateSettings')] + [ValidateNotNullOrEmpty()] [string] $TraceLevel, + [Parameter(ParameterSetName='SeparateSettings')] + [ValidateNotNullOrEmpty()] [string] $TraceFormat, + [Parameter(ParameterSetName='SeparateSettings')] + [ValidateNotNullOrEmpty()] [string] $ResourcePath, + [Parameter(ParameterSetName='SeparateSettings')] + [ValidateNotNullOrEmpty()] [string] $RestrictedPath, + [Parameter(ParameterSetName='SeparateSettings')] + [ValidateNotNullOrEmpty()] [string] $IgnoreSettingsFile, + + [Parameter(ParameterSetName="SettingsDictionary")] + [System.Collections.Specialized.OrderedDictionary] $Settings + ) + begin { + if ($null -ne $Settings) { + if ($Settings.Contains('DSC_TRACE_LEVEL')) { + $TraceLevel = $Settings['DSC_TRACE_LEVEL'] + } + if ($Settings.Contains('DSC_TRACE_FORMAT')) { + $TraceFormat = $Settings['DSC_TRACE_FORMAT'] + } + if ($Settings.Contains('DSC_RESOURCE_PATH')) { + $ResourcePath = $Settings['DSC_RESOURCE_PATH'] + } + if ($Settings.Contains('DSC_RESTRICTED_PATH')) { + $RestrictedPath = $Settings['DSC_RESTRICTED_PATH'] + } + if ($Settings.Contains('DSC_IGNORE_SETTINGS_FILE')) { + $IgnoreSettingsFile = $Settings['DSC_IGNORE_SETTINGS_FILE'] + } + } + } + + process { + if ($TraceLevel) { + $env:DSC_TRACE_LEVEL = $TraceLevel + } + if ($TraceFormat) { + $env:DSC_TRACE_FORMAT = $TraceFormat + } + if ($ResourcePath) { + $env:DSC_RESOURCE_PATH = $ResourcePath + } + if ($RestrictedPath) { + $env:DSC_RESTRICTED_PATH = $RestrictedPath + } + if ($IgnoreSettingsFile) { + $env:DSC_IGNORE_SETTINGS_FILE = $IgnoreSettingsFile + } + } + } + + function Initialize-SettingsContext { + [CmdletBinding()] + param() + + process { + Initialize-PolicyFolder + Initialize-SettingsFileContext + Initialize-SettingsEnvironmentContext + } + } + + function Restore-SettingsContext { + [CmdletBinding()] + param() + + process { + Restore-SettingsFileContext + Restore-PolicyFolder + Restore-SettingsEnvironmentContext + } + } + + function Get-SettingsContext { + [CmdletBinding()] + param() + + process { + [pscustomobject]@{ + SettingsFiles = [pscustomobject]@{ + Machine = [pscustomobject]@{ + FilePath = Get-SettingsFilePath -Scope Machine + Exists = Test-SettingsFilePath -Scope Machine + Data = Get-SettingsFile -Scope Machine + } + User = [pscustomobject]@{ + FilePath = Get-SettingsFilePath -Scope User + Exists = Test-SettingsFilePath -Scope User + Data = Get-SettingsFile -Scope User + } + Workspace = [pscustomobject]@{ + FilePath = Get-SettingsFilePath -Scope Workspace + Exists = Test-SettingsFilePath -Scope Workspace + Data = Get-SettingsFile -Scope Workspace + } + Policy = [pscustomobject]@{ + FilePath = Get-SettingsFilePath -Scope Policy + Exists = Test-SettingsFilePath -Scope Policy + Data = Get-SettingsFile -Scope Policy + } + } + Environment = Get-SettingsEnvironment + } + } } - It 'ensure a new resource_path value in settings has effect' { + class TestContext { + [string]$Message + [object]$Result + [int]$ExitCode + [pscustomobject]$SettingsContext + [string[]]$Traces - $script:dscDefaultv1Settings."resourcePath"."directories" = @("TestDir1","TestDir2") - $script:dscDefaultv1Settings | ConvertTo-Json -Depth 90 | Set-Content -Force -Path $script:dscSettingsFilePath - dsc -l debug resource list 2> $TestDrive/tracing.txt - $expectedString = 'Using Resource Path: TestDir1'+[System.IO.Path]::PathSeparator+'TestDir2' - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedString -Because (Get-Content $TestDrive/tracing.txt -Raw) + [System.Collections.Specialized.OrderedDictionary] ToData() { + $data = [ordered]@{} + if ($null -ne $this.Message) { + $data.message = $this.Message + } + if ($null -ne $this.Result) { + $data.result = $this.Result + } + $data.exitCode = $this.ExitCode + $data.SettingsContext = $this.SettingsContext + $data.Traces = $this.Traces + return $data + } + + [string] ToString() { + return $this.ToData() | ConvertTo-Json -Depth 10 + } } - It 'Confirm settings override priorities' { + function New-TestContext { + [CmdletBinding()] + [OutputType('TestContext')] + param( + [string]$Message, + [object]$Result, + [string]$TraceFilePath + ) - if (! $IsWindows) { - Set-ItResult -Skip -Because "Setting policy requires sudo" - return + begin { + $context = [TestContext]@{ + ExitCode = $LASTEXITCODE + } } - $script:dscDefaultv1Settings."tracing"."level" = "TRACE" - $script:dscDefaultv1Settings."resourcePath"."directories" = @("PolicyDir") - $script:dscDefaultv1Settings | ConvertTo-Json -Depth 90 | Set-Content -Force -Path $script:policyFilePath + process { + if (-not [string]::IsNullOrEmpty($Message)) { + $context.Message = $Message + } + if ($null -ne $Result) { + $context.Result = $Result + } + $context.ExitCode = $LASTEXITCODE + $context.SettingsContext = Get-SettingsContext + $context.Traces = Get-Content -Path $TraceFilePath - $script:dscDefaultv1Settings."tracing"."level" = "TRACE" - $script:dscDefaultv1Settings."resourcePath"."directories" = @("SettingsDir") - $script:dscDefaultv1Settings | ConvertTo-Json -Depth 90 | Set-Content -Force -Path $script:dscSettingsFilePath + $context + } + } +} - $script:dscDefaultSettings."1"."tracing"."level" = "TRACE" - $script:dscDefaultSettings."1"."resourcePath"."directories" = @("Defaultv1SettingsDir") - $script:dscDefaultSettings | ConvertTo-Json -Depth 90 | Set-Content -Force -Path $script:dscDefaultSettingsFilePath +Describe 'dsc settings tests' { + BeforeAll { + Write-Verbose -Message "Initial settings context: $(Get-SettingsContext | ConvertTo-Json -Depth 10)" + Initialize-SettingsContext + Write-Verbose -Message "Settings context after initialization: $(Get-SettingsContext | ConvertTo-Json -Depth 10)" + } + AfterAll { + Write-Verbose -Message "Restoring settings context..." + Restore-SettingsContext + Write-Verbose -Message "Settings context after restoration: $(Get-SettingsContext | ConvertTo-Json -Depth 10)" + } + AfterEach { + Reset-SettingsEnvironment + Reset-SettingsFileContext + } - # ensure policy overrides everything - dsc -l debug resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Trace-level is Trace" - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Using Resource Path: PolicyDir' + Context 'cli settings' { + Context '--trace-level' { + it "emits expected messaages for '<_>' trace level" -ForEach @("error", "warn", "info", "debug", "trace") { + $level = $_ + $messages = [pscustomobject]@{ + Trace = "trace resource message for level: $level" + Debug = "debug message for level: $level" + Info = "info message for level: $level" + Warning = "warning message for level: $level" + Error = "error message for level: $level" + } + $params = @( + '--trace-level', $level + 'resource', 'get' + '--resource', 'Microsoft.DSC.Transitional/PowerShellScript' + '--input', ([ordered]@{ + getScript = @( + "Write-Information '$($messages.Trace)'" + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt - # without policy, command-line args have priority - Remove-Item -Path $script:policyFilePath - dsc -l debug resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Trace-level is Debug" - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Using Resource Path: SettingsDir' + # All levels should find the error message + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + if ($level -ne "error") { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + } + if ($level -notin @('error', 'warn')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + } + if ($level -notin ('error', 'warn', 'info')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + if ($level -eq 'trace') { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + It "errors for an invalid value" { + $value = 'invalid' + $expected = [regex]::escape("error: invalid value '$value' for '--trace-level '") + dsc --trace-level $value resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + } + Context '--trace-format' { + It "emits expected format for '<_>' trace format" -ForEach @('default', 'plaintext', 'json') { + $format = $_ + $invalidKey = "trace_format.$_" + $params = @( + '--trace-format', $format + 'resource', 'get' + '--resource', 'Microsoft.DSC.Debug/Echo' + '--input', (@{ $invalidKey = $format} | ConvertTo-Json -Compress) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt - # without policy and command-line args, settings file is used - dsc resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Trace-level is Trace" - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Using Resource Path: SettingsDir' + $expectedMessage = "Schema: Additional properties are not allowed ('$invalidKey' was unexpected)" + if ($format -eq 'json') { + $traces = Get-Content $TestDrive/tracing.txt | ConvertFrom-Json + $traces.count | Should -Be 1 -Because $testContext + $traces[0].level | Should -Be 'Error' -Because $testContext + $traces[0].fields.message | Should -Be $expectedMessage -Because $testContext + } else { + $expected = [regex]::Escape("ERROR $expectedMessage") + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because $testContext + } + } - # without policy and command-line args and settings file, the default settings file is used - Remove-Item -Path $script:dscSettingsFilePath - dsc resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Trace-level is Trace" - "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Using Resource Path: Defaultv1SettingsDir' + It "errors for an invalid value" { + $value = 'invalid' + $expected = [regex]::escape("error: invalid value '$value' for '--trace-format '") + dsc --trace-format $value resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + } + Context '--ignore-settings-file' { + BeforeAll { + $userPreferenceData = [ordered]@{ + tracing = [ordered]@{ + level = 'info' + } + } + } + BeforeEach { + Set-SettingsFile -Scope User -Settings $userPreferenceData + } + It "loads settings files when the flag is not specified" { + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + It "loads settings files when the flag is specified as false" { + dsc --ignore-settings-file=false resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + It "does not load settings files when the flag is specified without a value" { + dsc --ignore-settings-file resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to be ignored' -TraceFilePath $TestDrive/tracing.txt + ) + } + It "does not load settings files when the flag is specified as true" { + dsc --ignore-settings-file=true resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to be ignored' -TraceFilePath $TestDrive/tracing.txt + ) + } + } } - It 'DSC_IGNORE_SETTINGS_FILE environment variable disables settings file' { - $oldEnv = $env:DSC_IGNORE_SETTINGS_FILE - try { - $env:DSC_IGNORE_SETTINGS_FILE = "1" - $null = dsc -l warn resource list 2> $TestDrive/tracing.txt - $errorLog = Get-Content "$TestDrive/tracing.txt" -Raw - $errorLog | Should -BeLike "*WARN*Ignoring settings file due to environment variable 'DSC_IGNORE_SETTINGS_FILE' being set or '--ignore-settings-file' flag being used*" + Context 'environment settings' { + Context 'DSC_TRACE_LEVEL' { + it "emits expected messaages for '<_>' trace level" -ForEach @("error", "warn", "info", "debug", "trace") { + $level = $_ + Set-SettingsEnvironment -TraceLevel $level + $messages = [pscustomobject]@{ + Trace = "trace resource message for level: $level" + Debug = "debug message for level: $level" + Info = "info message for level: $level" + Warning = "warning message for level: $level" + Error = "error message for level: $level" + } + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Transitional/PowerShellScript' + '--input', ([ordered]@{ + getScript = @( + "Write-Information '$($messages.Trace)'" + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + # All levels should find the error message + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + if ($level -ne "error") { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + } + if ($level -notin @('error', 'warn')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + } + if ($level -notin ('error', 'warn', 'info')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + if ($level -eq 'trace') { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace level" + Set-SettingsEnvironment -TraceLevel $value + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } } - finally { - $env:DSC_IGNORE_SETTINGS_FILE = $oldEnv + Context 'DSC_TRACE_FORMAT' { + It "emits expected format for '<_>' trace format" -ForEach @('default', 'plaintext', 'json') { + $format = $_ + $invalidKey = "trace_format.$_" + Set-SettingsEnvironment -TraceFormat $format -TraceLevel Error + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Debug/Echo' + '--input', (@{ $invalidKey = $format} | ConvertTo-Json -Compress) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = "Schema: Additional properties are not allowed ('$invalidKey' was unexpected)" + if ($format -eq 'json') { + $traces = Get-Content $TestDrive/tracing.txt | ConvertFrom-Json + $traces.count | Should -Be 1 -Because $testContext + $traces[0].level | Should -Be 'Error' -Because $testContext + $traces[0].fields.message | Should -Be $expectedMessage -Because $testContext + } else { + $expected = [regex]::Escape("ERROR $expectedMessage") + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because $testContext + } + } + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace format" + Set-SettingsEnvironment -TraceFormat $value + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + } + Context 'DSC_RESOURCE_PATH' { + BeforeAll { + $resourcePath = "$TestDrive/resources" + $resourceManifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'DSC.Test/ResourcePath' + version = '0.1.0' + get = [ordered]@{ + executable = 'dsc' + args = @(@{ + jsonInputArg = '--input' + mandatory = $true + }) + } + schema = @{ + command = @{ + executable = 'dscecho' + } + } + } + $resourceManifestJson = $resourceManifest | ConvertTo-Json -Depth 10 + } + BeforeEach { + New-Item -ItemType Directory -Path $resourcePath -Force + $resourceManifestJson | Set-Content -Path (Join-Path $resourcePath "test.dsc.resource.json") -Force + } + AfterEach { + Remove-Item -Path $resourcePath -Recurse -Force + } + + It "adds discoverable resource paths" { + Set-SettingsEnvironment -ResourcePath $resourcePath + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + } + Context "DSC_RESTRICTED_PATH" { + BeforeAll { + $escapedDscHome = [Regex]::Escape($script:dscHome) + $resourcePath = Join-Path -Path $TestDrive -ChildPath 'resources' + $escapedResourcePath = [Regex]::Escape($resourcePath) + $escapedResourcePathJson = $IsWindows ? [Regex]::Escape($escapedResourcePath) : $escapedResourcePath + $resourceManifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'DSC.Test/ResourcePath' + version = '0.1.0' + get = [ordered]@{ + executable = 'dscecho' + args = @(@{ + jsonInputArg = '--input' + mandatory = $true + }) + } + schema = @{ + command = @{ + executable = 'dscecho' + } + } + } + $resourceManifestJson = $resourceManifest | ConvertTo-Json -Depth 10 + } + BeforeEach { + New-Item -ItemType Directory -Path $resourcePath -Force + $resourceManifestJson | Set-Content -Path (Join-Path $resourcePath "test.dsc.resource.json") -Force + } + AfterEach { + Remove-Item -Path $resourcePath -Recurse -Force + } + + It "adds discoverable resource paths" { + Set-SettingsEnvironment -RestrictedPath $resourcePath + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "restricts discovery for other paths" { + Set-SettingsEnvironment -RestrictedPath $resourcePath + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "raises a warning when DSC executable home is not in the restricted path" { + Set-SettingsEnvironment -RestrictedPath $resourcePath + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "errors when invoking an executable outside of the specified paths" { + Set-SettingsEnvironment -RestrictedPath $resourcePath + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $LASTEXITCODE | Should -Be 2 -Because $testContext + $result | Should -BeNullOrEmpty -Because $testContext + $expectedError = @( + "ERROR Command: Operation Executable '{0}' not found" -f $resourceManifest.get.executable + "with working directory '{0}'" -f $escapedResourcePath + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedError -Because $testContext + } + } + Context 'DSC_IGNORE_SETTINGS_FILE' { + BeforeAll { + $userPreferenceData = [ordered]@{ + tracing = [ordered]@{ + level = 'info' + } + } + } + BeforeEach { + Set-SettingsFile -Scope User -Settings $userPreferenceData + } + + It "loads the settings file when variable isn't set" { + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + It "loads settings files when variable is '<_>'" -ForEach @('false', 'FALSE', 'fAlSe', '0') { + Set-SettingsEnvironment -IgnoreSettingsFile $_ + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + It "skips loading settings files when variable is '<_>'" -ForEach @('true', 'TRUE', 'tRuE', '1') { + Set-SettingsEnvironment -IgnoreSettingsFile $_ + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + Get-Content -Raw -Path "$TestDrive/tracing.txt" | Should -BeNullOrEmpty -Because ( + New-TestContext -Message 'expected no trace message when ignoring settings file' -TraceFilePath $TestDrive/tracing.txt + ) + } } } - It '--ignore-settings-file command-line argument disables settings file' { - $oldEnv = $env:DSC_IGNORE_SETTINGS_FILE - try { - $env:DSC_IGNORE_SETTINGS_FILE = $null - $null = dsc --ignore-settings-file -l warn resource list 2> $TestDrive/tracing.txt - $errorLog = Get-Content "$TestDrive/tracing.txt" -Raw - $errorLog | Should -BeLike "*WARN*Ignoring settings file due to environment variable 'DSC_IGNORE_SETTINGS_FILE' being set or '--ignore-settings-file' flag being used*" + Context "preference settings" { + BeforeDiscovery { + [string[]]$scopes = if ($script:skipSystem) { + @('User', 'Workspace') + } else { + @('Machine', 'User', 'Workspace') + } } - finally { - $env:DSC_IGNORE_SETTINGS_FILE = $oldEnv + + Context '<_> scope' -ForEach $scopes { + BeforeAll { + $scope = $_ + } + + Context 'tracing.level' { + It "emits expected messaages for '<_>' trace level" -ForEach @("error", "warn", "info", "debug", "trace") { + $level = $_ + $data = [ordered]@{ + tracing = [ordered]@{ + level = $level + } + } + Set-SettingsFile -Scope $scope -Settings $data + $messages = [pscustomobject]@{ + Trace = "trace resource message for level: $level" + Debug = "debug message for level: $level" + Info = "info message for level: $level" + Warning = "warning message for level: $level" + Error = "error message for level: $level" + } + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Transitional/PowerShellScript' + '--input', ([ordered]@{ + getScript = @( + "Write-Information '$($messages.Trace)'" + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + # All levels should find the error message + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + if ($level -ne "error") { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + } + if ($level -notin @('error', 'warn')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + } + if ($level -notin ('error', 'warn', 'info')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + if ($level -eq 'trace') { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace level" + $data = [ordered]@{ + tracing = [ordered]@{ + level = $value + } + } + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because { + New-TestContext -TraceFilePath $TestDrive/tracing.txt + } + } + } + + Context 'tracing.format' { + It "emits expected format for '<_>' trace format" -ForEach @('default', 'plaintext', 'json') { + $format = $_ + $invalidKey = "trace_format.$_" + $data = [ordered]@{ + tracing = [ordered]@{ + format = $format + } + } + Set-SettingsFile -Scope $scope -Settings $data + Set-SettingsEnvironment -TraceLevel Error + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Debug/Echo' + '--input', (@{ $invalidKey = $format} | ConvertTo-Json -Compress) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = "Schema: Additional properties are not allowed ('$invalidKey' was unexpected)" + if ($format -eq 'json') { + $traces = Get-Content $TestDrive/tracing.txt | ConvertFrom-Json + $traces.count | Should -Be 1 -Because $testContext + $traces[0].level | Should -Be 'Error' -Because $testContext + $traces[0].fields.message | Should -Be $expectedMessage -Because $testContext + } else { + $expected = [regex]::Escape("ERROR $expectedMessage") + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because $testContext + } + } + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace format" + $data = [ordered]@{ + tracing = [ordered]@{ + format = $value + } + } + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + } + + Context "resourcePath" { + BeforeAll { + $escapedDscHome = [Regex]::Escape($script:dscHome) + $resourcePath = Join-Path -Path $TestDrive -ChildPath 'resources' + $escapedResourcePath = [Regex]::Escape($resourcePath) + $escapedResourcePathJson = $IsWindows ? [Regex]::Escape($escapedResourcePath) : $escapedResourcePath + [string[]]$directoriesWithoutDscHome = @($resourcePath) + [string[]]$directoriesWithDscHome = @($resourcePath, $script:dscHome) + $data = [ordered]@{ + resourcePath = [ordered]@{} + } + $dscechoPath = Get-Command dscecho | Select-Object -ExpandProperty Path + [string]$binPath = Join-Path -Path $TestDrive -ChildPath 'bin' + [string[]]$binPaths = @( + $binPath + (Get-Command powershell).Path | Split-Path -Parent + (Get-Command pwsh).Path | Split-Path -Parent + ) + $resourceManifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'DSC.Test/ResourcePath' + version = '0.1.0' + get = [ordered]@{ + executable = Join-Path $binPath 'dscecho' + args = @(@{ + jsonInputArg = '--input' + mandatory = $true + }) + } + schema = @{ + command = @{ + executable = Join-Path $binPath 'dscecho' + } + } + } + $resourceManifestJson = $resourceManifest | ConvertTo-Json -Depth 10 + } + + BeforeEach { + New-Item -ItemType Directory -Path $resourcePath -Force + $resourceManifestJson | Set-Content -Path (Join-Path $resourcePath "test.dsc.resource.json") -Force + New-Item -ItemType Directory -Path $binPath -Force + Copy-Item -Path $dscechoPath -Destination $binPath -Force + } + AfterEach { + # Avoid flaky race condition + Start-Sleep -Seconds 1 + Remove-Item -Path $resourcePath -Recurse -Force + Remove-Item -Path $binPath -Recurse -Force + } + + Context "when restricted is true" { + BeforeAll { + $data.resourcePath.restricted = $true + } + + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "raises an error during discovery" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = [regex]::Escape((@( + 'unable to discover any manifests -' + 'DSC setting `resourcePath.restricted` is `true`' + 'and `resourcePath.directories` is empty' + ) -join ' ')) + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedMessage -Because $testContext + } + } + Context "when directories is empty" { + BeforeAll { + $data.resourcePath.directories = @() + } + + It "raises an error during discovery" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = [regex]::Escape((@( + 'unable to discover any manifests -' + 'DSC setting `resourcePath.restricted` is `true`' + 'and `resourcePath.directories` is empty' + ) -join ' ')) + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedMessage -Because $testContext + } + } + Context "when directories doesn't include DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + AfterAll { + $data.resourcePath.remove('directories') + } + + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "restricts discovery for other paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "raises a warning for inability to use built-in resources and extensions" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "errors when invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -BeExactly 2 -Because $testContext + $result | Should -BeNullOrEmpty -Because $testContext + + $exe = Split-Path -Path $resourceManifest.get.executable -Leaf + $parent = Split-Path -Path $resourceManifest.get.executable -Parent + $expectedError = @( + 'ERROR unable to invoke `{0}` -' -f $exe + 'DSC setting `resourcePath.restricted` is `true` and executable' + 'parent folder `{0}` is not in `resourcePath.directories`' -f [regex]::Escape($parent) + '\(\[".+"\]\)' + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedError -Because $testContext + } + It "allows invoking an executable within the specified paths" { + $data.resourcePath.directories += $binPaths + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + Context "when directories includes DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithDscHome + } + + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "restricts discovery for other paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedResources = $script:dscBuiltInResources.Where({ + $pattern = @( + '^' # Anchor to start of string + $escapedDscHome # Start with DSC home folder + '(\\|\/)' # Allow exactly one directory separator + '[^\\\/]+' # Exclude resources in subfolders + ) -join '' + # Count only resources that match the pattern and have no condition, + # Since the condition is generally a which statement for resources + # that ship a manifest separately from the command to invoke, like + # the PowerShell adapter - these are _not_ discovered when the path + # is restricted. Ignore adapted resources. + ( + $_.path -match $pattern -and + [string]::IsNullOrEmpty($_.manifest.condition) -and + [string]::IsNullOrEmpty($_.requireAdapter) + ) + }) + $actualResources = $result | ConvertFrom-Json -Depth 99 | Where-Object { + [string]::IsNullOrEmpty($_.requireAdapter) + } + $actualResources.Count | Should -Be ($expectedResources.Count + 1) -Because $testContext + } + It "does not raise a warning for inability to use built-in resources and extensions" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "errors when invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -BeExactly 2 -Because $testContext + $result | Should -BeNullOrEmpty -Because $testContext + + $exe = Split-Path -Path $resourceManifest.get.executable -Leaf + $parent = Split-Path -Path $resourceManifest.get.executable -Parent + $expectedError = @( + 'ERROR unable to invoke `{0}` -' -f $exe + 'DSC setting `resourcePath.restricted` is `true` and executable' + 'parent folder `{0}` is not in `resourcePath.directories`' -f [regex]::Escape($parent) + '\(\[".+"\]\)' + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedError -Because $testContext + } + It "allows invoking an executable within the specified paths" { + $data.resourcePath.directories += $binPaths + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + + Context "when restricted is false" { + BeforeAll { + $data.resourcePath.restricted = $false + } + + Context "when appendEnvPath is true" { + BeforeAll { + $data.resourcePath.appendEnvPath = $true + } + + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $result.Count | Should -Be $script:dscBuiltInResources.Count -Because $testContext + } + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "can't discover resources outside of DSC exe home and PATH" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -BeNullOrEmpty -Because $testContext + } + } + Context "when directories doesn't include DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $expectedCount = $script:dscBuiltInResources.Count + 1 + $result.Count | Should -Be $expectedCount -Because $testContext + } + + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "doesn't raise a warning about missing DSC exe home in directories" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + Context "when directories includes DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithDscHome + } + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $expectedCount = $script:dscBuiltInResources.Count + 1 + $result.Count | Should -Be $expectedCount -Because $testContext + } + + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "doesn't raise a warning about missing DSC exe home in directories" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + Context "when appendEnvPath is false" { + BeforeAll { + $data.resourcePath.appendEnvPath = $false + } + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "discovers all builtin resources in DSC exe home" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + + $builtInInHome = $script:dscBuiltInResources.Where({$_.Path.StartsWith($script:dscHome)}) + $actualResources = $result | ConvertFrom-Json -Depth 99 + # Ensure all builtin resources in DSC exe home are returned + foreach ($resource in $builtInInHome) { + $resource.type | Should -BeIn $actualResources.type -Because ( + $resource + | Select-Object -Property type, path, requireAdapter, @{n='condition'; e={$_.manifest.condition}} + | ConvertTo-Json + ) + } + # Count must be greater than or equal, depending on resources from discovery extensions + $result.Count | Should -BeGreaterOrEqual $builtInInHome.Count -Because $testContext + } + It "doesn't write a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "can't discover resources outside of DSC exe home or discovery extensions" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -BeNullOrEmpty -Because $testContext + } + } + Context "when directories is defined" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + + It "discovers all builtin resources in DSC exe home" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + + $builtInInHome = $script:dscBuiltInResources.Where({$_.Path.StartsWith($script:dscHome)}) + $actualResources = $result | ConvertFrom-Json -Depth 99 + # Ensure all builtin resources in DSC exe home are returned + foreach ($resource in $builtInInHome) { + $resource.type | Should -BeIn $actualResources.type -Because ( + $resource + | Select-Object -Property type, path, requireAdapter, @{n='condition'; e={$_.manifest.condition}} + | ConvertTo-Json + ) + } + # Count must be greater than or equal, depending on resources from discovery extensions + $result.Count | Should -BeGreaterOrEqual $builtInInHome.Count -Because $testContext + } + It "doesn't write a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "discovers resources in specified directories" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + } + } + } + } + + Context 'policy settings' -Skip:$script:skipSystem { + BeforeAll { + $scope = 'policy' + } + + Context 'forbidIgnoreSettingsFile' { + BeforeAll { + $userData = [ordered]@{ + tracing = [ordered]@{ + level = 'debug' + } + } + $policyData = [ordered]@{} + $messages = [pscustomobject]@{ + Debug = "debug message for test" + Info = "info message for test" + Warning = "warning message for test" + Error = "error message for test" + } + $resourceInput = [ordered]@{ + getScript = @( + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json + $resourceType = 'Microsoft.DSC.Transitional/PowerShellScript' + } + + BeforeEach { + Set-SettingsFile -Scope user -Settings $userData + } + + Context 'when undefined' { + it "loads settings from preference files when ``ignoreSettingsFile`` is not ``true``" { + Set-SettingsFile -Scope $scope -Settings $policyData + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + It "allows non-policy scope '<_>' to ignore settings files" -ForEach @('environment', 'cli') { + Set-SettingsFile -Scope $scope -Settings $policyData + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + switch ($_) { + 'environment' { + Set-SettingsEnvironment -IgnoreSettingsFile 'true' + } + 'cli' { + $params = @('--ignore-settings-file') + $params + } + } + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + } + } + Context 'when false' { + BeforeAll { + $policyData.forbidIgnoreSettingsFile = $false + } + AfterEach { + if ($policyData.Keys -contains 'ignoreSettingsFile') { + $policyData.Remove('ignoreSettingsFile') + } + } + it "loads settings from preference files when ``ignoreSettingsFile`` is not ``true``" { + Set-SettingsFile -Scope $scope -Settings $policyData + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + It "allows ignoring settings files with the " -ForEach @( + @{ id = 'env'; setting = 'DSC_IGNORE_SETTINGS_FILE env var' } + @{ id = 'cli'; setting = '--ignore-settings-file cli arg' } + @{ id = 'policy'; setting = 'ignoreSettingsFile policy field'} + ) { + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + switch ($id) { + 'env' { + Set-SettingsEnvironment -IgnoreSettingsFile 'true' + } + 'cli' { + $params = @('--ignore-settings-file') + $params + } + 'policy' { + $policyData.ignoreSettingsFile = $true + } + } + Set-SettingsFile -Scope $scope -Settings $policyData + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + } + } + Context "when true" { + BeforeAll { + $policyData.forbidIgnoreSettingsFile = $true + } + AfterAll { + $policyData = [ordered]@{} + } + + it 'loads settings from preference files when `ignoreSettingsFile` is not `true`' { + Set-SettingsFile -Scope $scope -Settings $policyData + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + It 'forbids ignoring settings files with the ' -ForEach @( + @{ id = 'env'; setting = 'DSC_IGNORE_SETTINGS_FILE env var' } + @{ id = 'cli'; setting = '--ignore-settings-file cli arg' } + @{ id = 'policy'; setting = 'ignoreSettingsFile policy field'} + ) { + $params = @( + 'resource', 'get' + '--resource', $resourceType + '--input', $resourceInput + ) + $messagePrefix = 'WARN\s+.+:\s+\d+:' + $messageSuffix = '.+`forbidIgnoreSettingsFile`' + switch ($id) { + 'env' { + Set-SettingsEnvironment -IgnoreSettingsFile 'true' + $message = 'ignoring the environment variable `DSC_IGNORE_SETTINGS_FILE`' + } + 'cli' { + $params = @('--ignore-settings-file') + $params + $message = 'ignoring the command line argument `--ignore-settings-file`' + } + 'policy' { + $policyData.ignoreSettingsFile = $true + $message = 'ignoring the policy setting `ignoreSettingsFile`' + } + } + Set-SettingsFile -Scope $scope -Settings $policyData + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = @( + $messagePrefix + $message + $messageSuffix + ) -join '\s+' + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedMessage -Because $testContext + } + } + } + + Context "ignoreSettingsFile" { + BeforeAll { + $policyData = [ordered]@{} + $userPreferenceData = [ordered]@{ + tracing = [ordered]@{ + level = 'info' + } + } + } + BeforeEach { + Set-SettingsFile -Scope User -Settings $userPreferenceData + } + + Context "when undefined" { + BeforeAll { + if ($policyData.Keys -contains 'ignoreSettingsFile') { + $policyData.Remove('ignoreSettingsFile') + } + } + + it "loads settings files" { + Set-SettingsFile -Scope $scope -Settings $policyData + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + } + Context "when false" { + BeforeAll { + $policyData.ignoreSettingsFile = $false + } + + It "loads settings files" { + Set-SettingsFile -Scope $scope -Settings $policyData + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Trace-level is Info' -Because ( + New-TestContext -Message 'expected user scope tracing.level as info to emit trace message' -TraceFilePath $TestDrive/tracing.txt + ) + } + } + Context "when true" { + BeforeAll { + $policyData.ignoreSettingsFile = $true + } + + It "ignores settings files" { + Set-SettingsFile -Scope $scope -Settings $policyData + dsc resource list Microsoft.DSC.Debug/Echo 2> $TestDrive/tracing.txt + Get-Content -Raw -Path "$TestDrive/tracing.txt" | Should -BeNullOrEmpty -Because ( + New-TestContext -Message 'expected no trace message when ignoring settings file' -TraceFilePath $TestDrive/tracing.txt + ) + } + } + } + + Context 'tracing.level' { + It "emits expected messaages for '<_>' trace level" -ForEach @("error", "warn", "info", "debug", "trace") { + $level = $_ + $data = [ordered]@{ + tracing = [ordered]@{ + level = $level + } + } + Set-SettingsFile -Scope $scope -Settings $data + $messages = [pscustomobject]@{ + Trace = "trace resource message for level: $level" + Debug = "debug message for level: $level" + Info = "info message for level: $level" + Warning = "warning message for level: $level" + Error = "error message for level: $level" + } + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Transitional/PowerShellScript' + '--input', ([ordered]@{ + getScript = @( + "Write-Information '$($messages.Trace)'" + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + # All levels should find the error message + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + if ($level -ne "error") { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + } + if ($level -notin @('error', 'warn')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + } + if ($level -notin ('error', 'warn', 'info')) { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + } + if ($level -eq 'trace') { + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace level" + $data = [ordered]@{ + tracing = [ordered]@{ + level = $value + } + } + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because { + New-TestContext -TraceFilePath $TestDrive/tracing.txt + } + } + } + + Context 'tracing.format' { + It "emits expected format for '<_>' trace format" -ForEach @('default', 'plaintext', 'json') { + $format = $_ + $invalidKey = "trace_format.$_" + $data = [ordered]@{ + tracing = [ordered]@{ + format = $format + } + } + Set-SettingsFile -Scope $scope -Settings $data + Set-SettingsEnvironment -TraceLevel Error + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Debug/Echo' + '--input', (@{ $invalidKey = $format} | ConvertTo-Json -Compress) + ) + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = "Schema: Additional properties are not allowed ('$invalidKey' was unexpected)" + if ($format -eq 'json') { + $traces = Get-Content $TestDrive/tracing.txt | ConvertFrom-Json + $traces.count | Should -Be 1 -Because $testContext + $traces[0].level | Should -Be 'Error' -Because $testContext + $traces[0].fields.message | Should -Be $expectedMessage -Because $testContext + } else { + $expected = [regex]::Escape("ERROR $expectedMessage") + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because $testContext + } + } + + It "raises a warning for an invalid value" { + $value = 'invalid' + $expected = "unable to case-insensitively parse '$value' as a valid DSC settings trace format" + $data = [ordered]@{ + tracing = [ordered]@{ + format = $value + } + } + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list *echo* 2> $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expected -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + } + + Context "resourcePath" { + BeforeAll { + $escapedDscHome = [Regex]::Escape($script:dscHome) + $resourcePath = Join-Path -Path $TestDrive -ChildPath 'resources' + $escapedResourcePath = [Regex]::Escape($resourcePath) + $escapedResourcePathJson = $IsWindows ? [Regex]::Escape($escapedResourcePath) : $escapedResourcePath + [string[]]$directoriesWithoutDscHome = @($resourcePath) + [string[]]$directoriesWithDscHome = @($resourcePath, $script:dscHome) + $data = [ordered]@{ + resourcePath = [ordered]@{} + } + $dscechoPath = Get-Command dscecho | Select-Object -ExpandProperty Path + [string]$binPath = Join-Path -Path $TestDrive -ChildPath 'bin' + [string[]]$binPaths = @( + $binPath + (Get-Command powershell).Path | Split-Path -Parent + (Get-Command pwsh).Path | Split-Path -Parent + ) + $resourceManifest = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json' + type = 'DSC.Test/ResourcePath' + version = '0.1.0' + get = [ordered]@{ + executable = Join-Path $binPath 'dscecho' + args = @(@{ + jsonInputArg = '--input' + mandatory = $true + }) + } + schema = @{ + command = @{ + executable = Join-Path $binPath 'dscecho' + } + } + } + $resourceManifestJson = $resourceManifest | ConvertTo-Json -Depth 10 + } + + BeforeEach { + New-Item -ItemType Directory -Path $resourcePath -Force + $resourceManifestJson | Set-Content -Path (Join-Path $resourcePath "test.dsc.resource.json") -Force + New-Item -ItemType Directory -Path $binPath -Force + Copy-Item -Path $dscechoPath -Destination $binPath -Force + } + AfterEach { + # Avoid flaky race condition + Start-Sleep -Seconds 1 + Remove-Item -Path $resourcePath -Recurse -Force + Remove-Item -Path $binPath -Recurse -Force + } + + Context "when restricted is true" { + BeforeAll { + $data.resourcePath.restricted = $true + } + + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "raises an error during discovery" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = [regex]::Escape((@( + 'unable to discover any manifests -' + 'DSC setting `resourcePath.restricted` is `true`' + 'and `resourcePath.directories` is empty' + ) -join ' ')) + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedMessage -Because $testContext + } + } + Context "when directories is empty" { + BeforeAll { + $data.resourcePath.directories = @() + } + + It "raises an error during discovery" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedMessage = [regex]::Escape((@( + 'unable to discover any manifests -' + 'DSC setting `resourcePath.restricted` is `true`' + 'and `resourcePath.directories` is empty' + ) -join ' ')) + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedMessage -Because $testContext + } + } + Context "when directories doesn't include DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + AfterAll { + $data.resourcePath.remove('directories') + } + + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "restricts discovery for other paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "raises a warning for inability to use built-in resources and extensions" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "errors when invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -BeExactly 2 -Because $testContext + $result | Should -BeNullOrEmpty -Because $testContext + + $exe = Split-Path -Path $resourceManifest.get.executable -Leaf + $parent = Split-Path -Path $resourceManifest.get.executable -Parent + $expectedError = @( + 'ERROR unable to invoke `{0}` -' -f $exe + 'DSC setting `resourcePath.restricted` is `true` and executable' + 'parent folder `{0}` is not in `resourcePath.directories`' -f [regex]::Escape($parent) + '\(\[".+"\]\)' + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedError -Because $testContext + } + It "allows invoking an executable within the specified paths" { + $data.resourcePath.directories += $binPaths + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + Context "when directories includes DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithDscHome + } + + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "restricts discovery for other paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $expectedResources = $script:dscBuiltInResources.Where({ + $pattern = @( + '^' # Anchor to start of string + $escapedDscHome # Start with DSC home folder + '(\\|\/)' # Allow exactly one directory separator + '[^\\\/]+' # Exclude resources in subfolders + ) -join '' + # Count only resources that match the pattern and have no condition, + # Since the condition is generally a which statement for resources + # that ship a manifest separately from the command to invoke, like + # the PowerShell adapter - these are _not_ discovered when the path + # is restricted. + ( + $_.path -match $pattern -and + [string]::IsNullOrEmpty($_.manifest.condition) -and + [string]::IsNullOrEmpty($_.requireAdapter) + ) + }) + $actualResources = $result | ConvertFrom-Json -Depth 99 | Where-Object { + [string]::IsNullOrEmpty($_.requireAdapter) + } + $actualResources.Count | Should -Be ($expectedResources.Count + 1) -Because $testContext + } + It "does not raise a warning for inability to use built-in resources and extensions" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "errors when invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -BeExactly 2 -Because $testContext + $result | Should -BeNullOrEmpty -Because $testContext + + $exe = Split-Path -Path $resourceManifest.get.executable -Leaf + $parent = Split-Path -Path $resourceManifest.get.executable -Parent + $expectedError = @( + 'ERROR unable to invoke `{0}` -' -f $exe + 'DSC setting `resourcePath.restricted` is `true` and executable' + 'parent folder `{0}` is not in `resourcePath.directories`' -f [regex]::Escape($parent) + '\(\[".+"\]\)' + ) -join ' ' + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $expectedError -Because $testContext + } + It "allows invoking an executable within the specified paths" { + $data.resourcePath.directories += $binPaths + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + + Context "when restricted is false" { + BeforeAll { + $data.resourcePath.restricted = $false + } + + Context "when appendEnvPath is true" { + BeforeAll { + $data.resourcePath.appendEnvPath = $true + } + + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $result.Count | Should -Be $script:dscBuiltInResources.Count -Because $testContext + } + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "can't discover resources outside of DSC exe home and PATH" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -BeNullOrEmpty -Because $testContext + } + } + Context "when directories doesn't include DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $expectedCount = $script:dscBuiltInResources.Count + 1 + $result.Count | Should -Be $expectedCount -Because $testContext + } + + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "doesn't raise a warning about missing DSC exe home in directories" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + Context "when directories includes DSC exe home" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithDscHome + } + It "discovers all builtin resources" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + $expectedCount = $script:dscBuiltInResources.Count + 1 + $result.Count | Should -Be $expectedCount -Because $testContext + } + + It "writes a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "doesn't raise a warning about missing DSC exe home in directories" { + Set-SettingsFile -Scope $scope -Settings $data + dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $expectedWarning = @( + 'WARN cannot discover or invoke built-in resources or extensions' + "- DSC executable home '{0}' is not included" -f $escapedDscHome + 'in the restricted resource path directories \(\["{0}"\]\)' -f $escapedResourcePathJson + ) -join ' ' + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $expectedWarning -Because ( + New-TestContext -TraceFilePath $TestDrive/tracing.txt + ) + } + It "adds discoverable resource paths" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + $data = $result | ConvertFrom-Json -Depth 10 + $data.type | Should -Be $resourceManifest.type -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + Context "when appendEnvPath is false" { + BeforeAll { + $data.resourcePath.appendEnvPath = $false + } + Context "when directories is undefined" { + BeforeAll { + if ($data.resourcePath.Keys -contains 'directories') { + $data.resourcePath.Remove('directories') + } + } + + It "discovers all builtin resources in DSC exe home" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + + $builtInInHome = $script:dscBuiltInResources.Where({$_.Path.StartsWith($script:dscHome)}) + $actualResources = $result | ConvertFrom-Json -Depth 99 + # Ensure all builtin resources in DSC exe home are returned + foreach ($resource in $builtInInHome) { + $resource.type | Should -BeIn $actualResources.type -Because ( + $resource + | Select-Object -Property type, path, requireAdapter, @{n='condition'; e={$_.manifest.condition}} + | ConvertTo-Json + ) + } + # Count must be greater than or equal, depending on resources from discovery extensions + $result.Count | Should -BeGreaterOrEqual $builtInInHome.Count -Because $testContext + } + It "doesn't write a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "can't discover resources outside of DSC exe home or discovery extensions" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -BeNullOrEmpty -Because $testContext + } + } + Context "when directories is defined" { + BeforeAll { + $data.resourcePath.directories = $directoriesWithoutDscHome + } + + It "discovers all builtin resources in DSC exe home" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + $testContext.exitCode | Should -Be 0 -Because $testContext + + $builtInInHome = $script:dscBuiltInResources.Where({$_.Path.StartsWith($script:dscHome)}) + $actualResources = $result | ConvertFrom-Json -Depth 99 + # Ensure all builtin resources in DSC exe home are returned + foreach ($resource in $builtInInHome) { + $resource.type | Should -BeIn $actualResources.type -Because ( + $resource + | Select-Object -Property type, path, requireAdapter, @{n='condition'; e={$_.manifest.condition}} + | ConvertTo-Json + ) + } + # Count must be greater than or equal, depending on resources from discovery extensions + $result.Count | Should -BeGreaterOrEqual $builtInInHome.Count -Because $testContext + } + It "doesn't write a debug message for appending PATH to resourcePath" { + Set-SettingsFile -Scope $scope -Settings $data + dsc --trace-level debug resource list 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly 'Appending PATH to resourcePath' -Because $testContext + } + It "discovers resources in specified directories" { + Set-SettingsFile -Scope $scope -Settings $data + $result = dsc resource list $resourceManifest.type 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $result | Should -Not -BeNullOrEmpty -Because $testContext + $result.Count | Should -Be 1 -Because $testContext + } + It "allows invoking an executable outside of the specified paths" { + Set-SettingsFile -Scope $scope -Settings $data + $params = @( + 'resource', 'get' + '--resource', $resourceManifest.type + '--input', '{"output": "test"}' + ) + $result = dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt -Result $result + + $testContext.exitCode | Should -Be 0 -Because $testContext + $result | Should -Not -BeNullOrEmpty -Because $testContext + $resultData = $result | ConvertFrom-Json -Depth 10 + $resultData.actualState.output | Should -Be 'test' -Because $testContext + } + } + } + } + } + } + Context "settings cascade" { + BeforeAll { + $messages = [pscustomobject]@{ + Trace = "trace message for test" + Debug = "debug message for test" + Info = "info message for test" + Warning = "warning message for test" + Error = "error message for test" + } + $params = @( + 'resource', 'get' + '--resource', 'Microsoft.DSC.Transitional/PowerShellScript' + '--input', ([ordered]@{ + getScript = @( + "Write-Information '$($messages.Trace)'" + "Write-Debug '$($messages.Debug)'" + "Write-Verbose '$($messages.Info)'" + "Write-Warning '$($messages.Warning)'" + "Write-Error '$($messages.Error)'" + ) -join "`n" + } | ConvertTo-Json) + ) + $machinePreferences = @{ + Scope = 'machine' + Settings = [ordered]@{ + tracing = [ordered]@{ + format = 'json' + } + } + } + $userPreferences = @{ + Scope = 'user' + Settings = [ordered]@{ + tracing = [ordered]@{ + level = 'trace' + format = 'default' + } + } + } + $workspacePreferences = @{ + Scope = 'workspace' + Settings = [ordered]@{ + tracing = [ordered]@{ + level = 'debug' + } + } + } + $environmentSettings = @{ + TraceLevel = 'info' + } + $cliArgs = @('--trace-level', 'error') + $policySettings = @{ + Scope = 'policy' + Settings = [ordered]@{ + tracing = [ordered]@{ + format = 'json' + } + } + } + } + + Context "machine scope" -Skip:$script:skipSystem { + It 'overrides default settings' { + Set-SettingsFile @machinePreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $traces = Get-Content -Path $TestDrive/tracing.txt | ConvertFrom-Json -Depth 10 + $traces.Count | Should -BeGreaterOrEqual 2 -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Error -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Warning -Because $testContext + } + } + Context 'user scope' { + It 'overrides default settings' { + Set-SettingsFile @userPreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides machine settings' -Skip:$script:skipSystem { + Set-SettingsFile @machinePreferences + Set-SettingsFile @userPreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + Context 'workspace scope' { + It 'overrides default settings' { + Set-SettingsFile @workspacePreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides machine scope' -Skip:$script:skipSystem { + Set-SettingsFile @machinePreferences + Set-SettingsFile @workspacePreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $traces = Get-Content -Path $TestDrive/tracing.txt | ConvertFrom-Json -Depth 10 + $traces.Count | Should -BeGreaterOrEqual 4 -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Error -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Warning -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Info -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Debug -Because $testContext + } + It 'overrides user scope' { + Set-SettingsFile @userPreferences + Set-SettingsFile @workspacePreferences + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + Context 'environment scope' { + It 'overrides default settings' { + Set-SettingsEnvironment @environmentSettings + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides machine scope' -Skip:$script:skipSystem { + Set-SettingsFile @machinePreferences + Set-SettingsEnvironment @environmentSettings + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $traces = Get-Content -Path $TestDrive/tracing.txt | ConvertFrom-Json -Depth 10 + $traces.Count | Should -BeGreaterOrEqual 3 -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Error -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Warning -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Info -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Debug -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Trace -Because $testContext + } + It 'overrides user scope' { + Set-SettingsFile @userPreferences + Set-SettingsEnvironment @environmentSettings + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides workspace scope' { + Set-SettingsFile @workspacePreferences + Set-SettingsEnvironment @environmentSettings + + dsc @params 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + Context 'cli scope' { + BeforeAll { + [string[]]$dscParams = $cliArgs + $params + } + It 'overrides default settings' { + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides machine scope' -Skip:$script:skipSystem { + Set-SettingsFile @machinePreferences + + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $traces = Get-Content -Path $TestDrive/tracing.txt | ConvertFrom-Json -Depth 10 + $traces.Count | Should -BeGreaterOrEqual 3 -Because $testContext + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Error -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Warning -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Info -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Debug -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Trace -Because $testContext + } + It 'overrides user scope' { + Set-SettingsFile @userPreferences + + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides workspace scope' { + Set-SettingsFile @workspacePreferences + + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + It 'overrides environment scope' { + Set-SettingsEnvironment @environmentSettings + + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + "$TestDrive/tracing.txt" | Should -FileContentMatchExactly $messages.Error -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Warning -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Info -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Debug -Because $testContext + "$TestDrive/tracing.txt" | Should -Not -FileContentMatchExactly $messages.Trace -Because $testContext + } + } + Context 'policy scope' -Skip:$script:skipSystem { + It 'overrides all other scopes' { + Set-SettingsFile @machinePreferences + Set-SettingsFile @userPreferences + Set-SettingsFile @workspacePreferences + Set-SettingsEnvironment @environmentSettings + Set-SettingsFile @policySettings + + [string[]]$dscParams = $cliArgs + $params + + dsc @dscParams 2> $TestDrive/tracing.txt + $testContext = New-TestContext -TraceFilePath $TestDrive/tracing.txt + + $traces = Get-Content -Path $TestDrive/tracing.txt | ConvertFrom-Json -Depth 10 + $traces.fields.message -join "`n" | Should -MatchExactly $messages.Error -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Warning -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Info -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Debug -Because $testContext + $traces.fields.message -join "`n" | Should -Not -MatchExactly $messages.Trace -Because $testContext + } } } } diff --git a/dsc/tests/other/dsc_settings_policy_security.tests.ps1 b/dsc/tests/other/dsc_settings_policy_security.tests.ps1 index fc5fbec72..faa8a7246 100644 --- a/dsc/tests/other/dsc_settings_policy_security.tests.ps1 +++ b/dsc/tests/other/dsc_settings_policy_security.tests.ps1 @@ -14,7 +14,7 @@ Describe 'tests for policy folder security validation' { Context 'Windows policy folder with insecure ACL emits warning' -Skip:(!$IsWindows -or !$isElevated) { BeforeAll { $script:policyDirPath = Join-Path $env:ProgramData "dsc" - $script:policyFilePath = Join-Path $script:policyDirPath "dsc.settings.json" + $script:policyFilePath = Join-Path $script:policyDirPath "dsc.policy.json" $script:existedBefore = Test-Path $script:policyDirPath if ($script:existedBefore) { @@ -42,7 +42,8 @@ Describe 'tests for policy folder security validation' { It 'Should emit a warning about insecure policy folder' { dsc -l warn resource list 2> $TestDrive/tracing.txt - "$TestDrive/tracing.txt" | Should -FileContentMatch "is not secure, settings file will not be used" + $contents = Get-Content -Raw "$TestDrive/tracing.txt" + "$TestDrive/tracing.txt" | Should -FileContentMatch "is not secure, settings file will not be used" -Because "Emitted to stderr:`n---`n$contents`n---" } It 'Should not exit with an error code' { @@ -54,7 +55,7 @@ Describe 'tests for policy folder security validation' { Context 'Linux policy folder with insecure permissions emits warning' -Skip:(!$IsLinux -or !$isElevated) { BeforeAll { $script:policyDirPath = "/etc/dsc" - $script:policyFilePath = Join-Path $script:policyDirPath "dsc.settings.json" + $script:policyFilePath = Join-Path $script:policyDirPath "dsc.policy.json" $script:existedBefore = Test-Path $script:policyDirPath if ($script:existedBefore) { diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 6acaf9653..2dfc2d2d0 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -117,7 +117,6 @@ importingParametersFromInput = "Importing parameters from simple input" invalidParamsFormat = "Invalid parameters format: %{error}" [discovery.commandDiscovery] -couldNotReadSetting = "Could not read 'resourcePath' setting" appendingEnvPath = "Appending PATH to resourcePath" originalPath = "Original PATH: %{path}" failedGetEnvPath = "Failed to get PATH environment variable" @@ -158,8 +157,6 @@ conditionNotMet = "Condition '%{condition}' not met, skipping manifest at '%{pat adaptedResourcePathNotFound = "Adapted resource '%{resource}' path not found: %{path}" invalidManifestFileName = "Invalid manifest file name '%{path}'" failedLoadManifest = "Failed to load manifest: %{err}" -restrictedPath = "DSC_RESTRICTED_PATH: %{path}" -resourcePath = "DSC_RESOURCE_PATH: %{path}" adapterNameFilter = "Using type name filter '%{filter}' as filter for adapter name" adapterNameFilterForDiscovery = "Using type name filter '%{filter}' for adapter name" resourceNameFilter = "Using type name filter '%{filter}' as filter for resource name" @@ -172,6 +169,8 @@ failedCreateLookupTableDirectory = "Unable to create parent directories of the l failedGetLookupTableDirectory = "Unable to get directory of the lookup_table file %{path}" failedSerializeLookupTable = "Unable to serialize lookup_table to json" readLookupTable = "Read %{count} items into lookup table from %{path}" +restrictedResourcePathEmptyDirectories = "unable to discover any manifests - DSC setting `%{restricted_setting}` is `true` and `%{directories_setting}` is empty" +warnExeHomeNotInRestrictedPath = "cannot discover or invoke built-in resources or extensions - DSC executable home '%{exe_home}' is not included in the restricted resource path directories (%{dirs})" [dscresources.commandResource] invokeGet = "Invoking get for '%{resource}'" @@ -920,6 +919,7 @@ booleanConversion = "Function boolean argument conversion" exitCode = "exit code" commandResource = "Command: Resource" commandExecutable = "Command: Executable" +commandNotInRestrictedDirs = "unable to invoke `%{name}` - DSC setting `resourcePath.restricted` is `true` and executable parent folder `%{parent}` is not in `resourcePath.directories` (%{dirs})" manifestDescription = "manifest description" commandOperation = "Command: Operation" forExecutable = "for executable" @@ -990,10 +990,10 @@ policyFolderNotSecureLinux = "Only root should have write access (no group/other policyFolderNotSecureWindows = "Only SYSTEM and Administrators should have write access" traceResolved = "resolved settings from all loaded sources: %{effective}" traceResolvedScope = "resolved settings after applying %{scope}: %{effective}" -warnCliIgnoreSetting = "ignoring the command line argument `%{cli_arg}` because '%{policy_field}' is set to true" -warnEnvIgnoreSetting = "ignoring the environment variable `%{env_var}` because '%{policy_field}' is set to true" +warnCliIgnoreSetting = "ignoring the command line argument `%{cli_arg}` because the `%{policy_field}` policy is set to `true`" +warnEnvIgnoreSetting = "ignoring the environment variable `%{env_var}` because the `%{policy_field}` policy is set to `true`" warnLoadScopeFailed = "failed to load %{scope} settings: %{error}" -warnPolicyIgnoreSetting = "ignoring the policy setting `%{ignore_settings_file}` because `%{forbid_ignore_settings_file}` is set to true" +warnPolicyIgnoreSetting = "ignoring the policy setting `%{ignore_settings_file}` because the `%{forbid_ignore_settings_file}` policy is set to `true`" [settings.errors] invalidScope = "unable to case-insensitively parse '%{text}' as a valid DSC settings scope - valid scopes are: %{variant_list}, and %{variant_last}" @@ -1021,17 +1021,10 @@ debugLoadingFile = "trying to load %{scope} file from '%{file_path}' for setting debugLoadingFile = "trying to load preference file for %{scope} scope from '%{file_path}' for settings..." [util] -foundSetting = "Found setting '%{name}' in %{path}" -notFoundSetting = "Setting '%{name}' not found in %{path}" failedToGetExePath = "Can't get 'dsc' executable path" -settingNotFound = "Setting '%{name}' not found" failedToAbsolutizePath = "Failed to absolutize path '%{path}'" executableNotFoundInWorkingDirectory = "Executable '%{executable}' not found with working directory '%{cwd}'" executableNotFound = "Executable '%{executable}' not found" -policyFolderNotSecure = "Policy folder '%{path}' is not secure, settings file will not be used. Required permissions: %{required}" -policyFolderNotSecureLinux = "Only root should have write access (no group/other write bits)" -policyFolderNotSecureWindows = "Only SYSTEM and Administrators should have write access" -ignoreSettingsFile = "Ignoring settings file due to environment variable 'DSC_IGNORE_SETTINGS_FILE' being set or '--ignore-settings-file' flag being used" [types.date_version] invalidDay = "day `%{day}` for month `%{month}` is invalid - must be between `01` and `%{max_days}`" diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index e7c9eb2a5..e7a578032 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{discovery::{DiscoveryExtensionCache, DiscoveryManifestCache, DiscoveryResourceCache, discovery_trait::{DiscoveryFilter, DiscoveryKind, ResourceDiscovery}, matches_adapter_requirement}, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::SetDeleteArgKind}, parser::Statement, types::{FullyQualifiedTypeName, TypeNameFilter}}; +use crate::{discovery::{DiscoveryExtensionCache, DiscoveryManifestCache, DiscoveryResourceCache, discovery_trait::{DiscoveryFilter, DiscoveryKind, ResourceDiscovery}, matches_adapter_requirement}, dscresources::{adapted_resource_manifest::AdaptedDscResourceManifest, resource_manifest::SetDeleteArgKind}, parser::Statement, settings::fields::ResourcePathResolvedSettings, types::{FullyQualifiedTypeName, TypeNameFilter}}; use crate::{locked_clear, locked_is_empty, locked_extend, locked_clone, locked_get}; use crate::configure::{config_doc::ResourceDiscoveryMode, context::Context}; use crate::dscresources::adapted_resource_manifest::AdaptedPathOrContent; @@ -14,6 +14,7 @@ use crate::extensions::extension_manifest::ExtensionManifest; use crate::progress::{ProgressBar, ProgressFormat}; use crate::schemas::dsc_repo::DscRepoSchema; use crate::schemas::transforms::idiomaticize_externally_tagged_enum; +use crate::settings::global as global_settings; use rust_i18n::t; use schemars::JsonSchema; use serde::Deserialize; @@ -23,10 +24,10 @@ use std::env; use std::ffi::OsStr; use std::fs::{create_dir_all, read, read_to_string, write}; use std::path::{Path, PathBuf}; -use std::str::FromStr; +// use std::str::FromStr; use tracing::{debug, info, trace, warn}; -use crate::util::get_setting; +// use crate::util::get_setting; use crate::util::{canonicalize_which, get_exe_path}; // NOTE: if new types of file extensions are added, ensure they are added to `process_discover_args` in `lib/dsc-lib/src/extensions/discover.rs` @@ -58,34 +59,20 @@ pub enum ImportedManifest { Extension(DscExtension), } - #[derive(Clone)] pub struct CommandDiscovery { progress_format: ProgressFormat, discovery_mode: ResourceDiscoveryMode, } -#[derive(Deserialize)] -pub struct ResourcePathSetting { - /// whether to allow overriding with the `DSC_RESTRICTED_PATH` or `DSC_RESOURCE_PATH` environment variables - #[serde(rename = "allowEnvOverride")] - allow_env_override: bool, - /// whether to append the PATH environment variable to the list of resource directories - #[serde(rename = "appendEnvPath")] - append_env_path: bool, - /// array of directories that DSC should search for non-built-in resources - directories: Vec -} - -impl Default for ResourcePathSetting { - fn default() -> ResourcePathSetting { - ResourcePathSetting { - allow_env_override: true, - append_env_path: true, - directories: vec![], - } - } -} +/// Cache for resolved resource paths. +/// +/// This cache enables us to compute the directories to search for manifests +/// and executables once, making repeated lookups much quicker. It also +/// avoids the potential munging of the PATH environment variable. +/// +/// This cache is used and surfaced by [`CommandDiscovery::get_resource_paths`]. +static RESOLVED_RESOURCE_PATHS: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); impl CommandDiscovery { #[must_use] @@ -99,121 +86,192 @@ impl CommandDiscovery { #[must_use] pub fn get_extensions(&self) -> DiscoveryExtensionCache { locked_clone!(EXTENSIONS) } - fn get_resource_path_setting() -> Result - { - if let Ok(v) = get_setting("resourcePath") { - // if there is a policy value defined - use it; otherwise use setting value - if v.policy != serde_json::Value::Null { - match serde_json::from_value::(v.policy) { - Ok(v) => { - return Ok(v); - }, - Err(e) => { return Err(DscError::Setting(format!("{e}"))); } - } - } else if v.setting != serde_json::Value::Null { - match serde_json::from_value::(v.setting) { - Ok(v) => { - return Ok(v); - }, - Err(e) => { return Err(DscError::Setting(format!("{e}"))); } - } + /// Retrieves the resolved resource paths, computing and caching them if + /// necessary. + /// + /// This associated function first checks whether the resource paths have + /// already been resolved and cached. If they have, it returns the cached + /// paths immediately. + /// + /// When resolving the resource paths, the function retrieves the resolved + /// DSC settings and: + /// + /// 1. If `resourcePath.restricted` is `true`, overrides `PATH` with the + /// value of `resourcePath.directories`. + /// 1. If `resourcePath.restricted` is `false`, ensures that the folder + /// containing the DSC executable is included in both the `PATH` + /// environment variable and the list of resource path directories. + /// + /// If `resourcePath.appendEnvPath` is `true`, appends the directories + /// in `PATH` to the list of resource path directories. + /// 1. Ensures the set of directories is unique, removing duplicates. + /// 1. Caches the resolved set of directories and returns them. + /// + /// # Returns + /// + /// Returns a `Result` containing the list of resolved resource paths if + /// successful, or a [`DscError`] if an error occurs. + /// + /// # Errors + /// + /// This function raises errors when: + /// + /// - `resourcePath.restricted` is `true` and `resourcePath.directories` + /// is an empty list, because DSC won't be able to discover any manifests + /// or invoke any commands. + /// - When the `PATH` environment variable isn't defined or is defined + /// incorrectly. + /// - When joining the directories into a new `PATH` fails, which could + /// occur due to invalid paths in `resourcePath.directories` or system + /// limitations. + fn get_resource_paths() -> Result, DscError> { + // Check if the resource paths have already been resolved and cached; + // if so, return the cached paths immediately to avoid redundant + // computation and potentially mutating PATH repeatedly. + if let Ok(resolved_paths) = RESOLVED_RESOURCE_PATHS.read() + && !resolved_paths.is_empty() { + return Ok(resolved_paths.clone()); } - } - - Err(DscError::Setting(t!("discovery.commandDiscovery.couldNotReadSetting").to_string())) - } - - fn get_resource_paths() -> Result, DscError> - { - let mut resource_path_setting = ResourcePathSetting::default(); - match Self::get_resource_path_setting() { - Ok(v) => { - resource_path_setting = v; - }, - Err(e) => { - debug!("{e}"); + let ResourcePathResolvedSettings{ + directories, + restricted, + append_env_path + } = &global_settings::get_or_init().resolved().resource_path.clone(); + debug!( + "Resolved resource path settings: directories = `{}`, restricted = `{}`, append_env_path = `{}`", + serde_json::to_string(&directories.value).unwrap(), + restricted.value, + append_env_path.value + ); + + let mut dirs = directories.value.clone(); + + if restricted.value { + debug!("resourcePath.restricted is true, set by {}", restricted.scope); + // If the resource path is restricted and directories is empty, + // raise error - DSC won't be able to invoke or discover any + // commands. + if directories.value.is_empty() { + debug!("resourcePath.directories is empty, set by {}", directories.scope); + let msg = t!( + "discovery.commandDiscovery.restrictedResourcePathEmptyDirectories", + restricted_setting = "resourcePath.restricted", + directories_setting = "resourcePath.directories" + ).to_string(); + return Err(DscError::Operation(msg)); } - } - - let mut paths: Vec = vec![]; - - let dsc_restricted_path = env::var_os("DSC_RESTRICTED_PATH"); - let dsc_resource_path = env::var_os("DSC_RESOURCE_PATH"); - if resource_path_setting.allow_env_override && let Some(restricted_path) = &dsc_restricted_path { - debug!("{}", t!("discovery.commandDiscovery.restrictedPath", path = restricted_path.to_string_lossy() : {:?})); - paths.append(&mut env::split_paths(&restricted_path).collect::>()); - - // when using restricted path, intent is to isolate the search of manifests and executables to the restricted path - // so we replace the PATH with the restricted path - if let Ok(new_path) = env::join_paths(paths.clone()) { + debug!("resourcePath.directories is {}, set by {}", serde_json::to_string(&directories.value).unwrap(), directories.scope); + // If the directories doesn't include the DSC executable home, warn + // the user that no built-in manifests or commands will be usable. + if !dirs_contains_exe_home(&directories.value)? { + debug!("resourcePath.directories does not include the DSC executable home"); + // Warning for not-included exe home in restricted directories + warn!("{}", t!( + "discovery.commandDiscovery.warnExeHomeNotInRestrictedPath", + dirs = serde_json::to_string(&directories.value).unwrap(), + exe_home = get_exe_path()?.parent().unwrap_or_else(|| Path::new("")).to_string_lossy(), + )) + } else { + debug!("resourcePath.directories includes the DSC executable home"); + } + // when using restricted path, intent is to isolate the search of + // manifests and executables to the restricted path so we replace + // the PATH with `directories` + if let Ok(new_path) = env::join_paths(directories.value.clone()) { + debug!( + "Setting resourcePath.restricted resolved as 'true'; overriding PATH with restricted directories: '{}'", + new_path.display() + ); unsafe { env::set_var("PATH", new_path); } } else { return Err(DscError::Operation(t!("discovery.commandDiscovery.failedJoinRestrictedPath").to_string())); } - } else if resource_path_setting.allow_env_override && let Some(resource_path) = &dsc_resource_path { - debug!("{}", t!("discovery.commandDiscovery.resourcePath", path = resource_path.to_string_lossy() : {:?})); - paths.append(&mut env::split_paths(&resource_path).collect::>()); - - // just add exe home to PATH env var if not already in PATH env var - let env_paths = env::var_os("PATH").map(|paths| env::split_paths(&paths).collect::>()).unwrap_or_default(); - _ = add_exe_home_to_path(env_paths)?; } else { - for p in resource_path_setting.directories { - let v = PathBuf::from_str(&p); - paths.push(v.unwrap_or_default()); - } - - if resource_path_setting.append_env_path { - debug!("{}", t!("discovery.commandDiscovery.appendingEnvPath")); - match env::var_os("PATH") { - Some(value) => { - trace!("{}", t!("discovery.commandDiscovery.originalPath", path = value.to_string_lossy())); - paths.append(&mut env::split_paths(&value).collect::>()); - }, - None => { - return Err(DscError::Operation(t!("discovery.commandDiscovery.failedGetEnvPath").to_string())); + debug!("resourcePath.restricted is false, set by {}", restricted.scope); + let Some(current_env_path) = env::var_os("PATH") else { + return Err(DscError::Operation(t!("discovery.commandDiscovery.failedGetEnvPath").to_string())); + }; + + // if exe home is not already in PATH env var then add it to env var + // and list of searched paths + if let Some(exe_home) = get_exe_path()?.parent() { + let exe_home_pb = &exe_home.to_path_buf(); + let mut env_paths = env::split_paths(¤t_env_path).collect::>(); + if env_paths.contains(exe_home_pb) { + debug!("{}", t!("discovery.commandDiscovery.exeHomeAlreadyInPath", path = exe_home.to_string_lossy())); + } else { + debug!("{}", t!("discovery.commandDiscovery.addExeHomeToPath", path = exe_home.to_string_lossy())); + + env_paths.push(exe_home_pb.clone()); + + if let Ok(new_path) = env::join_paths(env_paths) { + unsafe { + env::set_var("PATH", new_path); + } + } else { + warn!("{}", t!("discovery.commandDiscovery.failedJoinEnvPath").to_string()); } } + + if !dirs.contains(exe_home_pb) { + dirs.push(exe_home_pb.clone()); + } } - // if exe home is not already in PATH env var then add it to env var and list of searched paths - paths = add_exe_home_to_path(paths)?; + // If `append_env_path` is true, append the current PATH to the list + // of directories to search + if append_env_path.value { + debug!("{}", t!("discovery.commandDiscovery.appendingEnvPath")); + trace!("{}", t!("discovery.commandDiscovery.originalPath", path = current_env_path.to_string_lossy())); + dirs.append(&mut env::split_paths(¤t_env_path).collect::>()); + } } // remove duplicate entries let mut uniques: HashSet = HashSet::new(); - paths.retain(|e|uniques.insert((*e).clone())); + dirs.retain(|e|uniques.insert((*e).clone())); + debug!("Directories to search: {}", serde_json::to_string(&dirs).unwrap()); - if let Ok(final_resource_path) = env::join_paths(paths.clone()) { + if let Ok(final_resource_path) = env::join_paths(dirs.clone()) { debug!("{}", t!("discovery.commandDiscovery.usingResourcePath", path = final_resource_path.to_string_lossy())); } - Ok(paths) + // Cache the resolved resource paths for future use + if let Ok(mut resolved_paths) = RESOLVED_RESOURCE_PATHS.write() { + *resolved_paths = dirs.clone(); + } + + Ok(dirs) } } -fn add_exe_home_to_path(mut paths: Vec) -> Result, DscError> { +/// Indicates whether the given list of directories contains the DSC +/// executable's home directory. +/// +/// # Arguments +/// +/// - `dirs` - A reference to a slice of `PathBuf` representing the directories +/// to check. +/// +/// # Returns +/// +/// A `Result` with the value `true` if the executable's home directory is in +/// the list of directories and `false` otherwise, or a [`DscError`] if an +/// error occurs. +/// +/// # Errors +/// +/// This function raises an error when it fails to retrieve the DSC +/// executable's home directory. +fn dirs_contains_exe_home(dirs: &[PathBuf]) -> Result { if let Some(exe_home) = get_exe_path()?.parent() { - let exe_home_pb = exe_home.to_path_buf(); - if paths.contains(&exe_home_pb) { - trace!("{}", t!("discovery.commandDiscovery.exeHomeAlreadyInPath", path = exe_home.to_string_lossy())); - } else { - trace!("{}", t!("discovery.commandDiscovery.addExeHomeToPath", path = exe_home.to_string_lossy())); - paths.push(exe_home_pb); - - if let Ok(new_path) = env::join_paths(paths.clone()) { - unsafe { - env::set_var("PATH", new_path); - } - } else { - warn!("{}", t!("discovery.commandDiscovery.failedJoinEnvPath").to_string()); - } - } + Ok(dirs.contains(&exe_home.to_path_buf())) + } else { + Ok(false) } - Ok(paths) } impl Default for CommandDiscovery { @@ -223,7 +281,6 @@ impl Default for CommandDiscovery { } impl ResourceDiscovery for CommandDiscovery { - #[allow(clippy::too_many_lines)] fn discover(&mut self, kind: &DiscoveryKind, filter: &TypeNameFilter) -> Result<(), DscError> { if self.discovery_mode == ResourceDiscoveryMode::PreDeployment && !locked_is_empty!(RESOURCES) { @@ -255,69 +312,67 @@ impl ResourceDiscovery for CommandDiscovery { let mut resources = DiscoveryResourceCache::new(); let mut extensions = DiscoveryExtensionCache::new(); - if let Ok(paths) = CommandDiscovery::get_resource_paths() { - for path in paths { - if path.exists() && path.is_dir() { - for entry in path.read_dir().unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - if path.is_file() { - let Some(os_file_name) = path.file_name() else { - // skip if not a file - continue; - }; - let Some(file_name) = os_file_name.to_str() else { - // skip if not a valid file name - continue; + for path in CommandDiscovery::get_resource_paths()? { + if path.exists() && path.is_dir() { + for entry in path.read_dir().unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_file() { + let Some(os_file_name) = path.file_name() else { + // skip if not a file + continue; + }; + let Some(file_name) = os_file_name.to_str() else { + // skip if not a valid file name + continue; + }; + let file_name_lowercase = file_name.to_lowercase(); + if DSC_MANIFEST_LIST_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext)) || + (kind == &DiscoveryKind::Resource && (DSC_RESOURCE_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) || DSC_ADAPTED_RESOURCE_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) || + (kind == &DiscoveryKind::Extension && DSC_EXTENSION_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) { + trace!("{}", t!("discovery.commandDiscovery.foundManifest", path = path.to_string_lossy())); + let imported_manifests = match load_manifest(&path) + { + Ok(r) => r, + Err(e) => { + // At this point we can't determine whether or not the bad manifest contains + // resource that is requested by resource/config operation + // if it is, then "ResouceNotFound" error will be issued later + // and here we just write as info + info!("{}", t!("discovery.commandDiscovery.failedLoadManifest", err = e)); + continue; + }, }; - let file_name_lowercase = file_name.to_lowercase(); - if DSC_MANIFEST_LIST_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext)) || - (kind == &DiscoveryKind::Resource && (DSC_RESOURCE_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) || DSC_ADAPTED_RESOURCE_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) || - (kind == &DiscoveryKind::Extension && DSC_EXTENSION_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext))) { - trace!("{}", t!("discovery.commandDiscovery.foundManifest", path = path.to_string_lossy())); - let imported_manifests = match load_manifest(&path) - { - Ok(r) => r, - Err(e) => { - // At this point we can't determine whether or not the bad manifest contains - // resource that is requested by resource/config operation - // if it is, then "ResouceNotFound" error will be issued later - // and here we just write as info - info!("{}", t!("discovery.commandDiscovery.failedLoadManifest", err = e)); - continue; - }, - }; - - for imported_manifest in imported_manifests { - match imported_manifest { - ImportedManifest::Extension(extension) => { - if filter.is_match(&extension.type_name) { - trace!("{}", t!("discovery.commandDiscovery.extensionFound", extension = extension.type_name, version = extension.version)); - // we only keep newest version of the extension so compare the version and only keep the newest - if let Some(existing_extension) = extensions.get_mut(&extension.type_name) { - if extension.version > existing_extension.version { - extensions.insert(extension.type_name.clone(), extension.clone()); - } - } else { + + for imported_manifest in imported_manifests { + match imported_manifest { + ImportedManifest::Extension(extension) => { + if filter.is_match(&extension.type_name) { + trace!("{}", t!("discovery.commandDiscovery.extensionFound", extension = extension.type_name, version = extension.version)); + // we only keep newest version of the extension so compare the version and only keep the newest + if let Some(existing_extension) = extensions.get_mut(&extension.type_name) { + if extension.version > existing_extension.version { extensions.insert(extension.type_name.clone(), extension.clone()); } + } else { + extensions.insert(extension.type_name.clone(), extension.clone()); } - }, - ImportedManifest::Resource(resource) => { - if filter.is_match(&resource.type_name) { - if let Some(manifest) = &resource.manifest { - if manifest.kind == Some(Kind::Adapter) { - trace!("{}", t!("discovery.commandDiscovery.adapterFound", adapter = resource.type_name, version = resource.version)); - insert_resource(&mut adapters, &resource); - } - // also make sure to add adapters as a resource as well - trace!("{}", t!("discovery.commandDiscovery.resourceFound", resource = resource.type_name, version = resource.version)); - insert_resource(&mut resources, &resource); - } - if let Some(_adapter) = &resource.require_adapter { - trace!("{}", t!("discovery.commandDiscovery.adaptedResourceFound", resource = resource.type_name, version = resource.version)); - insert_resource(&mut resources, &resource); + } + }, + ImportedManifest::Resource(resource) => { + if filter.is_match(&resource.type_name) { + if let Some(manifest) = &resource.manifest { + if manifest.kind == Some(Kind::Adapter) { + trace!("{}", t!("discovery.commandDiscovery.adapterFound", adapter = resource.type_name, version = resource.version)); + insert_resource(&mut adapters, &resource); } + // also make sure to add adapters as a resource as well + trace!("{}", t!("discovery.commandDiscovery.resourceFound", resource = resource.type_name, version = resource.version)); + insert_resource(&mut resources, &resource); + } + if let Some(_adapter) = &resource.require_adapter { + trace!("{}", t!("discovery.commandDiscovery.adaptedResourceFound", resource = resource.type_name, version = resource.version)); + insert_resource(&mut resources, &resource); } } } diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 70d75140f..d97ebe78e 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -3,7 +3,7 @@ use miette::Diagnostic; use rust_i18n::t; -use std::str::Utf8Error; +use std::{path::PathBuf, str::Utf8Error}; use indicatif::style::TemplateError; use thiserror::Error; @@ -29,6 +29,18 @@ pub enum DscError { #[error("{0}")] CommandNotFound(String), + #[error("{t}", t = t!( + "dscerror.commandNotInRestrictedDirs", + name = exe_name, + parent = exe_folder.to_string_lossy(), + dirs = serde_json::to_string(&restricted_dirs).unwrap() + ))] + CommandNotInRestrictedDirs { + exe_name: String, + exe_folder: PathBuf, + restricted_dirs: Vec, + }, + #[error("{t} {0} {t2} '{1}'", t = t!("dscerror.commandOperation"), t2 = t!("dscerror.forExecutable"))] CommandOperation(String, String), diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index c024ee6ae..ec37e59ba 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -7,8 +7,9 @@ use jsonschema::Validator; use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; -use std::{collections::HashMap, env, path::Path, process::Stdio}; -use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, types::ExitCodesMap, util::canonicalize_which}; +use std::{collections::HashMap, env, path::{Path, PathBuf}, process::Stdio}; +use crate::{configure::{config_doc::{ExecutionKind, SecurityContextKind}, config_result::{ResourceGetResult, ResourceTestResult}, schema_cache::{RESOURCE_SCHEMAS, get_resource_schema}}, dscresources::{dscresource::Operation, resource_manifest::{ExportSchemaKind, ExportSchemaOrFiltering, SchemaArgKind}}, settings::fields::ResourcePathResolvedSettings, types::ExitCodesMap, util::canonicalize_which}; +use crate::settings::global as global_settings; use crate::dscerror::DscError; use crate::locked_insert; use super::{ @@ -932,29 +933,59 @@ async fn run_process_async(executable: &str, args: Option>, input: O } } +pub fn verify_executable_is_invokable(executable: &Path) -> Result<(), DscError> { + let ResourcePathResolvedSettings{ + directories, + restricted, + .. + } = &global_settings::get_or_init().resolved().resource_path.clone(); + if !restricted.value { + return Ok(()) + } + + let executable_folder = executable.parent().unwrap().to_path_buf(); + + if !directories.value.clone().contains(&executable_folder) { + return Err(DscError::CommandNotInRestrictedDirs { + exe_name: executable.file_name().unwrap().to_string_lossy().trim_end_matches(".exe").to_string(), + exe_folder: executable_folder, + restricted_dirs: directories.value.clone(), + }); + } + + Ok(()) +} + /// Invoke a command and return the exit code, stdout, and stderr. /// /// # Arguments /// -/// * `executable` - The command to execute -/// * `args` - Optional arguments to pass to the command -/// * `input` - Optional input to pass to the command -/// * `cwd` - Optional working directory to execute the command in -/// * `env` - Optional environment variable mappings to add or update -/// * `exit_codes` - Descriptions of exit codes, either defined by the manifest or using the +/// - `executable` - The command to execute +/// - `args` - Optional arguments to pass to the command +/// - `input` - Optional input to pass to the command +/// - `cwd` - Optional working directory to execute the command in +/// - `env` - Optional environment variable mappings to add or update +/// - `exit_codes` - Descriptions of exit codes, either defined by the manifest or using the /// default descriptions for success and failure. /// /// # Errors /// -/// Error is returned if the command fails to execute or stdin/stdout/stderr cannot be opened. +/// This function returns an error when: +/// +/// - The command isn't discoverable +/// - The DSC settings resolve with `resourcePath.restricted` as `true` and the executable isn't in +/// the `resourcePath.directories` list +/// - The command fails to execute +/// - The function can't open stdin, stdout, or stderr for the command /// /// # Panics /// -/// Will panic if tokio runtime can't be created. +/// This function panics if it can't create a tokio runtime. /// #[allow(clippy::implicit_hasher)] pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&Path>, env: Option>, exit_codes: &ExitCodesMap) -> Result<(i32, String, String), DscError> { let executable = canonicalize_which(executable, cwd)?; + verify_executable_is_invokable(&PathBuf::from(&executable))?; let run_async = async { trace!("{}", t!("dscresources.commandResource.commandInvoke", executable = executable, args = args : {:?})); diff --git a/lib/dsc-lib/src/settings/dsc_settings.rs b/lib/dsc-lib/src/settings/dsc_settings.rs index b7ad30269..9abce5f36 100644 --- a/lib/dsc-lib/src/settings/dsc_settings.rs +++ b/lib/dsc-lib/src/settings/dsc_settings.rs @@ -1215,7 +1215,7 @@ impl DscSettings { warn!("{}", t!( "settings.dsc_settings.warnCliIgnoreSetting", cli_arg = "--ignore-settings-file", - policy_field = "forbid_ignore_settings_file" + policy_field = "forbidIgnoreSettingsFile" )); } else { if resolving.ignore_settings_file.scope < scope { @@ -1275,7 +1275,7 @@ impl DscSettings { warn!("{}", t!( "settings.dsc_settings.warnEnvIgnoreSetting", env_var = "DSC_IGNORE_SETTINGS_FILE", - policy_field = "forbid_ignore_settings_file" + policy_field = "forbidIgnoreSettingsFile" )); } else { if resolving.ignore_settings_file.scope < scope { @@ -1357,8 +1357,8 @@ impl DscSettings { if *forbid_ignore && *ignore{ warn!("{}", t!( "settings.dsc_settings.warnPolicyIgnoreSetting", - ignore_settings_file = "ignore_settings_file", - forbid_ignore_settings_file = "forbid_ignore_settings_file" + ignore_settings_file = "ignoreSettingsFile", + forbid_ignore_settings_file = "forbidIgnoreSettingsFile" )); } else { resolving.ignore_settings_file = DscSettingsResolvedField::for_policy(ignore); diff --git a/lib/dsc-lib/src/settings/fields/tracing.rs b/lib/dsc-lib/src/settings/fields/tracing.rs index 24c250747..f704e2600 100644 --- a/lib/dsc-lib/src/settings/fields/tracing.rs +++ b/lib/dsc-lib/src/settings/fields/tracing.rs @@ -213,6 +213,30 @@ impl From for TracingLevelField { } } +impl From for TracingLevelField { + fn from(level: crate::dscresources::command_resource::TraceLevel) -> Self { + match level { + crate::dscresources::command_resource::TraceLevel::Error => TracingLevelField::Error, + crate::dscresources::command_resource::TraceLevel::Warn => TracingLevelField::Warn, + crate::dscresources::command_resource::TraceLevel::Info => TracingLevelField::Info, + crate::dscresources::command_resource::TraceLevel::Debug => TracingLevelField::Debug, + crate::dscresources::command_resource::TraceLevel::Trace => TracingLevelField::Trace, + } + } +} + +impl From for crate::dscresources::command_resource::TraceLevel { + fn from(level: TracingLevelField) -> Self { + match level { + TracingLevelField::Error => crate::dscresources::command_resource::TraceLevel::Error, + TracingLevelField::Warn => crate::dscresources::command_resource::TraceLevel::Warn, + TracingLevelField::Info => crate::dscresources::command_resource::TraceLevel::Info, + TracingLevelField::Debug => crate::dscresources::command_resource::TraceLevel::Debug, + TracingLevelField::Trace => crate::dscresources::command_resource::TraceLevel::Trace, + } + } +} + define_string_enum_field!{ base_name: "format", folder_path: "settings/fields/tracing", diff --git a/lib/dsc-lib/src/settings/global.rs b/lib/dsc-lib/src/settings/global.rs new file mode 100644 index 000000000..cc31cf858 --- /dev/null +++ b/lib/dsc-lib/src/settings/global.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Defines and manages a global instance of the DSC settings. +//! +//! This module provides functions to access and initialize the global DSC +//! settings instance. This avoids needing to pass the DSC settings to every +//! function or module that leverages the settings. + +use std::sync::{OnceLock, Mutex, MutexGuard}; + +use crate::settings::{DscSettings, sources::CommandLineData}; + +/// Defines a global instance of the DSC settings, accessible throughout the +/// application. +pub static DSC_SETTINGS_GLOBAL: OnceLock> = OnceLock::new(); + +/// Retrieves the global instance of the DSC settings, initializing it if +/// needed. +/// +/// # Returns +/// +/// A `MutexGuard` to the global `DscSettings` instance. +pub fn get_or_init() -> MutexGuard<'static, DscSettings> { + DSC_SETTINGS_GLOBAL.get_or_init(|| Mutex::new(DscSettings::new())) + .lock() + .unwrap() +} + +/// Retrieves the global instance of the DSC settings, initializing it if +/// needed with the provided command line data. +/// +/// # Returns +/// +/// A `MutexGuard` to the global `DscSettings` instance. +pub fn get_or_init_with_command_line(cli_data: CommandLineData) -> MutexGuard<'static, DscSettings> { + DSC_SETTINGS_GLOBAL.get_or_init(|| Mutex::new(DscSettings::new_with_command_line(cli_data))) + .lock() + .unwrap() +} + +/// Retrieves the global instance of the DSC settings if it's been initialized. +/// +/// # Returns +/// +/// An `Option` containing a `MutexGuard` to the global `DscSettings` instance +/// if it's available. +pub fn get() -> Option> { + DSC_SETTINGS_GLOBAL.get().map(|mutex| mutex.lock().unwrap()) +} diff --git a/lib/dsc-lib/src/settings/mod.rs b/lib/dsc-lib/src/settings/mod.rs index 592a985c1..e08ed4354 100644 --- a/lib/dsc-lib/src/settings/mod.rs +++ b/lib/dsc-lib/src/settings/mod.rs @@ -336,6 +336,7 @@ pub use dsc_settings_scope::DscSettingsScope; #[cfg_attr(test, macro_use)] // Only enable macros for testing mod dsc_settings; pub use dsc_settings::*; +pub mod global; mod resolved; pub use resolved::*; mod errors; diff --git a/lib/dsc-lib/src/util.rs b/lib/dsc-lib/src/util.rs index a291f8d55..d0a1abe15 100644 --- a/lib/dsc-lib/src/util.rs +++ b/lib/dsc-lib/src/util.rs @@ -6,30 +6,12 @@ use rust_i18n::t; use serde_json::Value; use std::{ fs, - fs::{canonicalize, File}, - io::BufReader, + fs::canonicalize, path::{Path, PathBuf}, env, }; -use tracing::{debug, warn}; use which::which; -pub const DSC_IGNORE_SETTINGS_FILE: &str = "DSC_IGNORE_SETTINGS_FILE"; - -pub struct DscSettingValue { - pub setting: Value, - pub policy: Value, -} - -impl Default for DscSettingValue { - fn default() -> DscSettingValue { - DscSettingValue { - setting: Value::Null, - policy: Value::Null, - } - } -} - /// Return JSON string whether the input is JSON or YAML /// /// # Arguments @@ -109,147 +91,6 @@ mod tests { let regex = convert_wildcard_to_regex(wildcard); assert_eq!(regex, r"^a\+b\(c\)\[d\]\^e\$f\|g\\h$"); } - - #[cfg(not(target_os = "windows"))] - mod linux_permission_tests { - use super::*; - use std::fs as stdfs; - - #[test] - fn test_verify_linux_permissions_nonexistent_path() { - let result = verify_linux_permissions(Path::new("/nonexistent/path/that/does/not/exist")); - assert!(!result, "nonexistent path should return false (fail closed)"); - } - - #[test] - fn test_verify_linux_permissions_non_root_owned() { - // A temp dir is owned by the current (non-root) user on CI - let dir = stdfs::canonicalize(env::temp_dir()).expect("temp dir"); - let test_dir = dir.join("dsc_test_permissions"); - let _ = stdfs::create_dir(&test_dir); - let result = verify_linux_permissions(&test_dir); - let _ = stdfs::remove_dir(&test_dir); - // On CI (non-root), the temp dir is not owned by root - if nix::unistd::Uid::effective().is_root() { - // If running as root, dir is root-owned, check mode bits instead - return; - } - assert!(!result, "directory not owned by root should return false"); - } - - #[test] - fn test_get_settings_policy_file_path_returns_some_when_dir_missing() { - // If /etc/dsc doesn't exist, the function should return Some with the path - // (the file may not exist but the path is still returned for attempted loading) - if Path::new("/etc/dsc").exists() { - // Can't test this case when the dir exists - return; - } - let result = get_settings_policy_file_path(); - assert!(result.is_some(), "should return Some when /etc/dsc doesn't exist"); - let path = result.unwrap(); - assert!(path.to_string_lossy().contains("dsc.settings.json")); - } - - #[test] - fn test_get_settings_policy_file_path_with_insecure_dir() { - // When /etc/dsc exists but is not root-owned, returns None - if !Path::new("/etc/dsc").exists() || nix::unistd::Uid::effective().is_root() { - return; - } - // /etc/dsc exists and we're not root - if it's owned by root - // it should return Some; this test just exercises the code path - let _result = get_settings_policy_file_path(); - } - } -} - -/// Will search setting files for the specified setting. -/// Performance implication: Use this function economically as every call opens/reads several config files. -/// TODO: cache the config -/// -/// # Arguments -/// -/// * `value_name` - The name of the setting. -/// -/// # Errors -/// -/// Will return `Err` if could not find requested setting. -pub fn get_setting(value_name: &str) -> Result { - - const SETTINGS_FILE_NAME: &str = "dsc.settings.json"; - // Note that default settings file has root nodes as settings schema version that is specific to this version of dsc - const DEFAULT_SETTINGS_FILE_NAME: &str = "dsc_default.settings.json"; - const DEFAULT_SETTINGS_SCHEMA_VERSION: &str = "1"; - - let mut result: DscSettingValue = DscSettingValue::default(); - let mut settings_file_path : PathBuf; - - if env::var(DSC_IGNORE_SETTINGS_FILE).is_ok() { - warn!("{}", t!("util.ignoreSettingsFile")); - return Ok(result); - } - - if let Some(exe_home) = get_exe_path()?.parent() { - // First, get setting from the default settings file - settings_file_path = exe_home.join(DEFAULT_SETTINGS_FILE_NAME); - if let Ok(v) = load_value_from_json(&settings_file_path, DEFAULT_SETTINGS_SCHEMA_VERSION) { - if let Some(n) = v.get(value_name) { - result.setting = n.clone(); - debug!("{}", t!("util.foundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } - } else { - debug!("{}", t!("util.notFoundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } - - // Second, get setting from the active settings file overwriting previous value - settings_file_path = exe_home.join(SETTINGS_FILE_NAME); - if let Ok(v) = load_value_from_json(&settings_file_path, value_name) { - result.setting = v; - debug!("{}", t!("util.foundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } else { - debug!("{}", t!("util.notFoundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } - } else { - debug!("{}", t!("util.failedToGetExePath")); - } - - // Third, get setting from the policy - if let Some(settings_file_path) = get_settings_policy_file_path() { - if let Ok(v) = load_value_from_json(&settings_file_path, value_name) { - result.policy = v; - debug!("{}", t!("util.foundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } else { - debug!("{}", t!("util.notFoundSetting", name = value_name, path = settings_file_path.to_string_lossy())); - } - } - - if (result.setting == serde_json::Value::Null) && (result.policy == serde_json::Value::Null) { - return Err(DscError::NotSupported(t!("util.settingNotFound", name = value_name).to_string())); - } - - Ok(result) -} - -fn load_value_from_json(path: &PathBuf, value_name: &str) -> Result { - let file = File::open(path)?; - let reader = BufReader::new(file); - let root: serde_json::Value = match serde_json::from_reader(reader) { - Ok(j) => j, - Err(err) => { - return Err(DscError::Json(err)); - } - }; - - if let Some(r) = root.as_object() { - for (key, value) in r { - if *key == value_name { - return Ok(value.clone()) - } - } - } - - Err(DscError::NotSupported(value_name.to_string())) } /// Gets path to the current dsc process. @@ -270,177 +111,6 @@ pub fn get_exe_path() -> Result { Err(DscError::NotSupported(t!("util.failedToGetExePath").to_string())) } -#[cfg(target_os = "windows")] -fn get_settings_policy_file_path() -> Option -{ - // $env:ProgramData+"\dsc\dsc.settings.json" - let Ok(local_program_data_path) = std::env::var("ProgramData") else { return None; }; - let dsc_folder = Path::new(&local_program_data_path).join("dsc"); - let settings_path = dsc_folder.join("dsc.settings.json").display().to_string(); - - if !dsc_folder.exists() { - return Some(PathBuf::from(settings_path)); - } - - if !verify_windows_acl(&dsc_folder) { - let required = t!("util.policyFolderNotSecureWindows", path = dsc_folder.display()); - warn!("{}", t!("util.policyFolderNotSecure", path = dsc_folder.display(), required = required)); - return None; - } - - Some(PathBuf::from(settings_path)) -} - -/// Returns `true` if only SYSTEM and Administrators have write access; `false` otherwise. -#[cfg(target_os = "windows")] -fn verify_windows_acl(dsc_folder: &Path) -> bool { - use windows::Win32::Security::{ - Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}, - IsWellKnownSid, GetAce, - WinBuiltinAdministratorsSid, WinLocalSystemSid, - ACL, ACCESS_ALLOWED_ACE, PSECURITY_DESCRIPTOR, - }; - use windows::Win32::Foundation::{LocalFree, HLOCAL}; - use windows::core::PCWSTR; - use std::ptr; - - const FILE_WRITE_DATA: u32 = 0x0002; - const FILE_APPEND_DATA: u32 = 0x0004; - const WRITE_DAC: u32 = 0x0004_0000; - const WRITE_OWNER: u32 = 0x0008_0000; - const GENERIC_WRITE: u32 = 0x4000_0000; - const GENERIC_ALL: u32 = 0x1000_0000; - const WRITE_MASK: u32 = FILE_WRITE_DATA | FILE_APPEND_DATA | WRITE_DAC | WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL; - - const INHERIT_ONLY_ACE: u8 = 0x08; - - let folder_wide: Vec = dsc_folder - .to_string_lossy() - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - - let mut p_sd: PSECURITY_DESCRIPTOR = PSECURITY_DESCRIPTOR(ptr::null_mut()); - let mut p_dacl: *mut ACL = ptr::null_mut(); - - let result = unsafe { - GetNamedSecurityInfoW( - PCWSTR(folder_wide.as_ptr()), - SE_FILE_OBJECT, - windows::Win32::Security::DACL_SECURITY_INFORMATION, - None, - None, - Some(&mut p_dacl), - None, - &mut p_sd, - ) - }; - - // Fail closed: if we can't read the security descriptor, treat as insecure - if result.is_err() { - return false; - } - - // A NULL DACL means full access to everyone - if p_dacl.is_null() { - unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } - return false; - } - - let ace_count = unsafe { (*p_dacl).AceCount }; - - for i in 0..ace_count { - let mut ace_ptr: *mut core::ffi::c_void = ptr::null_mut(); - let ok = unsafe { GetAce(p_dacl, i as u32, &mut ace_ptr) }; - if ok.is_err() { - // Fail closed: if we can't read an ACE, treat as insecure - unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } - return false; - } - - let header = unsafe { &*(ace_ptr as *const windows::Win32::Security::ACE_HEADER) }; - - // Skip inherit-only ACEs as they don't apply to this folder - if (header.AceFlags & INHERIT_ONLY_ACE) != 0 { - continue; - } - - // Skip deny ACEs (types 1, 6, 10) - they restrict access, not grant it - if header.AceType == 1 || header.AceType == 6 || header.AceType == 10 { - continue; - } - - // For ACCESS_ALLOWED_ACE_TYPE (0), check the SID - if header.AceType == 0 { - let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; - if (ace.Mask & WRITE_MASK) == 0 { - continue; - } - - let sid_ptr = &ace.SidStart as *const u32 as *const core::ffi::c_void; - let psid = windows::Win32::Security::PSID(sid_ptr as *mut core::ffi::c_void); - - let is_system = unsafe { IsWellKnownSid(psid, WinLocalSystemSid).as_bool() }; - let is_admin = unsafe { IsWellKnownSid(psid, WinBuiltinAdministratorsSid).as_bool() }; - - if !is_system && !is_admin { - unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } - return false; - } - continue; - } - - // For any other allow-type ACE (callback, object, etc.) we cannot - // reliably extract the SID, so fail closed to be safe - let ace = unsafe { &*(ace_ptr as *const ACCESS_ALLOWED_ACE) }; - if (ace.Mask & WRITE_MASK) != 0 { - unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } - return false; - } - } - - unsafe { let _ = LocalFree(Some(HLOCAL(p_sd.0.cast()))); } - true -} - -#[cfg(not(target_os = "windows"))] -fn get_settings_policy_file_path() -> Option -{ - // "/etc/dsc/dsc.settings.json" - // This location is writable only by root, but readable by all users - let dsc_folder = Path::new("/etc").join("dsc"); - let settings_path = dsc_folder.join("dsc.settings.json"); - - if !dsc_folder.exists() { - return Some(settings_path); - } - - if !verify_linux_permissions(&dsc_folder) { - let required = t!("util.policyFolderNotSecureLinux"); - warn!("{}", t!("util.policyFolderNotSecure", path = dsc_folder.display(), required = required)); - return None; - } - - Some(settings_path) -} - -/// Returns `true` if the folder is owned by root and has no group/other write bits; `false` otherwise. -#[cfg(not(target_os = "windows"))] -fn verify_linux_permissions(folder: &Path) -> bool { - use std::os::unix::fs::MetadataExt; - - // Fail closed: if we can't read metadata, treat as insecure - let Ok(metadata) = fs::metadata(folder) else { - return false; - }; - - let mode = metadata.mode(); - let uid = metadata.uid(); - - // Verify owner is root and no group or other write bits are set - uid == 0 && (mode & 0o020) == 0 && (mode & 0o002) == 0 -} - /// Generates a resource ID from the specified type and name. /// /// # Arguments @@ -465,16 +135,29 @@ pub fn canonicalize_which(executable: &str, cwd: Option<&Path>) -> Result Date: Thu, 29 Oct 2026 13:35:21 -0500 Subject: [PATCH 2/2] (MAINT) Fix csp integration tests Prior to this change, some of the csp integration tests failed when run in a non-elevated context. This change: - Guards the test behavior on whether the current context is elevated. - Uses `test_case` to treat each test case in the tests as separate tests instead of failing the entire test when one case fails. --- Cargo.lock | 2 + adapters/csp/Cargo.toml | 6 +++ adapters/csp/tests/cli.rs | 87 ++++++++++++++++++++++----------------- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69e10f7ad..4743604ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,9 +588,11 @@ name = "csp" version = "0.1.0" dependencies = [ "clap", + "is_elevated", "rust-i18n", "serde", "serde_json", + "test-case", "windows 0.62.2", "wmi", ] diff --git a/adapters/csp/Cargo.toml b/adapters/csp/Cargo.toml index 19f7d8653..ef6f3841d 100644 --- a/adapters/csp/Cargo.toml +++ b/adapters/csp/Cargo.toml @@ -13,6 +13,12 @@ serde_json = { workspace = true } windows = { workspace = true } wmi = { workspace = true } +[dev-dependencies] +test-case = { workspace = true } + +[target.'cfg(windows)'.dev-dependencies] +is_elevated = { workspace = true } + [package.metadata.i18n] available-locales = ["en-us"] default-locale = "en-us" diff --git a/adapters/csp/tests/cli.rs b/adapters/csp/tests/cli.rs index dbfd4afff..bca05c924 100644 --- a/adapters/csp/tests/cli.rs +++ b/adapters/csp/tests/cli.rs @@ -3,6 +3,11 @@ use std::process::Command; +use test_case::test_case; + +#[cfg(windows)] +use is_elevated::is_elevated; + #[test] fn help_lists_all_operations() { let output = Command::new(env!("CARGO_BIN_EXE_csp")) @@ -16,18 +21,18 @@ fn help_lists_all_operations() { } } -#[test] -fn invalid_arguments_return_json_diagnostics() { - for args in [vec!["invalid"], vec!["get"], vec!["export", "--unexpected"]] { - let output = Command::new(env!("CARGO_BIN_EXE_csp")) - .args(args) - .output() - .unwrap(); - assert_eq!(output.status.code(), Some(1)); - assert!(output.stdout.is_empty()); - let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); - assert!(error["error"].as_str().is_some_and(|s| !s.is_empty())); - } +#[test_case(vec!["invalid"]; "invalid command")] +#[test_case(vec!["get"]; "missing arguments for get")] +#[test_case(vec!["export", "--unexpected"]; "unexpected argument for export")] +fn invalid_arguments_return_json_diagnostics(args: Vec<&str>) { + let output = Command::new(env!("CARGO_BIN_EXE_csp")) + .args(&args) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert!(error["error"].as_str().is_some_and(|s| !s.is_empty())); } #[cfg(windows)] @@ -53,49 +58,55 @@ fn invoke_with_stdin(operation: &str, input: &[u8]) -> std::process::Output { } #[cfg(windows)] -#[test] -fn accepts_large_json_payloads_over_stdin_for_all_operations() { - let input = serde_json::json!({"Payload": "x".repeat(70_000)}).to_string(); - assert!(input.encode_utf16().count() > 32_767); - for operation in ["get", "set", "test", "export"] { +#[test_case("get"; "for get operation")] +#[test_case("set"; "for set operation")] +#[test_case("test"; "for test operation")] +#[test_case("export"; "for export operation")] +fn accepts_large_json_payloads_over_stdin(operation: &str) { + if is_elevated() { + let input = serde_json::json!({"Payload": "x".repeat(70_000)}).to_string(); + assert!(input.encode_utf16().count() > 32_767); let output = invoke_with_stdin(operation, input.as_bytes()); assert_eq!(output.status.code(), Some(2), "{operation}: {output:?}"); assert!(output.stdout.is_empty()); let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); assert_eq!(error["error"], "Unknown or output-only property 'Payload'."); + } else { + eprintln!("Skipping test - current context isn't elevated."); } } #[cfg(windows)] -#[test] -fn reports_invalid_or_unreadable_stdin_as_json_errors() { - for (input, expected) in [ - (b"{".as_slice(), "Invalid JSON input:"), - (b"".as_slice(), "Invalid JSON input:"), - (b"\xff".as_slice(), "Could not read JSON input from stdin:"), - ] { +#[test_case(b"{".as_slice(), "Invalid JSON input:"; "invalid JSON input")] +#[test_case(b"".as_slice(), "Invalid JSON input:"; "empty JSON input")] +#[test_case(b"\xff".as_slice(), "Could not read JSON input from stdin:"; "unreadable JSON input")] +fn reports_invalid_or_unreadable_stdin_as_json_errors(input: &[u8], expected: &str) { + if is_elevated() { let output = invoke_with_stdin("get", input); assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); assert!(error["error"].as_str().unwrap().starts_with(expected)); + } else { + eprintln!("Skipping test - current context isn't elevated."); } } -#[test] -fn manifest_sends_operation_payloads_over_stdin() { +#[test_case("get"; "get operation")] +#[test_case("set"; "set operation")] +#[test_case("test"; "test operation")] +#[test_case("export"; "export operation")] +fn manifest_sends_operation_payloads_over_stdin(operation: &str) { let manifest: serde_json::Value = serde_json::from_str(include_str!("../csp.dsc.resource.json")).unwrap(); - for operation in ["get", "set", "test", "export"] { - assert_eq!(manifest[operation]["input"], "stdin"); - assert_eq!( - manifest[operation]["args"], - serde_json::json!([ - operation, - {"resourceTypeArg": "--resource-type"}, - "--input", - "-" - ]) - ); - } + assert_eq!(manifest[operation]["input"], "stdin"); + assert_eq!( + manifest[operation]["args"], + serde_json::json!([ + operation, + {"resourceTypeArg": "--resource-type"}, + "--input", + "-" + ]) + ); }