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..d2eff712a --- /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": false + }, + { + "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..6c6e4ec04 --- /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}'." +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}" +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." +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}'." +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}'." +elementHasNoValueName = "Policy element '%{element}' does not define a registry value name." +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}'." +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..b7721fa8c --- /dev/null +++ b/adapters/group_policy_template/src/admx.rs @@ -0,0 +1,1173 @@ +// 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( + t!( + "admx.readDirectory", + path = policy_definitions.display(), + error = 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: resource_type_name(parent_name, &category_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::Data(RegistryValueData::DWord(0))) + }); + 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: policy_property_name(required_attribute("name")?), + 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 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(); + element_properties.insert("state".to_string(), state_schema); + for element in &policy.elements { + element_properties.insert(element.id.clone(), element_schema(element)); + } + json!({ + "type": "object", + "title": policy.display_name, + "description": policy.description, + "additionalProperties": false, + "properties": element_properties + }) + }; + 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": "array", + "title": element.id, + "items": { "type": "string" }, + "uniqueItems": true + }), + 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 resource_type_name(parent_category: &str, category: &str) -> String { + format!( + "GPO.{}/{}", + resource_name_segment(parent_category), + resource_name_segment(category) + ) +} + +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)) +} + +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 (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 (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) + .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::{ + AdapterError, ElementKind, PolicyClass, PolicyValue, adml_path, create_listed_resource, + 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; + 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() { + 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_"); + assert_eq!( + 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] + 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::Data(RegistryValueData::DWord(0))) + ); + + 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"]["properties"]["NumberValue"]["minimum"], + 1 + ); + assert_eq!( + listed.schema["embedded"]["properties"]["ComplexPolicy"]["properties"]["EnumValue"]["oneOf"] + [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"]) + ); + } + + #[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 new file mode 100644 index 000000000..efafcdfb4 --- /dev/null +++ b/adapters/group_policy_template/src/main.rs @@ -0,0 +1,112 @@ +// 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(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" { + 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) + } + } + 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()) +} + +#[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 new file mode 100644 index 000000000..955be6005 --- /dev/null +++ b/adapters/group_policy_template/src/registry.rs @@ -0,0 +1,1006 @@ +// 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"; + +#[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(), + )), + } + } + + 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", + Self::Disabled => "Disabled", + 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( + input: &str, + resource_type: &str, + resource_path: &str, +) -> Result, AdapterError> { + 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"); + 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; + } + 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> { + 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(); + let empty_list_request = Value::Array(Vec::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 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); + } + } + return Ok(Some(Value::Object(result))); + } + Ok(Some(Value::String(state.as_simple_str().to_string()))) +} + +fn read_state(policy: &Policy, scope: &str) -> Result { + if state_matches(policy, scope, true)? { + Ok(PolicyState::Enabled) + } else if state_matches(policy, scope, false)? { + Ok(PolicyState::Disabled) + } else { + Ok(PolicyState::NotConfigured) + } +} + +fn write_policy(policy: &Policy, scope: &str, input: &Value) -> Result<(), AdapterError> { + if input.is_string() { + 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()) + })?; + if let Some(state) = object.get("state") { + write_state(policy, scope, PolicyState::parse(state, policy)?)?; + } + for (name, value) in object { + if name == "state" { + 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_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 { + 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 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, + 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 matches!(value, Some(PolicyValue::Delete)) && 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) { + requested.as_array().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotArray", element = element.id).to_string()) + })?; + let helper = RegistryHelper::new(&key_path(scope, element_key), None, None) + .map_err(registry_error)?; + 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::Array(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_array().ok_or_else(|| { + AdapterError::Input(t!("registry.listNotArray", element = element.id).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.to_string()), + 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_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), + 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::{ + 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}; + 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() { + assert_eq!( + key_path("currentUser", "Software\\Policies"), + "HKCU\\Software\\Policies" + ); + assert_eq!( + key_path("allUsers", "Software\\Policies"), + "HKLM\\Software\\Policies" + ); + } + + #[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(); + 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()); + 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_eq!( + read_policy(&machine, "currentUser", None).unwrap(), + Some(json!("notConfigured")) + ); + } + + #[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::Data(RegistryValueData::DWord(0))); + 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: Some(format!("{key}\\List")), + value_name: None, + kind: ElementKind::List, + }, + ]; + + let desired = json!({ + "state": "Enabled", + "Boolean": true, + "Decimal": 42, + "Enum": "choice", + "Multi": ["one", "two"], + "Text": "value", + "List": ["first", "second"] + }); + + let result = (|| { + write_policy(®istry_policy, "currentUser", &desired)?; + assert!(state_matches(®istry_policy, "currentUser", true)?); + + 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!(["first", "second"])); + + 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)?.unwrap()["state"], + "NotConfigured" + ); + Ok::<(), crate::admx::AdapterError>(()) + })(); + + 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 ["first", "second"] { + apply_value("currentUser", &list_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!({"state": "invalid"})).is_err()); + assert!(write_policy(&value_policy, "currentUser", &json!({"Unknown": true})).is_err()); + assert!(!state_matches(&value_policy, "currentUser", true).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() + ); + } +} 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..0f2aea64a --- /dev/null +++ b/adapters/group_policy_template/tests/group_policy_template.tests.ps1 @@ -0,0 +1,231 @@ +# 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/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) + + $json = @{ + 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) + 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 | ForEach-Object { + $_.requireAdapter | Should -BeExactly $adapterType + $_.path | Should -Exist + } + } + + 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) + $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' + $actualState.ModuleLogging.Listbox_ModuleNames.GetType().IsArray | Should -BeTrue + foreach ($property in $actualState.psobject.Properties | Where-Object Name -NE 'scope') { + if ($property.Value -is [string]) { + $property.Value | Should -BeIn 'enabled', 'disabled', 'notConfigured' + } + else { + $property.Value.state | Should -BeIn 'Enabled', 'Disabled', 'NotConfigured' + } + } + } + + 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 = '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 = 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' + + 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 + } + } + + 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) + } + } | 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] + } + } + 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/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") 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