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 + } + } +}