From 79a15dae40534ee569d81fbbf3d40dc480f08bbb Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 21 Aug 2026 16:18:04 -0700 Subject: [PATCH 01/13] Add Group Policy template adapter Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 19 + Cargo.toml | 8 +- .../group_policy_template/.project.data.json | 14 + adapters/group_policy_template/Cargo.toml | 20 + .../group_policy_template.dsc.resource.json | 61 ++ .../group_policy_template/locales/en-us.toml | 50 ++ adapters/group_policy_template/src/admx.rs | 790 ++++++++++++++++++ adapters/group_policy_template/src/main.rs | 89 ++ .../group_policy_template/src/registry.rs | 559 +++++++++++++ .../tests/group_policy_template.tests.ps1 | 95 +++ 10 files changed, 1704 insertions(+), 1 deletion(-) create mode 100644 adapters/group_policy_template/.project.data.json create mode 100644 adapters/group_policy_template/Cargo.toml create mode 100644 adapters/group_policy_template/group_policy_template.dsc.resource.json create mode 100644 adapters/group_policy_template/locales/en-us.toml create mode 100644 adapters/group_policy_template/src/admx.rs create mode 100644 adapters/group_policy_template/src/main.rs create mode 100644 adapters/group_policy_template/src/registry.rs create mode 100644 adapters/group_policy_template/tests/group_policy_template.tests.ps1 diff --git a/Cargo.lock b/Cargo.lock index 2056c7034..12b8e6dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "group_policy_template" +version = "0.1.0" +dependencies = [ + "dsc-lib-registry", + "roxmltree", + "rust-i18n", + "serde", + "serde_json", + "thiserror 2.0.19", + "windows 0.62.2", +] + [[package]] name = "h2" version = "0.4.15" @@ -2802,6 +2815,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rt-format" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 65eccb184..900da9bcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", "xtask", @@ -57,6 +58,7 @@ default-members = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", "xtask", @@ -90,6 +92,7 @@ Windows = [ "resources/windows_firewall", "resources/windows_service", "resources/WindowsUpdate", + "adapters/group_policy_template", "tools/dsctest", "tools/test_group_resource", "xtask", @@ -211,6 +214,8 @@ regex = { version = "1.13.1" } registry = { version = "1.3" } # dsc rmcp = { version = "2.2.0" } +# group_policy_template +roxmltree = { version = "0.20.0" } # dsc_lib rt-format = { version = "0.3" } # dsc, dsc-lib, dsc-bicep-ext, dscecho, registry, dsc-lib-registry, runcommandonset, sshdconfig @@ -284,7 +289,8 @@ windows = { version = "0.62", features = [ "Win32_System_Ole", "Win32_System_Services", "Win32_System_Variant", - "Win32_System_UpdateAgent" + "Win32_System_UpdateAgent", + "Win32_Globalization" ] } # build-only dependencies diff --git a/adapters/group_policy_template/.project.data.json b/adapters/group_policy_template/.project.data.json new file mode 100644 index 000000000..aff343a56 --- /dev/null +++ b/adapters/group_policy_template/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "group_policy_template", + "Kind": "Adapter", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "group_policy_template" + ], + "CopyFiles": { + "Windows": [ + "group_policy_template.dsc.resource.json" + ] + } +} diff --git a/adapters/group_policy_template/Cargo.toml b/adapters/group_policy_template/Cargo.toml new file mode 100644 index 000000000..adf30c1cf --- /dev/null +++ b/adapters/group_policy_template/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "group_policy_template" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +roxmltree = { workspace = true } +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(windows)'.dependencies] +dsc-lib-registry = { workspace = true } +windows = { workspace = true } diff --git a/adapters/group_policy_template/group_policy_template.dsc.resource.json b/adapters/group_policy_template/group_policy_template.dsc.resource.json new file mode 100644 index 000000000..32fdc93a8 --- /dev/null +++ b/adapters/group_policy_template/group_policy_template.dsc.resource.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Adapter/GroupPolicyTemplate", + "version": "0.1.0", + "kind": "adapter", + "description": "Adapts Windows Group Policy ADMX templates into DSC resources.", + "tags": [ + "Windows", + "GroupPolicy" + ], + "adapter": { + "list": { + "executable": "group_policy_template", + "args": [ + "list" + ] + }, + "inputKind": "single" + }, + "get": { + "executable": "group_policy_template", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + }, + { + "resourceTypeArg": "--resource-type" + }, + { + "resourcePathArg": "--resource-path" + } + ] + }, + "set": { + "executable": "group_policy_template", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + }, + { + "resourceTypeArg": "--resource-type" + }, + { + "resourcePathArg": "--resource-path" + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "ADMX, ADML, or registry operation failed" + } +} diff --git a/adapters/group_policy_template/locales/en-us.toml b/adapters/group_policy_template/locales/en-us.toml new file mode 100644 index 000000000..61184e603 --- /dev/null +++ b/adapters/group_policy_template/locales/en-us.toml @@ -0,0 +1,50 @@ +_version = 1 + +[main] +windowsOnly = "The Group Policy Template adapter is only supported on Windows." +missingOperation = "Missing operation. Expected list, get, or set." +unknownOperation = "Unknown operation: '%{operation}'. Expected list, get, or set." +missingArgument = "Missing required argument '%{argument}'." + +[admx] +readDirectory = "Failed to read policy definitions directory '%{path}': %{error}" +readFile = "Failed to read '%{path}': %{error}" +parseFile = "Failed to parse '%{path}': %{error}" +serializeResource = "Failed to serialize an adapted resource: %{error}" +skipTemplate = "Skipped policy template '%{path}': %{error}" +resourceNotFound = "Adapted resource '%{resource}' was not found in '%{path}'." +missingPolicyAttribute = "Policy '%{policy}' is missing required attribute '%{attribute}'." +missingPolicyValue = "Policy '%{policy}' is missing '%{value}'." +emptyPolicyValue = "A policy value does not contain a registry value." +unsupportedValueType = "Unsupported ADMX registry value type '%{value_type}'." +missingElementId = "A policy element is missing its required id." +unsupportedElementType = "Unsupported ADMX policy element type '%{element_type}'." +invalidPolicyClass = "Unsupported ADMX policy class '%{class}'." +missingValueName = "A registry value list item is missing its valueName." +invalidPath = "The ADMX path is invalid." +admlNotFound = "No ADML file was found for template '%{template}' and locale '%{locale}'." +systemRootNotFound = "The SystemRoot environment variable is not defined." +invalidNumber = "Invalid numeric policy value '%{value}': %{error}" +invalidBinary = "Invalid binary policy value '%{value}': %{error}" + +[schema] +scopeTitle = "Scope" +scopeDescription = "Specifies whether policy settings apply to all users through HKEY_LOCAL_MACHINE or the current user through HKEY_CURRENT_USER." +enabledTitle = "Enabled" + +[registry] +invalidInput = "Invalid JSON input: %{error}" +invalidScope = "The scope must be either 'allUsers' or 'currentUser'." +unknownPolicy = "Policy property '%{policy}' does not exist on adapted resource '%{resource}'." +policyNotBoolean = "Policy property '%{policy}' must be a Boolean." +invalidPolicyValue = "Policy property '%{policy}' must be a Boolean or an object." +policyHasNoToggle = "Policy '%{policy}' has no enabled or disabled registry value; configure its child settings instead." +unknownElement = "Element '%{element}' does not exist on policy '%{policy}'." +elementHasNoValueName = "Policy element '%{element}' does not define a registry value name." +listNotObject = "List element '%{element}' must be an object." +listValueNotString = "Every value in list element '%{element}' must be a string." +invalidElementValue = "The value for policy element '%{element}' does not match its ADMX definition." +scopeNotSupported = "Policy '%{policy}' does not support scope '%{scope}'." +unrecognizedValue = "Registry value '%{value_name}' under '%{key}' does not match the policy's enabled or disabled value." +operationFailed = "Registry operation failed: %{error}" +serializeResult = "Failed to serialize the resource state: %{error}" diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs new file mode 100644 index 000000000..bd66e5af7 --- /dev/null +++ b/adapters/group_policy_template/src/admx.rs @@ -0,0 +1,790 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib_registry::config::RegistryValueData; +use roxmltree::{Document, Node}; +use rust_i18n::t; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use windows::Win32::Globalization::GetUserDefaultLocaleName; + +const ADAPTER_TYPE: &str = "Microsoft.Adapter/GroupPolicyTemplate"; +const LOCALE_NAME_MAX_LENGTH: usize = 85; + +#[derive(Debug, Error)] +pub enum AdapterError { + #[error("{0}")] + Input(String), + #[error("{0}")] + Resource(String), +} + +impl AdapterError { + pub fn is_input_error(&self) -> bool { + matches!(self, Self::Input(_)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyValue { + Data(dsc_lib_registry::config::RegistryValueData), + Delete, +} + +#[derive(Debug, Clone)] +pub struct Policy { + pub name: String, + pub display_name: String, + pub description: Option, + pub class: PolicyClass, + pub key: String, + pub value_name: Option, + pub enabled: Option, + pub disabled: Option, + pub elements: Vec, + pub enabled_list: Vec, + pub disabled_list: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyClass { + Both, + Machine, + User, +} + +#[derive(Debug, Clone)] +pub struct RegistrySetting { + pub key: Option, + pub value_name: String, + pub value: PolicyValue, +} + +#[derive(Debug, Clone)] +pub struct PolicyElement { + pub id: String, + pub key: Option, + pub value_name: Option, + pub kind: ElementKind, +} + +#[derive(Debug, Clone)] +pub enum ElementKind { + Boolean { + true_value: PolicyValue, + false_value: PolicyValue, + }, + Decimal { + minimum: Option, + maximum: Option, + store_as_text: bool, + }, + Enum(Vec), + List, + MultiText, + Text { + expandable: bool, + }, +} + +#[derive(Debug, Clone)] +pub struct EnumItem { + pub title: String, + pub value: PolicyValue, +} + +#[derive(Debug, Clone)] +pub struct CategoryResource { + pub type_name: String, + pub display_name: String, + pub description: String, + pub policies: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListedResource { + #[serde(rename = "type")] + type_name: String, + kind: &'static str, + version: &'static str, + capabilities: [&'static str; 2], + path: PathBuf, + directory: PathBuf, + implemented_as: &'static str, + author: &'static str, + properties: Vec, + require_adapter: &'static str, + description: String, + schema: Map, +} + +pub fn list_resources() -> Result, AdapterError> { + let policy_definitions = policy_definitions_path()?; + let locale = user_locale(); + let mut result = Vec::new(); + let entries = fs::read_dir(&policy_definitions).map_err(|error| { + AdapterError::Resource( + t!( + "admx.readDirectory", + path = policy_definitions.display(), + error = error + ) + .to_string(), + ) + })?; + + for entry in entries { + let path = entry + .map_err(|error| AdapterError::Resource(error.to_string()))? + .path(); + if !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("admx")) + { + continue; + } + + match parse_template(&path, &locale) { + Ok(resources) => { + for resource in resources { + let listed = create_listed_resource(&resource, &path); + result.push(serde_json::to_string(&listed).map_err(|error| { + AdapterError::Resource( + t!("admx.serializeResource", error = error).to_string(), + ) + })?); + } + } + Err(error) => { + eprintln!( + "{}", + json!({ + "warn": t!( + "admx.skipTemplate", + path = path.display(), + error = error + ) + }) + ); + } + } + } + Ok(result) +} + +pub fn load_resource(path: &Path, resource_type: &str) -> Result { + parse_template(path, &user_locale())? + .into_iter() + .find(|resource| resource.type_name.eq_ignore_ascii_case(resource_type)) + .ok_or_else(|| { + AdapterError::Input( + t!( + "admx.resourceNotFound", + resource = resource_type, + path = path.display() + ) + .to_string(), + ) + }) +} + +fn parse_template(path: &Path, locale: &str) -> Result, AdapterError> { + let admx_content = read_xml(path).map_err(|error| { + AdapterError::Resource( + t!("admx.readFile", path = path.display(), error = error).to_string(), + ) + })?; + let document = Document::parse(&admx_content).map_err(|error| { + AdapterError::Resource( + t!("admx.parseFile", path = path.display(), error = error).to_string(), + ) + })?; + let strings = load_strings(path, locale)?; + + let categories: HashMap)> = document + .descendants() + .filter(|node| node.has_tag_name("category")) + .filter_map(|category| { + let name = category.attribute("name")?.to_string(); + let display_name = resolve_reference(category.attribute("displayName")?, &strings); + let parent = child(category, "parentCategory") + .and_then(|node| node.attribute("ref")) + .map(reference_name); + Some((name, (display_name, parent))) + }) + .collect(); + + let mut policies_by_category: HashMap> = HashMap::new(); + for policy_node in document + .descendants() + .filter(|node| node.has_tag_name("policy")) + { + let Some(category) = child(policy_node, "parentCategory") + .and_then(|node| node.attribute("ref")) + .map(reference_name) + else { + continue; + }; + let policy = parse_policy(policy_node, &strings)?; + policies_by_category + .entry(category) + .or_default() + .push(policy); + } + + let template_description = load_adml_description(path, locale).unwrap_or_default(); + let mut resources = Vec::new(); + for (category_name, policies) in policies_by_category { + let (display_name, parent) = categories + .get(&category_name) + .cloned() + .unwrap_or_else(|| (category_name.clone(), None)); + let parent_name = parent.as_deref().unwrap_or(&category_name); + resources.push(CategoryResource { + type_name: format!( + "GPO.{}/{}", + resource_name_segment(parent_name), + resource_name_segment(&display_name) + ), + display_name, + description: template_description.clone(), + policies, + }); + } + resources.sort_by(|left, right| left.type_name.cmp(&right.type_name)); + Ok(resources) +} + +fn parse_policy( + node: Node<'_, '_>, + strings: &HashMap, +) -> Result { + let required_attribute = |name: &str| { + node.attribute(name).ok_or_else(|| { + AdapterError::Resource( + t!( + "admx.missingPolicyAttribute", + policy = node.attribute("name").unwrap_or_default(), + attribute = name + ) + .to_string(), + ) + }) + }; + + let value_name = node.attribute("valueName").map(ToString::to_string); + let enabled = child(node, "enabledValue") + .map(parse_policy_value) + .transpose()? + .or_else(|| { + value_name + .as_ref() + .map(|_| PolicyValue::Data(RegistryValueData::DWord(1))) + }); + let disabled = child(node, "disabledValue") + .map(parse_policy_value) + .transpose()? + .or_else(|| value_name.as_ref().map(|_| PolicyValue::Delete)); + let elements = child(node, "elements") + .map(|elements| { + elements + .children() + .filter(Node::is_element) + .map(|element| parse_element(element, strings)) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + let class = match required_attribute("class")? { + "Both" => PolicyClass::Both, + "Machine" => PolicyClass::Machine, + "User" => PolicyClass::User, + value => { + return Err(AdapterError::Resource( + t!("admx.invalidPolicyClass", class = value).to_string(), + )); + } + }; + + Ok(Policy { + name: required_attribute("name")?.to_string(), + display_name: resolve_reference(required_attribute("displayName")?, strings), + description: node + .attribute("explainText") + .map(|value| resolve_reference(value, strings)), + class, + key: required_attribute("key")?.to_string(), + value_name, + enabled, + disabled, + elements, + enabled_list: child(node, "enabledList") + .map(parse_value_list) + .transpose()? + .unwrap_or_default(), + disabled_list: child(node, "disabledList") + .map(parse_value_list) + .transpose()? + .unwrap_or_default(), + }) +} + +fn parse_value_list(node: Node<'_, '_>) -> Result, AdapterError> { + node.children() + .filter(|child| child.has_tag_name("item")) + .map(|item| { + let value = child(item, "value") + .ok_or_else(|| AdapterError::Resource(t!("admx.emptyPolicyValue").to_string())) + .and_then(parse_policy_value)?; + Ok(RegistrySetting { + key: item.attribute("key").map(ToString::to_string), + value_name: item + .attribute("valueName") + .ok_or_else(|| AdapterError::Resource(t!("admx.missingValueName").to_string()))? + .to_string(), + value, + }) + }) + .collect() +} + +fn parse_element( + node: Node<'_, '_>, + strings: &HashMap, +) -> Result { + let id = node + .attribute("id") + .ok_or_else(|| AdapterError::Resource(t!("admx.missingElementId").to_string()))? + .to_string(); + let value_name = node.attribute("valueName").map(ToString::to_string); + let kind = match node.tag_name().name() { + "boolean" => ElementKind::Boolean { + true_value: child(node, "trueValue") + .map(parse_policy_value) + .transpose()? + .unwrap_or(PolicyValue::Data(RegistryValueData::DWord(1))), + false_value: child(node, "falseValue") + .map(parse_policy_value) + .transpose()? + .unwrap_or(PolicyValue::Data(RegistryValueData::DWord(0))), + }, + "decimal" => ElementKind::Decimal { + minimum: node.attribute("minValue").map(parse_u64).transpose()?, + maximum: node.attribute("maxValue").map(parse_u64).transpose()?, + store_as_text: node.attribute("storeAsText") == Some("true"), + }, + "enum" => ElementKind::Enum( + node.children() + .filter(|child| child.has_tag_name("item")) + .map(|item| { + let value_container = child(item, "value").ok_or_else(|| { + AdapterError::Resource(t!("admx.emptyPolicyValue").to_string()) + })?; + let value = parse_policy_value(value_container)?; + Ok(EnumItem { + title: resolve_reference( + item.attribute("displayName").unwrap_or_default(), + strings, + ), + value, + }) + }) + .collect::, AdapterError>>()?, + ), + "list" => ElementKind::List, + "multiText" => ElementKind::MultiText, + "text" => ElementKind::Text { + expandable: node.attribute("expandable") == Some("true"), + }, + unsupported => { + return Err(AdapterError::Resource( + t!("admx.unsupportedElementType", element_type = unsupported).to_string(), + )); + } + }; + Ok(PolicyElement { + id, + key: node.attribute("key").map(ToString::to_string), + value_name, + kind, + }) +} + +fn parse_policy_value(container: Node<'_, '_>) -> Result { + use dsc_lib_registry::config::RegistryValueData; + + let Some(value) = container.children().find(Node::is_element) else { + return Err(AdapterError::Resource( + t!("admx.emptyPolicyValue").to_string(), + )); + }; + let text = value.text().unwrap_or_default().trim(); + let data = match value.tag_name().name() { + "delete" => return Ok(PolicyValue::Delete), + "decimal" => RegistryValueData::DWord(parse_u32(value.attribute("value").unwrap_or(text))?), + "longDecimal" => { + RegistryValueData::QWord(parse_u64(value.attribute("value").unwrap_or(text))?) + } + "string" => RegistryValueData::String(text.to_string()), + "expandableString" => RegistryValueData::ExpandString(text.to_string()), + "multiString" => RegistryValueData::MultiString( + value + .children() + .filter(|child| child.has_tag_name("string")) + .filter_map(|child| child.text()) + .map(ToString::to_string) + .collect(), + ), + "binary" => RegistryValueData::Binary(parse_binary(text)?), + unsupported => { + return Err(AdapterError::Resource( + t!("admx.unsupportedValueType", value_type = unsupported).to_string(), + )); + } + }; + Ok(PolicyValue::Data(data)) +} + +fn create_listed_resource(resource: &CategoryResource, path: &Path) -> ListedResource { + let mut properties = Map::new(); + properties.insert( + "scope".to_string(), + json!({ + "type": "string", + "title": t!("schema.scopeTitle"), + "description": t!("schema.scopeDescription"), + "enum": ["allUsers", "currentUser"], + "default": "currentUser" + }), + ); + for policy in &resource.policies { + let boolean_schema = policy + .enabled + .as_ref() + .zip(policy.disabled.as_ref()) + .map(|_| { + json!({ + "type": "boolean", + "title": policy.display_name, + "description": policy.description + }) + }); + let object_schema = if policy.elements.is_empty() { + None + } else { + let mut element_properties = Map::new(); + if boolean_schema.is_some() { + element_properties.insert( + "enabled".to_string(), + json!({ + "type": "boolean", + "title": t!("schema.enabledTitle") + }), + ); + } + for element in &policy.elements { + element_properties.insert(element.id.clone(), element_schema(element)); + } + Some(json!({ + "type": "object", + "title": policy.display_name, + "description": policy.description, + "additionalProperties": false, + "properties": element_properties + })) + }; + let property = match (boolean_schema, object_schema) { + (Some(boolean), Some(object)) => json!({ "oneOf": [boolean, object] }), + (Some(boolean), None) => boolean, + (None, Some(object)) => object, + (None, None) => json!({ + "type": "boolean", + "title": policy.display_name, + "description": policy.description + }), + }; + properties.insert(policy.name.clone(), property); + } + + let embedded = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": resource.display_name, + "description": resource.description, + "type": "object", + "additionalProperties": false, + "properties": properties + }); + let mut schema = Map::new(); + schema.insert("embedded".to_string(), embedded); + + ListedResource { + type_name: resource.type_name.clone(), + kind: "resource", + version: "0.1.0", + capabilities: ["get", "set"], + path: path.to_path_buf(), + directory: path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(), + implemented_as: "adapter", + author: "Microsoft", + properties: std::iter::once("scope".to_string()) + .chain(resource.policies.iter().map(|policy| policy.name.clone())) + .collect(), + require_adapter: ADAPTER_TYPE, + description: resource.description.clone(), + schema, + } +} + +fn element_schema(element: &PolicyElement) -> Value { + match &element.kind { + ElementKind::Boolean { .. } => json!({ + "type": "boolean", + "title": element.id + }), + ElementKind::Decimal { + minimum, maximum, .. + } => { + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("integer".to_string())); + schema.insert("title".to_string(), Value::String(element.id.clone())); + if let Some(minimum) = minimum { + schema.insert("minimum".to_string(), Value::from(*minimum)); + } + if let Some(maximum) = maximum { + schema.insert("maximum".to_string(), Value::from(*maximum)); + } + Value::Object(schema) + } + ElementKind::Enum(items) => json!({ + "title": element.id, + "oneOf": items.iter().map(|item| json!({ + "const": policy_value_to_json(&item.value), + "title": item.title + })).collect::>() + }), + ElementKind::List => json!({ + "type": "object", + "title": element.id, + "additionalProperties": { "type": "string" } + }), + ElementKind::MultiText => json!({ + "type": "array", + "title": element.id, + "items": { "type": "string" } + }), + ElementKind::Text { .. } => json!({ + "type": "string", + "title": element.id + }), + } +} + +pub fn registry_value_to_json(value: &RegistryValueData) -> Value { + match value { + RegistryValueData::String(value) | RegistryValueData::ExpandString(value) => { + Value::String(value.clone()) + } + RegistryValueData::DWord(value) => Value::from(*value), + RegistryValueData::QWord(value) => Value::from(*value), + RegistryValueData::Binary(value) => { + Value::Array(value.iter().copied().map(Value::from).collect()) + } + RegistryValueData::MultiString(value) => { + Value::Array(value.iter().cloned().map(Value::String).collect()) + } + RegistryValueData::None => Value::Null, + } +} + +pub fn policy_value_to_json(value: &PolicyValue) -> Value { + match value { + PolicyValue::Data(data) => registry_value_to_json(data), + PolicyValue::Delete => Value::Null, + } +} + +fn load_strings(path: &Path, locale: &str) -> Result, AdapterError> { + let adml_path = adml_path(path, locale)?; + let content = read_xml(&adml_path).map_err(|error| { + AdapterError::Resource( + t!("admx.readFile", path = adml_path.display(), error = error).to_string(), + ) + })?; + let document = Document::parse(&content).map_err(|error| { + AdapterError::Resource( + t!("admx.parseFile", path = adml_path.display(), error = error).to_string(), + ) + })?; + Ok(document + .descendants() + .filter(|node| node.has_tag_name("string")) + .filter_map(|node| Some((node.attribute("id")?.to_string(), node.text()?.to_string()))) + .collect()) +} + +fn load_adml_description(path: &Path, locale: &str) -> Option { + let adml_path = adml_path(path, locale).ok()?; + let content = read_xml(&adml_path).ok()?; + let document = Document::parse(&content).ok()?; + document + .root_element() + .children() + .find(|node| node.has_tag_name("description")) + .and_then(|node| node.text()) + .map(ToString::to_string) +} + +fn adml_path(admx_path: &Path, locale: &str) -> Result { + let file_name = admx_path + .file_stem() + .ok_or_else(|| AdapterError::Resource(t!("admx.invalidPath").to_string()))?; + let base = admx_path + .parent() + .ok_or_else(|| AdapterError::Resource(t!("admx.invalidPath").to_string()))?; + let localized = base.join(locale).join(file_name).with_extension("adml"); + if localized.exists() { + return Ok(localized); + } + let fallback = base.join("en-US").join(file_name).with_extension("adml"); + if fallback.exists() { + return Ok(fallback); + } + Err(AdapterError::Resource( + t!( + "admx.admlNotFound", + template = admx_path.display(), + locale = locale + ) + .to_string(), + )) +} + +fn policy_definitions_path() -> Result { + let system_root = env::var_os("SystemRoot") + .ok_or_else(|| AdapterError::Resource(t!("admx.systemRootNotFound").to_string()))?; + Ok(PathBuf::from(system_root).join("PolicyDefinitions")) +} + +fn user_locale() -> String { + let mut buffer = [0_u16; LOCALE_NAME_MAX_LENGTH]; + // SAFETY: The buffer is writable for the specified length and the API writes a + // null-terminated locale name no larger than LOCALE_NAME_MAX_LENGTH. + let length = unsafe { GetUserDefaultLocaleName(&mut buffer) }; + if let Ok(length) = usize::try_from(length) + && length > 1 + { + String::from_utf16_lossy(&buffer[..length - 1]) + } else { + "en-US".to_string() + } +} + +fn resolve_reference(value: &str, strings: &HashMap) -> String { + value + .strip_prefix("$(string.") + .and_then(|value| value.strip_suffix(')')) + .and_then(|key| strings.get(key)) + .cloned() + .unwrap_or_else(|| value.to_string()) +} + +fn reference_name(reference: &str) -> String { + reference + .rsplit_once(':') + .map_or(reference, |(_, name)| name) + .to_string() +} + +fn resource_name_segment(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect() +} + +fn child<'a>(node: Node<'a, 'a>, name: &str) -> Option> { + node.children().find(|child| child.has_tag_name(name)) +} + +fn parse_u32(value: &str) -> Result { + value.parse().map_err(|error| { + AdapterError::Resource(t!("admx.invalidNumber", value = value, error = error).to_string()) + }) +} + +fn parse_u64(value: &str) -> Result { + value.parse().map_err(|error| { + AdapterError::Resource(t!("admx.invalidNumber", value = value, error = error).to_string()) + }) +} + +fn parse_binary(value: &str) -> Result, AdapterError> { + value + .split([',', ' ', '\t', '\r', '\n']) + .filter(|part| !part.is_empty()) + .map(|part| { + u8::from_str_radix(part.trim_start_matches("0x"), 16).map_err(|error| { + AdapterError::Resource( + t!("admx.invalidBinary", value = part, error = error).to_string(), + ) + }) + }) + .collect() +} + +fn read_xml(path: &Path) -> Result { + let bytes = fs::read(path)?; + if bytes.starts_with(&[0xff, 0xfe]) { + let words = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + return String::from_utf16(&words) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + if bytes.starts_with(&[0xfe, 0xff]) { + let words = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect::>(); + return String::from_utf16(&words) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)); + } + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) +} + +#[cfg(test)] +mod tests { + use super::{parse_binary, reference_name, resource_name_segment}; + + #[test] + fn normalizes_resource_name_parts() { + assert_eq!( + reference_name("windows:WindowsComponents"), + "WindowsComponents" + ); + assert_eq!( + resource_name_segment("Windows PowerShell"), + "Windows_PowerShell" + ); + assert_eq!(resource_name_segment("App-V (Client)"), "App_V__Client_"); + } + + #[test] + fn parses_binary_values() { + assert_eq!(parse_binary("01, ff, 0A").unwrap(), vec![1, 255, 10]); + } +} diff --git a/adapters/group_policy_template/src/main.rs b/adapters/group_policy_template/src/main.rs new file mode 100644 index 000000000..56b9e28f9 --- /dev/null +++ b/adapters/group_policy_template/src/main.rs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(windows)] +mod admx; +#[cfg(windows)] +mod registry; + +use rust_i18n::t; +use serde_json::json; +use std::process::exit; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_RESOURCE_ERROR: i32 = 3; + +fn write_error(message: &str) { + eprintln!("{}", json!({ "error": message })); +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_RESOURCE_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let Some(operation) = args.first().map(String::as_str) else { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + }; + + let result = match operation { + "list" => admx::list_resources(), + "get" | "set" => { + let Some(input) = argument_value(&args, "--input") else { + write_error(&t!("main.missingArgument", argument = "--input")); + exit(EXIT_INVALID_ARGS); + }; + let Some(resource_type) = argument_value(&args, "--resource-type") else { + write_error(&t!("main.missingArgument", argument = "--resource-type")); + exit(EXIT_INVALID_ARGS); + }; + let Some(resource_path) = argument_value(&args, "--resource-path") else { + write_error(&t!("main.missingArgument", argument = "--resource-path")); + exit(EXIT_INVALID_ARGS); + }; + if operation == "get" { + registry::get(input, resource_type, resource_path) + } else { + registry::set(input, resource_type, resource_path) + } + } + unknown => { + write_error(&t!("main.unknownOperation", operation = unknown)); + exit(EXIT_INVALID_ARGS); + } + }; + + match result { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + exit(EXIT_SUCCESS); + } + Err(error) => { + write_error(&error.to_string()); + let code = if error.is_input_error() { + EXIT_INVALID_INPUT + } else { + EXIT_RESOURCE_ERROR + }; + exit(code); + } + } +} + +#[cfg(windows)] +fn argument_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2) + .find(|pair| pair[0] == name) + .map(|pair| pair[1].as_str()) +} diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs new file mode 100644 index 000000000..e18f3c899 --- /dev/null +++ b/adapters/group_policy_template/src/registry.rs @@ -0,0 +1,559 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::admx::{ + AdapterError, ElementKind, Policy, PolicyClass, PolicyElement, PolicyValue, load_resource, + policy_value_to_json, registry_value_to_json, +}; +use dsc_lib_registry::{RegistryHelper, config::RegistryValueData}; +use rust_i18n::t; +use serde_json::{Map, Value}; +use std::path::Path; + +const DEFAULT_SCOPE: &str = "currentUser"; + +pub fn get( + input: &str, + resource_type: &str, + resource_path: &str, +) -> Result, AdapterError> { + let input = parse_input(input)?; + let scope = parse_scope(&input)?; + let resource = load_resource(Path::new(resource_path), resource_type)?; + let include_all = input.keys().all(|key| key == "scope"); + let mut result = Map::new(); + result.insert("scope".to_string(), Value::String(scope.to_string())); + + for policy in &resource.policies { + if !include_all && !input.contains_key(&policy.name) { + continue; + } + if include_all && !scope_is_supported(policy, scope) { + continue; + } + let requested = input.get(&policy.name); + if let Some(value) = read_policy(policy, scope, requested)? { + result.insert(policy.name.clone(), value); + } + } + + serialize_result(&result) +} + +pub fn set( + input: &str, + resource_type: &str, + resource_path: &str, +) -> Result, AdapterError> { + let input = parse_input(input)?; + let scope = parse_scope(&input)?; + let resource = load_resource(Path::new(resource_path), resource_type)?; + + for (name, value) in &input { + if name == "scope" { + continue; + } + let policy = resource + .policies + .iter() + .find(|policy| policy.name == *name) + .ok_or_else(|| { + AdapterError::Input( + t!( + "registry.unknownPolicy", + policy = name, + resource = resource_type + ) + .to_string(), + ) + })?; + validate_scope(policy, scope)?; + write_policy(policy, scope, value)?; + } + + get( + input_to_string(&input)?.as_str(), + resource_type, + resource_path, + ) +} + +fn read_policy( + policy: &Policy, + scope: &str, + requested: Option<&Value>, +) -> Result, AdapterError> { + validate_scope(policy, scope)?; + if let Some(Value::Object(requested_elements)) = requested { + let mut result = Map::new(); + if requested_elements.contains_key("enabled") + && let Some(enabled) = read_enabled(policy, scope)? + { + result.insert("enabled".to_string(), Value::Bool(enabled)); + } + for element in &policy.elements { + let Some(requested_value) = requested_elements.get(&element.id) else { + continue; + }; + if let Some(value) = read_element(policy, element, scope, requested_value)? { + result.insert(element.id.clone(), value); + } + } + return Ok(Some(Value::Object(result))); + } + read_enabled(policy, scope).map(|value| value.map(Value::Bool)) +} + +fn read_enabled(policy: &Policy, scope: &str) -> Result, AdapterError> { + if state_matches(policy, scope, true)? { + Ok(Some(true)) + } else if state_matches(policy, scope, false)? { + Ok(Some(false)) + } else { + Err(AdapterError::Resource( + t!( + "registry.unrecognizedValue", + key = policy.key, + value_name = policy.value_name.as_deref().unwrap_or_default() + ) + .to_string(), + )) + } +} + +fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), AdapterError> { + if let Some(enabled) = input.as_bool() { + return write_enabled(policy, scope, enabled); + } + let object = input.as_object().ok_or_else(|| { + AdapterError::Input(t!("registry.invalidPolicyValue", policy = policy.name).to_string()) + })?; + if let Some(enabled) = object.get("enabled") { + write_enabled( + policy, + scope, + enabled.as_bool().ok_or_else(|| { + AdapterError::Input( + t!("registry.policyNotBoolean", policy = policy.name).to_string(), + ) + })?, + )?; + } + for (name, value) in object { + if name == "enabled" { + continue; + } + let element = policy + .elements + .iter() + .find(|element| element.id == *name) + .ok_or_else(|| { + AdapterError::Input( + t!( + "registry.unknownElement", + element = name, + policy = policy.name + ) + .to_string(), + ) + })?; + write_element(policy, element, scope, value)?; + } + Ok(()) +} + +fn write_enabled(policy: &Policy, scope: &str, enabled: bool) -> Result<(), AdapterError> { + let value = if enabled { + policy.enabled.as_ref() + } else { + policy.disabled.as_ref() + }; + let list = if enabled { + &policy.enabled_list + } else { + &policy.disabled_list + }; + if value.is_none() && list.is_empty() { + return Err(AdapterError::Input( + t!("registry.policyHasNoToggle", policy = policy.name).to_string(), + )); + } + if let (Some(value_name), Some(value)) = (&policy.value_name, value) { + apply_value(scope, &policy.key, value_name, value)?; + } + for setting in list { + apply_value( + scope, + setting.key.as_deref().unwrap_or(&policy.key), + &setting.value_name, + &setting.value, + )?; + } + Ok(()) +} + +fn apply_value( + scope: &str, + key: &str, + value_name: &str, + value: &PolicyValue, +) -> Result<(), AdapterError> { + let path = key_path(scope, key); + match value { + PolicyValue::Data(data) => { + RegistryHelper::new(&path, Some(value_name.to_string()), Some(data.clone())) + .map_err(registry_error)? + .set() + .map_err(registry_error)?; + } + PolicyValue::Delete => { + let registry = dsc_lib_registry::config::Registry { + key_path: path, + value_name: Some(value_name.to_string()), + exist: Some(false), + ..Default::default() + }; + let helper = RegistryHelper::new_from_registry(®istry).map_err(registry_error)?; + helper.remove().map_err(registry_error)?; + } + } + Ok(()) +} + +fn state_matches(policy: &Policy, scope: &str, enabled: bool) -> Result { + let value = if enabled { + policy.enabled.as_ref() + } else { + policy.disabled.as_ref() + }; + let list = if enabled { + &policy.enabled_list + } else { + &policy.disabled_list + }; + if value.is_none() && list.is_empty() { + return Ok(false); + } + if let (Some(value_name), Some(value)) = (&policy.value_name, value) + && !value_matches(scope, &policy.key, value_name, value)? + { + return Ok(false); + } + for setting in list { + if !value_matches( + scope, + setting.key.as_deref().unwrap_or(&policy.key), + &setting.value_name, + &setting.value, + )? { + return Ok(false); + } + } + Ok(true) +} + +fn value_matches( + scope: &str, + key: &str, + value_name: &str, + expected: &PolicyValue, +) -> Result { + let actual = RegistryHelper::new(&key_path(scope, key), Some(value_name.to_string()), None) + .map_err(registry_error)? + .get() + .map_err(registry_error)? + .value_data; + Ok(match (expected, actual) { + (PolicyValue::Delete, None) => true, + (PolicyValue::Data(expected), Some(actual)) => expected == &actual, + _ => false, + }) +} + +fn read_element( + policy: &Policy, + element: &PolicyElement, + scope: &str, + requested: &Value, +) -> Result, AdapterError> { + let element_key = element.key.as_deref().unwrap_or(&policy.key); + if matches!(element.kind, ElementKind::List) { + let requested_values = requested.as_object().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotObject", element = element.id).to_string()) + })?; + let mut result = Map::new(); + for value_name in requested_values.keys() { + let helper = RegistryHelper::new( + &key_path(scope, element_key), + Some(value_name.clone()), + None, + ) + .map_err(registry_error)?; + if let Some(data) = helper.get().map_err(registry_error)?.value_data { + result.insert(value_name.clone(), registry_value_to_json(&data)); + } + } + return Ok(Some(Value::Object(result))); + } + let value_name = element.value_name.as_ref().ok_or_else(|| { + AdapterError::Resource( + t!("registry.elementHasNoValueName", element = element.id).to_string(), + ) + })?; + let helper = RegistryHelper::new( + &key_path(scope, element_key), + Some(value_name.clone()), + None, + ) + .map_err(registry_error)?; + let Some(data) = helper.get().map_err(registry_error)?.value_data else { + return Ok(None); + }; + Ok(Some(element_data_to_json(element, &data)?)) +} + +fn write_element( + policy: &Policy, + element: &PolicyElement, + scope: &str, + value: &Value, +) -> Result<(), AdapterError> { + let element_key = element.key.as_deref().unwrap_or(&policy.key); + if matches!(element.kind, ElementKind::List) { + let values = value.as_object().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotObject", element = element.id).to_string()) + })?; + for (value_name, value) in values { + let data = RegistryValueData::String( + value + .as_str() + .ok_or_else(|| { + AdapterError::Input( + t!("registry.listValueNotString", element = element.id).to_string(), + ) + })? + .to_string(), + ); + RegistryHelper::new( + &key_path(scope, element_key), + Some(value_name.clone()), + Some(data), + ) + .map_err(registry_error)? + .set() + .map_err(registry_error)?; + } + return Ok(()); + } + let value_name = element.value_name.as_ref().ok_or_else(|| { + AdapterError::Resource( + t!("registry.elementHasNoValueName", element = element.id).to_string(), + ) + })?; + match json_to_element_data(element, value)? { + PolicyValue::Data(data) => { + RegistryHelper::new( + &key_path(scope, element_key), + Some(value_name.clone()), + Some(data), + ) + .map_err(registry_error)? + .set() + .map_err(registry_error)?; + } + PolicyValue::Delete => { + let registry = dsc_lib_registry::config::Registry { + key_path: key_path(scope, element_key), + value_name: Some(value_name.clone()), + exist: Some(false), + ..Default::default() + }; + RegistryHelper::new_from_registry(®istry) + .map_err(registry_error)? + .remove() + .map_err(registry_error)?; + } + } + Ok(()) +} + +fn json_to_element_data( + element: &PolicyElement, + value: &Value, +) -> Result { + match &element.kind { + ElementKind::Boolean { + true_value, + false_value, + } => value + .as_bool() + .map(|value| { + if value { + true_value.clone() + } else { + false_value.clone() + } + }) + .ok_or_else(|| invalid_element_value(element)), + ElementKind::Decimal { store_as_text, .. } => { + let number = value + .as_u64() + .ok_or_else(|| invalid_element_value(element))?; + if *store_as_text { + Ok(PolicyValue::Data(RegistryValueData::String( + number.to_string(), + ))) + } else { + u32::try_from(number) + .map(|value| PolicyValue::Data(RegistryValueData::DWord(value))) + .map_err(|_| invalid_element_value(element)) + } + } + ElementKind::Enum(items) => items + .iter() + .find(|item| policy_value_to_json(&item.value) == *value) + .map(|item| item.value.clone()) + .ok_or_else(|| invalid_element_value(element)), + ElementKind::MultiText => value + .as_array() + .and_then(|items| { + items + .iter() + .map(|item| item.as_str().map(ToString::to_string)) + .collect::>>() + }) + .map(|value| PolicyValue::Data(RegistryValueData::MultiString(value))) + .ok_or_else(|| invalid_element_value(element)), + ElementKind::Text { expandable } => value + .as_str() + .map(|value| { + if *expandable { + PolicyValue::Data(RegistryValueData::ExpandString(value.to_string())) + } else { + PolicyValue::Data(RegistryValueData::String(value.to_string())) + } + }) + .ok_or_else(|| invalid_element_value(element)), + ElementKind::List => Err(invalid_element_value(element)), + } +} + +fn element_data_to_json( + element: &PolicyElement, + data: &RegistryValueData, +) -> Result { + match &element.kind { + ElementKind::Boolean { + true_value: PolicyValue::Data(value), + false_value, + } if data == value => Ok(Value::Bool(true)), + ElementKind::Boolean { + true_value: _, + false_value: PolicyValue::Data(value), + } if data == value => Ok(Value::Bool(false)), + ElementKind::Enum(items) + if items + .iter() + .any(|item| matches!(&item.value, PolicyValue::Data(value) if value == data)) => + { + Ok(registry_value_to_json(data)) + } + ElementKind::Decimal { + store_as_text: true, + .. + } => match data { + RegistryValueData::String(value) => value + .parse::() + .map(Value::from) + .map_err(|_| invalid_element_value(element)), + _ => Err(invalid_element_value(element)), + }, + ElementKind::Decimal { .. } + | ElementKind::MultiText + | ElementKind::Text { .. } + | ElementKind::List => Ok(registry_value_to_json(data)), + _ => Err(invalid_element_value(element)), + } +} + +fn invalid_element_value(element: &PolicyElement) -> AdapterError { + AdapterError::Input(t!("registry.invalidElementValue", element = element.id).to_string()) +} + +fn validate_scope(policy: &Policy, scope: &str) -> Result<(), AdapterError> { + if scope_is_supported(policy, scope) { + Ok(()) + } else { + Err(AdapterError::Input( + t!( + "registry.scopeNotSupported", + policy = policy.name, + scope = scope + ) + .to_string(), + )) + } +} + +fn scope_is_supported(policy: &Policy, scope: &str) -> bool { + matches!( + (policy.class, scope), + (PolicyClass::Both, _) + | (PolicyClass::Machine, "allUsers") + | (PolicyClass::User, "currentUser") + ) +} + +fn parse_input(input: &str) -> Result, AdapterError> { + serde_json::from_str(input).map_err(|error| { + AdapterError::Input(t!("registry.invalidInput", error = error).to_string()) + }) +} + +fn parse_scope(input: &Map) -> Result<&str, AdapterError> { + match input.get("scope") { + None => Ok(DEFAULT_SCOPE), + Some(Value::String(scope)) if matches!(scope.as_str(), "allUsers" | "currentUser") => { + Ok(scope) + } + _ => Err(AdapterError::Input(t!("registry.invalidScope").to_string())), + } +} + +fn key_path(scope: &str, key: &str) -> String { + let hive = if scope == "allUsers" { "HKLM" } else { "HKCU" }; + format!("{hive}\\{key}") +} + +fn registry_error(error: impl std::fmt::Display) -> AdapterError { + AdapterError::Resource(t!("registry.operationFailed", error = error).to_string()) +} + +fn input_to_string(input: &Map) -> Result { + serde_json::to_string(input).map_err(|error| { + AdapterError::Resource(t!("registry.serializeResult", error = error).to_string()) + }) +} + +fn serialize_result(result: &Map) -> Result, AdapterError> { + Ok(vec![serde_json::to_string(result).map_err(|error| { + AdapterError::Resource(t!("registry.serializeResult", error = error).to_string()) + })?]) +} + +#[cfg(test)] +mod tests { + use super::key_path; + + #[test] + fn maps_scope_to_registry_hive() { + assert_eq!( + key_path("currentUser", "Software\\Policies"), + "HKCU\\Software\\Policies" + ); + assert_eq!( + key_path("allUsers", "Software\\Policies"), + "HKLM\\Software\\Policies" + ); + } +} diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 new file mode 100644 index 000000000..bf4fe2efe --- /dev/null +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -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 { + $adapterType = 'Microsoft.Adapter/GroupPolicyTemplate' + $resourceType = 'GPO.ControlPanel/Add_or_Remove_Programs' + $keyPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Uninstall' + $valueName = 'NoAddPage' + + function Invoke-GroupPolicyGet { + param([bool]$Enabled) + + $json = @{ + NoAddPage = $Enabled + } | 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) + return ($out | ConvertFrom-Json).actualState + } + } + + It 'Lists resources corresponding to installed ADMX templates' { + $admxFiles = @(Get-ChildItem -LiteralPath (Join-Path $env:SystemRoot 'PolicyDefinitions') -Filter '*.admx') + $resources = @(dsc resource list --adapter $adapterType | ConvertFrom-Json) + + $LASTEXITCODE | Should -Be 0 + $admxFiles.Count | Should -BeGreaterThan 0 + $resources.Count | Should -BeGreaterThan 0 + $resources.requireAdapter | Should -Contain $adapterType + $resources.path | ForEach-Object { $_ | Should -Exist } + } + + Context 'Current user policy state' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalKeyExists = Test-Path -LiteralPath $keyPath + $script:originalValueExists = $false + $script:originalValue = $null + $script:originalValueKind = $null + if (Test-Path -LiteralPath $keyPath) { + $key = Get-Item -LiteralPath $keyPath + if ($key.GetValueNames() -contains $valueName) { + $script:originalValueExists = $true + $script:originalValue = $key.GetValue($valueName, $null, 'DoNotExpandEnvironmentNames') + $script:originalValueKind = $key.GetValueKind($valueName) + } + } + } + + AfterAll { + if ($script:originalValueExists) { + if (!(Test-Path -LiteralPath $keyPath)) { + New-Item -Path $keyPath -Force | Out-Null + } + $key = Get-Item -LiteralPath $keyPath + $key.SetValue($valueName, $script:originalValue, $script:originalValueKind) + } + else { + Remove-ItemProperty -LiteralPath $keyPath -Name $valueName -ErrorAction Ignore + if (!$script:originalKeyExists -and (Test-Path -LiteralPath $keyPath)) { + Remove-Item -LiteralPath $keyPath -ErrorAction Ignore + } + } + } + + It 'Sets and gets a policy in current user scope' { + $resources = @(dsc resource list $resourceType --adapter $adapterType | ConvertFrom-Json) + if ($resources.Count -eq 0) { + Set-ItResult -Skipped -Because 'AddRemovePrograms.admx is not installed.' + return + } + + $json = @{ + NoAddPage = $true + } | 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.scope | Should -BeExactly 'currentUser' + ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeTrue + (Invoke-GroupPolicyGet -Enabled $true).NoAddPage | Should -BeTrue + } + } +} From 7c240612847e70ade8f0a6d1eb5eafcce9d46d1b Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 07:05:02 -0700 Subject: [PATCH 02/13] Address Group Policy adapter review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/group_policy_template/src/admx.rs | 11 ++++++- .../group_policy_template/src/registry.rs | 30 +++++++++++++++++++ .../tests/group_policy_template.tests.ps1 | 6 ++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index bd66e5af7..7e253dc20 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -141,7 +141,16 @@ pub fn list_resources() -> Result, AdapterError> { for entry in entries { let path = entry - .map_err(|error| AdapterError::Resource(error.to_string()))? + .map_err(|error| { + AdapterError::Resource( + t!( + "admx.readDirectory", + path = policy_definitions.display(), + error = error + ) + .to_string(), + ) + })? .path(); if !path .extension() diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index e18f3c899..c487cdd21 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -109,6 +109,8 @@ fn read_enabled(policy: &Policy, scope: &str) -> Result, AdapterErr Ok(Some(true)) } else if state_matches(policy, scope, false)? { Ok(Some(false)) + } else if policy_values_are_absent(policy, scope)? { + Ok(None) } else { Err(AdapterError::Resource( t!( @@ -121,6 +123,34 @@ fn read_enabled(policy: &Policy, scope: &str) -> Result, AdapterErr } } +fn policy_values_are_absent(policy: &Policy, scope: &str) -> Result { + let mut values = Vec::new(); + if let Some(value_name) = &policy.value_name { + values.push((policy.key.as_str(), value_name.as_str())); + } + for setting in policy.enabled_list.iter().chain(&policy.disabled_list) { + values.push(( + setting.key.as_deref().unwrap_or(&policy.key), + setting.value_name.as_str(), + )); + } + if values.is_empty() { + return Ok(false); + } + + for (key, value_name) in values { + let actual = RegistryHelper::new(&key_path(scope, key), Some(value_name.to_string()), None) + .map_err(registry_error)? + .get() + .map_err(registry_error)? + .value_data; + if actual.is_some() { + return Ok(false); + } + } + Ok(true) +} + fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), AdapterError> { if let Some(enabled) = input.as_bool() { return write_enabled(policy, scope, enabled); diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index bf4fe2efe..523830256 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -38,8 +38,10 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $LASTEXITCODE | Should -Be 0 $admxFiles.Count | Should -BeGreaterThan 0 $resources.Count | Should -BeGreaterThan 0 - $resources.requireAdapter | Should -Contain $adapterType - $resources.path | ForEach-Object { $_ | Should -Exist } + $resources | ForEach-Object { + $_.requireAdapter | Should -BeExactly $adapterType + $_.path | Should -Exist + } } Context 'Current user policy state' -Skip:(!$isAdmin) { From 68d1e4279f8cfea8195ef1ee77bd5c097b630628 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 07:41:30 -0700 Subject: [PATCH 03/13] Fix Group Policy adapter CI failures Update UTF-16 decoding for the newer Clippy lint and keep localization keys aligned with their uses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template/locales/en-us.toml | 2 +- adapters/group_policy_template/src/admx.rs | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/adapters/group_policy_template/locales/en-us.toml b/adapters/group_policy_template/locales/en-us.toml index 61184e603..b13912593 100644 --- a/adapters/group_policy_template/locales/en-us.toml +++ b/adapters/group_policy_template/locales/en-us.toml @@ -14,7 +14,6 @@ serializeResource = "Failed to serialize an adapted resource: %{error}" skipTemplate = "Skipped policy template '%{path}': %{error}" resourceNotFound = "Adapted resource '%{resource}' was not found in '%{path}'." missingPolicyAttribute = "Policy '%{policy}' is missing required attribute '%{attribute}'." -missingPolicyValue = "Policy '%{policy}' is missing '%{value}'." emptyPolicyValue = "A policy value does not contain a registry value." unsupportedValueType = "Unsupported ADMX registry value type '%{value_type}'." missingElementId = "A policy element is missing its required id." @@ -26,6 +25,7 @@ admlNotFound = "No ADML file was found for template '%{template}' and locale '%{ systemRootNotFound = "The SystemRoot environment variable is not defined." invalidNumber = "Invalid numeric policy value '%{value}': %{error}" invalidBinary = "Invalid binary policy value '%{value}': %{error}" +invalidUtf16Length = "The UTF-16 XML file '%{path}' has an odd number of bytes." [schema] scopeTitle = "Scope" diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index 7e253dc20..4dccd9a9e 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -756,16 +756,30 @@ fn parse_binary(value: &str) -> Result, AdapterError> { fn read_xml(path: &Path) -> Result { let bytes = fs::read(path)?; if bytes.starts_with(&[0xff, 0xfe]) { - let words = bytes[2..] - .chunks_exact(2) + let (chunks, remainder) = bytes[2..].as_chunks::<2>(); + if !remainder.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + t!("admx.invalidUtf16Length", path = path.display()).to_string(), + )); + } + let words = chunks + .iter() .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect::>(); return String::from_utf16(&words) .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)); } if bytes.starts_with(&[0xfe, 0xff]) { - let words = bytes[2..] - .chunks_exact(2) + let (chunks, remainder) = bytes[2..].as_chunks::<2>(); + if !remainder.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + t!("admx.invalidUtf16Length", path = path.display()).to_string(), + )); + } + let words = chunks + .iter() .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) .collect::>(); return String::from_utf16(&words) From d6514fc12410684438b3d4462be80e179153e92b Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 09:16:28 -0700 Subject: [PATCH 04/13] Address Group Policy adapter naming feedback Use stable ADMX category identifiers for adapted resource type names and explicitly select the adapter in mutation tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/group_policy_template/src/admx.rs | 20 +++++++++++++------ .../tests/group_policy_template.tests.ps1 | 6 +++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index 4dccd9a9e..5329fb8ec 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -256,11 +256,7 @@ fn parse_template(path: &Path, locale: &str) -> Result, Ad .unwrap_or_else(|| (category_name.clone(), None)); let parent_name = parent.as_deref().unwrap_or(&category_name); resources.push(CategoryResource { - type_name: format!( - "GPO.{}/{}", - resource_name_segment(parent_name), - resource_name_segment(&display_name) - ), + type_name: resource_type_name(parent_name, &category_name), display_name, description: template_description.clone(), policies, @@ -723,6 +719,14 @@ fn resource_name_segment(value: &str) -> String { .collect() } +fn resource_type_name(parent_category: &str, category: &str) -> String { + format!( + "GPO.{}/{}", + resource_name_segment(parent_category), + resource_name_segment(category) + ) +} + fn child<'a>(node: Node<'a, 'a>, name: &str) -> Option> { node.children().find(|child| child.has_tag_name(name)) } @@ -791,7 +795,7 @@ fn read_xml(path: &Path) -> Result { #[cfg(test)] mod tests { - use super::{parse_binary, reference_name, resource_name_segment}; + use super::{parse_binary, reference_name, resource_name_segment, resource_type_name}; #[test] fn normalizes_resource_name_parts() { @@ -804,6 +808,10 @@ mod tests { "Windows_PowerShell" ); assert_eq!(resource_name_segment("App-V (Client)"), "App_V__Client_"); + assert_eq!( + resource_type_name("WindowsComponents", "PowerShell"), + "GPO.WindowsComponents/PowerShell" + ); } #[test] diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index 523830256..482a889b8 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -15,7 +15,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { BeforeAll { $adapterType = 'Microsoft.Adapter/GroupPolicyTemplate' - $resourceType = 'GPO.ControlPanel/Add_or_Remove_Programs' + $resourceType = 'GPO.ControlPanel/Arp' $keyPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Uninstall' $valueName = 'NoAddPage' @@ -25,7 +25,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $json = @{ NoAddPage = $Enabled } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log + $out = $json | dsc resource get -r $resourceType --adapter $adapterType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) return ($out | ConvertFrom-Json).actualState } @@ -86,7 +86,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $json = @{ NoAddPage = $true } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$TestDrive/error.log + $out = $json | dsc resource set -r $resourceType --adapter $adapterType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) ($out | ConvertFrom-Json).afterState.scope | Should -BeExactly 'currentUser' From dd402081c8a1d897243e29df309543f74d5d2751 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 09:52:01 -0700 Subject: [PATCH 05/13] Fix Group Policy adapter test arguments Remove the unsupported adapter selector from resource get and set test invocations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/group_policy_template.tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index 482a889b8..cd06a50d5 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -25,7 +25,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $json = @{ NoAddPage = $Enabled } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType --adapter $adapterType -f - 2>$TestDrive/error.log + $out = $json | dsc resource get -r $resourceType -f - 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) return ($out | ConvertFrom-Json).actualState } @@ -86,7 +86,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $json = @{ NoAddPage = $true } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType --adapter $adapterType -f - 2>$TestDrive/error.log + $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.scope | Should -BeExactly 'currentUser' From 1f5bbeb574cbfb9e53dbf8e6f2b9e56196badfef Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 15:56:01 -0700 Subject: [PATCH 06/13] Fix adapter lookup cache test Prime the lookup table with all Microsoft adapters so repeat discovery does not add Group Policy resources unexpectedly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dsc/tests/dsc_discovery.tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsc/tests/dsc_discovery.tests.ps1 b/dsc/tests/dsc_discovery.tests.ps1 index abc064c8f..e7a6207cd 100644 --- a/dsc/tests/dsc_discovery.tests.ps1 +++ b/dsc/tests/dsc_discovery.tests.ps1 @@ -154,7 +154,7 @@ Describe 'tests for resource discovery' { Test-Path $script:lookupTableFilePath -PathType Leaf | Should -BeFalse # initial invocation should populate and save adapter lookup table - $null = dsc -l trace resource list -a 'Microsoft.*/PowerShell' 2> $TestDrive/tracing.txt + $null = dsc -l trace resource list -a 'Microsoft.*/*' 2> $TestDrive/tracing.txt "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Read 0 items into lookup table" "$TestDrive/tracing.txt" | Should -FileContentMatchExactly "Saving lookup table" -Because (Get-Content -Raw "$TestDrive/tracing.txt") From d2f19850512d29d2f4f517e2a738cce9dcb11ee5 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 19:18:17 -0700 Subject: [PATCH 07/13] Expand Group Policy adapter test coverage Cover ADMX and ADML parsing, generated schemas, value conversion, validation paths, encoding handling, and isolated current-user registry round trips. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- adapters/group_policy_template/src/admx.rs | 345 ++++++++++++++++- adapters/group_policy_template/src/main.rs | 22 ++ .../group_policy_template/src/registry.rs | 350 +++++++++++++++++- 3 files changed, 715 insertions(+), 2 deletions(-) diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index 5329fb8ec..158f46348 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -795,7 +795,116 @@ fn read_xml(path: &Path) -> Result { #[cfg(test)] mod tests { - use super::{parse_binary, reference_name, resource_name_segment, resource_type_name}; + use super::{ + AdapterError, ElementKind, PolicyClass, PolicyValue, adml_path, create_listed_resource, + parse_binary, parse_template, policy_value_to_json, read_xml, reference_name, + registry_value_to_json, resolve_reference, resource_name_segment, resource_type_name, + }; + use dsc_lib_registry::config::RegistryValueData; + use serde_json::json; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + static FIXTURE_ID: AtomicU64 = AtomicU64::new(0); + + struct Fixture { + system_root: PathBuf, + root: PathBuf, + admx: PathBuf, + } + + impl Fixture { + fn new(admx: &str, adml: &str) -> Self { + let id = FIXTURE_ID.fetch_add(1, Ordering::Relaxed); + let system_root = std::env::temp_dir().join(format!( + "group_policy_template_{}_{}", + std::process::id(), + id + )); + let root = system_root.join("PolicyDefinitions"); + let locale = root.join("en-US"); + fs::create_dir_all(&locale).unwrap(); + let admx_path = root.join("fixture.admx"); + fs::write(&admx_path, admx).unwrap(); + fs::write(locale.join("fixture.adml"), adml).unwrap(); + Self { + system_root, + root, + admx: admx_path, + } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.system_root).unwrap(); + } + } + + const ADML: &str = r#" + + Fixture policies + Localized template description + + + Localized Category + Complex Policy + Localized policy help + First choice + Second choice + Simple Policy + + +"#; + + const ADMX: &str = r#" + + + + + + + + + + + + + + + yes + no + + + + + + two + + + + + + + + %TEMP% + + onetwo + + + + 01, ff, 0A + + + + + + + +"#; #[test] fn normalizes_resource_name_parts() { @@ -817,5 +926,239 @@ mod tests { #[test] fn parses_binary_values() { assert_eq!(parse_binary("01, ff, 0A").unwrap(), vec![1, 255, 10]); + assert!(parse_binary("invalid").is_err()); + } + + #[test] + fn parses_template_and_generates_localized_schema() { + let fixture = Fixture::new(ADMX, ADML); + let resources = parse_template(&fixture.admx, "fr-FR").unwrap(); + assert_eq!(resources.len(), 1); + + let resource = &resources[0]; + assert_eq!(resource.type_name, "GPO.WindowsComponents/StableCategory"); + assert_eq!(resource.display_name, "Localized Category"); + assert_eq!(resource.description, "Localized template description"); + assert_eq!(resource.policies.len(), 2); + + let complex = resource + .policies + .iter() + .find(|policy| policy.name == "ComplexPolicy") + .unwrap(); + assert_eq!(complex.display_name, "Complex Policy"); + assert_eq!( + complex.description.as_deref(), + Some("Localized policy help") + ); + assert_eq!(complex.class, PolicyClass::Both); + assert_eq!( + complex.enabled, + Some(PolicyValue::Data(RegistryValueData::DWord(7))) + ); + assert_eq!(complex.disabled, Some(PolicyValue::Delete)); + assert_eq!(complex.elements.len(), 8); + assert_eq!(complex.enabled_list.len(), 2); + assert_eq!(complex.disabled_list.len(), 2); + assert!(matches!( + complex.elements[0].kind, + ElementKind::Boolean { .. } + )); + assert!(matches!( + complex.elements[1].kind, + ElementKind::Decimal { + minimum: Some(1), + maximum: Some(10), + store_as_text: false + } + )); + assert!(matches!(complex.elements[3].kind, ElementKind::Enum(_))); + assert!(matches!(complex.elements[4].kind, ElementKind::List)); + assert!(matches!(complex.elements[5].kind, ElementKind::MultiText)); + assert!(matches!( + complex.elements[7].kind, + ElementKind::Text { expandable: true } + )); + + let simple = resource + .policies + .iter() + .find(|policy| policy.name == "SimplePolicy") + .unwrap(); + assert_eq!(simple.class, PolicyClass::Machine); + assert_eq!( + simple.enabled, + Some(PolicyValue::Data(RegistryValueData::DWord(1))) + ); + assert_eq!(simple.disabled, Some(PolicyValue::Delete)); + + let listed = create_listed_resource(resource, &fixture.admx); + assert_eq!(listed.type_name, resource.type_name); + assert_eq!(listed.require_adapter, super::ADAPTER_TYPE); + assert_eq!(listed.capabilities, ["get", "set"]); + assert_eq!( + listed.schema["embedded"]["properties"]["scope"]["default"], + "currentUser" + ); + assert_eq!( + listed.schema["embedded"]["properties"]["ComplexPolicy"]["oneOf"][1]["properties"]["NumberValue"] + ["minimum"], + 1 + ); + assert_eq!( + listed.schema["embedded"]["properties"]["ComplexPolicy"]["oneOf"][1]["properties"]["EnumValue"] + ["oneOf"][1]["title"], + "Second choice" + ); + assert_eq!( + listed.schema["embedded"]["properties"]["SimplePolicy"]["type"], + "boolean" + ); + } + + #[test] + fn lists_resources_from_policy_definitions() { + let fixture = Fixture::new(ADMX, ADML); + let original_system_root = std::env::var_os("SystemRoot"); + // SAFETY: This test restores the process environment before returning, and no + // other adapter test reads SystemRoot. + unsafe { std::env::set_var("SystemRoot", &fixture.system_root) }; + let result = super::list_resources(); + if let Some(original) = original_system_root { + // SAFETY: Restores the value captured immediately before this test. + unsafe { std::env::set_var("SystemRoot", original) }; + } else { + // SAFETY: Restores the absence of the variable captured before this test. + unsafe { std::env::remove_var("SystemRoot") }; + } + + let resources = result.unwrap(); + assert_eq!(resources.len(), 1); + let resource: serde_json::Value = serde_json::from_str(&resources[0]).unwrap(); + assert_eq!(resource["type"], "GPO.WindowsComponents/StableCategory"); + assert_eq!( + resource["requireAdapter"], + "Microsoft.Adapter/GroupPolicyTemplate" + ); + } + + #[test] + fn converts_every_registry_value_to_json() { + assert_eq!( + registry_value_to_json(&RegistryValueData::String("value".to_string())), + json!("value") + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::ExpandString("%TEMP%".to_string())), + json!("%TEMP%") + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::DWord(42)), + json!(42) + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::QWord(4_294_967_296)), + json!(4_294_967_296_u64) + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::Binary(vec![1, 2])), + json!([1, 2]) + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::MultiString(vec![ + "one".to_string(), + "two".to_string() + ])), + json!(["one", "two"]) + ); + assert_eq!( + registry_value_to_json(&RegistryValueData::None), + json!(null) + ); + assert_eq!(policy_value_to_json(&PolicyValue::Delete), json!(null)); + } + + #[test] + fn reads_utf8_utf16_and_rejects_malformed_utf16() { + let fixture = Fixture::new(ADMX, ADML); + assert_eq!(read_xml(&fixture.admx).unwrap(), ADMX); + + let le = fixture.root.join("le.xml"); + let be = fixture.root.join("be.xml"); + let malformed = fixture.root.join("malformed.xml"); + let text = "value"; + let words: Vec = text.encode_utf16().collect(); + let mut le_bytes = vec![0xff, 0xfe]; + le_bytes.extend(words.iter().flat_map(|word| word.to_le_bytes())); + let mut be_bytes = vec![0xfe, 0xff]; + be_bytes.extend(words.iter().flat_map(|word| word.to_be_bytes())); + fs::write(&le, le_bytes).unwrap(); + fs::write(&be, be_bytes).unwrap(); + fs::write(&malformed, [0xff, 0xfe, 0x01]).unwrap(); + + assert_eq!(read_xml(&le).unwrap(), text); + assert_eq!(read_xml(&be).unwrap(), text); + assert!(read_xml(&malformed).is_err()); + } + + #[test] + fn handles_localization_and_template_errors() { + let fixture = Fixture::new(ADMX, ADML); + assert!( + adml_path(&fixture.admx, "fr-FR") + .unwrap() + .ends_with(Path::new("en-US").join("fixture").with_extension("adml")) + ); + assert_eq!( + resolve_reference( + "$(string.Category)", + &std::collections::HashMap::from([( + "Category".to_string(), + "Localized".to_string() + )]) + ), + "Localized" + ); + assert_eq!( + resolve_reference("$(string.Missing)", &std::collections::HashMap::new()), + "$(string.Missing)" + ); + assert!(matches!( + AdapterError::Input("input".to_string()), + error if error.is_input_error() + )); + assert!(!AdapterError::Resource("resource".to_string()).is_input_error()); + + let invalid_class = ADMX.replace("class=\"Both\"", "class=\"Invalid\""); + let invalid = Fixture::new(&invalid_class, ADML); + assert!( + parse_template(&invalid.admx, "en-US") + .unwrap_err() + .to_string() + .contains("Invalid") + ); + + let unsupported = ADMX.replace( + "", + "", + ); + let invalid = Fixture::new(&unsupported, ADML); + assert!( + parse_template(&invalid.admx, "en-US") + .unwrap_err() + .to_string() + .contains("unsupported") + ); + + for invalid_admx in [ + ADMX.replace(" id=\"BooleanValue\"", ""), + ADMX.replace("", "7"), + ADMX.replace(" minValue=\"1\"", " minValue=\"invalid\""), + ADMX.replace(" valueName=\"DisabledBinary\"", ""), + ADMX.replace("01, ff, 0A", ""), + ] { + let invalid = Fixture::new(&invalid_admx, ADML); + assert!(parse_template(&invalid.admx, "en-US").is_err()); + } } } diff --git a/adapters/group_policy_template/src/main.rs b/adapters/group_policy_template/src/main.rs index 56b9e28f9..74d0e36c0 100644 --- a/adapters/group_policy_template/src/main.rs +++ b/adapters/group_policy_template/src/main.rs @@ -87,3 +87,25 @@ fn argument_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> { .find(|pair| pair[0] == name) .map(|pair| pair[1].as_str()) } + +#[cfg(all(test, windows))] +mod tests { + use super::argument_value; + + #[test] + fn finds_named_argument_values() { + let args = vec![ + "get".to_string(), + "--input".to_string(), + "{}".to_string(), + "--resource-type".to_string(), + "GPO.Parent/Category".to_string(), + ]; + assert_eq!(argument_value(&args, "--input"), Some("{}")); + assert_eq!( + argument_value(&args, "--resource-type"), + Some("GPO.Parent/Category") + ); + assert_eq!(argument_value(&args, "--missing"), None); + } +} diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index c487cdd21..2a0415b9e 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -573,7 +573,42 @@ fn serialize_result(result: &Map) -> Result, AdapterE #[cfg(test)] mod tests { - use super::key_path; + use super::{ + apply_value, element_data_to_json, get, input_to_string, json_to_element_data, key_path, + parse_input, parse_scope, policy_values_are_absent, read_policy, scope_is_supported, + serialize_result, set, state_matches, validate_scope, write_policy, + }; + use crate::admx::{ElementKind, EnumItem, Policy, PolicyClass, PolicyElement, PolicyValue}; + use dsc_lib_registry::{ + RegistryHelper, + config::{Registry, RegistryValueData}, + }; + use serde_json::{Map, Value, json}; + + fn element(id: &str, kind: ElementKind) -> PolicyElement { + PolicyElement { + id: id.to_string(), + key: None, + value_name: Some(id.to_string()), + kind, + } + } + + fn policy(class: PolicyClass) -> Policy { + Policy { + name: "Policy".to_string(), + display_name: "Policy".to_string(), + description: None, + class, + key: "Software\\Fixture".to_string(), + value_name: None, + enabled: None, + disabled: None, + elements: Vec::new(), + enabled_list: Vec::new(), + disabled_list: Vec::new(), + } + } #[test] fn maps_scope_to_registry_hive() { @@ -586,4 +621,317 @@ mod tests { "HKLM\\Software\\Policies" ); } + + #[test] + fn parses_input_scope_and_serializes_results() { + let input = parse_input(r#"{"scope":"allUsers","Policy":true}"#).unwrap(); + assert_eq!(parse_scope(&input).unwrap(), "allUsers"); + assert_eq!(parse_scope(&Map::new()).unwrap(), "currentUser"); + assert!(parse_scope(&Map::from_iter([("scope".to_string(), json!("invalid"))])).is_err()); + assert!(parse_input("not json").is_err()); + assert_eq!( + serde_json::from_str::(&input_to_string(&input).unwrap()).unwrap(), + json!({"Policy": true, "scope": "allUsers"}) + ); + assert_eq!( + serialize_result(&Map::from_iter([("value".to_string(), json!(1))])).unwrap(), + vec![r#"{"value":1}"#] + ); + } + + #[test] + fn validates_policy_scope() { + let both = policy(PolicyClass::Both); + let machine = policy(PolicyClass::Machine); + let user = policy(PolicyClass::User); + assert!(scope_is_supported(&both, "currentUser")); + assert!(scope_is_supported(&both, "allUsers")); + assert!(scope_is_supported(&machine, "allUsers")); + assert!(!scope_is_supported(&machine, "currentUser")); + assert!(scope_is_supported(&user, "currentUser")); + assert!(!scope_is_supported(&user, "allUsers")); + assert!(validate_scope(&machine, "allUsers").is_ok()); + assert!(validate_scope(&machine, "currentUser").is_err()); + assert!(read_policy(&machine, "currentUser", None).is_err()); + } + + #[test] + fn round_trips_policy_state_in_current_user_registry() { + let key = format!( + "Software\\Microsoft\\DSC\\GroupPolicyTemplateTests\\{}", + std::process::id() + ); + let mut registry_policy = policy(PolicyClass::User); + registry_policy.key = key.clone(); + registry_policy.value_name = Some("State".to_string()); + registry_policy.enabled = Some(PolicyValue::Data(RegistryValueData::DWord(1))); + registry_policy.disabled = Some(PolicyValue::Delete); + registry_policy.elements = vec![ + element( + "Boolean", + ElementKind::Boolean { + true_value: PolicyValue::Data(RegistryValueData::DWord(1)), + false_value: PolicyValue::Data(RegistryValueData::DWord(0)), + }, + ), + element( + "Decimal", + ElementKind::Decimal { + minimum: None, + maximum: None, + store_as_text: false, + }, + ), + element( + "Enum", + ElementKind::Enum(vec![EnumItem { + title: "Choice".to_string(), + value: PolicyValue::Data(RegistryValueData::String("choice".to_string())), + }]), + ), + element("Multi", ElementKind::MultiText), + element("Text", ElementKind::Text { expandable: false }), + PolicyElement { + id: "List".to_string(), + key: None, + value_name: None, + kind: ElementKind::List, + }, + ]; + + let desired = json!({ + "enabled": true, + "Boolean": true, + "Decimal": 42, + "Enum": "choice", + "Multi": ["one", "two"], + "Text": "value", + "List": { + "ListOne": "first", + "ListTwo": "second" + } + }); + + let result = (|| { + write_policy(®istry_policy, "currentUser", &desired)?; + assert!(state_matches(®istry_policy, "currentUser", true)?); + assert!(!policy_values_are_absent(®istry_policy, "currentUser")?); + + let actual = read_policy(®istry_policy, "currentUser", Some(&desired))?.unwrap(); + assert_eq!(actual, desired); + + write_policy(®istry_policy, "currentUser", &json!(false))?; + assert_eq!( + read_policy(®istry_policy, "currentUser", None)?, + Some(Value::Bool(false)) + ); + Ok::<(), crate::admx::AdapterError>(()) + })(); + + for value_name in [ + "State", "Boolean", "Decimal", "Enum", "Multi", "Text", "ListOne", "ListTwo", + ] { + apply_value("currentUser", &key, value_name, &PolicyValue::Delete).unwrap(); + } + RegistryHelper::new_from_registry(&Registry { + key_path: key_path("currentUser", &key), + exist: Some(false), + ..Default::default() + }) + .unwrap() + .remove() + .unwrap(); + result.unwrap(); + } + + #[test] + fn public_operations_reject_invalid_input_before_loading_resources() { + assert!(get("not json", "GPO.Parent/Category", "missing.admx").is_err()); + assert!(set("not json", "GPO.Parent/Category", "missing.admx").is_err()); + } + + #[test] + fn converts_json_to_every_element_type() { + let boolean = element( + "Boolean", + ElementKind::Boolean { + true_value: PolicyValue::Data(RegistryValueData::DWord(10)), + false_value: PolicyValue::Delete, + }, + ); + assert_eq!( + json_to_element_data(&boolean, &json!(true)).unwrap(), + PolicyValue::Data(RegistryValueData::DWord(10)) + ); + assert_eq!( + json_to_element_data(&boolean, &json!(false)).unwrap(), + PolicyValue::Delete + ); + assert!(json_to_element_data(&boolean, &json!("true")).is_err()); + + let decimal = element( + "Decimal", + ElementKind::Decimal { + minimum: None, + maximum: None, + store_as_text: false, + }, + ); + assert_eq!( + json_to_element_data(&decimal, &json!(42)).unwrap(), + PolicyValue::Data(RegistryValueData::DWord(42)) + ); + assert!(json_to_element_data(&decimal, &json!(u64::from(u32::MAX) + 1)).is_err()); + + let text_decimal = element( + "TextDecimal", + ElementKind::Decimal { + minimum: None, + maximum: None, + store_as_text: true, + }, + ); + assert_eq!( + json_to_element_data(&text_decimal, &json!(42)).unwrap(), + PolicyValue::Data(RegistryValueData::String("42".to_string())) + ); + + let choice = PolicyValue::Data(RegistryValueData::String("choice".to_string())); + let enumeration = element( + "Enum", + ElementKind::Enum(vec![EnumItem { + title: "Choice".to_string(), + value: choice.clone(), + }]), + ); + assert_eq!( + json_to_element_data(&enumeration, &json!("choice")).unwrap(), + choice + ); + assert!(json_to_element_data(&enumeration, &json!("other")).is_err()); + + let multi = element("Multi", ElementKind::MultiText); + assert_eq!( + json_to_element_data(&multi, &json!(["one", "two"])).unwrap(), + PolicyValue::Data(RegistryValueData::MultiString(vec![ + "one".to_string(), + "two".to_string() + ])) + ); + assert!(json_to_element_data(&multi, &json!([1])).is_err()); + + for expandable in [false, true] { + let text = element("Text", ElementKind::Text { expandable }); + let expected = if expandable { + RegistryValueData::ExpandString("value".to_string()) + } else { + RegistryValueData::String("value".to_string()) + }; + assert_eq!( + json_to_element_data(&text, &json!("value")).unwrap(), + PolicyValue::Data(expected) + ); + assert!(json_to_element_data(&text, &json!(1)).is_err()); + } + + let list = element("List", ElementKind::List); + assert!(json_to_element_data(&list, &json!({})).is_err()); + } + + #[test] + fn converts_registry_data_for_every_element_type() { + let boolean = element( + "Boolean", + ElementKind::Boolean { + true_value: PolicyValue::Data(RegistryValueData::DWord(1)), + false_value: PolicyValue::Data(RegistryValueData::DWord(0)), + }, + ); + assert_eq!( + element_data_to_json(&boolean, &RegistryValueData::DWord(1)).unwrap(), + Value::Bool(true) + ); + assert_eq!( + element_data_to_json(&boolean, &RegistryValueData::DWord(0)).unwrap(), + Value::Bool(false) + ); + assert!(element_data_to_json(&boolean, &RegistryValueData::DWord(2)).is_err()); + + let enumeration = element( + "Enum", + ElementKind::Enum(vec![EnumItem { + title: "One".to_string(), + value: PolicyValue::Data(RegistryValueData::DWord(1)), + }]), + ); + assert_eq!( + element_data_to_json(&enumeration, &RegistryValueData::DWord(1)).unwrap(), + json!(1) + ); + assert!(element_data_to_json(&enumeration, &RegistryValueData::DWord(2)).is_err()); + + let text_decimal = element( + "TextDecimal", + ElementKind::Decimal { + minimum: None, + maximum: None, + store_as_text: true, + }, + ); + assert_eq!( + element_data_to_json(&text_decimal, &RegistryValueData::String("42".to_string())) + .unwrap(), + json!(42) + ); + assert!( + element_data_to_json( + &text_decimal, + &RegistryValueData::String("invalid".to_string()) + ) + .is_err() + ); + + for kind in [ + ElementKind::Decimal { + minimum: None, + maximum: None, + store_as_text: false, + }, + ElementKind::MultiText, + ElementKind::Text { expandable: false }, + ElementKind::List, + ] { + assert_eq!( + element_data_to_json(&element("Value", kind), &RegistryValueData::DWord(7)) + .unwrap(), + json!(7) + ); + } + } + + #[test] + fn rejects_invalid_policy_objects_without_registry_access() { + let mut value_policy = policy(PolicyClass::Both); + assert!(write_policy(&value_policy, "currentUser", &json!("invalid")).is_err()); + assert!(write_policy(&value_policy, "currentUser", &json!(true)).is_err()); + assert!(write_policy(&value_policy, "currentUser", &json!({"enabled": "true"})).is_err()); + assert!(write_policy(&value_policy, "currentUser", &json!({"Unknown": true})).is_err()); + assert!(!state_matches(&value_policy, "currentUser", true).unwrap()); + assert!(!policy_values_are_absent(&value_policy, "currentUser").unwrap()); + + value_policy.elements.push(PolicyElement { + id: "MissingValueName".to_string(), + key: None, + value_name: None, + kind: ElementKind::Text { expandable: false }, + }); + assert!( + write_policy( + &value_policy, + "currentUser", + &json!({"MissingValueName": "value"}) + ) + .is_err() + ); + } } From e97a15359e7511a6d815ffb21b909eda9d5756e7 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 21:05:02 -0700 Subject: [PATCH 08/13] Support Group Policy get without input Treat absent or blank get input as an empty object so adapted resources return all current values using the default current-user scope. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template.dsc.resource.json | 2 +- adapters/group_policy_template/src/main.rs | 9 +++++---- adapters/group_policy_template/src/registry.rs | 17 ++++++++++++++--- .../tests/group_policy_template.tests.ps1 | 13 +++++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/adapters/group_policy_template/group_policy_template.dsc.resource.json b/adapters/group_policy_template/group_policy_template.dsc.resource.json index 32fdc93a8..d2eff712a 100644 --- a/adapters/group_policy_template/group_policy_template.dsc.resource.json +++ b/adapters/group_policy_template/group_policy_template.dsc.resource.json @@ -23,7 +23,7 @@ "get", { "jsonInputArg": "--input", - "mandatory": true + "mandatory": false }, { "resourceTypeArg": "--resource-type" diff --git a/adapters/group_policy_template/src/main.rs b/adapters/group_policy_template/src/main.rs index 74d0e36c0..efafcdfb4 100644 --- a/adapters/group_policy_template/src/main.rs +++ b/adapters/group_policy_template/src/main.rs @@ -38,10 +38,6 @@ fn main() { let result = match operation { "list" => admx::list_resources(), "get" | "set" => { - let Some(input) = argument_value(&args, "--input") else { - write_error(&t!("main.missingArgument", argument = "--input")); - exit(EXIT_INVALID_ARGS); - }; let Some(resource_type) = argument_value(&args, "--resource-type") else { write_error(&t!("main.missingArgument", argument = "--resource-type")); exit(EXIT_INVALID_ARGS); @@ -51,8 +47,13 @@ fn main() { exit(EXIT_INVALID_ARGS); }; if operation == "get" { + let input = argument_value(&args, "--input").unwrap_or_default(); registry::get(input, resource_type, resource_path) } else { + let Some(input) = argument_value(&args, "--input") else { + write_error(&t!("main.missingArgument", argument = "--input")); + exit(EXIT_INVALID_ARGS); + }; registry::set(input, resource_type, resource_path) } } diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index 2a0415b9e..1d7a0cc4e 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -17,7 +17,7 @@ pub fn get( resource_type: &str, resource_path: &str, ) -> Result, AdapterError> { - let input = parse_input(input)?; + let input = parse_get_input(input)?; let scope = parse_scope(&input)?; let resource = load_resource(Path::new(resource_path), resource_type)?; let include_all = input.keys().all(|key| key == "scope"); @@ -540,6 +540,14 @@ fn parse_input(input: &str) -> Result, AdapterError> { }) } +fn parse_get_input(input: &str) -> Result, AdapterError> { + if input.trim().is_empty() { + Ok(Map::new()) + } else { + parse_input(input) + } +} + fn parse_scope(input: &Map) -> Result<&str, AdapterError> { match input.get("scope") { None => Ok(DEFAULT_SCOPE), @@ -575,8 +583,8 @@ fn serialize_result(result: &Map) -> Result, AdapterE mod tests { use super::{ apply_value, element_data_to_json, get, input_to_string, json_to_element_data, key_path, - parse_input, parse_scope, policy_values_are_absent, read_policy, scope_is_supported, - serialize_result, set, state_matches, validate_scope, write_policy, + parse_get_input, parse_input, parse_scope, policy_values_are_absent, read_policy, + scope_is_supported, serialize_result, set, state_matches, validate_scope, write_policy, }; use crate::admx::{ElementKind, EnumItem, Policy, PolicyClass, PolicyElement, PolicyValue}; use dsc_lib_registry::{ @@ -625,6 +633,9 @@ mod tests { #[test] fn parses_input_scope_and_serializes_results() { let input = parse_input(r#"{"scope":"allUsers","Policy":true}"#).unwrap(); + assert!(parse_get_input("").unwrap().is_empty()); + assert!(parse_get_input(" ").unwrap().is_empty()); + assert!(parse_get_input("not json").is_err()); assert_eq!(parse_scope(&input).unwrap(), "allUsers"); assert_eq!(parse_scope(&Map::new()).unwrap(), "currentUser"); assert!(parse_scope(&Map::from_iter([("scope".to_string(), json!("invalid"))])).is_err()); diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index cd06a50d5..8b99b76bd 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -44,6 +44,19 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { } } + It 'Gets all current user values without input' { + $resources = @(dsc resource list 'GPO.WindowsComponents/PowerShell' --adapter $adapterType | ConvertFrom-Json) + if ($resources.Count -eq 0) { + Set-ItResult -Skipped -Because 'PowerShellExecutionPolicy.admx is not installed.' + return + } + + $out = dsc resource get -r 'GPO.WindowsComponents/PowerShell' 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + ($out | ConvertFrom-Json).actualState.scope | Should -BeExactly 'currentUser' + } + Context 'Current user policy state' -Skip:(!$isAdmin) { BeforeAll { $script:originalKeyExists = Test-Path -LiteralPath $keyPath From 1d1ee1bb3ffd4ce841247c27b1b5895527bd8144 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 21:09:59 -0700 Subject: [PATCH 09/13] Test no-input Group Policy value retrieval Verify a configured current-user policy is returned by the adapted resource get command when no input is supplied. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/group_policy_template.tests.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index 8b99b76bd..d3b3ac64e 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -105,6 +105,13 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { ($out | ConvertFrom-Json).afterState.scope | Should -BeExactly 'currentUser' ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeTrue (Invoke-GroupPolicyGet -Enabled $true).NoAddPage | Should -BeTrue + + $out = dsc resource get -r $resourceType 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actualState = ($out | ConvertFrom-Json).actualState + $actualState.scope | Should -BeExactly 'currentUser' + $actualState.NoAddPage | Should -BeTrue } } } From 76cf9e8458a33bb3aee210b8ea5087c2cf070839 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 21:28:53 -0700 Subject: [PATCH 10/13] Model Group Policy settings as explicit states Normalize policy names, expose Enabled, Disabled, and NotConfigured states, and always return a state for every adapted policy property. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template/locales/en-us.toml | 7 +- adapters/group_policy_template/src/admx.rs | 89 +++++---- .../group_policy_template/src/registry.rs | 180 ++++++++++-------- .../tests/group_policy_template.tests.ps1 | 38 +++- 4 files changed, 180 insertions(+), 134 deletions(-) diff --git a/adapters/group_policy_template/locales/en-us.toml b/adapters/group_policy_template/locales/en-us.toml index b13912593..bb43a45cf 100644 --- a/adapters/group_policy_template/locales/en-us.toml +++ b/adapters/group_policy_template/locales/en-us.toml @@ -30,14 +30,14 @@ invalidUtf16Length = "The UTF-16 XML file '%{path}' has an odd number of bytes." [schema] scopeTitle = "Scope" scopeDescription = "Specifies whether policy settings apply to all users through HKEY_LOCAL_MACHINE or the current user through HKEY_CURRENT_USER." -enabledTitle = "Enabled" +stateTitle = "State" [registry] invalidInput = "Invalid JSON input: %{error}" invalidScope = "The scope must be either 'allUsers' or 'currentUser'." unknownPolicy = "Policy property '%{policy}' does not exist on adapted resource '%{resource}'." -policyNotBoolean = "Policy property '%{policy}' must be a Boolean." -invalidPolicyValue = "Policy property '%{policy}' must be a Boolean or an object." +invalidPolicyState = "Policy property '%{policy}' must be 'Enabled', 'Disabled', or 'NotConfigured'." +invalidPolicyValue = "Policy property '%{policy}' must be a state string or an object." policyHasNoToggle = "Policy '%{policy}' has no enabled or disabled registry value; configure its child settings instead." unknownElement = "Element '%{element}' does not exist on policy '%{policy}'." elementHasNoValueName = "Policy element '%{element}' does not define a registry value name." @@ -45,6 +45,5 @@ listNotObject = "List element '%{element}' must be an object." listValueNotString = "Every value in list element '%{element}' must be a string." invalidElementValue = "The value for policy element '%{element}' does not match its ADMX definition." scopeNotSupported = "Policy '%{policy}' does not support scope '%{scope}'." -unrecognizedValue = "Registry value '%{value_name}' under '%{key}' does not match the policy's enabled or disabled value." operationFailed = "Registry operation failed: %{error}" serializeResult = "Failed to serialize the resource state: %{error}" diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index 158f46348..d2f359b8d 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -295,7 +295,11 @@ fn parse_policy( let disabled = child(node, "disabledValue") .map(parse_policy_value) .transpose()? - .or_else(|| value_name.as_ref().map(|_| PolicyValue::Delete)); + .or_else(|| { + value_name + .as_ref() + .map(|_| PolicyValue::Data(RegistryValueData::DWord(0))) + }); let elements = child(node, "elements") .map(|elements| { elements @@ -318,7 +322,7 @@ fn parse_policy( }; Ok(Policy { - name: required_attribute("name")?.to_string(), + name: policy_property_name(required_attribute("name")?), display_name: resolve_reference(required_attribute("displayName")?, strings), description: node .attribute("explainText") @@ -469,50 +473,31 @@ fn create_listed_resource(resource: &CategoryResource, path: &Path) -> ListedRes }), ); for policy in &resource.policies { - let boolean_schema = policy - .enabled - .as_ref() - .zip(policy.disabled.as_ref()) - .map(|_| { - json!({ - "type": "boolean", - "title": policy.display_name, - "description": policy.description - }) - }); - let object_schema = if policy.elements.is_empty() { - None + let state_schema = json!({ + "type": "string", + "title": t!("schema.stateTitle"), + "enum": ["Enabled", "Disabled", "NotConfigured"] + }); + let property = if policy.elements.is_empty() { + json!({ + "type": "string", + "title": policy.display_name, + "description": policy.description, + "enum": ["Enabled", "Disabled", "NotConfigured"] + }) } else { let mut element_properties = Map::new(); - if boolean_schema.is_some() { - element_properties.insert( - "enabled".to_string(), - json!({ - "type": "boolean", - "title": t!("schema.enabledTitle") - }), - ); - } + element_properties.insert("state".to_string(), state_schema); for element in &policy.elements { element_properties.insert(element.id.clone(), element_schema(element)); } - Some(json!({ + json!({ "type": "object", "title": policy.display_name, "description": policy.description, "additionalProperties": false, "properties": element_properties - })) - }; - let property = match (boolean_schema, object_schema) { - (Some(boolean), Some(object)) => json!({ "oneOf": [boolean, object] }), - (Some(boolean), None) => boolean, - (None, Some(object)) => object, - (None, None) => json!({ - "type": "boolean", - "title": policy.display_name, - "description": policy.description - }), + }) }; properties.insert(policy.name.clone(), property); } @@ -727,6 +712,14 @@ fn resource_type_name(parent_category: &str, category: &str) -> String { ) } +fn policy_property_name(name: &str) -> String { + name.strip_prefix("Enabled") + .or_else(|| name.strip_prefix("Enable")) + .filter(|name| !name.is_empty()) + .unwrap_or(name) + .to_string() +} + fn child<'a>(node: Node<'a, 'a>, name: &str) -> Option> { node.children().find(|child| child.has_tag_name(name)) } @@ -797,8 +790,9 @@ fn read_xml(path: &Path) -> Result { mod tests { use super::{ AdapterError, ElementKind, PolicyClass, PolicyValue, adml_path, create_listed_resource, - parse_binary, parse_template, policy_value_to_json, read_xml, reference_name, - registry_value_to_json, resolve_reference, resource_name_segment, resource_type_name, + parse_binary, parse_template, policy_property_name, policy_value_to_json, read_xml, + reference_name, registry_value_to_json, resolve_reference, resource_name_segment, + resource_type_name, }; use dsc_lib_registry::config::RegistryValueData; use serde_json::json; @@ -921,6 +915,9 @@ mod tests { resource_type_name("WindowsComponents", "PowerShell"), "GPO.WindowsComponents/PowerShell" ); + assert_eq!(policy_property_name("EnableModuleLogging"), "ModuleLogging"); + assert_eq!(policy_property_name("EnabledFeature"), "Feature"); + assert_eq!(policy_property_name("NoAddPage"), "NoAddPage"); } #[test] @@ -990,7 +987,10 @@ mod tests { simple.enabled, Some(PolicyValue::Data(RegistryValueData::DWord(1))) ); - assert_eq!(simple.disabled, Some(PolicyValue::Delete)); + assert_eq!( + simple.disabled, + Some(PolicyValue::Data(RegistryValueData::DWord(0))) + ); let listed = create_listed_resource(resource, &fixture.admx); assert_eq!(listed.type_name, resource.type_name); @@ -1001,18 +1001,17 @@ mod tests { "currentUser" ); assert_eq!( - listed.schema["embedded"]["properties"]["ComplexPolicy"]["oneOf"][1]["properties"]["NumberValue"] - ["minimum"], + listed.schema["embedded"]["properties"]["ComplexPolicy"]["properties"]["NumberValue"]["minimum"], 1 ); assert_eq!( - listed.schema["embedded"]["properties"]["ComplexPolicy"]["oneOf"][1]["properties"]["EnumValue"] - ["oneOf"][1]["title"], + listed.schema["embedded"]["properties"]["ComplexPolicy"]["properties"]["EnumValue"]["oneOf"] + [1]["title"], "Second choice" ); assert_eq!( - listed.schema["embedded"]["properties"]["SimplePolicy"]["type"], - "boolean" + listed.schema["embedded"]["properties"]["SimplePolicy"]["enum"], + json!(["Enabled", "Disabled", "NotConfigured"]) ); } diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index 1d7a0cc4e..86b47354c 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -12,6 +12,34 @@ use std::path::Path; const DEFAULT_SCOPE: &str = "currentUser"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PolicyState { + Enabled, + Disabled, + NotConfigured, +} + +impl PolicyState { + fn parse(value: &Value, policy: &Policy) -> Result { + match value.as_str() { + Some("Enabled") => Ok(Self::Enabled), + Some("Disabled") => Ok(Self::Disabled), + Some("NotConfigured") => Ok(Self::NotConfigured), + _ => Err(AdapterError::Input( + t!("registry.invalidPolicyState", policy = policy.name).to_string(), + )), + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Enabled => "Enabled", + Self::Disabled => "Disabled", + Self::NotConfigured => "NotConfigured", + } + } +} + pub fn get( input: &str, resource_type: &str, @@ -28,9 +56,6 @@ pub fn get( if !include_all && !input.contains_key(&policy.name) { continue; } - if include_all && !scope_is_supported(policy, scope) { - continue; - } let requested = input.get(&policy.name); if let Some(value) = read_policy(policy, scope, requested)? { result.insert(policy.name.clone(), value); @@ -83,17 +108,33 @@ fn read_policy( scope: &str, requested: Option<&Value>, ) -> Result, AdapterError> { - validate_scope(policy, scope)?; - if let Some(Value::Object(requested_elements)) = requested { + let scope_supported = scope_is_supported(policy, scope); + let state = if scope_supported { + read_state(policy, scope)? + } else { + PolicyState::NotConfigured + }; + if !policy.elements.is_empty() { let mut result = Map::new(); - if requested_elements.contains_key("enabled") - && let Some(enabled) = read_enabled(policy, scope)? - { - result.insert("enabled".to_string(), Value::Bool(enabled)); + let empty_list_request = Value::Object(Map::new()); + result.insert( + "state".to_string(), + Value::String(state.as_str().to_string()), + ); + if !scope_supported { + return Ok(Some(Value::Object(result))); } for element in &policy.elements { - let Some(requested_value) = requested_elements.get(&element.id) else { - continue; + let requested_value = match requested { + Some(Value::Object(requested_elements)) => { + let Some(value) = requested_elements.get(&element.id) else { + continue; + }; + value + } + Some(_) => continue, + None if matches!(element.kind, ElementKind::List) => &empty_list_request, + None => &Value::Null, }; if let Some(value) = read_element(policy, element, scope, requested_value)? { result.insert(element.id.clone(), value); @@ -101,76 +142,31 @@ fn read_policy( } return Ok(Some(Value::Object(result))); } - read_enabled(policy, scope).map(|value| value.map(Value::Bool)) + Ok(Some(Value::String(state.as_str().to_string()))) } -fn read_enabled(policy: &Policy, scope: &str) -> Result, AdapterError> { +fn read_state(policy: &Policy, scope: &str) -> Result { if state_matches(policy, scope, true)? { - Ok(Some(true)) + Ok(PolicyState::Enabled) } else if state_matches(policy, scope, false)? { - Ok(Some(false)) - } else if policy_values_are_absent(policy, scope)? { - Ok(None) + Ok(PolicyState::Disabled) } else { - Err(AdapterError::Resource( - t!( - "registry.unrecognizedValue", - key = policy.key, - value_name = policy.value_name.as_deref().unwrap_or_default() - ) - .to_string(), - )) - } -} - -fn policy_values_are_absent(policy: &Policy, scope: &str) -> Result { - let mut values = Vec::new(); - if let Some(value_name) = &policy.value_name { - values.push((policy.key.as_str(), value_name.as_str())); - } - for setting in policy.enabled_list.iter().chain(&policy.disabled_list) { - values.push(( - setting.key.as_deref().unwrap_or(&policy.key), - setting.value_name.as_str(), - )); - } - if values.is_empty() { - return Ok(false); + Ok(PolicyState::NotConfigured) } - - for (key, value_name) in values { - let actual = RegistryHelper::new(&key_path(scope, key), Some(value_name.to_string()), None) - .map_err(registry_error)? - .get() - .map_err(registry_error)? - .value_data; - if actual.is_some() { - return Ok(false); - } - } - Ok(true) } fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), AdapterError> { - if let Some(enabled) = input.as_bool() { - return write_enabled(policy, scope, enabled); + if input.is_string() { + return write_state(policy, scope, PolicyState::parse(input, policy)?); } let object = input.as_object().ok_or_else(|| { AdapterError::Input(t!("registry.invalidPolicyValue", policy = policy.name).to_string()) })?; - if let Some(enabled) = object.get("enabled") { - write_enabled( - policy, - scope, - enabled.as_bool().ok_or_else(|| { - AdapterError::Input( - t!("registry.policyNotBoolean", policy = policy.name).to_string(), - ) - })?, - )?; + if let Some(state) = object.get("state") { + write_state(policy, scope, PolicyState::parse(state, policy)?)?; } for (name, value) in object { - if name == "enabled" { + if name == "state" { continue; } let element = policy @@ -192,7 +188,11 @@ fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), Adapt Ok(()) } -fn write_enabled(policy: &Policy, scope: &str, enabled: bool) -> Result<(), AdapterError> { +fn write_state(policy: &Policy, scope: &str, state: PolicyState) -> Result<(), AdapterError> { + if state == PolicyState::NotConfigured { + return write_not_configured(policy, scope); + } + let enabled = state == PolicyState::Enabled; let value = if enabled { policy.enabled.as_ref() } else { @@ -222,6 +222,21 @@ fn write_enabled(policy: &Policy, scope: &str, enabled: bool) -> Result<(), Adap Ok(()) } +fn write_not_configured(policy: &Policy, scope: &str) -> Result<(), AdapterError> { + if let Some(value_name) = &policy.value_name { + apply_value(scope, &policy.key, value_name, &PolicyValue::Delete)?; + } + for setting in policy.enabled_list.iter().chain(&policy.disabled_list) { + apply_value( + scope, + setting.key.as_deref().unwrap_or(&policy.key), + &setting.value_name, + &PolicyValue::Delete, + )?; + } + Ok(()) +} + fn apply_value( scope: &str, key: &str, @@ -264,6 +279,9 @@ fn state_matches(policy: &Policy, scope: &str, enabled: bool) -> Result) -> Result, AdapterE mod tests { use super::{ apply_value, element_data_to_json, get, input_to_string, json_to_element_data, key_path, - parse_get_input, parse_input, parse_scope, policy_values_are_absent, read_policy, - scope_is_supported, serialize_result, set, state_matches, validate_scope, write_policy, + parse_get_input, parse_input, parse_scope, read_policy, scope_is_supported, + serialize_result, set, state_matches, validate_scope, write_policy, }; use crate::admx::{ElementKind, EnumItem, Policy, PolicyClass, PolicyElement, PolicyValue}; use dsc_lib_registry::{ @@ -663,7 +681,10 @@ mod tests { assert!(!scope_is_supported(&user, "allUsers")); assert!(validate_scope(&machine, "allUsers").is_ok()); assert!(validate_scope(&machine, "currentUser").is_err()); - assert!(read_policy(&machine, "currentUser", None).is_err()); + assert_eq!( + read_policy(&machine, "currentUser", None).unwrap(), + Some(json!("NotConfigured")) + ); } #[test] @@ -676,7 +697,7 @@ mod tests { registry_policy.key = key.clone(); registry_policy.value_name = Some("State".to_string()); registry_policy.enabled = Some(PolicyValue::Data(RegistryValueData::DWord(1))); - registry_policy.disabled = Some(PolicyValue::Delete); + registry_policy.disabled = Some(PolicyValue::Data(RegistryValueData::DWord(0))); registry_policy.elements = vec![ element( "Boolean", @@ -711,7 +732,7 @@ mod tests { ]; let desired = json!({ - "enabled": true, + "state": "Enabled", "Boolean": true, "Decimal": 42, "Enum": "choice", @@ -726,15 +747,19 @@ mod tests { let result = (|| { write_policy(®istry_policy, "currentUser", &desired)?; assert!(state_matches(®istry_policy, "currentUser", true)?); - assert!(!policy_values_are_absent(®istry_policy, "currentUser")?); let actual = read_policy(®istry_policy, "currentUser", Some(&desired))?.unwrap(); assert_eq!(actual, desired); - write_policy(®istry_policy, "currentUser", &json!(false))?; + write_policy(®istry_policy, "currentUser", &json!("Disabled"))?; + assert_eq!( + read_policy(®istry_policy, "currentUser", None)?.unwrap()["state"], + "Disabled" + ); + write_policy(®istry_policy, "currentUser", &json!("NotConfigured"))?; assert_eq!( - read_policy(®istry_policy, "currentUser", None)?, - Some(Value::Bool(false)) + read_policy(®istry_policy, "currentUser", None)?.unwrap()["state"], + "NotConfigured" ); Ok::<(), crate::admx::AdapterError>(()) })(); @@ -925,10 +950,9 @@ mod tests { let mut value_policy = policy(PolicyClass::Both); assert!(write_policy(&value_policy, "currentUser", &json!("invalid")).is_err()); assert!(write_policy(&value_policy, "currentUser", &json!(true)).is_err()); - assert!(write_policy(&value_policy, "currentUser", &json!({"enabled": "true"})).is_err()); + assert!(write_policy(&value_policy, "currentUser", &json!({"state": "invalid"})).is_err()); assert!(write_policy(&value_policy, "currentUser", &json!({"Unknown": true})).is_err()); assert!(!state_matches(&value_policy, "currentUser", true).unwrap()); - assert!(!policy_values_are_absent(&value_policy, "currentUser").unwrap()); value_policy.elements.push(PolicyElement { id: "MissingValueName".to_string(), diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index d3b3ac64e..653dda33f 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -20,10 +20,10 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $valueName = 'NoAddPage' function Invoke-GroupPolicyGet { - param([bool]$Enabled) + param([string]$State) $json = @{ - NoAddPage = $Enabled + NoAddPage = $State } | 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) @@ -54,7 +54,20 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $out = dsc resource get -r 'GPO.WindowsComponents/PowerShell' 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) - ($out | ConvertFrom-Json).actualState.scope | Should -BeExactly 'currentUser' + $actualState = ($out | ConvertFrom-Json).actualState + $actualState.scope | Should -BeExactly 'currentUser' + $actualState.psobject.Properties.Name | Should -Contain 'ModuleLogging' + $actualState.psobject.Properties.Name | Should -Not -Contain 'EnableModuleLogging' + $actualState.psobject.Properties.Name | Should -Not -Contain 'EnabledModuleLogging' + foreach ($property in $actualState.psobject.Properties | Where-Object Name -NE 'scope') { + $state = if ($property.Value -is [string]) { + $property.Value + } + else { + $property.Value.state + } + $state | Should -BeIn 'Enabled', 'Disabled', 'NotConfigured' + } } Context 'Current user policy state' -Skip:(!$isAdmin) { @@ -97,21 +110,32 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { } $json = @{ - NoAddPage = $true + NoAddPage = 'Enabled' } | 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.scope | Should -BeExactly 'currentUser' - ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeTrue - (Invoke-GroupPolicyGet -Enabled $true).NoAddPage | Should -BeTrue + ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeExactly 'Enabled' + (Invoke-GroupPolicyGet -State 'Enabled').NoAddPage | Should -BeExactly 'Enabled' $out = dsc resource get -r $resourceType 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $actualState = ($out | ConvertFrom-Json).actualState $actualState.scope | Should -BeExactly 'currentUser' - $actualState.NoAddPage | Should -BeTrue + $actualState.NoAddPage | Should -BeExactly 'Enabled' + + foreach ($state in 'Disabled', 'NotConfigured') { + $json = @{ + NoAddPage = $state + } | 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.NoAddPage | Should -BeExactly $state + (Invoke-GroupPolicyGet -State $state).NoAddPage | Should -BeExactly $state + } } } } From 16243ac3df0660a6a1b6879a215f89e54fee5d8a Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 21:49:16 -0700 Subject: [PATCH 11/13] Return configured Group Policy list values Enumerate registry values for ADMX list elements during no-input get operations and cover the PowerShell module logging scenario. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template/src/registry.rs | 26 +++++- .../tests/group_policy_template.tests.ps1 | 90 +++++++++++++++++++ lib/dsc-lib-registry/locales/en-us.toml | 1 + lib/dsc-lib-registry/src/lib.rs | 31 +++++++ 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index 86b47354c..30792938b 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -330,6 +330,14 @@ fn read_element( AdapterError::Input(t!("registry.listNotObject", element = element.id).to_string()) })?; let mut result = Map::new(); + if requested_values.is_empty() { + let helper = RegistryHelper::new(&key_path(scope, element_key), None, None) + .map_err(registry_error)?; + for (value_name, data) in helper.get_values().map_err(registry_error)? { + result.insert(value_name, registry_value_to_json(&data)); + } + return Ok(Some(Value::Object(result))); + } for value_name in requested_values.keys() { let helper = RegistryHelper::new( &key_path(scope, element_key), @@ -725,7 +733,7 @@ mod tests { element("Text", ElementKind::Text { expandable: false }), PolicyElement { id: "List".to_string(), - key: None, + key: Some(format!("{key}\\List")), value_name: None, kind: ElementKind::List, }, @@ -750,6 +758,14 @@ mod tests { let actual = read_policy(®istry_policy, "currentUser", Some(&desired))?.unwrap(); assert_eq!(actual, desired); + let actual = read_policy(®istry_policy, "currentUser", None)?.unwrap(); + assert_eq!( + actual["List"], + json!({ + "ListOne": "first", + "ListTwo": "second" + }) + ); write_policy(®istry_policy, "currentUser", &json!("Disabled"))?; assert_eq!( @@ -764,11 +780,13 @@ mod tests { Ok::<(), crate::admx::AdapterError>(()) })(); - for value_name in [ - "State", "Boolean", "Decimal", "Enum", "Multi", "Text", "ListOne", "ListTwo", - ] { + for value_name in ["State", "Boolean", "Decimal", "Enum", "Multi", "Text"] { apply_value("currentUser", &key, value_name, &PolicyValue::Delete).unwrap(); } + let list_key = format!("{key}\\List"); + for value_name in ["ListOne", "ListTwo"] { + apply_value("currentUser", &list_key, value_name, &PolicyValue::Delete).unwrap(); + } RegistryHelper::new_from_registry(&Registry { key_path: key_path("currentUser", &key), exist: Some(false), diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index 653dda33f..b6992c6e1 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -18,6 +18,10 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $resourceType = 'GPO.ControlPanel/Arp' $keyPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Uninstall' $valueName = 'NoAddPage' + $moduleStateKeyPath = 'HKCU:\Software\Policies\Microsoft\Windows\PowerShell\ModuleLogging' + $moduleListKeyPath = Join-Path $moduleStateKeyPath 'ModuleNames' + $moduleStateValueName = 'EnableModuleLogging' + $testModuleName = "DscGroupPolicyTemplateTest_$PID" function Invoke-GroupPolicyGet { param([string]$State) @@ -137,5 +141,91 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { (Invoke-GroupPolicyGet -State $state).NoAddPage | Should -BeExactly $state } } + + It 'Gets all configured values for a policy list without input' { + $resources = @(dsc resource list 'GPO.WindowsComponents/PowerShell' --adapter $adapterType | ConvertFrom-Json) + if ($resources.Count -eq 0) { + Set-ItResult -Skipped -Because 'PowerShellExecutionPolicy.admx is not installed.' + return + } + + $stateKeyExisted = Test-Path -LiteralPath $moduleStateKeyPath + $listKeyExisted = Test-Path -LiteralPath $moduleListKeyPath + $stateValueExisted = $false + $stateValue = $null + $stateValueKind = $null + $listValueExisted = $false + $listValue = $null + $listValueKind = $null + + if ($stateKeyExisted) { + $stateKey = Get-Item -LiteralPath $moduleStateKeyPath + if ($stateKey.GetValueNames() -contains $moduleStateValueName) { + $stateValueExisted = $true + $stateValue = $stateKey.GetValue($moduleStateValueName, $null, 'DoNotExpandEnvironmentNames') + $stateValueKind = $stateKey.GetValueKind($moduleStateValueName) + } + } + if ($listKeyExisted) { + $listKey = Get-Item -LiteralPath $moduleListKeyPath + if ($listKey.GetValueNames() -contains $testModuleName) { + $listValueExisted = $true + $listValue = $listKey.GetValue($testModuleName, $null, 'DoNotExpandEnvironmentNames') + $listValueKind = $listKey.GetValueKind($testModuleName) + } + } + + try { + $json = @{ + ModuleLogging = @{ + state = 'Enabled' + Listbox_ModuleNames = @{ + $testModuleName = 'PSResourceGet' + } + } + } | ConvertTo-Json -Depth 5 -Compress + $json | dsc resource set -r 'GPO.WindowsComponents/PowerShell' -f - 2>$TestDrive/error.log | Out-Null + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $out = dsc resource get -r 'GPO.WindowsComponents/PowerShell' 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actualState = ($out | ConvertFrom-Json).actualState + $actualState.ModuleLogging.Listbox_ModuleNames.$testModuleName | + Should -BeExactly 'PSResourceGet' + } + finally { + if ($listValueExisted) { + (Get-Item -LiteralPath $moduleListKeyPath).SetValue( + $testModuleName, + $listValue, + $listValueKind) + } + else { + Remove-ItemProperty -LiteralPath $moduleListKeyPath -Name $testModuleName -ErrorAction Ignore + } + if ($stateValueExisted) { + (Get-Item -LiteralPath $moduleStateKeyPath).SetValue( + $moduleStateValueName, + $stateValue, + $stateValueKind) + } + else { + Remove-ItemProperty -LiteralPath $moduleStateKeyPath -Name $moduleStateValueName -ErrorAction Ignore + } + if (!$listKeyExisted -and (Test-Path -LiteralPath $moduleListKeyPath)) { + $listKey = Get-Item -LiteralPath $moduleListKeyPath + if ($listKey.ValueCount -eq 0 -and $listKey.SubKeyCount -eq 0) { + Remove-Item -LiteralPath $moduleListKeyPath + } + } + if (!$stateKeyExisted -and (Test-Path -LiteralPath $moduleStateKeyPath)) { + $stateKey = Get-Item -LiteralPath $moduleStateKeyPath + if ($stateKey.ValueCount -eq 0 -and $stateKey.SubKeyCount -eq 0) { + Remove-Item -LiteralPath $moduleStateKeyPath + } + } + } + } } } diff --git a/lib/dsc-lib-registry/locales/en-us.toml b/lib/dsc-lib-registry/locales/en-us.toml index 3ab710b3d..ff1d82573 100644 --- a/lib/dsc-lib-registry/locales/en-us.toml +++ b/lib/dsc-lib-registry/locales/en-us.toml @@ -7,6 +7,7 @@ _version = 1 invalidHive = "Invalid hive" json = "JSON" offlineRegistry = "Offline registry" +offlineValueEnumeration = "Value enumeration is not supported for offline registries" registry = "Registry" registryKey = "Registry key" registryKeyNotFound = "Registry key not found" diff --git a/lib/dsc-lib-registry/src/lib.rs b/lib/dsc-lib-registry/src/lib.rs index ff786271b..0ec6bce9c 100644 --- a/lib/dsc-lib-registry/src/lib.rs +++ b/lib/dsc-lib-registry/src/lib.rs @@ -183,6 +183,37 @@ impl RegistryHelper { } } + /// Enumerate all named values under the configured registry key. + /// + /// A missing key is returned as an empty collection. + /// + /// # Errors + /// + /// * `RegistryError` - The error that occurred. + pub fn get_values(&self) -> Result, RegistryError> { + if self.offline_hive.is_some() { + return Err(RegistryError::OfflineRegistry( + t!("error.offlineValueEnumeration").to_string(), + )); + } + + let (reg_key, _) = match self.open(Security::Read) { + Ok(result) => result, + Err(RegistryError::RegistryKeyNotFound(_)) => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + + let mut values = Vec::new(); + for value in reg_key.values() { + let value = value.map_err(|error| RegistryError::Registry(error.into()))?; + if let Some(data) = convert_reg_value(value.data())? { + values.push((value.name().to_string_lossy(), data)); + } + } + + Ok(values) + } + /// Set in registry. /// /// # Returns From faafbe160334640826642eef23bd34e3c5dcb26e Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 22 Aug 2026 22:25:08 -0700 Subject: [PATCH 12/13] Represent Group Policy lists as string arrays Generate array schemas for ADMX list elements and map their registry values to stable string lists for get and set operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template/locales/en-us.toml | 2 +- adapters/group_policy_template/src/admx.rs | 14 +- .../group_policy_template/src/registry.rs | 73 ++++----- .../tests/group_policy_template.tests.ps1 | 147 +++++++++--------- 4 files changed, 113 insertions(+), 123 deletions(-) diff --git a/adapters/group_policy_template/locales/en-us.toml b/adapters/group_policy_template/locales/en-us.toml index bb43a45cf..3cb23745c 100644 --- a/adapters/group_policy_template/locales/en-us.toml +++ b/adapters/group_policy_template/locales/en-us.toml @@ -41,7 +41,7 @@ invalidPolicyValue = "Policy property '%{policy}' must be a state string or an o policyHasNoToggle = "Policy '%{policy}' has no enabled or disabled registry value; configure its child settings instead." unknownElement = "Element '%{element}' does not exist on policy '%{policy}'." elementHasNoValueName = "Policy element '%{element}' does not define a registry value name." -listNotObject = "List element '%{element}' must be an object." +listNotArray = "List element '%{element}' must be an array." listValueNotString = "Every value in list element '%{element}' must be a string." invalidElementValue = "The value for policy element '%{element}' does not match its ADMX definition." scopeNotSupported = "Policy '%{policy}' does not support scope '%{scope}'." diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index d2f359b8d..72dabf45d 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -559,9 +559,10 @@ fn element_schema(element: &PolicyElement) -> Value { })).collect::>() }), ElementKind::List => json!({ - "type": "object", + "type": "array", "title": element.id, - "additionalProperties": { "type": "string" } + "items": { "type": "string" }, + "uniqueItems": true }), ElementKind::MultiText => json!({ "type": "array", @@ -1009,6 +1010,15 @@ mod tests { [1]["title"], "Second choice" ); + assert_eq!( + listed.schema["embedded"]["properties"]["ComplexPolicy"]["properties"]["ListValue"], + json!({ + "type": "array", + "title": "ListValue", + "items": { "type": "string" }, + "uniqueItems": true + }) + ); assert_eq!( listed.schema["embedded"]["properties"]["SimplePolicy"]["enum"], json!(["Enabled", "Disabled", "NotConfigured"]) diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index 30792938b..cba882e2a 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -116,7 +116,7 @@ fn read_policy( }; if !policy.elements.is_empty() { let mut result = Map::new(); - let empty_list_request = Value::Object(Map::new()); + let empty_list_request = Value::Array(Vec::new()); result.insert( "state".to_string(), Value::String(state.as_str().to_string()), @@ -326,30 +326,22 @@ fn read_element( ) -> Result, AdapterError> { let element_key = element.key.as_deref().unwrap_or(&policy.key); if matches!(element.kind, ElementKind::List) { - let requested_values = requested.as_object().ok_or_else(|| { - AdapterError::Input(t!("registry.listNotObject", element = element.id).to_string()) + requested.as_array().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotArray", element = element.id).to_string()) })?; - let mut result = Map::new(); - if requested_values.is_empty() { - let helper = RegistryHelper::new(&key_path(scope, element_key), None, None) - .map_err(registry_error)?; - for (value_name, data) in helper.get_values().map_err(registry_error)? { - result.insert(value_name, registry_value_to_json(&data)); - } - return Ok(Some(Value::Object(result))); - } - for value_name in requested_values.keys() { - let helper = RegistryHelper::new( - &key_path(scope, element_key), - Some(value_name.clone()), - None, - ) + let helper = RegistryHelper::new(&key_path(scope, element_key), None, None) .map_err(registry_error)?; - if let Some(data) = helper.get().map_err(registry_error)?.value_data { - result.insert(value_name.clone(), registry_value_to_json(&data)); + let mut values = helper.get_values().map_err(registry_error)?; + values.sort_by(|left, right| left.0.cmp(&right.0)); + let mut result = Vec::new(); + for (_, data) in values { + let value = registry_value_to_json(&data); + if !value.is_string() { + return Err(invalid_element_value(element)); } + result.push(value); } - return Ok(Some(Value::Object(result))); + return Ok(Some(Value::Array(result))); } let value_name = element.value_name.as_ref().ok_or_else(|| { AdapterError::Resource( @@ -376,23 +368,19 @@ fn write_element( ) -> Result<(), AdapterError> { let element_key = element.key.as_deref().unwrap_or(&policy.key); if matches!(element.kind, ElementKind::List) { - let values = value.as_object().ok_or_else(|| { - AdapterError::Input(t!("registry.listNotObject", element = element.id).to_string()) + let values = value.as_array().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotArray", element = element.id).to_string()) })?; - for (value_name, value) in values { - let data = RegistryValueData::String( - value - .as_str() - .ok_or_else(|| { - AdapterError::Input( - t!("registry.listValueNotString", element = element.id).to_string(), - ) - })? - .to_string(), - ); + for value in values { + let value = value.as_str().ok_or_else(|| { + AdapterError::Input( + t!("registry.listValueNotString", element = element.id).to_string(), + ) + })?; + let data = RegistryValueData::String(value.to_string()); RegistryHelper::new( &key_path(scope, element_key), - Some(value_name.clone()), + Some(value.to_string()), Some(data), ) .map_err(registry_error)? @@ -746,10 +734,7 @@ mod tests { "Enum": "choice", "Multi": ["one", "two"], "Text": "value", - "List": { - "ListOne": "first", - "ListTwo": "second" - } + "List": ["first", "second"] }); let result = (|| { @@ -759,13 +744,7 @@ mod tests { let actual = read_policy(®istry_policy, "currentUser", Some(&desired))?.unwrap(); assert_eq!(actual, desired); let actual = read_policy(®istry_policy, "currentUser", None)?.unwrap(); - assert_eq!( - actual["List"], - json!({ - "ListOne": "first", - "ListTwo": "second" - }) - ); + assert_eq!(actual["List"], json!(["first", "second"])); write_policy(®istry_policy, "currentUser", &json!("Disabled"))?; assert_eq!( @@ -784,7 +763,7 @@ mod tests { apply_value("currentUser", &key, value_name, &PolicyValue::Delete).unwrap(); } let list_key = format!("{key}\\List"); - for value_name in ["ListOne", "ListTwo"] { + for value_name in ["first", "second"] { apply_value("currentUser", &list_key, value_name, &PolicyValue::Delete).unwrap(); } RegistryHelper::new_from_registry(&Registry { diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index b6992c6e1..9f1f6581f 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -63,6 +63,7 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $actualState.psobject.Properties.Name | Should -Contain 'ModuleLogging' $actualState.psobject.Properties.Name | Should -Not -Contain 'EnableModuleLogging' $actualState.psobject.Properties.Name | Should -Not -Contain 'EnabledModuleLogging' + $actualState.ModuleLogging.Listbox_ModuleNames.GetType().IsArray | Should -BeTrue foreach ($property in $actualState.psobject.Properties | Where-Object Name -NE 'scope') { $state = if ($property.Value -is [string]) { $property.Value @@ -143,86 +144,86 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { } It 'Gets all configured values for a policy list without input' { - $resources = @(dsc resource list 'GPO.WindowsComponents/PowerShell' --adapter $adapterType | ConvertFrom-Json) - if ($resources.Count -eq 0) { - Set-ItResult -Skipped -Because 'PowerShellExecutionPolicy.admx is not installed.' - return - } + $resources = @(dsc resource list 'GPO.WindowsComponents/PowerShell' --adapter $adapterType | ConvertFrom-Json) + if ($resources.Count -eq 0) { + Set-ItResult -Skipped -Because 'PowerShellExecutionPolicy.admx is not installed.' + return + } - $stateKeyExisted = Test-Path -LiteralPath $moduleStateKeyPath - $listKeyExisted = Test-Path -LiteralPath $moduleListKeyPath - $stateValueExisted = $false - $stateValue = $null - $stateValueKind = $null - $listValueExisted = $false - $listValue = $null - $listValueKind = $null + $stateKeyExisted = Test-Path -LiteralPath $moduleStateKeyPath + $listKeyExisted = Test-Path -LiteralPath $moduleListKeyPath + $stateValueExisted = $false + $stateValue = $null + $stateValueKind = $null + $listValueExisted = $false + $listValue = $null + $listValueKind = $null + + if ($stateKeyExisted) { + $stateKey = Get-Item -LiteralPath $moduleStateKeyPath + if ($stateKey.GetValueNames() -contains $moduleStateValueName) { + $stateValueExisted = $true + $stateValue = $stateKey.GetValue($moduleStateValueName, $null, 'DoNotExpandEnvironmentNames') + $stateValueKind = $stateKey.GetValueKind($moduleStateValueName) + } + } + if ($listKeyExisted) { + $listKey = Get-Item -LiteralPath $moduleListKeyPath + if ($listKey.GetValueNames() -contains $testModuleName) { + $listValueExisted = $true + $listValue = $listKey.GetValue($testModuleName, $null, 'DoNotExpandEnvironmentNames') + $listValueKind = $listKey.GetValueKind($testModuleName) + } + } - if ($stateKeyExisted) { - $stateKey = Get-Item -LiteralPath $moduleStateKeyPath - if ($stateKey.GetValueNames() -contains $moduleStateValueName) { - $stateValueExisted = $true - $stateValue = $stateKey.GetValue($moduleStateValueName, $null, 'DoNotExpandEnvironmentNames') - $stateValueKind = $stateKey.GetValueKind($moduleStateValueName) + try { + $json = @{ + ModuleLogging = @{ + state = 'Enabled' + Listbox_ModuleNames = @($testModuleName) } + } | ConvertTo-Json -Depth 5 -Compress + $json | dsc resource set -r 'GPO.WindowsComponents/PowerShell' -f - 2>$TestDrive/error.log | Out-Null + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + + $out = dsc resource get -r 'GPO.WindowsComponents/PowerShell' 2>$TestDrive/error.log + + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) + $actualState = ($out | ConvertFrom-Json).actualState + $actualState.ModuleLogging.Listbox_ModuleNames | Should -Contain $testModuleName + $actualState.ModuleLogging.Listbox_ModuleNames | ForEach-Object { + $_ | Should -BeOfType [string] } - if ($listKeyExisted) { - $listKey = Get-Item -LiteralPath $moduleListKeyPath - if ($listKey.GetValueNames() -contains $testModuleName) { - $listValueExisted = $true - $listValue = $listKey.GetValue($testModuleName, $null, 'DoNotExpandEnvironmentNames') - $listValueKind = $listKey.GetValueKind($testModuleName) - } + } + finally { + if ($listValueExisted) { + (Get-Item -LiteralPath $moduleListKeyPath).SetValue( + $testModuleName, + $listValue, + $listValueKind) } - - try { - $json = @{ - ModuleLogging = @{ - state = 'Enabled' - Listbox_ModuleNames = @{ - $testModuleName = 'PSResourceGet' - } - } - } | ConvertTo-Json -Depth 5 -Compress - $json | dsc resource set -r 'GPO.WindowsComponents/PowerShell' -f - 2>$TestDrive/error.log | Out-Null - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) - - $out = dsc resource get -r 'GPO.WindowsComponents/PowerShell' 2>$TestDrive/error.log - - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) - $actualState = ($out | ConvertFrom-Json).actualState - $actualState.ModuleLogging.Listbox_ModuleNames.$testModuleName | - Should -BeExactly 'PSResourceGet' + else { + Remove-ItemProperty -LiteralPath $moduleListKeyPath -Name $testModuleName -ErrorAction Ignore } - finally { - if ($listValueExisted) { - (Get-Item -LiteralPath $moduleListKeyPath).SetValue( - $testModuleName, - $listValue, - $listValueKind) - } - else { - Remove-ItemProperty -LiteralPath $moduleListKeyPath -Name $testModuleName -ErrorAction Ignore - } - if ($stateValueExisted) { - (Get-Item -LiteralPath $moduleStateKeyPath).SetValue( - $moduleStateValueName, - $stateValue, - $stateValueKind) - } - else { - Remove-ItemProperty -LiteralPath $moduleStateKeyPath -Name $moduleStateValueName -ErrorAction Ignore - } - if (!$listKeyExisted -and (Test-Path -LiteralPath $moduleListKeyPath)) { - $listKey = Get-Item -LiteralPath $moduleListKeyPath - if ($listKey.ValueCount -eq 0 -and $listKey.SubKeyCount -eq 0) { - Remove-Item -LiteralPath $moduleListKeyPath - } + if ($stateValueExisted) { + (Get-Item -LiteralPath $moduleStateKeyPath).SetValue( + $moduleStateValueName, + $stateValue, + $stateValueKind) + } + else { + Remove-ItemProperty -LiteralPath $moduleStateKeyPath -Name $moduleStateValueName -ErrorAction Ignore + } + if (!$listKeyExisted -and (Test-Path -LiteralPath $moduleListKeyPath)) { + $listKey = Get-Item -LiteralPath $moduleListKeyPath + if ($listKey.ValueCount -eq 0 -and $listKey.SubKeyCount -eq 0) { + Remove-Item -LiteralPath $moduleListKeyPath } - if (!$stateKeyExisted -and (Test-Path -LiteralPath $moduleStateKeyPath)) { - $stateKey = Get-Item -LiteralPath $moduleStateKeyPath - if ($stateKey.ValueCount -eq 0 -and $stateKey.SubKeyCount -eq 0) { - Remove-Item -LiteralPath $moduleStateKeyPath + } + if (!$stateKeyExisted -and (Test-Path -LiteralPath $moduleStateKeyPath)) { + $stateKey = Get-Item -LiteralPath $moduleStateKeyPath + if ($stateKey.ValueCount -eq 0 -and $stateKey.SubKeyCount -eq 0) { + Remove-Item -LiteralPath $moduleStateKeyPath } } } From 2a5db7b0b7f58f4906bd1e49c8ee2f2bcef15ecf Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sun, 23 Aug 2026 07:31:46 -0700 Subject: [PATCH 13/13] Use lower-camel states for simple policies Expose ADMX policies without child settings as enabled, disabled, or notConfigured while preserving the existing nested policy state contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../group_policy_template/locales/en-us.toml | 1 + adapters/group_policy_template/src/admx.rs | 4 +- .../group_policy_template/src/registry.rs | 47 +++++++++++++++++-- .../tests/group_policy_template.tests.ps1 | 17 ++++--- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/adapters/group_policy_template/locales/en-us.toml b/adapters/group_policy_template/locales/en-us.toml index 3cb23745c..6c6e4ec04 100644 --- a/adapters/group_policy_template/locales/en-us.toml +++ b/adapters/group_policy_template/locales/en-us.toml @@ -37,6 +37,7 @@ invalidInput = "Invalid JSON input: %{error}" invalidScope = "The scope must be either 'allUsers' or 'currentUser'." unknownPolicy = "Policy property '%{policy}' does not exist on adapted resource '%{resource}'." invalidPolicyState = "Policy property '%{policy}' must be 'Enabled', 'Disabled', or 'NotConfigured'." +invalidSimplePolicyState = "Policy property '%{policy}' must be 'enabled', 'disabled', or 'notConfigured'." invalidPolicyValue = "Policy property '%{policy}' must be a state string or an object." policyHasNoToggle = "Policy '%{policy}' has no enabled or disabled registry value; configure its child settings instead." unknownElement = "Element '%{element}' does not exist on policy '%{policy}'." diff --git a/adapters/group_policy_template/src/admx.rs b/adapters/group_policy_template/src/admx.rs index 72dabf45d..b7721fa8c 100644 --- a/adapters/group_policy_template/src/admx.rs +++ b/adapters/group_policy_template/src/admx.rs @@ -483,7 +483,7 @@ fn create_listed_resource(resource: &CategoryResource, path: &Path) -> ListedRes "type": "string", "title": policy.display_name, "description": policy.description, - "enum": ["Enabled", "Disabled", "NotConfigured"] + "enum": ["enabled", "disabled", "notConfigured"] }) } else { let mut element_properties = Map::new(); @@ -1021,7 +1021,7 @@ mod tests { ); assert_eq!( listed.schema["embedded"]["properties"]["SimplePolicy"]["enum"], - json!(["Enabled", "Disabled", "NotConfigured"]) + json!(["enabled", "disabled", "notConfigured"]) ); } diff --git a/adapters/group_policy_template/src/registry.rs b/adapters/group_policy_template/src/registry.rs index cba882e2a..955be6005 100644 --- a/adapters/group_policy_template/src/registry.rs +++ b/adapters/group_policy_template/src/registry.rs @@ -31,6 +31,17 @@ impl PolicyState { } } + fn parse_simple(value: &Value, policy: &Policy) -> Result { + match value.as_str() { + Some("enabled") => Ok(Self::Enabled), + Some("disabled") => Ok(Self::Disabled), + Some("notConfigured") => Ok(Self::NotConfigured), + _ => Err(AdapterError::Input( + t!("registry.invalidSimplePolicyState", policy = policy.name).to_string(), + )), + } + } + const fn as_str(self) -> &'static str { match self { Self::Enabled => "Enabled", @@ -38,6 +49,14 @@ impl PolicyState { Self::NotConfigured => "NotConfigured", } } + + const fn as_simple_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + Self::NotConfigured => "notConfigured", + } + } } pub fn get( @@ -142,7 +161,7 @@ fn read_policy( } return Ok(Some(Value::Object(result))); } - Ok(Some(Value::String(state.as_str().to_string()))) + Ok(Some(Value::String(state.as_simple_str().to_string()))) } fn read_state(policy: &Policy, scope: &str) -> Result { @@ -157,7 +176,12 @@ fn read_state(policy: &Policy, scope: &str) -> Result fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), AdapterError> { if input.is_string() { - return write_state(policy, scope, PolicyState::parse(input, policy)?); + let state = if policy.elements.is_empty() { + PolicyState::parse_simple(input, policy)? + } else { + PolicyState::parse(input, policy)? + }; + return write_state(policy, scope, state); } let object = input.as_object().ok_or_else(|| { AdapterError::Input(t!("registry.invalidPolicyValue", policy = policy.name).to_string()) @@ -596,8 +620,8 @@ fn serialize_result(result: &Map) -> Result, AdapterE #[cfg(test)] mod tests { use super::{ - apply_value, element_data_to_json, get, input_to_string, json_to_element_data, key_path, - parse_get_input, parse_input, parse_scope, read_policy, scope_is_supported, + PolicyState, apply_value, element_data_to_json, get, input_to_string, json_to_element_data, + key_path, parse_get_input, parse_input, parse_scope, read_policy, scope_is_supported, serialize_result, set, state_matches, validate_scope, write_policy, }; use crate::admx::{ElementKind, EnumItem, Policy, PolicyClass, PolicyElement, PolicyValue}; @@ -644,6 +668,19 @@ mod tests { ); } + #[test] + fn uses_lower_camel_case_for_simple_policy_states() { + let simple = policy(PolicyClass::Both); + assert_eq!( + PolicyState::parse_simple(&json!("enabled"), &simple).unwrap(), + PolicyState::Enabled + ); + assert_eq!(PolicyState::Disabled.as_simple_str(), "disabled"); + assert_eq!(PolicyState::NotConfigured.as_simple_str(), "notConfigured"); + assert!(PolicyState::parse_simple(&json!("Enabled"), &simple).is_err()); + assert!(PolicyState::parse(&json!("enabled"), &simple).is_err()); + } + #[test] fn parses_input_scope_and_serializes_results() { let input = parse_input(r#"{"scope":"allUsers","Policy":true}"#).unwrap(); @@ -679,7 +716,7 @@ mod tests { assert!(validate_scope(&machine, "currentUser").is_err()); assert_eq!( read_policy(&machine, "currentUser", None).unwrap(), - Some(json!("NotConfigured")) + Some(json!("notConfigured")) ); } diff --git a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 index 9f1f6581f..0f2aea64a 100644 --- a/adapters/group_policy_template/tests/group_policy_template.tests.ps1 +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -65,13 +65,12 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { $actualState.psobject.Properties.Name | Should -Not -Contain 'EnabledModuleLogging' $actualState.ModuleLogging.Listbox_ModuleNames.GetType().IsArray | Should -BeTrue foreach ($property in $actualState.psobject.Properties | Where-Object Name -NE 'scope') { - $state = if ($property.Value -is [string]) { - $property.Value + if ($property.Value -is [string]) { + $property.Value | Should -BeIn 'enabled', 'disabled', 'notConfigured' } else { - $property.Value.state + $property.Value.state | Should -BeIn 'Enabled', 'Disabled', 'NotConfigured' } - $state | Should -BeIn 'Enabled', 'Disabled', 'NotConfigured' } } @@ -115,23 +114,23 @@ Describe 'Microsoft.Adapter/GroupPolicyTemplate tests' -Skip:(!$IsWindows) { } $json = @{ - NoAddPage = 'Enabled' + NoAddPage = 'enabled' } | 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.scope | Should -BeExactly 'currentUser' - ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeExactly 'Enabled' - (Invoke-GroupPolicyGet -State 'Enabled').NoAddPage | Should -BeExactly 'Enabled' + ($out | ConvertFrom-Json).afterState.NoAddPage | Should -BeExactly 'enabled' + (Invoke-GroupPolicyGet -State 'enabled').NoAddPage | Should -BeExactly 'enabled' $out = dsc resource get -r $resourceType 2>$TestDrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log) $actualState = ($out | ConvertFrom-Json).actualState $actualState.scope | Should -BeExactly 'currentUser' - $actualState.NoAddPage | Should -BeExactly 'Enabled' + $actualState.NoAddPage | Should -BeExactly 'enabled' - foreach ($state in 'Disabled', 'NotConfigured') { + foreach ($state in 'disabled', 'notConfigured') { $json = @{ NoAddPage = $state } | ConvertTo-Json -Compress