From e192e0a50ad9f6582fdb2c98aa6fe2e7ed513875 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 13 Aug 2026 13:51:52 -0700 Subject: [PATCH 1/8] Add Windows environment variable resource Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 11 + Cargo.toml | 3 + .../environment_variable/.project.data.json | 14 + resources/environment_variable/Cargo.toml | 18 ++ .../environment_variable.dsc.resource.json | 124 ++++++++ .../environment_variable/locales/en-us.toml | 31 ++ .../environment_variable/src/environment.rs | 278 ++++++++++++++++++ resources/environment_variable/src/main.rs | 112 +++++++ resources/environment_variable/src/types.rs | 169 +++++++++++ .../tests/environment_variable_get.tests.ps1 | 93 ++++++ .../tests/environment_variable_set.tests.ps1 | 212 +++++++++++++ 11 files changed, 1065 insertions(+) create mode 100644 resources/environment_variable/.project.data.json create mode 100644 resources/environment_variable/Cargo.toml create mode 100644 resources/environment_variable/environment_variable.dsc.resource.json create mode 100644 resources/environment_variable/locales/en-us.toml create mode 100644 resources/environment_variable/src/environment.rs create mode 100644 resources/environment_variable/src/main.rs create mode 100644 resources/environment_variable/src/types.rs create mode 100644 resources/environment_variable/tests/environment_variable_get.tests.ps1 create mode 100644 resources/environment_variable/tests/environment_variable_set.tests.ps1 diff --git a/Cargo.lock b/Cargo.lock index 80f251607..b2137d8d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1067,6 +1067,17 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "environment_variable" +version = "0.1.0" +dependencies = [ + "dsc-lib-registry", + "dsc-lib-security_context", + "rust-i18n", + "serde", + "serde_json", +] + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 65eccb184..5a9ef1bac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "resources/environment_variable", "tools/dsctest", "tools/test_group_resource", "xtask", @@ -57,6 +58,7 @@ default-members = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "resources/environment_variable", "tools/dsctest", "tools/test_group_resource", "xtask", @@ -90,6 +92,7 @@ Windows = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "resources/environment_variable", "tools/dsctest", "tools/test_group_resource", "xtask", diff --git a/resources/environment_variable/.project.data.json b/resources/environment_variable/.project.data.json new file mode 100644 index 000000000..c6d1a4526 --- /dev/null +++ b/resources/environment_variable/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "environment_variable", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "environment_variable" + ], + "CopyFiles": { + "Windows": [ + "environment_variable.dsc.resource.json" + ] + } +} diff --git a/resources/environment_variable/Cargo.toml b/resources/environment_variable/Cargo.toml new file mode 100644 index 000000000..bf49187a4 --- /dev/null +++ b/resources/environment_variable/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "environment_variable" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +dsc-lib-registry = { workspace = true } +dsc-lib-security_context = { workspace = true } diff --git a/resources/environment_variable/environment_variable.dsc.resource.json b/resources/environment_variable/environment_variable.dsc.resource.json new file mode 100644 index 000000000..dda741366 --- /dev/null +++ b/resources/environment_variable/environment_variable.dsc.resource.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/EnvironmentVariableList", + "description": "Manage user and machine environment variables stored in the Windows registry.", + "tags": [ + "Windows", + "Environment" + ], + "version": "0.1.0", + "get": { + "executable": "environment_variable", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "environment_variable", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "handlesExist": true, + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Environment variable resource error", + "4": "Elevation required: Setting or removing AllUsers environment variables requires an elevated process" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Environment Variable List", + "description": "Manage user and machine environment variables stored in the Windows registry.", + "type": "object", + "additionalProperties": false, + "required": [ + "environmentVariables" + ], + "properties": { + "environmentVariables": { + "type": "array", + "title": "Environment variables", + "description": "The environment variables to get or set.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "not": { + "required": [ + "value", + "pathValue" + ] + }, + "properties": { + "scope": { + "type": "string", + "title": "Scope", + "description": "The registry scope for the environment variable.", + "default": "CurrentUser", + "enum": [ + "AllUsers", + "CurrentUser" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The environment variable name.", + "minLength": 1 + }, + "value": { + "type": "string", + "title": "Value", + "description": "The environment variable value." + }, + "pathValue": { + "type": "array", + "title": "Path value", + "description": "The semicolon-delimited environment variable value represented as path entries.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^;]+$" + } + }, + "pathAction": { + "type": "string", + "title": "Path action", + "description": "How pathValue entries are combined with the current value.", + "writeOnly": true, + "default": "clobber", + "enum": [ + "prepend", + "append", + "clobber" + ] + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Whether the environment variable should exist. Set to false to remove it.", + "default": true + } + } + } + } + } + } + } +} diff --git a/resources/environment_variable/locales/en-us.toml b/resources/environment_variable/locales/en-us.toml new file mode 100644 index 000000000..b3b3251fc --- /dev/null +++ b/resources/environment_variable/locales/en-us.toml @@ -0,0 +1,31 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: environment_variable get --input | set --input " +unknownOperation = "Unknown operation: '%{operation}'. Expected: get or set" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +serializeError = "Failed to serialize resource output: %{error}" +windowsOnly = "The Microsoft.Windows/EnvironmentVariableList resource is only supported on Windows" +registryError = "Failed to access environment variable '%{name}' in scope '%{scope}': %{error}" + +[validation] +emptyList = "The environmentVariables array must contain at least one environment variable" +emptyName = "Environment variable name must not be empty" +invalidName = "Environment variable name '%{name}' contains an invalid null character" +duplicate = "Environment variable '%{name}' is specified more than once in scope '%{scope}'" +valueConflict = "Environment variable '%{name}' cannot specify both value and pathValue" +pathActionWithoutValue = "Environment variable '%{name}' can only specify pathAction with pathValue" +invalidPathEntry = "Environment variable '%{name}' has a pathValue entry that is empty or contains a semicolon or null character" +missingValue = "Environment variable '%{name}' must specify value or pathValue when _exist is true" + +[get] +readError = "Failed to read environment variable '%{name}' in scope '%{scope}': %{error}" +unsupportedType = "Environment variable '%{name}' in scope '%{scope}' uses an unsupported registry value type; expected REG_SZ or REG_EXPAND_SZ" + +[set] +elevationRequired = "Setting or removing AllUsers environment variables requires elevation. Run DSC from an elevated process." +readError = "Failed to read environment variable '%{name}' in scope '%{scope}' before setting it: %{error}" +writeError = "Failed to set environment variable '%{name}' in scope '%{scope}': %{error}" +removeError = "Failed to remove environment variable '%{name}' in scope '%{scope}': %{error}" diff --git a/resources/environment_variable/src/environment.rs b/resources/environment_variable/src/environment.rs new file mode 100644 index 000000000..952d51356 --- /dev/null +++ b/resources/environment_variable/src/environment.rs @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::types::{EnvironmentVariable, EnvironmentVariableList, PathAction, Scope}; +use dsc_lib_registry::{RegistryHelper, config::RegistryValueData}; +use dsc_lib_security_context::{SecurityContext, get_security_context}; +use rust_i18n::t; +use std::collections::HashSet; + +const CURRENT_USER_KEY: &str = r"HKCU\Environment"; +const ALL_USERS_KEY: &str = r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; + +#[derive(Debug)] +pub enum EnvironmentError { + ElevationRequired, + Resource(String), +} + +impl EnvironmentError { + pub fn is_elevation_required(&self) -> bool { + matches!(self, Self::ElevationRequired) + } +} + +impl std::fmt::Display for EnvironmentError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ElevationRequired => formatter.write_str(&t!("set.elevationRequired")), + Self::Resource(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for EnvironmentError {} + +pub fn get_variables( + input: &EnvironmentVariableList, +) -> Result { + let environment_variables = input + .environment_variables + .iter() + .map(get_variable) + .collect::, _>>()?; + + Ok(EnvironmentVariableList { + environment_variables, + }) +} + +pub fn set_variables( + input: &EnvironmentVariableList, +) -> Result { + if input + .environment_variables + .iter() + .any(|variable| variable.scope == Scope::AllUsers) + && get_security_context() != SecurityContext::Admin + { + return Err(EnvironmentError::ElevationRequired); + } + + let mut environment_variables = Vec::with_capacity(input.environment_variables.len()); + for variable in &input.environment_variables { + let helper = registry_helper(variable, None)?; + if !variable.exist.unwrap_or(true) { + helper + .remove() + .map_err(|error| operation_error("set.removeError", variable, &error))?; + environment_variables.push(EnvironmentVariable { + scope: variable.scope, + name: variable.name.clone(), + value: None, + path_value: None, + path_action: None, + exist: Some(false), + }); + continue; + } + + let current_data = helper + .get() + .map_err(|error| operation_error("set.readError", variable, &error))? + .value_data; + let desired_value = desired_value(variable, current_data.as_ref()); + let value_data = registry_data(&desired_value, current_data.as_ref()); + registry_helper(variable, Some(value_data))? + .set() + .map_err(|error| operation_error("set.writeError", variable, &error))?; + environment_variables.push(get_variable(variable)?); + } + + Ok(EnvironmentVariableList { + environment_variables, + }) +} + +fn get_variable(variable: &EnvironmentVariable) -> Result { + let state = registry_helper(variable, None)? + .get() + .map_err(|error| operation_error("get.readError", variable, &error))?; + + if state.exist == Some(false) { + return Ok(EnvironmentVariable { + scope: variable.scope, + name: variable.name.clone(), + value: None, + path_value: None, + path_action: None, + exist: Some(false), + }); + } + + let value = match state.value_data { + Some(RegistryValueData::String(value) | RegistryValueData::ExpandString(value)) => value, + Some(_) => { + return Err(EnvironmentError::Resource( + t!( + "get.unsupportedType", + name = variable.name.as_str(), + scope = variable.scope.to_string() + ) + .to_string(), + )); + } + None => String::new(), + }; + + let (value, path_value) = if variable.path_value.is_some() { + (None, Some(split_path(&value))) + } else { + (Some(value), None) + }; + + Ok(EnvironmentVariable { + scope: variable.scope, + name: variable.name.clone(), + value, + path_value, + path_action: None, + exist: Some(true), + }) +} + +fn registry_helper( + variable: &EnvironmentVariable, + value_data: Option, +) -> Result { + RegistryHelper::new( + key_path(variable.scope), + Some(variable.name.clone()), + value_data, + ) + .map_err(|error| operation_error("main.registryError", variable, &error)) +} + +fn key_path(scope: Scope) -> &'static str { + match scope { + Scope::AllUsers => ALL_USERS_KEY, + Scope::CurrentUser => CURRENT_USER_KEY, + } +} + +fn desired_value( + variable: &EnvironmentVariable, + current_data: Option<&RegistryValueData>, +) -> String { + if let Some(value) = &variable.value { + return value.clone(); + } + + let desired = variable.path_value.as_deref().unwrap_or_default(); + let existing = match current_data { + Some(RegistryValueData::String(value) | RegistryValueData::ExpandString(value)) => { + split_path(value) + } + _ => Vec::new(), + }; + + merge_path(&existing, desired, variable.path_action.unwrap_or_default()).join(";") +} + +fn registry_data(value: &str, current_data: Option<&RegistryValueData>) -> RegistryValueData { + if matches!(current_data, Some(RegistryValueData::ExpandString(_))) || value.contains('%') { + RegistryValueData::ExpandString(value.to_string()) + } else { + RegistryValueData::String(value.to_string()) + } +} + +fn split_path(value: &str) -> Vec { + value + .split(';') + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + +fn merge_path(existing: &[String], desired: &[String], action: PathAction) -> Vec { + let mut values = match action { + PathAction::Prepend => desired.iter().chain(existing).cloned().collect::>(), + PathAction::Append => { + let desired_keys = desired + .iter() + .map(|entry| entry.to_lowercase()) + .collect::>(); + existing + .iter() + .filter(|entry| !desired_keys.contains(&entry.to_lowercase())) + .chain(desired) + .cloned() + .collect::>() + } + PathAction::Clobber => desired.to_vec(), + }; + + let mut seen = HashSet::new(); + values.retain(|entry| seen.insert(entry.to_lowercase())); + values +} + +fn operation_error( + key: &str, + variable: &EnvironmentVariable, + error: &impl std::fmt::Display, +) -> EnvironmentError { + EnvironmentError::Resource( + t!( + key, + name = variable.name.as_str(), + scope = variable.scope.to_string(), + error = error.to_string() + ) + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::{merge_path, split_path}; + use crate::types::PathAction; + + #[test] + fn prepends_and_deduplicates_case_insensitively() { + let existing = vec!["C:\\Existing".to_string(), "C:\\Shared".to_string()]; + let desired = vec!["c:\\shared".to_string(), "C:\\New".to_string()]; + + assert_eq!( + merge_path(&existing, &desired, PathAction::Prepend), + vec!["c:\\shared", "C:\\New", "C:\\Existing"] + ); + } + + #[test] + fn appends_entries_at_the_end() { + let existing = vec!["C:\\Shared".to_string(), "C:\\Existing".to_string()]; + let desired = vec!["c:\\shared".to_string(), "C:\\New".to_string()]; + + assert_eq!( + merge_path(&existing, &desired, PathAction::Append), + vec!["C:\\Existing", "c:\\shared", "C:\\New"] + ); + } + + #[test] + fn clobber_deduplicates_desired_entries() { + let desired = vec!["C:\\One".to_string(), "c:\\one".to_string()]; + + assert_eq!( + merge_path(&[], &desired, PathAction::Clobber), + vec!["C:\\One"] + ); + } + + #[test] + fn splitting_omits_empty_path_segments() { + assert_eq!(split_path("C:\\One;;C:\\Two;"), vec!["C:\\One", "C:\\Two"]); + } +} diff --git a/resources/environment_variable/src/main.rs b/resources/environment_variable/src/main.rs new file mode 100644 index 000000000..20b79cd7a --- /dev/null +++ b/resources/environment_variable/src/main.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; + +#[cfg(windows)] +mod environment; + +use rust_i18n::t; +use std::process::exit; +use types::{EnvironmentVariableList, Operation}; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_RESOURCE_ERROR: i32 = 3; +const EXIT_ELEVATION_REQUIRED: i32 = 4; + +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({ "error": message })); +} + +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(error) => { + write_error(&t!("main.serializeError", error = error.to_string())); + exit(EXIT_RESOURCE_ERROR); + } + } +} + +fn require_input(input_json: Option, operation: Operation) -> EnvironmentVariableList { + let Some(json) = input_json else { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + }; + + let input: EnvironmentVariableList = match serde_json::from_str(&json) { + Ok(value) => value, + Err(error) => { + write_error(&t!("main.invalidJson", error = error.to_string())); + exit(EXIT_INVALID_INPUT); + } + }; + + if let Err(error) = input.validate(operation) { + write_error(&error); + exit(EXIT_INVALID_INPUT); + } + + input +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_RESOURCE_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + let result = match operation { + "get" => environment::get_variables(&require_input(input_json, Operation::Get)), + "set" => environment::set_variables(&require_input(input_json, Operation::Set)), + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + }; + + match result { + Ok(value) => { + print_json(&value); + exit(EXIT_SUCCESS); + } + Err(error) => { + write_error(&error.to_string()); + exit(if error.is_elevation_required() { + EXIT_ELEVATION_REQUIRED + } else { + EXIT_RESOURCE_ERROR + }); + } + } +} + +fn parse_input_arg(args: &[String]) -> Option { + let mut index = 2; + while index < args.len() { + if args[index] == "--input" || args[index] == "-i" { + if index + 1 < args.len() { + return Some(args[index + 1].clone()); + } + write_error(&t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + index += 1; + } + None +} diff --git a/resources/environment_variable/src/types.rs b/resources/environment_variable/src/types.rs new file mode 100644 index 000000000..4cb708349 --- /dev/null +++ b/resources/environment_variable/src/types.rs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy)] +pub enum Operation { + Get, + Set, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Scope { + AllUsers, + #[default] + CurrentUser, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAction { + Prepend, + Append, + #[default] + Clobber, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnvironmentVariableList { + pub environment_variables: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnvironmentVariable { + #[serde(default)] + pub scope: Scope, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path_value: Option>, + #[serde(default, skip_serializing)] + pub path_action: Option, + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, +} + +impl EnvironmentVariableList { + pub fn validate(&self, operation: Operation) -> Result<(), String> { + if self.environment_variables.is_empty() { + return Err(t!("validation.emptyList").to_string()); + } + + let mut identities = HashSet::new(); + for variable in &self.environment_variables { + variable.validate(operation)?; + let identity = (variable.scope, variable.name.to_lowercase()); + if !identities.insert(identity) { + return Err(t!( + "validation.duplicate", + name = variable.name.as_str(), + scope = variable.scope.to_string() + ) + .to_string()); + } + } + + Ok(()) + } +} + +impl EnvironmentVariable { + fn validate(&self, operation: Operation) -> Result<(), String> { + if self.name.is_empty() { + return Err(t!("validation.emptyName").to_string()); + } + if self.name.contains('\0') { + return Err(t!("validation.invalidName", name = self.name.as_str()).to_string()); + } + if self.value.is_some() && self.path_value.is_some() { + return Err(t!("validation.valueConflict", name = self.name.as_str()).to_string()); + } + if self.path_action.is_some() && self.path_value.is_none() { + return Err(t!( + "validation.pathActionWithoutValue", + name = self.name.as_str() + ) + .to_string()); + } + if let Some(entries) = &self.path_value + && entries + .iter() + .any(|entry| entry.is_empty() || entry.contains(';') || entry.contains('\0')) + { + return Err(t!("validation.invalidPathEntry", name = self.name.as_str()).to_string()); + } + if matches!(operation, Operation::Set) + && self.exist.unwrap_or(true) + && self.value.is_none() + && self.path_value.is_none() + { + return Err(t!("validation.missingValue", name = self.name.as_str()).to_string()); + } + + Ok(()) + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AllUsers => write!(formatter, "AllUsers"), + Self::CurrentUser => write!(formatter, "CurrentUser"), + } + } +} + +#[cfg(test)] +mod tests { + use super::{EnvironmentVariable, EnvironmentVariableList, Operation, PathAction, Scope}; + + fn variable(name: &str) -> EnvironmentVariable { + EnvironmentVariable { + scope: Scope::CurrentUser, + name: name.to_string(), + value: Some("value".to_string()), + path_value: None, + path_action: None, + exist: None, + } + } + + #[test] + fn rejects_duplicate_identity_case_insensitively() { + let mut second = variable("TEST_NAME"); + second.scope = Scope::CurrentUser; + let list = EnvironmentVariableList { + environment_variables: vec![variable("Test_Name"), second], + }; + + assert!(list.validate(Operation::Set).is_err()); + } + + #[test] + fn allows_same_name_in_different_scopes() { + let mut second = variable("Test_Name"); + second.scope = Scope::AllUsers; + let list = EnvironmentVariableList { + environment_variables: vec![variable("Test_Name"), second], + }; + + assert!(list.validate(Operation::Set).is_ok()); + } + + #[test] + fn rejects_path_action_without_path_value() { + let mut input = variable("Test_Name"); + input.path_action = Some(PathAction::Append); + let list = EnvironmentVariableList { + environment_variables: vec![input], + }; + + assert!(list.validate(Operation::Set).is_err()); + } +} diff --git a/resources/environment_variable/tests/environment_variable_get.tests.ps1 b/resources/environment_variable/tests/environment_variable_get.tests.ps1 new file mode 100644 index 000000000..37e6953cf --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_get.tests.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariableList get operation' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariableList' + $testName = "DSC_Environment_Get_$([guid]::NewGuid().ToString('N'))" + $testValue = 'C:\DSC\First;C:\DSC\Second' + [Environment]::SetEnvironmentVariable( + $testName, + $testValue, + [EnvironmentVariableTarget]::User) + } + + AfterAll { + [Environment]::SetEnvironmentVariable( + $testName, + $null, + [EnvironmentVariableTarget]::User) + } + + It 'Gets a CurrentUser variable using the default scope' { + $json = @{ + environmentVariables = @( + @{ name = $testName } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState.environmentVariables[0] + + $result.scope | Should -BeExactly 'CurrentUser' + $result.name | Should -BeExactly $testName + $result.value | Should -BeExactly $testValue + $result._exist | Should -BeTrue + $result.PSObject.Properties.Name | Should -Not -Contain 'pathAction' + } + + It 'Gets a variable as pathValue when pathValue is requested' { + $json = @{ + environmentVariables = @( + @{ + name = $testName + pathValue = @() + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState.environmentVariables[0] + + ($result.pathValue | ConvertTo-Json -Compress) | + Should -BeExactly '["C:\\DSC\\First","C:\\DSC\\Second"]' + $result.PSObject.Properties.Name | Should -Not -Contain 'value' + } + + It 'Returns _exist false for a missing variable' { + $missingName = "DSC_Environment_Missing_$([guid]::NewGuid().ToString('N'))" + $json = @{ + environmentVariables = @( + @{ name = $missingName } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState.environmentVariables[0] + + $result.name | Should -BeExactly $missingName + $result._exist | Should -BeFalse + $result.PSObject.Properties.Name | Should -Not -Contain 'value' + } + + It 'Gets multiple variables in input order' { + $missingName = "DSC_Environment_Missing_$([guid]::NewGuid().ToString('N'))" + $json = @{ + environmentVariables = @( + @{ name = $testName } + @{ name = $missingName } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState.environmentVariables + + $result.Count | Should -Be 2 + $result[0].name | Should -BeExactly $testName + $result[1].name | Should -BeExactly $missingName + } +} diff --git a/resources/environment_variable/tests/environment_variable_set.tests.ps1 b/resources/environment_variable/tests/environment_variable_set.tests.ps1 new file mode 100644 index 000000000..f479704bf --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_set.tests.ps1 @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariableList' + $namePrefix = "DSC_Environment_Set_$([guid]::NewGuid().ToString('N'))" + $testNames = @( + "${namePrefix}_Scalar" + "${namePrefix}_Path" + "${namePrefix}_First" + "${namePrefix}_Second" + ) + } + + AfterEach { + foreach ($name in $testNames) { + [Environment]::SetEnvironmentVariable( + $name, + $null, + [EnvironmentVariableTarget]::User) + } + } + + It 'Sets a scalar value with CurrentUser and _exist defaults' { + $json = @{ + environmentVariables = @( + @{ + name = $testNames[0] + value = 'DSC scalar value' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] + + $result.scope | Should -BeExactly 'CurrentUser' + $result.value | Should -BeExactly 'DSC scalar value' + $result._exist | Should -BeTrue + [Environment]::GetEnvironmentVariable( + $testNames[0], + [EnvironmentVariableTarget]::User) | Should -BeExactly 'DSC scalar value' + } + + It 'Clobbers a path value by default and removes duplicate entries case-insensitively' { + [Environment]::SetEnvironmentVariable( + $testNames[1], + 'C:\Old', + [EnvironmentVariableTarget]::User) + $json = @{ + environmentVariables = @( + @{ + name = $testNames[1] + pathValue = @('C:\One', 'c:\one', 'C:\Two') + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] + + ($result.pathValue | ConvertTo-Json -Compress) | + Should -BeExactly '["C:\\One","C:\\Two"]' + [Environment]::GetEnvironmentVariable( + $testNames[1], + [EnvironmentVariableTarget]::User) | Should -BeExactly 'C:\One;C:\Two' + } + + It 'Prepends path entries and moves an existing duplicate to the front' { + [Environment]::SetEnvironmentVariable( + $testNames[1], + 'C:\Existing;C:\Shared', + [EnvironmentVariableTarget]::User) + $json = @{ + environmentVariables = @( + @{ + name = $testNames[1] + pathValue = @('c:\shared', 'C:\New') + pathAction = 'prepend' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] + + ($result.pathValue | ConvertTo-Json -Compress) | + Should -BeExactly '["c:\\shared","C:\\New","C:\\Existing"]' + } + + It 'Appends path entries and moves an existing duplicate to the end' { + [Environment]::SetEnvironmentVariable( + $testNames[1], + 'C:\Shared;C:\Existing', + [EnvironmentVariableTarget]::User) + $json = @{ + environmentVariables = @( + @{ + name = $testNames[1] + pathValue = @('c:\shared', 'C:\New') + pathAction = 'append' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] + + ($result.pathValue | ConvertTo-Json -Compress) | + Should -BeExactly '["C:\\Existing","c:\\shared","C:\\New"]' + } + + It 'Removes a variable when _exist is false' { + [Environment]::SetEnvironmentVariable( + $testNames[0], + 'remove me', + [EnvironmentVariableTarget]::User) + $json = @{ + environmentVariables = @( + @{ + name = $testNames[0] + _exist = $false + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] + + $result._exist | Should -BeFalse + [Environment]::GetEnvironmentVariable( + $testNames[0], + [EnvironmentVariableTarget]::User) | Should -BeNullOrEmpty + } + + It 'Sets multiple variables in one request' { + $json = @{ + environmentVariables = @( + @{ + name = $testNames[2] + value = 'first' + } + @{ + name = $testNames[3] + value = 'second' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState.environmentVariables + + $result.Count | Should -Be 2 + $result[0].value | Should -BeExactly 'first' + $result[1].value | Should -BeExactly 'second' + } + + It 'Rejects value and pathValue together' { + $json = @{ + environmentVariables = @( + @{ + name = $testNames[0] + value = 'value' + pathValue = @('C:\Path') + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $out | Should -Match 'value.*pathValue' + } + + It 'Returns an actionable elevation error for AllUsers' -Skip:$isAdmin { + $machineName = "${namePrefix}_Machine" + $json = @{ + environmentVariables = @( + @{ + scope = 'AllUsers' + name = $machineName + value = 'requires elevation' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + + $LASTEXITCODE | Should -Not -Be 0 + $out | Should -Match 'elevation' + [Environment]::GetEnvironmentVariable( + $machineName, + [EnvironmentVariableTarget]::Machine) | Should -BeNullOrEmpty + } +} From 37349b9db660b14a0f7cd4e019efa314d92c7503 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 13 Aug 2026 14:05:53 -0700 Subject: [PATCH 2/8] Include environment resource in Windows package Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/create-dsc-resource/SKILL.md | 4 +++- data.build.json | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/skills/create-dsc-resource/SKILL.md b/.github/skills/create-dsc-resource/SKILL.md index b2209ae32..2dd310b3d 100644 --- a/.github/skills/create-dsc-resource/SKILL.md +++ b/.github/skills/create-dsc-resource/SKILL.md @@ -20,6 +20,7 @@ Management tasks or operations are specific to the resource type, but may includ - **Resource manifest**: A JSON file that defines the resource type name, supported operations (including executable and arguments), and JSON schema for input parameters - **Dependency management**: All crates must be listed in Cargo.toml specifying to use workspace dependencies. The root level Cargo.toml should be updated to include the new crates or associated to the DSC resource project. - **Project files**: A `.project.data.json` file in the root of the project folder defines properties of the project and non-code files to include during build +- **Release packaging**: Add every resource binary and manifest to the applicable platform list under `PackageFiles` in the root `data.build.json`. Project discovery alone does not include a resource in released packages. - **Localization**: For Rust-based resources, all user-facing strings must use `rust-i18n` for internationalization. For script-based resources (such as PowerShell), follow the existing localization and string-handling patterns used by those scripts or any repository-specific localization guidance. - **Copyright headers**: Every source file must start with the copyright header: ``` @@ -37,6 +38,7 @@ Management tasks or operations are specific to the resource type, but may includ - Create a resource manifest JSON file named `.dsc.resource.json` in the same directory using `./resources/windows_service/windows_service.dsc.resource.json` as an example - Create a `.project.data.json` file in the root of the resource project directory - Create a `locales/en-us.toml` file for localized strings +- Add the resource executable and manifest to the applicable `PackageFiles` platform list in the root `data.build.json` (for example, `.exe` and `.dsc.resource.json` under `PackageFiles.Windows`) ### 2. .project.data.json @@ -261,6 +263,7 @@ someError = "Failed to do something: %{error}" #### Build and Deployment +- Verify the root `data.build.json` lists the resource binary and every manifest under each applicable `PackageFiles` platform. A `.project.data.json` entry controls building and copying artifacts but does not by itself add those files to a released package. - The resource should be built using `build.ps1 -project ` from the root of the repository, which will handle building the Rust code and ensure it is found in PATH for testing ## What-If support @@ -560,4 +563,3 @@ When asked to add what-if to a new resource, perform these steps in order: 7. Add `whatIf*` localized strings under `[_helper]` and `args.configArgsWhatIfHelp` in `locales/en-us.toml`. 8. Create `.config.whatif.tests.ps1` (and the list variant if applicable) following the test template; cover create, update, delete-via-`_exist`, and `delete -w`. 9. Build with `./build.ps1 -project ` and run the new Pester file. - diff --git a/data.build.json b/data.build.json index 9a6d58d03..96e753c28 100644 --- a/data.build.json +++ b/data.build.json @@ -74,6 +74,8 @@ "dsc-bicep-ext.exe", "dscecho.exe", "echo.dsc.resource.json", + "environment_variable.exe", + "environment_variable.dsc.resource.json", "assertion.dsc.resource.json", "featureondemand.dsc.resource.json", "group.dsc.resource.json", From dcd912742c924e35e5313ccbc3e767322646022c Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 13 Aug 2026 16:39:16 -0700 Subject: [PATCH 3/8] Fix environment resource localization audit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../environment_variable/src/environment.rs | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/resources/environment_variable/src/environment.rs b/resources/environment_variable/src/environment.rs index 952d51356..2185aeec5 100644 --- a/resources/environment_variable/src/environment.rs +++ b/resources/environment_variable/src/environment.rs @@ -16,6 +16,15 @@ pub enum EnvironmentError { Resource(String), } +#[derive(Clone, Copy)] +enum OperationError { + Registry, + GetRead, + SetRead, + SetWrite, + SetRemove, +} + impl EnvironmentError { pub fn is_elevation_required(&self) -> bool { matches!(self, Self::ElevationRequired) @@ -65,7 +74,7 @@ pub fn set_variables( if !variable.exist.unwrap_or(true) { helper .remove() - .map_err(|error| operation_error("set.removeError", variable, &error))?; + .map_err(|error| operation_error(OperationError::SetRemove, variable, &error))?; environment_variables.push(EnvironmentVariable { scope: variable.scope, name: variable.name.clone(), @@ -79,13 +88,13 @@ pub fn set_variables( let current_data = helper .get() - .map_err(|error| operation_error("set.readError", variable, &error))? + .map_err(|error| operation_error(OperationError::SetRead, variable, &error))? .value_data; let desired_value = desired_value(variable, current_data.as_ref()); let value_data = registry_data(&desired_value, current_data.as_ref()); registry_helper(variable, Some(value_data))? .set() - .map_err(|error| operation_error("set.writeError", variable, &error))?; + .map_err(|error| operation_error(OperationError::SetWrite, variable, &error))?; environment_variables.push(get_variable(variable)?); } @@ -97,7 +106,7 @@ pub fn set_variables( fn get_variable(variable: &EnvironmentVariable) -> Result { let state = registry_helper(variable, None)? .get() - .map_err(|error| operation_error("get.readError", variable, &error))?; + .map_err(|error| operation_error(OperationError::GetRead, variable, &error))?; if state.exist == Some(false) { return Ok(EnvironmentVariable { @@ -150,7 +159,7 @@ fn registry_helper( Some(variable.name.clone()), value_data, ) - .map_err(|error| operation_error("main.registryError", variable, &error)) + .map_err(|error| operation_error(OperationError::Registry, variable, &error)) } fn key_path(scope: Scope) -> &'static str { @@ -219,19 +228,28 @@ fn merge_path(existing: &[String], desired: &[String], action: PathAction) -> Ve } fn operation_error( - key: &str, + operation: OperationError, variable: &EnvironmentVariable, error: &impl std::fmt::Display, ) -> EnvironmentError { - EnvironmentError::Resource( - t!( - key, - name = variable.name.as_str(), - scope = variable.scope.to_string(), - error = error.to_string() - ) - .to_string(), - ) + let name = variable.name.as_str(); + let scope = variable.scope.to_string(); + let error = error.to_string(); + let message = match operation { + OperationError::Registry => t!( + "main.registryError", + name = name, + scope = scope, + error = error + ), + OperationError::GetRead => t!("get.readError", name = name, scope = scope, error = error), + OperationError::SetRead => t!("set.readError", name = name, scope = scope, error = error), + OperationError::SetWrite => t!("set.writeError", name = name, scope = scope, error = error), + OperationError::SetRemove => { + t!("set.removeError", name = name, scope = scope, error = error) + } + }; + EnvironmentError::Resource(message.to_string()) } #[cfg(test)] From 9cf1f592ea9fc4704419995eb9091d7891a284ed Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Wed, 19 Aug 2026 16:32:22 -0700 Subject: [PATCH 4/8] Address environment resource review feedback Implement explicit path-action-aware testing, strengthen schema dependencies, and use camelCase scope values. Replace slow environment broadcast fixtures with direct registry setup for fast Pester coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../environment_variable.dsc.resource.json | 46 +++++- .../environment_variable/locales/en-us.toml | 4 +- .../environment_variable/src/environment.rs | 115 +++++++++++++- resources/environment_variable/src/main.rs | 1 + resources/environment_variable/src/types.rs | 13 +- .../tests/environment_variable_get.tests.ps1 | 12 +- .../tests/environment_variable_set.tests.ps1 | 31 ++-- .../tests/environment_variable_test.tests.ps1 | 144 ++++++++++++++++++ 8 files changed, 321 insertions(+), 45 deletions(-) create mode 100644 resources/environment_variable/tests/environment_variable_test.tests.ps1 diff --git a/resources/environment_variable/environment_variable.dsc.resource.json b/resources/environment_variable/environment_variable.dsc.resource.json index dda741366..616bb705d 100644 --- a/resources/environment_variable/environment_variable.dsc.resource.json +++ b/resources/environment_variable/environment_variable.dsc.resource.json @@ -30,6 +30,17 @@ "handlesExist": true, "return": "state" }, + "test": { + "executable": "environment_variable", + "args": [ + "test", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, "exitCodes": { "0": "Success", "1": "Invalid arguments", @@ -39,7 +50,7 @@ }, "schema": { "embedded": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Windows Environment Variable List", "description": "Manage user and machine environment variables stored in the Windows registry.", "type": "object", @@ -59,9 +70,24 @@ "required": [ "name" ], - "not": { - "required": [ - "value", + "dependentSchemas": { + "value": { + "not": { + "required": [ + "pathValue" + ] + } + }, + "pathValue": { + "not": { + "required": [ + "value" + ] + } + } + }, + "dependentRequired": { + "pathAction": [ "pathValue" ] }, @@ -70,10 +96,10 @@ "type": "string", "title": "Scope", "description": "The registry scope for the environment variable.", - "default": "CurrentUser", + "default": "currentUser", "enum": [ - "AllUsers", - "CurrentUser" + "allUsers", + "currentUser" ] }, "name": { @@ -117,6 +143,12 @@ } } } + }, + "_inDesiredState": { + "type": "boolean", + "title": "In desired state", + "description": "Whether all environment variables are in the desired state. Returned only by the test operation.", + "readOnly": true } } } diff --git a/resources/environment_variable/locales/en-us.toml b/resources/environment_variable/locales/en-us.toml index b3b3251fc..f59024fad 100644 --- a/resources/environment_variable/locales/en-us.toml +++ b/resources/environment_variable/locales/en-us.toml @@ -1,8 +1,8 @@ _version = 1 [main] -missingOperation = "Missing operation. Usage: environment_variable get --input | set --input " -unknownOperation = "Unknown operation: '%{operation}'. Expected: get or set" +missingOperation = "Missing operation. Usage: environment_variable get --input | set --input | test --input " +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or test" missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" invalidJson = "Invalid JSON input: %{error}" diff --git a/resources/environment_variable/src/environment.rs b/resources/environment_variable/src/environment.rs index 2185aeec5..9dab9c4e1 100644 --- a/resources/environment_variable/src/environment.rs +++ b/resources/environment_variable/src/environment.rs @@ -53,6 +53,39 @@ pub fn get_variables( Ok(EnvironmentVariableList { environment_variables, + in_desired_state: None, + }) +} + +pub fn test_variables( + input: &EnvironmentVariableList, +) -> Result { + let mut in_desired_state = true; + let mut environment_variables = Vec::with_capacity(input.environment_variables.len()); + + for variable in &input.environment_variables { + let state = registry_helper(variable, None)? + .get() + .map_err(|error| operation_error(OperationError::GetRead, variable, &error))?; + let exists = state.exist != Some(false); + let should_exist = variable.exist.unwrap_or(true); + + if exists != should_exist { + in_desired_state = false; + } else if exists { + let current_data = state.value_data.as_ref(); + let current_value = registry_string(variable, current_data)?; + if !value_in_desired_state(variable, ¤t_value, current_data) { + in_desired_state = false; + } + } + + environment_variables.push(get_variable(variable)?); + } + + Ok(EnvironmentVariableList { + environment_variables, + in_desired_state: Some(in_desired_state), }) } @@ -100,6 +133,7 @@ pub fn set_variables( Ok(EnvironmentVariableList { environment_variables, + in_desired_state: None, }) } @@ -196,6 +230,44 @@ fn registry_data(value: &str, current_data: Option<&RegistryValueData>) -> Regis } } +fn registry_string( + variable: &EnvironmentVariable, + current_data: Option<&RegistryValueData>, +) -> Result { + match current_data { + Some(RegistryValueData::String(value) | RegistryValueData::ExpandString(value)) => { + Ok(value.clone()) + } + Some(_) => Err(EnvironmentError::Resource( + t!( + "get.unsupportedType", + name = variable.name.as_str(), + scope = variable.scope.to_string() + ) + .to_string(), + )), + None => Ok(String::new()), + } +} + +fn value_in_desired_state( + variable: &EnvironmentVariable, + current_value: &str, + current_data: Option<&RegistryValueData>, +) -> bool { + if let Some(value) = &variable.value { + return current_value == value; + } + + let projected = desired_value(variable, current_data); + split_path(current_value) + .iter() + .map(|entry| entry.to_lowercase()) + .eq(split_path(&projected) + .iter() + .map(|entry| entry.to_lowercase())) +} + fn split_path(value: &str) -> Vec { value .split(';') @@ -254,8 +326,9 @@ fn operation_error( #[cfg(test)] mod tests { - use super::{merge_path, split_path}; - use crate::types::PathAction; + use super::{merge_path, split_path, value_in_desired_state}; + use crate::types::{EnvironmentVariable, PathAction, Scope}; + use dsc_lib_registry::config::RegistryValueData; #[test] fn prepends_and_deduplicates_case_insensitively() { @@ -293,4 +366,42 @@ mod tests { fn splitting_omits_empty_path_segments() { assert_eq!(split_path("C:\\One;;C:\\Two;"), vec!["C:\\One", "C:\\Two"]); } + + #[test] + fn prepend_is_in_desired_state_after_projecting_same_value() { + let variable = EnvironmentVariable { + scope: Scope::CurrentUser, + name: "Path".to_string(), + value: None, + path_value: Some(vec!["c:\\shared".to_string(), "C:\\New".to_string()]), + path_action: Some(PathAction::Prepend), + exist: None, + }; + let current = RegistryValueData::String("c:\\shared;C:\\New;C:\\Existing".to_string()); + + assert!(value_in_desired_state( + &variable, + "c:\\shared;C:\\New;C:\\Existing", + Some(¤t) + )); + } + + #[test] + fn prepend_is_not_in_desired_state_before_projection() { + let variable = EnvironmentVariable { + scope: Scope::CurrentUser, + name: "Path".to_string(), + value: None, + path_value: Some(vec!["C:\\New".to_string()]), + path_action: Some(PathAction::Prepend), + exist: None, + }; + let current = RegistryValueData::String("C:\\Existing".to_string()); + + assert!(!value_in_desired_state( + &variable, + "C:\\Existing", + Some(¤t) + )); + } } diff --git a/resources/environment_variable/src/main.rs b/resources/environment_variable/src/main.rs index 20b79cd7a..31c788259 100644 --- a/resources/environment_variable/src/main.rs +++ b/resources/environment_variable/src/main.rs @@ -74,6 +74,7 @@ fn main() { let result = match operation { "get" => environment::get_variables(&require_input(input_json, Operation::Get)), "set" => environment::set_variables(&require_input(input_json, Operation::Set)), + "test" => environment::test_variables(&require_input(input_json, Operation::Test)), _ => { write_error(&t!("main.unknownOperation", operation = operation)); exit(EXIT_INVALID_ARGS); diff --git a/resources/environment_variable/src/types.rs b/resources/environment_variable/src/types.rs index 4cb708349..f48a9e939 100644 --- a/resources/environment_variable/src/types.rs +++ b/resources/environment_variable/src/types.rs @@ -9,9 +9,11 @@ use std::collections::HashSet; pub enum Operation { Get, Set, + Test, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum Scope { AllUsers, #[default] @@ -31,6 +33,8 @@ pub enum PathAction { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EnvironmentVariableList { pub environment_variables: Vec, + #[serde(rename = "_inDesiredState", skip_serializing_if = "Option::is_none")] + pub in_desired_state: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -98,7 +102,7 @@ impl EnvironmentVariable { { return Err(t!("validation.invalidPathEntry", name = self.name.as_str()).to_string()); } - if matches!(operation, Operation::Set) + if matches!(operation, Operation::Set | Operation::Test) && self.exist.unwrap_or(true) && self.value.is_none() && self.path_value.is_none() @@ -113,8 +117,8 @@ impl EnvironmentVariable { impl std::fmt::Display for Scope { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::AllUsers => write!(formatter, "AllUsers"), - Self::CurrentUser => write!(formatter, "CurrentUser"), + Self::AllUsers => write!(formatter, "allUsers"), + Self::CurrentUser => write!(formatter, "currentUser"), } } } @@ -140,6 +144,7 @@ mod tests { second.scope = Scope::CurrentUser; let list = EnvironmentVariableList { environment_variables: vec![variable("Test_Name"), second], + in_desired_state: None, }; assert!(list.validate(Operation::Set).is_err()); @@ -151,6 +156,7 @@ mod tests { second.scope = Scope::AllUsers; let list = EnvironmentVariableList { environment_variables: vec![variable("Test_Name"), second], + in_desired_state: None, }; assert!(list.validate(Operation::Set).is_ok()); @@ -162,6 +168,7 @@ mod tests { input.path_action = Some(PathAction::Append); let list = EnvironmentVariableList { environment_variables: vec![input], + in_desired_state: None, }; assert!(list.validate(Operation::Set).is_err()); diff --git a/resources/environment_variable/tests/environment_variable_get.tests.ps1 b/resources/environment_variable/tests/environment_variable_get.tests.ps1 index 37e6953cf..dbe51ccfc 100644 --- a/resources/environment_variable/tests/environment_variable_get.tests.ps1 +++ b/resources/environment_variable/tests/environment_variable_get.tests.ps1 @@ -6,17 +6,11 @@ Describe 'Microsoft.Windows/EnvironmentVariableList get operation' -Skip:(!$IsWi $resourceType = 'Microsoft.Windows/EnvironmentVariableList' $testName = "DSC_Environment_Get_$([guid]::NewGuid().ToString('N'))" $testValue = 'C:\DSC\First;C:\DSC\Second' - [Environment]::SetEnvironmentVariable( - $testName, - $testValue, - [EnvironmentVariableTarget]::User) + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testName -Value $testValue -Type String } AfterAll { - [Environment]::SetEnvironmentVariable( - $testName, - $null, - [EnvironmentVariableTarget]::User) + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $testName -ErrorAction Ignore } It 'Gets a CurrentUser variable using the default scope' { @@ -30,7 +24,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList get operation' -Skip:(!$IsWi $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).actualState.environmentVariables[0] - $result.scope | Should -BeExactly 'CurrentUser' + $result.scope | Should -BeExactly 'currentUser' $result.name | Should -BeExactly $testName $result.value | Should -BeExactly $testValue $result._exist | Should -BeTrue diff --git a/resources/environment_variable/tests/environment_variable_set.tests.ps1 b/resources/environment_variable/tests/environment_variable_set.tests.ps1 index f479704bf..3e64d6135 100644 --- a/resources/environment_variable/tests/environment_variable_set.tests.ps1 +++ b/resources/environment_variable/tests/environment_variable_set.tests.ps1 @@ -26,10 +26,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi AfterEach { foreach ($name in $testNames) { - [Environment]::SetEnvironmentVariable( - $name, - $null, - [EnvironmentVariableTarget]::User) + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $name -ErrorAction Ignore } } @@ -47,7 +44,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState.environmentVariables[0] - $result.scope | Should -BeExactly 'CurrentUser' + $result.scope | Should -BeExactly 'currentUser' $result.value | Should -BeExactly 'DSC scalar value' $result._exist | Should -BeTrue [Environment]::GetEnvironmentVariable( @@ -56,10 +53,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi } It 'Clobbers a path value by default and removes duplicate entries case-insensitively' { - [Environment]::SetEnvironmentVariable( - $testNames[1], - 'C:\Old', - [EnvironmentVariableTarget]::User) + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testNames[1] -Value 'C:\Old' -Type String $json = @{ environmentVariables = @( @{ @@ -81,10 +75,8 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi } It 'Prepends path entries and moves an existing duplicate to the front' { - [Environment]::SetEnvironmentVariable( - $testNames[1], - 'C:\Existing;C:\Shared', - [EnvironmentVariableTarget]::User) + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testNames[1] ` + -Value 'C:\Existing;C:\Shared' -Type String $json = @{ environmentVariables = @( @{ @@ -104,10 +96,8 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi } It 'Appends path entries and moves an existing duplicate to the end' { - [Environment]::SetEnvironmentVariable( - $testNames[1], - 'C:\Shared;C:\Existing', - [EnvironmentVariableTarget]::User) + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testNames[1] ` + -Value 'C:\Shared;C:\Existing' -Type String $json = @{ environmentVariables = @( @{ @@ -127,10 +117,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi } It 'Removes a variable when _exist is false' { - [Environment]::SetEnvironmentVariable( - $testNames[0], - 'remove me', - [EnvironmentVariableTarget]::User) + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testNames[0] -Value 'remove me' -Type String $json = @{ environmentVariables = @( @{ @@ -194,7 +181,7 @@ Describe 'Microsoft.Windows/EnvironmentVariableList set operation' -Skip:(!$IsWi $json = @{ environmentVariables = @( @{ - scope = 'AllUsers' + scope = 'allUsers' name = $machineName value = 'requires elevation' } diff --git a/resources/environment_variable/tests/environment_variable_test.tests.ps1 b/resources/environment_variable/tests/environment_variable_test.tests.ps1 new file mode 100644 index 000000000..faa8c8628 --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_test.tests.ps1 @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariableList test operation' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariableList' + $namePrefix = "DSC_Environment_Test_$([guid]::NewGuid().ToString('N'))" + $scalarName = "${namePrefix}_Scalar" + $pathName = "${namePrefix}_Path" + } + + AfterEach { + foreach ($name in @($scalarName, $pathName)) { + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $name -ErrorAction Ignore + } + } + + It 'Reports a matching scalar value in desired state' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $scalarName -Value 'expected' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $scalarName + value = 'expected' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + + $result.inDesiredState | Should -BeTrue + $result.actualState.environmentVariables[0].scope | Should -BeExactly 'currentUser' + } + + It 'Reports a different scalar value outside desired state' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $scalarName -Value 'actual' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $scalarName + value = 'expected' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + + It 'Reports prepend in desired state after the requested entries are at the front' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $pathName ` + -Value 'C:\New;C:\Existing' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $pathName + pathValue = @('c:\new') + pathAction = 'prepend' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Reports prepend outside desired state before the requested entries are at the front' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $pathName -Value 'C:\Existing' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $pathName + pathValue = @('C:\New') + pathAction = 'prepend' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + + It 'Reports append in desired state when the requested entries are at the end' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $pathName ` + -Value 'C:\Existing;C:\New' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $pathName + pathValue = @('C:\New') + pathAction = 'append' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } + + It 'Reports clobber outside desired state when extra entries exist' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $pathName ` + -Value 'C:\Expected;C:\Extra' -Type String + $json = @{ + environmentVariables = @( + @{ + name = $pathName + pathValue = @('C:\Expected') + pathAction = 'clobber' + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeFalse + } + + It 'Reports a missing variable in desired state when _exist is false' { + $json = @{ + environmentVariables = @( + @{ + name = $scalarName + _exist = $false + } + ) + } | ConvertTo-Json -Compress -Depth 5 + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } +} From 40c146bea9f0b8932fee8988a78113adc1026caf Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 20 Aug 2026 10:18:40 -0700 Subject: [PATCH 5/8] Increase environment resource coverage Exercise registry-backed get, set, test, removal, path, error, and validation branches with instrumented Rust tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../environment_variable/src/environment.rs | 170 +++++++++++++++++- resources/environment_variable/src/types.rs | 61 +++++++ 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/resources/environment_variable/src/environment.rs b/resources/environment_variable/src/environment.rs index 9dab9c4e1..cc32995c6 100644 --- a/resources/environment_variable/src/environment.rs +++ b/resources/environment_variable/src/environment.rs @@ -326,9 +326,175 @@ fn operation_error( #[cfg(test)] mod tests { - use super::{merge_path, split_path, value_in_desired_state}; - use crate::types::{EnvironmentVariable, PathAction, Scope}; + use super::{ + CURRENT_USER_KEY, EnvironmentError, OperationError, get_variables, key_path, merge_path, + operation_error, set_variables, split_path, test_variables, value_in_desired_state, + }; + use crate::types::{EnvironmentVariable, EnvironmentVariableList, PathAction, Scope}; + use dsc_lib_registry::RegistryHelper; use dsc_lib_registry::config::RegistryValueData; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_ID: AtomicUsize = AtomicUsize::new(0); + + struct RegistryValueGuard { + name: String, + } + + impl RegistryValueGuard { + fn new() -> Self { + let id = TEST_ID.fetch_add(1, Ordering::Relaxed); + Self { + name: format!("DSC_Environment_RustTest_{}_{id}", std::process::id()), + } + } + + fn set(&self, data: RegistryValueData) { + RegistryHelper::new(CURRENT_USER_KEY, Some(self.name.clone()), Some(data)) + .unwrap() + .set() + .unwrap(); + } + } + + impl Drop for RegistryValueGuard { + fn drop(&mut self) { + RegistryHelper::new(CURRENT_USER_KEY, Some(self.name.clone()), None) + .unwrap() + .remove() + .unwrap(); + } + } + + fn variable(name: &str) -> EnvironmentVariable { + EnvironmentVariable { + scope: Scope::CurrentUser, + name: name.to_string(), + value: Some("expected".to_string()), + path_value: None, + path_action: None, + exist: None, + } + } + + fn list(variable: EnvironmentVariable) -> EnvironmentVariableList { + EnvironmentVariableList { + environment_variables: vec![variable], + in_desired_state: None, + } + } + + #[test] + fn registry_operations_round_trip_scalar_and_removal() { + let guard = RegistryValueGuard::new(); + let input = list(variable(&guard.name)); + + let set = set_variables(&input).unwrap(); + assert_eq!( + set.environment_variables[0].value.as_deref(), + Some("expected") + ); + + let get = get_variables(&input).unwrap(); + assert_eq!( + get.environment_variables[0].value.as_deref(), + Some("expected") + ); + + let test = test_variables(&input).unwrap(); + assert_eq!(test.in_desired_state, Some(true)); + + let mut different = variable(&guard.name); + different.value = Some("different".to_string()); + assert_eq!( + test_variables(&list(different)).unwrap().in_desired_state, + Some(false) + ); + + let mut remove = variable(&guard.name); + remove.value = None; + remove.exist = Some(false); + let removed = set_variables(&list(remove.clone())).unwrap(); + assert_eq!(removed.environment_variables[0].exist, Some(false)); + assert_eq!( + test_variables(&list(remove)).unwrap().in_desired_state, + Some(true) + ); + } + + #[test] + fn path_operations_preserve_expand_string_type() { + let guard = RegistryValueGuard::new(); + guard.set(RegistryValueData::ExpandString( + r"%SystemRoot%\Existing".to_string(), + )); + let input = list(EnvironmentVariable { + scope: Scope::CurrentUser, + name: guard.name.clone(), + value: None, + path_value: Some(vec![r"C:\New".to_string()]), + path_action: Some(PathAction::Append), + exist: None, + }); + + let set = set_variables(&input).unwrap(); + assert_eq!( + set.environment_variables[0].path_value.as_deref(), + Some([r"%SystemRoot%\Existing".to_string(), r"C:\New".to_string()].as_slice()) + ); + assert_eq!(test_variables(&input).unwrap().in_desired_state, Some(true)); + + let stored = RegistryHelper::new(CURRENT_USER_KEY, Some(guard.name.clone()), None) + .unwrap() + .get() + .unwrap(); + assert!(matches!( + stored.value_data, + Some(RegistryValueData::ExpandString(_)) + )); + } + + #[test] + fn unsupported_registry_type_returns_resource_error() { + let guard = RegistryValueGuard::new(); + guard.set(RegistryValueData::DWord(42)); + let input = list(variable(&guard.name)); + + let get_error = get_variables(&input).unwrap_err(); + assert!(get_error.to_string().contains(&guard.name)); + + let test_error = test_variables(&input).unwrap_err(); + assert!(test_error.to_string().contains(&guard.name)); + } + + #[test] + fn formats_error_variants_and_scope_paths() { + let variable = variable("TestName"); + let elevation = EnvironmentError::ElevationRequired; + assert!(elevation.is_elevation_required()); + assert!(!elevation.to_string().is_empty()); + + let resource = EnvironmentError::Resource("message".to_string()); + assert!(!resource.is_elevation_required()); + assert_eq!(resource.to_string(), "message"); + + assert_eq!(key_path(Scope::CurrentUser), CURRENT_USER_KEY); + assert!(key_path(Scope::AllUsers).starts_with("HKLM\\")); + + for operation in [ + OperationError::Registry, + OperationError::GetRead, + OperationError::SetRead, + OperationError::SetWrite, + OperationError::SetRemove, + ] { + assert!( + operation_error(operation, &variable, &"failure") + .to_string() + .contains("failure") + ); + } + } #[test] fn prepends_and_deduplicates_case_insensitively() { diff --git a/resources/environment_variable/src/types.rs b/resources/environment_variable/src/types.rs index f48a9e939..522db13cd 100644 --- a/resources/environment_variable/src/types.rs +++ b/resources/environment_variable/src/types.rs @@ -173,4 +173,65 @@ mod tests { assert!(list.validate(Operation::Set).is_err()); } + + #[test] + fn rejects_invalid_inputs() { + let empty = EnvironmentVariableList { + environment_variables: Vec::new(), + in_desired_state: None, + }; + assert!(empty.validate(Operation::Get).is_err()); + + for name in ["", "invalid\0name"] { + assert!( + EnvironmentVariableList { + environment_variables: vec![variable(name)], + in_desired_state: None, + } + .validate(Operation::Get) + .is_err() + ); + } + + let mut conflicting = variable("Test_Name"); + conflicting.path_value = Some(vec!["C:\\Path".to_string()]); + assert!( + EnvironmentVariableList { + environment_variables: vec![conflicting], + in_desired_state: None, + } + .validate(Operation::Set) + .is_err() + ); + + for entry in ["", "C:\\One;C:\\Two", "invalid\0path"] { + let mut invalid_path = variable("Test_Name"); + invalid_path.value = None; + invalid_path.path_value = Some(vec![entry.to_string()]); + assert!( + EnvironmentVariableList { + environment_variables: vec![invalid_path], + in_desired_state: None, + } + .validate(Operation::Set) + .is_err() + ); + } + + let mut missing_value = variable("Test_Name"); + missing_value.value = None; + let list = EnvironmentVariableList { + environment_variables: vec![missing_value], + in_desired_state: None, + }; + assert!(list.validate(Operation::Set).is_err()); + assert!(list.validate(Operation::Test).is_err()); + assert!(list.validate(Operation::Get).is_ok()); + } + + #[test] + fn formats_scope_values_as_camel_case() { + assert_eq!(Scope::AllUsers.to_string(), "allUsers"); + assert_eq!(Scope::CurrentUser.to_string(), "currentUser"); + } } From dc2933e0df65af14e427438985491303d9f1b708 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 20 Aug 2026 10:33:13 -0700 Subject: [PATCH 6/8] Fix int function Clippy lint Initialize the converted integer directly from the conditional expression to satisfy needless_late_init without changing behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib/src/functions/int.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/dsc-lib/src/functions/int.rs b/lib/dsc-lib/src/functions/int.rs index 5297e3aae..c4f81bc25 100644 --- a/lib/dsc-lib/src/functions/int.rs +++ b/lib/dsc-lib/src/functions/int.rs @@ -30,16 +30,15 @@ impl Function for Int { fn invoke(&self, args: &[Value], _context: &Context) -> Result { let arg = &args[0]; - let value: i64; - if arg.is_string() { + let value: i64 = if arg.is_string() { let input = arg.as_str().ok_or(DscError::FunctionArg("int".to_string(), t!("functions.int.invalidInput").to_string()))?; let result = input.parse::().map_err(|_| DscError::FunctionArg("int".to_string(), t!("functions.int.parseStringError").to_string()))?; - value = NumCast::from(result).ok_or(DscError::FunctionArg("int".to_string(), t!("functions.int.castError").to_string()))?; + NumCast::from(result).ok_or(DscError::FunctionArg("int".to_string(), t!("functions.int.castError").to_string()))? } else if arg.is_number() { - value = arg.as_i64().ok_or(DscError::FunctionArg("int".to_string(), t!("functions.int.parseNumError").to_string()))?; + arg.as_i64().ok_or(DscError::FunctionArg("int".to_string(), t!("functions.int.parseNumError").to_string()))? } else { return Err(DscError::FunctionArg("int".to_string(), t!("functions.invalidArgType").to_string())); - } + }; Ok(Value::Number(value.into())) } } From 7eb35dd6f72bc91b8a9e682528326487450c60b5 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 20 Aug 2026 11:10:33 -0700 Subject: [PATCH 7/8] Fix registry decoding Clippy lint Use fixed-size slice chunks for UTF-16 registry decoding while preserving the existing handling of trailing bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- lib/dsc-lib-registry/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index 3cd677c72..6c78f2ead 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -745,8 +745,8 @@ fn convert_value_data_to_offline(value_data: &RegistryValueData) -> Result<(u32, /// Decode a null-terminated UTF-16LE byte slice to a String. fn decode_utf16_bytes(data: &[u8]) -> String { - let u16_slice: Vec = data.chunks_exact(2) - .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + let u16_slice: Vec = data.as_chunks::<2>().0.iter() + .map(|chunk| u16::from_le_bytes(*chunk)) .collect(); // Strip trailing null let len = u16_slice.iter().position(|&c| c == 0).unwrap_or(u16_slice.len()); @@ -761,8 +761,8 @@ fn encode_utf16_bytes(s: &str) -> Vec { /// Decode REG_MULTI_SZ: double-null-terminated list of null-terminated UTF-16LE strings. fn decode_multi_sz(data: &[u8]) -> Vec { - let u16_slice: Vec = data.chunks_exact(2) - .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + let u16_slice: Vec = data.as_chunks::<2>().0.iter() + .map(|chunk| u16::from_le_bytes(*chunk)) .collect(); let mut strings = Vec::new(); let mut start = 0; From e83a1305664b5a7c605c37a0a041460f22d05e35 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 20 Aug 2026 12:19:26 -0700 Subject: [PATCH 8/8] Add single environment variable resource Expose Microsoft.Windows/EnvironmentVariable from the existing executable, replace the standalone list manifest with a shared manifest list, and add single-instance get, set, and test coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- data.build.json | 2 +- .../environment_variable/.project.data.json | 2 +- .../environment_variable.dsc.manifests.json | 304 ++++++++++++++++++ .../environment_variable.dsc.resource.json | 156 --------- .../environment_variable/locales/en-us.toml | 3 +- resources/environment_variable/src/main.rs | 46 ++- resources/environment_variable/src/types.rs | 18 ++ .../environment_variable_single_get.tests.ps1 | 39 +++ .../environment_variable_single_set.tests.ps1 | 45 +++ ...environment_variable_single_test.tests.ps1 | 44 +++ 10 files changed, 493 insertions(+), 166 deletions(-) create mode 100644 resources/environment_variable/environment_variable.dsc.manifests.json delete mode 100644 resources/environment_variable/environment_variable.dsc.resource.json create mode 100644 resources/environment_variable/tests/environment_variable_single_get.tests.ps1 create mode 100644 resources/environment_variable/tests/environment_variable_single_set.tests.ps1 create mode 100644 resources/environment_variable/tests/environment_variable_single_test.tests.ps1 diff --git a/data.build.json b/data.build.json index 96e753c28..350172261 100644 --- a/data.build.json +++ b/data.build.json @@ -75,7 +75,7 @@ "dscecho.exe", "echo.dsc.resource.json", "environment_variable.exe", - "environment_variable.dsc.resource.json", + "environment_variable.dsc.manifests.json", "assertion.dsc.resource.json", "featureondemand.dsc.resource.json", "group.dsc.resource.json", diff --git a/resources/environment_variable/.project.data.json b/resources/environment_variable/.project.data.json index c6d1a4526..8701f2602 100644 --- a/resources/environment_variable/.project.data.json +++ b/resources/environment_variable/.project.data.json @@ -8,7 +8,7 @@ ], "CopyFiles": { "Windows": [ - "environment_variable.dsc.resource.json" + "environment_variable.dsc.manifests.json" ] } } diff --git a/resources/environment_variable/environment_variable.dsc.manifests.json b/resources/environment_variable/environment_variable.dsc.manifests.json new file mode 100644 index 000000000..f611832e8 --- /dev/null +++ b/resources/environment_variable/environment_variable.dsc.manifests.json @@ -0,0 +1,304 @@ +{ + "resources": [ + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/EnvironmentVariable", + "description": "Manage a user or machine environment variable stored in the Windows registry.", + "tags": [ + "Windows", + "Environment" + ], + "version": "0.1.0", + "get": { + "executable": "environment_variable", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "environment_variable", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "handlesExist": true, + "return": "state" + }, + "test": { + "executable": "environment_variable", + "args": [ + "test", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Environment variable resource error", + "4": "Elevation required: Setting or removing AllUsers environment variables requires an elevated process" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Windows Environment Variable", + "description": "Manage a user or machine environment variable stored in the Windows registry.", + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "dependentSchemas": { + "value": { + "not": { + "required": [ + "pathValue" + ] + } + }, + "pathValue": { + "not": { + "required": [ + "value" + ] + } + } + }, + "dependentRequired": { + "pathAction": [ + "pathValue" + ] + }, + "properties": { + "scope": { + "type": "string", + "title": "Scope", + "description": "The registry scope for the environment variable.", + "default": "currentUser", + "enum": [ + "allUsers", + "currentUser" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The environment variable name.", + "minLength": 1 + }, + "value": { + "type": "string", + "title": "Value", + "description": "The environment variable value." + }, + "pathValue": { + "type": "array", + "title": "Path value", + "description": "The semicolon-delimited environment variable value represented as path entries.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^;]+$" + } + }, + "pathAction": { + "type": "string", + "title": "Path action", + "description": "How pathValue entries are combined with the current value.", + "writeOnly": true, + "default": "clobber", + "enum": [ + "prepend", + "append", + "clobber" + ] + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Whether the environment variable should exist. Set to false to remove it.", + "default": true + }, + "_inDesiredState": { + "type": "boolean", + "title": "In desired state", + "description": "Whether the environment variable is in the desired state. Returned only by the test operation.", + "readOnly": true + } + } + } + } + }, + { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/EnvironmentVariableList", + "description": "Manage user and machine environment variables stored in the Windows registry.", + "tags": [ + "Windows", + "Environment" + ], + "version": "0.1.0", + "get": { + "executable": "environment_variable", + "args": [ + "get", + "--list", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "environment_variable", + "args": [ + "set", + "--list", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "handlesExist": true, + "return": "state" + }, + "test": { + "executable": "environment_variable", + "args": [ + "test", + "--list", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Environment variable resource error", + "4": "Elevation required: Setting or removing AllUsers environment variables requires an elevated process" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Windows Environment Variable List", + "description": "Manage user and machine environment variables stored in the Windows registry.", + "type": "object", + "additionalProperties": false, + "required": [ + "environmentVariables" + ], + "properties": { + "environmentVariables": { + "type": "array", + "title": "Environment variables", + "description": "The environment variables to get or set.", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "dependentSchemas": { + "value": { + "not": { + "required": [ + "pathValue" + ] + } + }, + "pathValue": { + "not": { + "required": [ + "value" + ] + } + } + }, + "dependentRequired": { + "pathAction": [ + "pathValue" + ] + }, + "properties": { + "scope": { + "type": "string", + "title": "Scope", + "description": "The registry scope for the environment variable.", + "default": "currentUser", + "enum": [ + "allUsers", + "currentUser" + ] + }, + "name": { + "type": "string", + "title": "Name", + "description": "The environment variable name.", + "minLength": 1 + }, + "value": { + "type": "string", + "title": "Value", + "description": "The environment variable value." + }, + "pathValue": { + "type": "array", + "title": "Path value", + "description": "The semicolon-delimited environment variable value represented as path entries.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^;]+$" + } + }, + "pathAction": { + "type": "string", + "title": "Path action", + "description": "How pathValue entries are combined with the current value.", + "writeOnly": true, + "default": "clobber", + "enum": [ + "prepend", + "append", + "clobber" + ] + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Whether the environment variable should exist. Set to false to remove it.", + "default": true + } + } + } + }, + "_inDesiredState": { + "type": "boolean", + "title": "In desired state", + "description": "Whether all environment variables are in the desired state. Returned only by the test operation.", + "readOnly": true + } + } + } + } + } + ] +} diff --git a/resources/environment_variable/environment_variable.dsc.resource.json b/resources/environment_variable/environment_variable.dsc.resource.json deleted file mode 100644 index 616bb705d..000000000 --- a/resources/environment_variable/environment_variable.dsc.resource.json +++ /dev/null @@ -1,156 +0,0 @@ -{ - "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", - "type": "Microsoft.Windows/EnvironmentVariableList", - "description": "Manage user and machine environment variables stored in the Windows registry.", - "tags": [ - "Windows", - "Environment" - ], - "version": "0.1.0", - "get": { - "executable": "environment_variable", - "args": [ - "get", - { - "jsonInputArg": "--input", - "mandatory": true - } - ] - }, - "set": { - "executable": "environment_variable", - "args": [ - "set", - { - "jsonInputArg": "--input", - "mandatory": true - } - ], - "implementsPretest": false, - "handlesExist": true, - "return": "state" - }, - "test": { - "executable": "environment_variable", - "args": [ - "test", - { - "jsonInputArg": "--input", - "mandatory": true - } - ], - "return": "state" - }, - "exitCodes": { - "0": "Success", - "1": "Invalid arguments", - "2": "Invalid input", - "3": "Environment variable resource error", - "4": "Elevation required: Setting or removing AllUsers environment variables requires an elevated process" - }, - "schema": { - "embedded": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Windows Environment Variable List", - "description": "Manage user and machine environment variables stored in the Windows registry.", - "type": "object", - "additionalProperties": false, - "required": [ - "environmentVariables" - ], - "properties": { - "environmentVariables": { - "type": "array", - "title": "Environment variables", - "description": "The environment variables to get or set.", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "name" - ], - "dependentSchemas": { - "value": { - "not": { - "required": [ - "pathValue" - ] - } - }, - "pathValue": { - "not": { - "required": [ - "value" - ] - } - } - }, - "dependentRequired": { - "pathAction": [ - "pathValue" - ] - }, - "properties": { - "scope": { - "type": "string", - "title": "Scope", - "description": "The registry scope for the environment variable.", - "default": "currentUser", - "enum": [ - "allUsers", - "currentUser" - ] - }, - "name": { - "type": "string", - "title": "Name", - "description": "The environment variable name.", - "minLength": 1 - }, - "value": { - "type": "string", - "title": "Value", - "description": "The environment variable value." - }, - "pathValue": { - "type": "array", - "title": "Path value", - "description": "The semicolon-delimited environment variable value represented as path entries.", - "items": { - "type": "string", - "minLength": 1, - "pattern": "^[^;]+$" - } - }, - "pathAction": { - "type": "string", - "title": "Path action", - "description": "How pathValue entries are combined with the current value.", - "writeOnly": true, - "default": "clobber", - "enum": [ - "prepend", - "append", - "clobber" - ] - }, - "_exist": { - "type": "boolean", - "title": "Exists", - "description": "Whether the environment variable should exist. Set to false to remove it.", - "default": true - } - } - } - }, - "_inDesiredState": { - "type": "boolean", - "title": "In desired state", - "description": "Whether all environment variables are in the desired state. Returned only by the test operation.", - "readOnly": true - } - } - } - } -} diff --git a/resources/environment_variable/locales/en-us.toml b/resources/environment_variable/locales/en-us.toml index f59024fad..557bfa321 100644 --- a/resources/environment_variable/locales/en-us.toml +++ b/resources/environment_variable/locales/en-us.toml @@ -7,7 +7,8 @@ missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" invalidJson = "Invalid JSON input: %{error}" serializeError = "Failed to serialize resource output: %{error}" -windowsOnly = "The Microsoft.Windows/EnvironmentVariableList resource is only supported on Windows" +missingState = "The environment variable operation returned no state" +windowsOnly = "The Microsoft.Windows environment variable resources are only supported on Windows" registryError = "Failed to access environment variable '%{name}' in scope '%{scope}': %{error}" [validation] diff --git a/resources/environment_variable/src/main.rs b/resources/environment_variable/src/main.rs index 31c788259..434c9fcb8 100644 --- a/resources/environment_variable/src/main.rs +++ b/resources/environment_variable/src/main.rs @@ -8,7 +8,7 @@ mod environment; use rust_i18n::t; use std::process::exit; -use types::{EnvironmentVariableList, Operation}; +use types::{EnvironmentVariable, EnvironmentVariableList, Operation}; rust_i18n::i18n!("locales", fallback = "en-us"); @@ -32,13 +32,21 @@ fn print_json(value: &impl serde::Serialize) { } } -fn require_input(input_json: Option, operation: Operation) -> EnvironmentVariableList { +fn require_input( + input_json: Option, + operation: Operation, + is_list: bool, +) -> EnvironmentVariableList { let Some(json) = input_json else { write_error(&t!("main.missingInput")); exit(EXIT_INVALID_ARGS); }; - let input: EnvironmentVariableList = match serde_json::from_str(&json) { + let input = match if is_list { + serde_json::from_str::(&json) + } else { + serde_json::from_str::(&json).map(EnvironmentVariableList::from) + } { Ok(value) => value, Err(error) => { write_error(&t!("main.invalidJson", error = error.to_string())); @@ -54,6 +62,29 @@ fn require_input(input_json: Option, operation: Operation) -> Environmen input } +fn print_result(mut value: EnvironmentVariableList, is_list: bool) { + if is_list { + print_json(&value); + return; + } + + let Some(variable) = value.environment_variables.pop() else { + write_error(&t!("main.missingState")); + exit(EXIT_RESOURCE_ERROR); + }; + let mut output = match serde_json::to_value(variable) { + Ok(value) => value, + Err(error) => { + write_error(&t!("main.serializeError", error = error.to_string())); + exit(EXIT_RESOURCE_ERROR); + } + }; + if let Some(in_desired_state) = value.in_desired_state { + output["_inDesiredState"] = serde_json::Value::Bool(in_desired_state); + } + print_json(&output); +} + #[cfg(not(windows))] fn main() { write_error(&t!("main.windowsOnly")); @@ -70,11 +101,12 @@ fn main() { let operation = args[1].as_str(); let input_json = parse_input_arg(&args); + let is_list = args.iter().any(|arg| arg == "--list"); let result = match operation { - "get" => environment::get_variables(&require_input(input_json, Operation::Get)), - "set" => environment::set_variables(&require_input(input_json, Operation::Set)), - "test" => environment::test_variables(&require_input(input_json, Operation::Test)), + "get" => environment::get_variables(&require_input(input_json, Operation::Get, is_list)), + "set" => environment::set_variables(&require_input(input_json, Operation::Set, is_list)), + "test" => environment::test_variables(&require_input(input_json, Operation::Test, is_list)), _ => { write_error(&t!("main.unknownOperation", operation = operation)); exit(EXIT_INVALID_ARGS); @@ -83,7 +115,7 @@ fn main() { match result { Ok(value) => { - print_json(&value); + print_result(value, is_list); exit(EXIT_SUCCESS); } Err(error) => { diff --git a/resources/environment_variable/src/types.rs b/resources/environment_variable/src/types.rs index 522db13cd..988c2fd86 100644 --- a/resources/environment_variable/src/types.rs +++ b/resources/environment_variable/src/types.rs @@ -53,6 +53,15 @@ pub struct EnvironmentVariable { pub exist: Option, } +impl From for EnvironmentVariableList { + fn from(variable: EnvironmentVariable) -> Self { + Self { + environment_variables: vec![variable], + in_desired_state: None, + } + } +} + impl EnvironmentVariableList { pub fn validate(&self, operation: Operation) -> Result<(), String> { if self.environment_variables.is_empty() { @@ -234,4 +243,13 @@ mod tests { assert_eq!(Scope::AllUsers.to_string(), "allUsers"); assert_eq!(Scope::CurrentUser.to_string(), "currentUser"); } + + #[test] + fn wraps_single_variable_in_list() { + let list = EnvironmentVariableList::from(variable("Test_Name")); + + assert_eq!(list.environment_variables.len(), 1); + assert_eq!(list.environment_variables[0].name, "Test_Name"); + assert_eq!(list.in_desired_state, None); + } } diff --git a/resources/environment_variable/tests/environment_variable_single_get.tests.ps1 b/resources/environment_variable/tests/environment_variable_single_get.tests.ps1 new file mode 100644 index 000000000..7725a3336 --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_single_get.tests.ps1 @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariable get operation' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariable' + $testName = "DSC_Environment_Single_Get_$([guid]::NewGuid().ToString('N'))" + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testName -Value 'single value' -Type String + } + + AfterAll { + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $testName -ErrorAction Ignore + } + + It 'Gets one environment variable without a list envelope' { + $json = @{ name = $testName } | ConvertTo-Json -Compress + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState + + $result.scope | Should -BeExactly 'currentUser' + $result.name | Should -BeExactly $testName + $result.value | Should -BeExactly 'single value' + $result._exist | Should -BeTrue + $result.PSObject.Properties.Name | Should -Not -Contain 'environmentVariables' + } + + It 'Returns _exist false for a missing variable' { + $json = @{ name = "${testName}_Missing" } | ConvertTo-Json -Compress + + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).actualState + + $result._exist | Should -BeFalse + $result.PSObject.Properties.Name | Should -Not -Contain 'value' + } +} diff --git a/resources/environment_variable/tests/environment_variable_single_set.tests.ps1 b/resources/environment_variable/tests/environment_variable_single_set.tests.ps1 new file mode 100644 index 000000000..b51b304b8 --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_single_set.tests.ps1 @@ -0,0 +1,45 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariable set operation' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariable' + $testName = "DSC_Environment_Single_Set_$([guid]::NewGuid().ToString('N'))" + } + + AfterEach { + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $testName -ErrorAction Ignore + } + + It 'Sets one scalar environment variable without a list envelope' { + $json = @{ + name = $testName + value = 'single value' + } | ConvertTo-Json -Compress + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = ($out | ConvertFrom-Json).afterState + + $result.name | Should -BeExactly $testName + $result.value | Should -BeExactly 'single value' + $result.PSObject.Properties.Name | Should -Not -Contain 'environmentVariables' + (Get-ItemPropertyValue -Path 'HKCU:\Environment' -Name $testName) | + Should -BeExactly 'single value' + } + + It 'Removes one environment variable with _exist false' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testName -Value 'remove me' -Type String + $json = @{ + name = $testName + _exist = $false + } | ConvertTo-Json -Compress + + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).afterState._exist | Should -BeFalse + { Get-ItemPropertyValue -Path 'HKCU:\Environment' -Name $testName -ErrorAction Stop } | + Should -Throw + } +} diff --git a/resources/environment_variable/tests/environment_variable_single_test.tests.ps1 b/resources/environment_variable/tests/environment_variable_single_test.tests.ps1 new file mode 100644 index 000000000..b39e1816f --- /dev/null +++ b/resources/environment_variable/tests/environment_variable_single_test.tests.ps1 @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/EnvironmentVariable test operation' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Windows/EnvironmentVariable' + $testName = "DSC_Environment_Single_Test_$([guid]::NewGuid().ToString('N'))" + } + + AfterEach { + Remove-ItemProperty -Path 'HKCU:\Environment' -Name $testName -ErrorAction Ignore + } + + It 'Reports a matching scalar value in desired state' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testName -Value 'expected' -Type String + $json = @{ + name = $testName + value = 'expected' + } | ConvertTo-Json -Compress + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + $result = $out | ConvertFrom-Json + + $result.inDesiredState | Should -BeTrue + $result.actualState.name | Should -BeExactly $testName + $result.actualState.PSObject.Properties.Name | Should -Not -Contain 'environmentVariables' + } + + It 'Honors pathAction for one environment variable' { + Set-ItemProperty -Path 'HKCU:\Environment' -Name $testName ` + -Value 'C:\Existing;C:\New' -Type String + $json = @{ + name = $testName + pathValue = @('C:\New') + pathAction = 'append' + } | ConvertTo-Json -Compress + + $out = $json | dsc resource test -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + + ($out | ConvertFrom-Json).inDesiredState | Should -BeTrue + } +}