From d8564e5dbaf6eae4818b1bd2f6945ae69084aa34 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 14 Mar 2026 09:55:38 -0700 Subject: [PATCH 1/3] Add `Microsoft.Windows/FeatureOnDemandList` resource --- .vscode/settings.json | 8 +- resources/dism_dsc/.project.data.json | 2 +- .../featureondemand.dsc.resource.json | 123 +++++++++++ resources/dism_dsc/locales/en-us.toml | 28 ++- .../optionalfeature.dsc.resource.json | 9 +- .../dism_dsc/src/feature_on_demand/export.rs | 98 +++++++++ .../dism_dsc/src/feature_on_demand/get.rs | 50 +++++ .../dism_dsc/src/feature_on_demand/mod.rs | 11 + .../dism_dsc/src/feature_on_demand/set.rs | 76 +++++++ .../dism_dsc/src/feature_on_demand/types.rs | 47 ++++ resources/dism_dsc/src/main.rs | 33 ++- .../dism_dsc/src/optional_feature/dism.rs | 205 +++++++++++++++-- .../dism_dsc/src/optional_feature/export.rs | 111 +--------- .../dism_dsc/src/optional_feature/mod.rs | 2 +- .../dism_dsc/src/optional_feature/set.rs | 5 +- .../dism_dsc/src/optional_feature/types.rs | 57 ++--- resources/dism_dsc/src/util.rs | 207 ++++++++++++++++++ .../tests/featureOnDemand_export.tests.ps1 | 148 +++++++++++++ .../tests/featureOnDemand_get.tests.ps1 | 92 ++++++++ .../tests/featureOnDemand_set.tests.ps1 | 91 ++++++++ .../tests/optionalFeature_export.tests.ps1 | 11 +- 21 files changed, 1225 insertions(+), 189 deletions(-) create mode 100644 resources/dism_dsc/featureondemand.dsc.resource.json create mode 100644 resources/dism_dsc/src/feature_on_demand/export.rs create mode 100644 resources/dism_dsc/src/feature_on_demand/get.rs create mode 100644 resources/dism_dsc/src/feature_on_demand/mod.rs create mode 100644 resources/dism_dsc/src/feature_on_demand/set.rs create mode 100644 resources/dism_dsc/src/feature_on_demand/types.rs create mode 100644 resources/dism_dsc/src/util.rs create mode 100644 resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 create mode 100644 resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 create mode 100644 resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 diff --git a/.vscode/settings.json b/.vscode/settings.json index c9ed26460..2282390d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,11 @@ "." ], "azure-pipelines.1ESPipelineTemplatesSchemaFile": true, - "powershell.codeFormatting.preset": "OTBS" + "powershell.codeFormatting.preset": "OTBS", + "chat.tools.terminal.autoApprove": { + "cargo check": true, + "cargo test": true, + "cargo clippy": true, + "Invoke-Pester": true + } } \ No newline at end of file diff --git a/resources/dism_dsc/.project.data.json b/resources/dism_dsc/.project.data.json index 9471d7ed4..9e456e3b9 100644 --- a/resources/dism_dsc/.project.data.json +++ b/resources/dism_dsc/.project.data.json @@ -5,6 +5,6 @@ "SupportedPlatformOS": "Windows", "Binaries": ["dism_dsc"], "CopyFiles": { - "Windows": ["optionalfeature.dsc.resource.json"] + "Windows": ["optionalfeature.dsc.resource.json", "featureondemand.dsc.resource.json"] } } diff --git a/resources/dism_dsc/featureondemand.dsc.resource.json b/resources/dism_dsc/featureondemand.dsc.resource.json new file mode 100644 index 000000000..6f5a465ce --- /dev/null +++ b/resources/dism_dsc/featureondemand.dsc.resource.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage Windows Features on Demand (capabilities) using the DISM API.", + "tags": [ + "windows", + "dism", + "capability", + "featureondemand", + "fod" + ], + "type": "Microsoft.Windows/FeatureOnDemandList", + "version": "0.1.0", + "get": { + "executable": "dism_dsc", + "args": [ + "get", + "feature-on-demand" + ], + "input": "stdin", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "dism_dsc", + "args": [ + "export", + "feature-on-demand" + ], + "input": "stdin", + "requireSecurityContext": "elevated" + }, + "set": { + "executable": "dism_dsc", + "args": [ + "set", + "feature-on-demand" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/FeatureOnDemandList/v0.1.0/schema.json", + "title": "Windows Feature on Demand", + "description": "Manage Windows Features on Demand (capabilities) using the DISM API. Supports get, set, and export operations.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/featureondemandlist/resource\n", + "markdownDescription": "The `Microsoft.Windows/FeatureOnDemandList` resource enables you to manage Windows Features on Demand (capabilities) using the DISM API.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/featureondemandlist/resource\n", + "type": "object", + "additionalProperties": false, + "required": [ + "capabilities" + ], + "properties": { + "_restartRequired": { + "type": "array", + "title": "Restart required", + "description": "Indicates that a system restart is required to complete the state change. Returned by the set operation when DISM reports that a reboot is needed.", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "capabilities": { + "type": "array", + "title": "Capabilities", + "description": "An array of Feature on Demand (capability) filters or information objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Capability name", + "description": "The name of the Windows capability (Feature on Demand). Required for get operation. For export operation, this is optional and wildcards (*) are supported for case-insensitive filtering." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the capability exists on the system. Set to false when the requested capability name is not recognized by DISM." + }, + "state": { + "type": "string", + "enum": [ + "NotPresent", + "UninstallPending", + "Staged", + "Removed", + "Installed", + "InstallPending", + "Superseded", + "PartiallyInstalled" + ], + "title": "Capability state", + "description": "The current state of the capability. For set operations, only Installed and NotPresent are accepted; other states are returned by get and export operations and are not valid inputs for set." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the capability. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the capability. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + }, + "downloadSize": { + "type": "integer", + "title": "Download size", + "description": "The download size of the capability in bytes. Returned by get and export operations." + }, + "installSize": { + "type": "integer", + "title": "Install size", + "description": "The install size of the capability in bytes. Returned by get and export operations." + } + } + } + } + } + } + } +} diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index 5e037dff2..d80544af4 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -1,10 +1,11 @@ _version = 1 [main] -missingOperation = "Missing operation argument" -usage = "Usage: dism_dsc " +missingArguments = "Missing operation and resource type arguments" +usage = "Usage: dism_dsc " windowsOnly = "This resource is only supported on Windows" unknownOperation = "Unknown operation '%{operation}'" +unknownResourceType = "Unknown resource type '%{resource_type}'. Expected 'optional-feature' or 'feature-on-demand'" errorReadingInput = "Error reading input: %{err}" [get] @@ -17,6 +18,24 @@ failedSerializeOutput = "Failed to serialize output: %{err}" failedParseInput = "Failed to parse input: %{err}" failedSerializeOutput = "Failed to serialize output: %{err}" +[fod_get] +failedParseInput = "Failed to parse input: %{err}" +capabilitiesArrayEmpty = "Capabilities array cannot be empty for get operation" +nameRequired = "name is required for get operation" +failedSerializeOutput = "Failed to serialize output: %{err}" + +[fod_export] +failedParseInput = "Failed to parse input: %{err}" +failedSerializeOutput = "Failed to serialize output: %{err}" + +[fod_set] +failedParseInput = "Failed to parse input: %{err}" +capabilitiesArrayEmpty = "Capabilities array cannot be empty for set operation" +nameRequired = "name is required for set operation" +stateRequired = "state is required for set operation" +unsupportedDesiredState = "Unsupported desired state '%{state}'. Supported states for set are: Installed, NotPresent" +failedSerializeOutput = "Failed to serialize output: %{err}" + [set] failedParseInput = "Failed to parse input: %{err}" featuresArrayEmpty = "Features array cannot be empty for set operation" @@ -34,3 +53,8 @@ getFeatureInfoFailed = "DismGetFeatureInfo failed for '%{name}': HRESULT %{hr}" enableFeatureFailed = "DismEnableFeature failed for '%{name}': HRESULT %{hr}" disableFeatureFailed = "DismDisableFeature failed for '%{name}': HRESULT %{hr}" getFeaturesFailed = "DismGetFeatures failed: HRESULT %{hr}" +capabilitiesNotSupported = "Capability operations are not supported on this system. DismGetCapabilities not found in dismapi.dll." +getCapabilitiesFailed = "DismGetCapabilities failed: HRESULT %{hr}" +getCapabilityInfoFailed = "DismGetCapabilityInfo failed for '%{name}': HRESULT %{hr}" +addCapabilityFailed = "DismAddCapability failed for '%{name}': HRESULT %{hr}" +removeCapabilityFailed = "DismRemoveCapability failed for '%{name}': HRESULT %{hr}" diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index 1a26dfd24..77c4b82c9 100644 --- a/resources/dism_dsc/optionalfeature.dsc.resource.json +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -12,7 +12,8 @@ "get": { "executable": "dism_dsc", "args": [ - "get" + "get", + "optional-feature" ], "input": "stdin", "requireSecurityContext": "elevated" @@ -20,7 +21,8 @@ "set": { "executable": "dism_dsc", "args": [ - "set" + "set", + "optional-feature" ], "input": "stdin", "implementsPretest": false, @@ -30,7 +32,8 @@ "export": { "executable": "dism_dsc", "args": [ - "export" + "export", + "optional-feature" ], "input": "stdin", "requireSecurityContext": "elevated" diff --git a/resources/dism_dsc/src/feature_on_demand/export.rs b/resources/dism_dsc/src/feature_on_demand/export.rs new file mode 100644 index 000000000..9d486251d --- /dev/null +++ b/resources/dism_dsc/src/feature_on_demand/export.rs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::feature_on_demand::types::{CapabilityState, FeatureOnDemandInfo, FeatureOnDemandList}; +use crate::util::{matches_wildcard, WildcardFilterable}; + +pub fn handle_export(input: &str) -> Result { + let filters: Vec = if input.trim().is_empty() { + vec![FeatureOnDemandInfo::default()] + } else { + let list: FeatureOnDemandList = serde_json::from_str(input) + .map_err(|e| t!("fod_export.failedParseInput", err = e.to_string()).to_string())?; + list.capabilities + }; + + let session = DismSessionHandle::open()?; + let all_basics = session.get_all_capability_basics()?; + + // Check if any filter requires full info (displayName, description, downloadSize, installSize) + let needs_full_info = filters.iter().any(|f| { + f.display_name.is_some() || f.description.is_some() + || f.download_size.is_some() || f.install_size.is_some() + }); + + let mut results = Vec::new(); + + let (filters_with_name, filters_without_name): (Vec<&FeatureOnDemandInfo>, Vec<&FeatureOnDemandInfo>) = + if needs_full_info { + filters.iter().partition(|f| f.name.is_some()) + } else { + (Vec::new(), Vec::new()) + }; + + for (name, state_val) in &all_basics { + let state = CapabilityState::from_dism(*state_val); + + if needs_full_info { + let mut should_get_full = !filters_without_name.is_empty(); + if !should_get_full { + for f in &filters_with_name { + if let Some(ref filter_name) = f.name { + if matches_wildcard(name, filter_name) { + should_get_full = true; + break; + } + } + } + } + if !should_get_full { + continue; + } + + let info = match session.get_capability_info(name) { + Ok(raw) if !raw.unknown => FeatureOnDemandInfo { + name: Some(raw.name), + exist: None, + state: CapabilityState::from_dism(raw.state), + display_name: Some(raw.display_name), + description: Some(raw.description), + download_size: Some(raw.download_size), + install_size: Some(raw.install_size), + }, + _ => FeatureOnDemandInfo { + name: Some(name.clone()), + exist: None, + state, + display_name: None, + description: None, + download_size: None, + install_size: None, + }, + }; + + if info.matches_any_filter(&filters) { + results.push(info); + } + } else { + // Fast path: only need name and state for filtering, skip expensive + // per-capability DismGetCapabilityInfo calls to match dism /online /get-capabilities speed. + let basic = FeatureOnDemandInfo { + name: Some(name.clone()), + state: state.clone(), + ..FeatureOnDemandInfo::default() + }; + + if basic.matches_any_filter(&filters) { + results.push(basic); + } + } + } + + let output = FeatureOnDemandList { restart_required_meta: None, capabilities: results }; + serde_json::to_string(&output) + .map_err(|e| t!("fod_export.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/feature_on_demand/get.rs b/resources/dism_dsc/src/feature_on_demand/get.rs new file mode 100644 index 000000000..f2e43123a --- /dev/null +++ b/resources/dism_dsc/src/feature_on_demand/get.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::feature_on_demand::types::{CapabilityState, FeatureOnDemandInfo, FeatureOnDemandList}; + +pub fn handle_get(input: &str) -> Result { + let capability_list: FeatureOnDemandList = serde_json::from_str(input) + .map_err(|e| t!("fod_get.failedParseInput", err = e.to_string()).to_string())?; + + if capability_list.capabilities.is_empty() { + return Err(t!("fod_get.capabilitiesArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results = Vec::new(); + + for cap_input in &capability_list.capabilities { + let name = cap_input + .name + .as_ref() + .ok_or_else(|| t!("fod_get.nameRequired").to_string())?; + + let raw = session.get_capability_info(name)?; + let info = if raw.unknown { + FeatureOnDemandInfo { + name: Some(name.clone()), + exist: Some(false), + ..FeatureOnDemandInfo::default() + } + } else { + FeatureOnDemandInfo { + name: Some(raw.name), + exist: None, + state: CapabilityState::from_dism(raw.state), + display_name: Some(raw.display_name), + description: Some(raw.description), + download_size: Some(raw.download_size), + install_size: Some(raw.install_size), + } + }; + results.push(info); + } + + let output = FeatureOnDemandList { restart_required_meta: None, capabilities: results }; + serde_json::to_string(&output) + .map_err(|e| t!("fod_get.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/feature_on_demand/mod.rs b/resources/dism_dsc/src/feature_on_demand/mod.rs new file mode 100644 index 000000000..76d5dc65e --- /dev/null +++ b/resources/dism_dsc/src/feature_on_demand/mod.rs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; +mod get; +mod set; +mod export; + +pub use get::handle_get; +pub use set::handle_set; +pub use export::handle_export; diff --git a/resources/dism_dsc/src/feature_on_demand/set.rs b/resources/dism_dsc/src/feature_on_demand/set.rs new file mode 100644 index 000000000..e1afb48af --- /dev/null +++ b/resources/dism_dsc/src/feature_on_demand/set.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::{Map, Value}; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::feature_on_demand::types::{CapabilityState, FeatureOnDemandInfo, FeatureOnDemandList}; +use crate::util::get_computer_name; + +pub fn handle_set(input: &str) -> Result { + let capability_list: FeatureOnDemandList = serde_json::from_str(input) + .map_err(|e| t!("fod_set.failedParseInput", err = e.to_string()).to_string())?; + + if capability_list.capabilities.is_empty() { + return Err(t!("fod_set.capabilitiesArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results = Vec::new(); + let mut reboot_required = false; + + for cap_input in &capability_list.capabilities { + let name = cap_input + .name + .as_ref() + .ok_or_else(|| t!("fod_set.nameRequired").to_string())?; + + let desired_state = cap_input + .state + .as_ref() + .ok_or_else(|| t!("fod_set.stateRequired").to_string())?; + + let needs_reboot = match desired_state { + CapabilityState::Installed => { + session.add_capability(name)? + } + CapabilityState::NotPresent => { + session.remove_capability(name)? + } + _ => { + return Err(t!( + "fod_set.unsupportedDesiredState", + state = desired_state.to_string() + ) + .to_string()); + } + }; + + reboot_required = reboot_required || needs_reboot; + + let raw = session.get_capability_info(name)?; + let info = FeatureOnDemandInfo { + name: Some(raw.name), + exist: None, + state: CapabilityState::from_dism(raw.state), + display_name: Some(raw.display_name), + description: Some(raw.description), + download_size: Some(raw.download_size), + install_size: Some(raw.install_size), + }; + results.push(info); + } + + let restart_required_meta = if reboot_required { + let mut entry = Map::new(); + entry.insert("system".to_string(), Value::String(get_computer_name())); + Some(vec![entry]) + } else { + None + }; + + let output = FeatureOnDemandList { restart_required_meta, capabilities: results }; + serde_json::to_string(&output) + .map_err(|e| t!("fod_set.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/feature_on_demand/types.rs b/resources/dism_dsc/src/feature_on_demand/types.rs new file mode 100644 index 000000000..04b8a3f37 --- /dev/null +++ b/resources/dism_dsc/src/feature_on_demand/types.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::util::{DismState, WildcardFilterable, matches_optional_wildcard, matches_optional_exact}; + +pub type CapabilityState = DismState; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FeatureOnDemandList { + #[serde(rename = "_restartRequired", skip_serializing_if = "Option::is_none")] + pub restart_required_meta: Option>>, + pub capabilities: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct FeatureOnDemandInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub install_size: Option, +} + +impl WildcardFilterable for FeatureOnDemandInfo { + fn matches_filter(&self, filter: &Self) -> bool { + matches_optional_wildcard(&self.name, &filter.name) + && matches_optional_exact(&self.state, &filter.state) + && matches_optional_wildcard(&self.display_name, &filter.display_name) + && matches_optional_wildcard(&self.description, &filter.description) + && matches_optional_exact(&self.download_size, &filter.download_size) + && matches_optional_exact(&self.install_size, &filter.install_size) + } +} diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs index d50052590..9a671fcdf 100644 --- a/resources/dism_dsc/src/main.rs +++ b/resources/dism_dsc/src/main.rs @@ -1,17 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(windows)] +mod util; #[cfg(windows)] mod optional_feature; +#[cfg(windows)] +mod feature_on_demand; use rust_i18n::t; use std::io::{self, Read, IsTerminal}; rust_i18n::i18n!("locales", fallback = "en-us"); -fn read_stdin(required: bool) -> Result { +fn read_stdin() -> Result { let mut buffer = String::new(); - if required || !io::stdin().is_terminal() { + if !io::stdin().is_terminal() { io::stdin() .read_to_string(&mut buffer) .map_err(|e| t!("main.errorReadingInput", err = e).to_string())?; @@ -19,8 +23,8 @@ fn read_stdin(required: bool) -> Result { Ok(buffer) } -fn dispatch(handler: impl FnOnce(&str) -> Result, stdin_required: bool) { - let buffer = match read_stdin(stdin_required) { +fn dispatch(handler: impl FnOnce(&str) -> Result) { + let buffer = match read_stdin() { Ok(b) => b, Err(e) => { eprintln!("{e}"); @@ -50,18 +54,27 @@ fn main() { fn main() { let args: Vec = std::env::args().collect(); - if args.len() < 2 { - eprintln!("Error: {}", t!("main.missingOperation")); + if args.len() < 3 { + eprintln!("Error: {}", t!("main.missingArguments")); eprintln!("{}", t!("main.usage")); std::process::exit(1); } let operation = args[1].as_str(); + let resource_type = args[2].as_str(); - match operation { - "export" => dispatch(optional_feature::handle_export, false), - "get" => dispatch(optional_feature::handle_get, true), - "set" => dispatch(optional_feature::handle_set, true), + match (operation, resource_type) { + ("get", "optional-feature") => dispatch(optional_feature::handle_get), + ("set", "optional-feature") => dispatch(optional_feature::handle_set), + ("export", "optional-feature") => dispatch(optional_feature::handle_export), + ("get", "feature-on-demand") => dispatch(feature_on_demand::handle_get), + ("set", "feature-on-demand") => dispatch(feature_on_demand::handle_set), + ("export", "feature-on-demand") => dispatch(feature_on_demand::handle_export), + ("get" | "set" | "export", _) => { + eprintln!("{}", t!("main.unknownResourceType", resource_type = resource_type)); + eprintln!("{}", t!("main.usage")); + std::process::exit(1); + } _ => { eprintln!("{}", t!("main.unknownOperation", operation = operation)); eprintln!("{}", t!("main.usage")); diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index 2ae36f3f0..a2c1fd740 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -41,6 +41,33 @@ struct DismFeatureInfo { custom_property_count: u32, } +#[repr(C, packed)] +struct DismCapability { + name: *const u16, + state: i32, +} + +#[repr(C, packed)] +struct DismCapabilityDetail { + name: *const u16, + state: i32, + display_name: *const u16, + description: *const u16, + download_size: u32, + install_size: u32, +} + +/// Raw capability information returned from the DISM API. +pub struct DismCapabilityResult { + pub name: String, + pub unknown: bool, + pub state: i32, + pub display_name: String, + pub description: String, + pub download_size: u32, + pub install_size: u32, +} + // Function pointer types for the DISM API type DismInitializeFn = unsafe extern "system" fn(i32, *const u16, *const u16) -> i32; @@ -72,6 +99,27 @@ type DismDisableFeatureFn = unsafe extern "system" fn( *mut c_void, // Progress callback (NULL) *mut c_void, // UserData (NULL) ) -> i32; +type DismGetCapabilitiesFn = + unsafe extern "system" fn(u32, *mut *mut DismCapability, *mut u32) -> i32; +type DismGetCapabilityInfoFn = + unsafe extern "system" fn(u32, *const u16, *mut *mut DismCapabilityDetail) -> i32; +type DismAddCapabilityFn = unsafe extern "system" fn( + u32, // Session + *const u16, // Name + i32, // LimitAccess (BOOL) + *const *const u16,// SourcePaths (NULL) + u32, // SourcePathCount + *mut c_void, // CancelEvent (NULL) + *mut c_void, // Progress callback (NULL) + *mut c_void, // UserData (NULL) +) -> i32; +type DismRemoveCapabilityFn = unsafe extern "system" fn( + u32, // Session + *const u16, // Name + *mut c_void, // CancelEvent (NULL) + *mut c_void, // Progress callback (NULL) + *mut c_void, // UserData (NULL) +) -> i32; type DismCloseSessionFn = unsafe extern "system" fn(u32) -> i32; type DismShutdownFn = unsafe extern "system" fn() -> i32; type DismDeleteFn = unsafe extern "system" fn(*const c_void) -> i32; @@ -116,6 +164,10 @@ struct DismApi { enable_feature: DismEnableFeatureFn, disable_feature: DismDisableFeatureFn, delete: DismDeleteFn, + get_capabilities: Option, + get_capability_info: Option, + add_capability: Option, + remove_capability: Option, } impl DismApi { @@ -145,6 +197,10 @@ impl DismApi { enable_feature: load_fn(lib, b"DismEnableFeature\0")?, disable_feature: load_fn(lib, b"DismDisableFeature\0")?, delete: load_fn(lib, b"DismDelete\0")?, + get_capabilities: load_fn(lib, b"DismGetCapabilities\0").ok(), + get_capability_info: load_fn(lib, b"DismGetCapabilityInfo\0").ok(), + add_capability: load_fn(lib, b"DismAddCapability\0").ok(), + remove_capability: load_fn(lib, b"DismRemoveCapability\0").ok(), }) } } @@ -233,18 +289,14 @@ impl DismSessionHandle { } let result = unsafe { - let feature_name_val = std::ptr::addr_of!((*info_ptr).feature_name).read_unaligned(); - let state_val = std::ptr::addr_of!((*info_ptr).state).read_unaligned(); - let display_name_val = std::ptr::addr_of!((*info_ptr).display_name).read_unaligned(); - let description_val = std::ptr::addr_of!((*info_ptr).description).read_unaligned(); - let restart_val = std::ptr::addr_of!((*info_ptr).restart_required).read_unaligned(); + let info = &*info_ptr; let feature_info = OptionalFeatureInfo { - feature_name: Some(from_wide_ptr(feature_name_val)), + feature_name: Some(from_wide_ptr(info.feature_name)), exist: None, - state: FeatureState::from_dism(state_val), - display_name: Some(from_wide_ptr(display_name_val)), - description: Some(from_wide_ptr(description_val)), - restart_required: RestartType::from_dism(restart_val), + state: FeatureState::from_dism(info.state), + display_name: Some(from_wide_ptr(info.display_name)), + description: Some(from_wide_ptr(info.description)), + restart_required: RestartType::from_dism(info.restart_required), }; (self.api.delete)(info_ptr as *const c_void); feature_info @@ -318,16 +370,141 @@ impl DismSessionHandle { let mut result = Vec::new(); unsafe { for i in 0..count as usize { - let name_ptr = std::ptr::addr_of!((*features_ptr.add(i)).feature_name).read_unaligned(); - let state_val = std::ptr::addr_of!((*features_ptr.add(i)).state).read_unaligned(); - let name = from_wide_ptr(name_ptr); - result.push((name, state_val)); + let feature = &*features_ptr.add(i); + let name = from_wide_ptr(feature.feature_name); + result.push((name, feature.state)); } (self.api.delete)(features_ptr as *const c_void); } Ok(result) } + + pub fn get_capability_info(&self, name: &str) -> Result { + let get_cap_info = self.api.get_capability_info + .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; + + let wide_name = to_wide_null(name); + let mut info_ptr: *mut DismCapabilityDetail = std::ptr::null_mut(); + + let hr = unsafe { + get_cap_info( + self.handle, + wide_name.as_ptr(), + &mut info_ptr, + ) + }; + + if hr == DISMAPI_E_UNKNOWN_FEATURE { + return Ok(DismCapabilityResult { + name: name.to_string(), + unknown: true, + state: 0, + display_name: String::new(), + description: String::new(), + download_size: 0, + install_size: 0, + }); + } + + if hr < 0 { + return Err(t!("dism.getCapabilityInfoFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + } + + let result = unsafe { + let detail = &*info_ptr; + let cap_info = DismCapabilityResult { + name: from_wide_ptr(detail.name), + unknown: false, + state: detail.state, + display_name: from_wide_ptr(detail.display_name), + description: from_wide_ptr(detail.description), + download_size: detail.download_size, + install_size: detail.install_size, + }; + (self.api.delete)(info_ptr as *const c_void); + cap_info + }; + + Ok(result) + } + + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). + pub fn add_capability(&self, name: &str) -> Result { + let add_cap = self.api.add_capability + .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; + + let wide_name = to_wide_null(name); + let hr = unsafe { + add_cap( + self.handle, + wide_name.as_ptr(), + 0, // LimitAccess = FALSE + std::ptr::null(), // SourcePaths + 0, // SourcePathCount + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + if hr < 0 { + return Err(t!("dism.addCapabilityFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). + pub fn remove_capability(&self, name: &str) -> Result { + let remove_cap = self.api.remove_capability + .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; + + let wide_name = to_wide_null(name); + let hr = unsafe { + remove_cap( + self.handle, + wide_name.as_ptr(), + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + if hr < 0 { + return Err(t!("dism.removeCapabilityFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + pub fn get_all_capability_basics(&self) -> Result, String> { + let get_caps = self.api.get_capabilities + .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; + + let mut caps_ptr: *mut DismCapability = std::ptr::null_mut(); + let mut count: u32 = 0; + + let hr = unsafe { + get_caps( + self.handle, + &mut caps_ptr, + &mut count, + ) + }; + + if hr < 0 { + return Err(t!("dism.getCapabilitiesFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); + } + + let mut result = Vec::new(); + unsafe { + for i in 0..count as usize { + let cap = &*caps_ptr.add(i); + let name = from_wide_ptr(cap.name); + result.push((name, cap.state)); + } + (self.api.delete)(caps_ptr as *const c_void); + } + + Ok(result) + } } impl Drop for DismSessionHandle { diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index 5366cacc5..71e6b4ff2 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -5,6 +5,7 @@ use rust_i18n::t; use crate::optional_feature::dism::DismSessionHandle; use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, OptionalFeatureList}; +use crate::util::{matches_wildcard, WildcardFilterable}; pub fn handle_export(input: &str) -> Result { let filters: Vec = if input.trim().is_empty() { @@ -45,7 +46,7 @@ pub fn handle_export(input: &str) -> Result { if !should_get_full { for f in &filters_with_name { if let Some(ref filter_name) = f.feature_name { - if filter_name == name { + if matches_wildcard(name, filter_name) { should_get_full = true; break; } @@ -70,31 +71,20 @@ pub fn handle_export(input: &str) -> Result { }, }; - if matches_any_filter(&info, &filters) { + if info.matches_any_filter(&filters) { results.push(info); } } else { - // Fast path: only need name and state for filtering + // Fast path: only need name and state for filtering, skip expensive + // per-feature DismGetFeatureInfo calls to match dism /online /get-features speed. let basic = OptionalFeatureInfo { feature_name: Some(name.clone()), state: state.clone(), ..OptionalFeatureInfo::default() }; - if matches_any_filter(&basic, &filters) { - match session.get_feature_info(name) { - Ok(info) => results.push(info), - Err(_) => { - results.push(OptionalFeatureInfo { - feature_name: Some(name.clone()), - exist: None, - state, - display_name: None, - description: None, - restart_required: None, - }); - } - } + if basic.matches_any_filter(&filters) { + results.push(basic); } } } @@ -103,90 +93,3 @@ pub fn handle_export(input: &str) -> Result { serde_json::to_string(&output) .map_err(|e| t!("export.failedSerializeOutput", err = e.to_string()).to_string()) } - -/// Check if the feature matches any filter (OR between filters, AND within each). -fn matches_any_filter(info: &OptionalFeatureInfo, filters: &[OptionalFeatureInfo]) -> bool { - filters.iter().any(|filter| matches_filter(info, filter)) -} - -/// Apply AND logic within a single filter: all specified criteria must match. -fn matches_filter(info: &OptionalFeatureInfo, filter: &OptionalFeatureInfo) -> bool { - // If filter has featureName, check with wildcard support - if let Some(filter_name) = &filter.feature_name { - match &info.feature_name { - Some(name) if matches_wildcard(name, filter_name) => {} - _ => return false, - } - } - - // If filter has state, check exact match - if let Some(filter_state) = &filter.state { - match &info.state { - Some(s) if s == filter_state => {} - _ => return false, - } - } - - // If filter has displayName, check with wildcard support - if let Some(filter_display) = &filter.display_name { - match &info.display_name { - Some(display) if matches_wildcard(display, filter_display) => {} - _ => return false, - } - } - - // If filter has description, check with wildcard support - if let Some(filter_desc) = &filter.description { - match &info.description { - Some(desc) if matches_wildcard(desc, filter_desc) => {} - _ => return false, - } - } - - true -} - -/// Match a string against a pattern that supports `*` wildcards (case-insensitive). -fn matches_wildcard(text: &str, pattern: &str) -> bool { - let text_lower = text.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - - if !pattern_lower.contains('*') { - return text_lower == pattern_lower; - } - - let parts: Vec<&str> = pattern_lower.split('*').collect(); - - // Check prefix (part before first *) - if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { - return false; - } - - // Track position through the text, starting after the prefix - let mut pos = parts[0].len(); - - // The suffix is the last part; we need to reserve space for it - let suffix = *parts.last().unwrap_or(&""); - let end = if suffix.is_empty() { - text_lower.len() - } else { - // Verify suffix matches and compute the boundary - if !text_lower.ends_with(suffix) { - return false; - } - text_lower.len() - suffix.len() - }; - - // Check middle parts appear in order within [pos..end] - for part in &parts[1..parts.len().saturating_sub(1)] { - if part.is_empty() { - continue; - } - match text_lower[pos..end].find(part) { - Some(idx) => pos += idx + part.len(), - None => return false, - } - } - - pos <= end -} diff --git a/resources/dism_dsc/src/optional_feature/mod.rs b/resources/dism_dsc/src/optional_feature/mod.rs index 95af23b42..281947b16 100644 --- a/resources/dism_dsc/src/optional_feature/mod.rs +++ b/resources/dism_dsc/src/optional_feature/mod.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. mod types; -mod dism; +pub mod dism; mod get; mod export; mod set; diff --git a/resources/dism_dsc/src/optional_feature/set.rs b/resources/dism_dsc/src/optional_feature/set.rs index 4fbfb0f98..4c49d3242 100644 --- a/resources/dism_dsc/src/optional_feature/set.rs +++ b/resources/dism_dsc/src/optional_feature/set.rs @@ -6,10 +6,7 @@ use serde_json::{Map, Value}; use crate::optional_feature::dism::DismSessionHandle; use crate::optional_feature::types::{FeatureState, OptionalFeatureList}; - -fn get_computer_name() -> String { - std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) -} +use crate::util::get_computer_name; pub fn handle_set(input: &str) -> Result { let feature_list: OptionalFeatureList = serde_json::from_str(input) diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index 12c83903c..6415d6a48 100644 --- a/resources/dism_dsc/src/optional_feature/types.rs +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::fmt; + +use crate::util::{DismState, WildcardFilterable, matches_optional_wildcard, matches_optional_exact}; + +pub type FeatureState = DismState; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -30,18 +33,6 @@ pub struct OptionalFeatureInfo { pub restart_required: Option, } -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub enum FeatureState { - NotPresent, - UninstallPending, - Staged, - Removed, - Installed, - InstallPending, - Superseded, - PartiallyInstalled, -} - #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] pub enum RestartType { No, @@ -49,37 +40,6 @@ pub enum RestartType { Required, } -impl fmt::Display for FeatureState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FeatureState::NotPresent => write!(f, "NotPresent"), - FeatureState::UninstallPending => write!(f, "UninstallPending"), - FeatureState::Staged => write!(f, "Staged"), - FeatureState::Removed => write!(f, "Removed"), - FeatureState::Installed => write!(f, "Installed"), - FeatureState::InstallPending => write!(f, "InstallPending"), - FeatureState::Superseded => write!(f, "Superseded"), - FeatureState::PartiallyInstalled => write!(f, "PartiallyInstalled"), - } - } -} - -impl FeatureState { - pub fn from_dism(state: i32) -> Option { - match state { - 0 => Some(FeatureState::NotPresent), - 1 => Some(FeatureState::UninstallPending), - 2 => Some(FeatureState::Staged), - 3 => Some(FeatureState::Removed), - 4 => Some(FeatureState::Installed), - 5 => Some(FeatureState::InstallPending), - 6 => Some(FeatureState::Superseded), - 7 => Some(FeatureState::PartiallyInstalled), - _ => None, - } - } -} - impl RestartType { pub fn from_dism(restart: i32) -> Option { match restart { @@ -90,3 +50,12 @@ impl RestartType { } } } + +impl WildcardFilterable for OptionalFeatureInfo { + fn matches_filter(&self, filter: &Self) -> bool { + matches_optional_wildcard(&self.feature_name, &filter.feature_name) + && matches_optional_exact(&self.state, &filter.state) + && matches_optional_wildcard(&self.display_name, &filter.display_name) + && matches_optional_wildcard(&self.description, &filter.description) + } +} diff --git a/resources/dism_dsc/src/util.rs b/resources/dism_dsc/src/util.rs new file mode 100644 index 000000000..b2c04492f --- /dev/null +++ b/resources/dism_dsc/src/util.rs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// DISM package/feature state values shared by both Optional Features and Features on Demand. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum DismState { + NotPresent, + UninstallPending, + Staged, + Removed, + Installed, + InstallPending, + Superseded, + PartiallyInstalled, +} + +impl fmt::Display for DismState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DismState::NotPresent => write!(f, "NotPresent"), + DismState::UninstallPending => write!(f, "UninstallPending"), + DismState::Staged => write!(f, "Staged"), + DismState::Removed => write!(f, "Removed"), + DismState::Installed => write!(f, "Installed"), + DismState::InstallPending => write!(f, "InstallPending"), + DismState::Superseded => write!(f, "Superseded"), + DismState::PartiallyInstalled => write!(f, "PartiallyInstalled"), + } + } +} + +impl DismState { + pub fn from_dism(state: i32) -> Option { + match state { + 0 => Some(DismState::NotPresent), + 1 => Some(DismState::UninstallPending), + 2 => Some(DismState::Staged), + 3 => Some(DismState::Removed), + 4 => Some(DismState::Installed), + 5 => Some(DismState::InstallPending), + 6 => Some(DismState::Superseded), + 7 => Some(DismState::PartiallyInstalled), + _ => None, + } + } +} + +/// Match a string against a pattern that supports `*` wildcards (case-insensitive). +pub fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if !pattern_lower.contains('*') { + return text_lower == pattern_lower; + } + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { + return false; + } + + let mut pos = parts[0].len(); + + let suffix = *parts.last().unwrap_or(&""); + let end = if suffix.is_empty() { + text_lower.len() + } else { + if !text_lower.ends_with(suffix) { + return false; + } + text_lower.len() - suffix.len() + }; + + for part in &parts[1..parts.len().saturating_sub(1)] { + if part.is_empty() { + continue; + } + match text_lower[pos..end].find(part) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + + pos <= end +} + +/// Check that an optional string field matches a wildcard filter pattern. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_wildcard(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(pattern) => match info_value { + Some(value) => matches_wildcard(value, pattern), + None => false, + }, + None => true, + } +} + +/// Check that an optional field matches an exact filter value. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_exact(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(expected) => match info_value { + Some(actual) => actual == expected, + None => false, + }, + None => true, + } +} + +/// Trait for types that support wildcard-based filter matching in export operations. +pub trait WildcardFilterable { + /// Returns true if this instance matches the given filter (AND logic within a single filter). + fn matches_filter(&self, filter: &Self) -> bool; + + /// Returns true if this instance matches any of the given filters (OR logic between filters). + fn matches_any_filter(&self, filters: &[Self]) -> bool + where + Self: Sized, + { + filters.iter().any(|filter| self.matches_filter(filter)) + } +} + +/// Returns the computer name from the COMPUTERNAME environment variable, or "localhost" as fallback. +pub fn get_computer_name() -> String { + std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_match() { + assert!(matches_wildcard("Hello", "Hello")); + assert!(matches_wildcard("Hello", "hello")); + assert!(!matches_wildcard("Hello", "World")); + } + + #[test] + fn test_star_only() { + assert!(matches_wildcard("anything", "*")); + assert!(matches_wildcard("", "*")); + } + + #[test] + fn test_prefix_wildcard() { + assert!(matches_wildcard("HelloWorld", "Hello*")); + assert!(matches_wildcard("Hello", "Hello*")); + assert!(!matches_wildcard("World", "Hello*")); + } + + #[test] + fn test_suffix_wildcard() { + assert!(matches_wildcard("HelloWorld", "*World")); + assert!(matches_wildcard("World", "*World")); + assert!(!matches_wildcard("Hello", "*World")); + } + + #[test] + fn test_middle_wildcard() { + assert!(matches_wildcard("HelloWorld", "Hello*World")); + assert!(matches_wildcard("HelloBeautifulWorld", "Hello*World")); + assert!(!matches_wildcard("HelloBeautiful", "Hello*World")); + } + + #[test] + fn test_multiple_wildcards() { + assert!(matches_wildcard("abcdef", "*b*d*")); + assert!(matches_wildcard("abcdef", "a*c*f")); + assert!(!matches_wildcard("abcdef", "a*z*f")); + } + + #[test] + fn test_double_star() { + assert!(matches_wildcard("abc", "**")); + assert!(matches_wildcard("abc", "a**c")); + assert!(matches_wildcard("", "**")); + } + + #[test] + fn test_empty_pattern() { + assert!(matches_wildcard("", "")); + assert!(!matches_wildcard("abc", "")); + } + + #[test] + fn test_case_insensitive() { + assert!(matches_wildcard("HELLO", "hello")); + assert!(matches_wildcard("HelloWorld", "hello*world")); + assert!(matches_wildcard("Microsoft.Windows.Feature", "*windows*")); + } + + #[test] + fn test_dism_state_from_dism() { + assert_eq!(DismState::from_dism(0), Some(DismState::NotPresent)); + assert_eq!(DismState::from_dism(4), Some(DismState::Installed)); + assert_eq!(DismState::from_dism(7), Some(DismState::PartiallyInstalled)); + assert_eq!(DismState::from_dism(8), None); + assert_eq!(DismState::from_dism(-1), None); + } +} diff --git a/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 new file mode 100644 index 000000000..d7c589938 --- /dev/null +++ b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + # Use dism command to get known capability names + $dismOutput = & dism /Online /Get-Capabilities /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to get capabilities using dism: $dismOutput" + } + $installedMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Installed\s*$' + $notPresentMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Not Present\s*$' + if (-not $installedMatches -or -not $notPresentMatches) { + throw "Failed to find both installed and not-present capabilities in DISM output.`nOutput:`n$dismOutput" + } + $knownCapabilityNameOne = $installedMatches[0].Matches[0].Groups[1].Value + $knownCapabilityNameTwo = $notPresentMatches[0].Matches[0].Groups[1].Value + + # Get the displayName for the known installed capability to use in wildcard displayName tests + $fullInfoJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '","displayName":"*"}]}' + $fullInfoOutput = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $fullInfoJson | ConvertFrom-Json + $knownDisplayName = $fullInfoOutput.resources[0].properties.capabilities[0].displayName + if (-not $knownDisplayName) { + throw "Failed to get displayName for $knownCapabilityNameOne" + } + # Extract a substring from the displayName for wildcard matching (use first word if multi-word) + $displayNameWords = $knownDisplayName -split '\s+' + $knownDisplayNameWord = $displayNameWords[0] + } + + It 'exports all capabilities with no input' -Skip:(!$isElevated) { + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + $capabilities.Count | Should -BeGreaterThan 0 + $capabilities[0].name | Should -Not -BeNullOrEmpty + $capabilities[0].state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + } + + It 'exports capabilities filtered by exact name' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + $capabilities.Count | Should -Be 1 + $cap = $capabilities[0] + $cap.name | Should -BeExactly $knownCapabilityNameOne + $cap.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + } + + It 'exports capabilities filtered by wildcard name' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"Language.Basic*"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + foreach ($cap in $capabilities) { + $cap.name | Should -BeLike 'Language.Basic*' + } + } + + It 'exports capabilities filtered by state' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + foreach ($cap in $capabilities) { + $cap.state | Should -BeExactly 'Installed' + } + } + + It 'exports capabilities with combined name and state filter' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"*","state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + foreach ($cap in $capabilities) { + $cap.state | Should -BeExactly 'Installed' + } + } + + It 'exports capabilities filtered by wildcard displayName' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"displayName":"*' + $knownDisplayNameWord + '*"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + foreach ($cap in $capabilities) { + $cap.displayName | Should -BeLike "*$knownDisplayNameWord*" + } + } + + It 'exports capabilities with multiple filters using OR logic' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '"},{"name":"' + $knownCapabilityNameTwo + '"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities | Should -Not -BeNullOrEmpty + $names = $capabilities | ForEach-Object { $_.name } + $names | Should -Contain $knownCapabilityNameOne + $names | Should -Contain $knownCapabilityNameTwo + } + + It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"ZZZNonExistent*"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities.Count | Should -Be 0 + } + + It 'returns complete capability properties when full-info filter is used' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '","displayName":"*"}]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities.Count | Should -Be 1 + $cap = $capabilities[0] + $cap.name | Should -BeExactly $knownCapabilityNameOne + $cap.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + $cap.displayName | Should -Not -BeNullOrEmpty + $cap.description | Should -Not -BeNullOrEmpty + $cap.downloadSize | Should -Not -BeNullOrEmpty + $cap.installSize | Should -Not -BeNullOrEmpty + } +} diff --git a/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 new file mode 100644 index 000000000..59cdf6bee --- /dev/null +++ b/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FeatureOnDemandList - get operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + BeforeAll { + # Use dism command to get known capability names + $dismOutput = & dism /Online /Get-Capabilities /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to get capabilities using dism: $dismOutput" + } + $installedMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Installed\s*$' + $notPresentMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Not Present\s*$' + if (-not $installedMatches -or -not $notPresentMatches) { + throw "Failed to find both installed and not-present capabilities in DISM output.`nOutput:`n$dismOutput" + } + $knownCapabilityNameOne = $installedMatches[0].Matches[0].Groups[1].Value + $knownCapabilityNameTwo = $notPresentMatches[0].Matches[0].Groups[1].Value + } + + It 'gets a known capability by name' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '"}]}' + $output = dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.capabilities | Should -Not -BeNullOrEmpty + $output.actualState.capabilities.Count | Should -Be 1 + $cap = $output.actualState.capabilities[0] + $cap.name | Should -BeExactly $knownCapabilityNameOne + $cap.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + $cap.displayName | Should -Not -BeNullOrEmpty + $cap.description | Should -Not -BeNullOrEmpty + $cap.downloadSize | Should -Not -BeNullOrEmpty + $cap.installSize | Should -Not -BeNullOrEmpty + } + + It 'gets multiple capabilities in a single request' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '"},{"name":"' + $knownCapabilityNameTwo + '"}]}' + $output = dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.capabilities.Count | Should -Be 2 + $output.actualState.capabilities[0].name | Should -BeExactly $knownCapabilityNameOne + $output.actualState.capabilities[1].name | Should -BeExactly $knownCapabilityNameTwo + } + + It 'returns error when name is missing' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"state":"Installed"}]}' + $testError = & { dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when capabilities array is empty' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[]}' + $testError = & { dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns _exist false for a non-existent capability name' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"NonExistent-Capability-1234567890"}]}' + $output = dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.capabilities | Should -Not -BeNullOrEmpty + $output.actualState.capabilities.Count | Should -Be 1 + $cap = $output.actualState.capabilities[0] + $cap.name | Should -BeExactly 'NonExistent-Capability-1234567890' + $cap._exist | Should -BeFalse + $cap.state | Should -BeNullOrEmpty + $cap.displayName | Should -BeNullOrEmpty + $cap.description | Should -BeNullOrEmpty + } + + It 'returns _exist false alongside valid capabilities' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '"},{"name":"NonExistent-Capability-1234567890"}]}' + $output = dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.capabilities.Count | Should -Be 2 + $output.actualState.capabilities[0].name | Should -BeExactly $knownCapabilityNameOne + $output.actualState.capabilities[0].PSObject.Properties.Name | Should -Not -Contain '_exist' + $output.actualState.capabilities[1].name | Should -BeExactly 'NonExistent-Capability-1234567890' + $output.actualState.capabilities[1]._exist | Should -BeFalse + } +} diff --git a/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 new file mode 100644 index 000000000..8dfeac0ca --- /dev/null +++ b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/FeatureOnDemandList - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + It 'returns error when name is missing' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"state":"Installed"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when state is missing' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when capabilities array is empty' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[]}' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for unsupported desired state' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Staged"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'can install a capability and returns updated state' -Skip:(!$isElevated) { + # Use a capability known to exist on most Windows systems + $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Installed"}]}' + $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.capabilities | Should -Not -BeNullOrEmpty + $output.afterState.capabilities.Count | Should -Be 1 + $cap = $output.afterState.capabilities[0] + $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.state | Should -BeIn @('Installed', 'InstallPending') + $cap.displayName | Should -Not -BeNullOrEmpty + } + + It 'can remove a capability and returns updated state' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"NotPresent"}]}' + $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.capabilities | Should -Not -BeNullOrEmpty + $output.afterState.capabilities.Count | Should -Be 1 + $cap = $output.afterState.capabilities[0] + $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') + } + + It 'sets state to Installed for an already installed capability' -Skip:(!$isElevated) { + # First ensure the capability is installed + $installJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Installed"}]}' + dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $installJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set Installed again — should succeed idempotently + $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $installJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.capabilities | Should -Not -BeNullOrEmpty + $cap = $output.afterState.capabilities[0] + $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.state | Should -Be 'Installed' + } + + It 'sets state to NotPresent for an already not-present capability' -Skip:(!$isElevated) { + # First ensure the capability is not present + $removeJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"NotPresent"}]}' + dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $removeJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set NotPresent again — should succeed idempotently + $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $removeJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.capabilities | Should -Not -BeNullOrEmpty + $cap = $output.afterState.capabilities[0] + $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.state | Should -BeIn @('NotPresent', 'Removed', 'Staged') + } +} diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 index cf1928bc5..85d13b66a 100644 --- a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -48,9 +48,10 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW $features.Count | Should -Be 1 $feature = $features[0] $feature.featureName | Should -BeExactly $knownFeatureNameOne - $feature.displayName | Should -Not -BeNullOrEmpty - $feature.description | Should -Not -BeNullOrEmpty - $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') + $feature.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) } It 'exports features filtered by wildcard featureName' -Skip:(!$isElevated) { @@ -116,8 +117,8 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW $features.Count | Should -Be 0 } - It 'returns complete feature properties in export results' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' + It 'returns complete feature properties when full-info filter is used' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '","displayName":"*"}]}' $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $features = $output.resources[0].properties.features From af8e5e6d8818c1ec200b620acba3c900c95c670d Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 14 Mar 2026 14:00:52 -0700 Subject: [PATCH 2/3] various fixes --- .../dism_dsc/src/feature_on_demand/set.rs | 8 ++++++- .../dism_dsc/src/optional_feature/dism.rs | 18 ++++++++++++++++ .../tests/featureOnDemand_set.tests.ps1 | 21 ++++++++++++------- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/resources/dism_dsc/src/feature_on_demand/set.rs b/resources/dism_dsc/src/feature_on_demand/set.rs index e1afb48af..60f4dbe95 100644 --- a/resources/dism_dsc/src/feature_on_demand/set.rs +++ b/resources/dism_dsc/src/feature_on_demand/set.rs @@ -31,12 +31,18 @@ pub fn handle_set(input: &str) -> Result { .as_ref() .ok_or_else(|| t!("fod_set.stateRequired").to_string())?; + let current = session.get_capability_info(name)?; + let current_state = CapabilityState::from_dism(current.state); + let needs_reboot = match desired_state { CapabilityState::Installed => { session.add_capability(name)? } CapabilityState::NotPresent => { - session.remove_capability(name)? + match current_state { + Some(CapabilityState::NotPresent) | Some(CapabilityState::Removed) => false, + _ => session.remove_capability(name)?, + } } _ => { return Err(t!( diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index a2c1fd740..0e92264e9 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -13,6 +13,7 @@ const DISM_LOG_ERRORS: i32 = 0; const DISM_PACKAGE_NONE: i32 = 0; const ERROR_SUCCESS_REBOOT_REQUIRED: i32 = 3010; const DISMAPI_E_UNKNOWN_FEATURE: i32 = 0x800F080Cu32 as i32; +const DISMAPI_E_CAPABILITY_NOT_APPLICABLE: i32 = 0x800F0825u32 as i32; const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; #[link(name = "kernel32")] @@ -426,6 +427,20 @@ impl DismSessionHandle { cap_info }; + // Some Windows versions return success with empty name for unknown capabilities + // instead of DISMAPI_E_UNKNOWN_FEATURE; detect and treat as unknown. + if result.name.is_empty() { + return Ok(DismCapabilityResult { + name: name.to_string(), + unknown: true, + state: 0, + display_name: String::new(), + description: String::new(), + download_size: 0, + install_size: 0, + }); + } + Ok(result) } @@ -468,6 +483,9 @@ impl DismSessionHandle { std::ptr::null_mut(), // UserData ) }; + if hr == DISMAPI_E_CAPABILITY_NOT_APPLICABLE { + return Ok(false); // Already not present — nothing to do + } if hr < 0 { return Err(t!("dism.removeCapabilityFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); } diff --git a/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 index 8dfeac0ca..afbc6cde1 100644 --- a/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 +++ b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 @@ -56,7 +56,8 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - set operation' -Skip:(!$IsWind $output.afterState.capabilities.Count | Should -Be 1 $cap = $output.afterState.capabilities[0] $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' - $cap.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') + # Installed is accepted because some base capabilities cannot be removed by DISM + $cap.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged', 'Installed') } It 'sets state to Installed for an already installed capability' -Skip:(!$isElevated) { @@ -75,17 +76,21 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - set operation' -Skip:(!$IsWind } It 'sets state to NotPresent for an already not-present capability' -Skip:(!$isElevated) { - # First ensure the capability is not present - $removeJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"NotPresent"}]}' - dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $removeJson | Out-Null - $LASTEXITCODE | Should -Be 0 + # Use a capability name that does not exist on the system, so it is already NotPresent + $notPresentJson = '{"capabilities":[{"name":"Test.NonExistent.Capability~~~0.0.1.0","state":"NotPresent"}]}' - # Set NotPresent again — should succeed idempotently - $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $removeJson | ConvertFrom-Json + # Set NotPresent — should succeed idempotently since it is already not present + $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $notPresentJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.afterState.capabilities | Should -Not -BeNullOrEmpty $cap = $output.afterState.capabilities[0] - $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.name | Should -BeExactly 'Test.NonExistent.Capability~~~0.0.1.0' $cap.state | Should -BeIn @('NotPresent', 'Removed', 'Staged') + + # Set NotPresent again — should still succeed idempotently + $output2 = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $notPresentJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $cap2 = $output2.afterState.capabilities[0] + $cap2.state | Should -BeIn @('NotPresent', 'Removed', 'Staged') } } From 2dd7f1e5e2e56f92e5a2fd952d3fc934d1a0de39 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Sat, 14 Mar 2026 15:08:02 -0700 Subject: [PATCH 3/3] fix dism usage --- resources/dism_dsc/locales/en-us.toml | 1 + .../dism_dsc/src/feature_on_demand/get.rs | 2 +- .../dism_dsc/src/feature_on_demand/set.rs | 12 ++- .../dism_dsc/src/optional_feature/dism.rs | 8 +- .../tests/featureOnDemand_export.tests.ps1 | 8 ++ .../tests/featureOnDemand_get.tests.ps1 | 8 ++ .../tests/featureOnDemand_set.tests.ps1 | 90 +++++++++++++------ 7 files changed, 97 insertions(+), 32 deletions(-) diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index d80544af4..b5165baf2 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -33,6 +33,7 @@ failedParseInput = "Failed to parse input: %{err}" capabilitiesArrayEmpty = "Capabilities array cannot be empty for set operation" nameRequired = "name is required for set operation" stateRequired = "state is required for set operation" +capabilityNotFound = "Capability '%{name}' was not found on this system" unsupportedDesiredState = "Unsupported desired state '%{state}'. Supported states for set are: Installed, NotPresent" failedSerializeOutput = "Failed to serialize output: %{err}" diff --git a/resources/dism_dsc/src/feature_on_demand/get.rs b/resources/dism_dsc/src/feature_on_demand/get.rs index f2e43123a..053c90d03 100644 --- a/resources/dism_dsc/src/feature_on_demand/get.rs +++ b/resources/dism_dsc/src/feature_on_demand/get.rs @@ -33,12 +33,12 @@ pub fn handle_get(input: &str) -> Result { } else { FeatureOnDemandInfo { name: Some(raw.name), - exist: None, state: CapabilityState::from_dism(raw.state), display_name: Some(raw.display_name), description: Some(raw.description), download_size: Some(raw.download_size), install_size: Some(raw.install_size), + ..FeatureOnDemandInfo::default() } }; results.push(info); diff --git a/resources/dism_dsc/src/feature_on_demand/set.rs b/resources/dism_dsc/src/feature_on_demand/set.rs index 60f4dbe95..78c1d95dd 100644 --- a/resources/dism_dsc/src/feature_on_demand/set.rs +++ b/resources/dism_dsc/src/feature_on_demand/set.rs @@ -32,11 +32,19 @@ pub fn handle_set(input: &str) -> Result { .ok_or_else(|| t!("fod_set.stateRequired").to_string())?; let current = session.get_capability_info(name)?; + + if current.unknown { + return Err(t!("fod_set.capabilityNotFound", name = name).to_string()); + } + let current_state = CapabilityState::from_dism(current.state); let needs_reboot = match desired_state { CapabilityState::Installed => { - session.add_capability(name)? + match current_state { + Some(CapabilityState::Installed) => false, + _ => session.add_capability(name)?, + } } CapabilityState::NotPresent => { match current_state { @@ -58,12 +66,12 @@ pub fn handle_set(input: &str) -> Result { let raw = session.get_capability_info(name)?; let info = FeatureOnDemandInfo { name: Some(raw.name), - exist: None, state: CapabilityState::from_dism(raw.state), display_name: Some(raw.display_name), description: Some(raw.description), download_size: Some(raw.download_size), install_size: Some(raw.install_size), + ..FeatureOnDemandInfo::default() }; results.push(info); } diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index 0e92264e9..291a44521 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -25,13 +25,13 @@ extern "system" { ) -> *mut c_void; } -#[repr(C, packed)] +#[repr(C, packed(4))] struct DismFeature { feature_name: *const u16, state: i32, } -#[repr(C, packed)] +#[repr(C, packed(4))] struct DismFeatureInfo { feature_name: *const u16, state: i32, @@ -42,13 +42,13 @@ struct DismFeatureInfo { custom_property_count: u32, } -#[repr(C, packed)] +#[repr(C, packed(4))] struct DismCapability { name: *const u16, state: i32, } -#[repr(C, packed)] +#[repr(C, packed(4))] struct DismCapabilityDetail { name: *const u16, state: i32, diff --git a/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 index d7c589938..2bb35b0e1 100644 --- a/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 +++ b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 @@ -128,6 +128,14 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW $capabilities.Count | Should -Be 0 } + It 'returns empty results for empty capabilities array input' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[]}' + $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $capabilities = $output.resources[0].properties.capabilities + $capabilities.Count | Should -Be 0 + } + It 'returns complete capability properties when full-info filter is used' -Skip:(!$isElevated) { $inputJson = '{"capabilities":[{"name":"' + $knownCapabilityNameOne + '","displayName":"*"}]}' $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json diff --git a/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 index 59cdf6bee..55b8e9ad4 100644 --- a/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 +++ b/resources/dism_dsc/tests/featureOnDemand_get.tests.ps1 @@ -57,12 +57,20 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - get operation' -Skip:(!$IsWind $inputJson = '{"capabilities":[{"state":"Installed"}]}' $testError = & { dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'name is required' } It 'returns error when capabilities array is empty' -Skip:(!$isElevated) { $inputJson = '{"capabilities":[]}' $testError = & { dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'cannot be empty' + } + + It 'returns error for malformed JSON input' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{invalid' + $testError = & { dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 } It 'returns _exist false for a non-existent capability name' -Skip:(!$isElevated) { diff --git a/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 index afbc6cde1..80a32eb42 100644 --- a/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 +++ b/resources/dism_dsc/tests/featureOnDemand_set.tests.ps1 @@ -11,80 +11,120 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - set operation' -Skip:(!$IsWind } } + BeforeAll { + # Dynamically discover capability names instead of hardcoding + $dismOutput = & dism /Online /Get-Capabilities /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to get capabilities using dism: $dismOutput" + } + $installedMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Installed\s*$' + $notPresentMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Not Present\s*$' + if (-not $installedMatches -or -not $notPresentMatches) { + throw "Failed to find both installed and not-present capabilities in DISM output.`nOutput:`n$dismOutput" + } + $knownInstalledCapability = $installedMatches[0].Matches[0].Groups[1].Value + $knownNotPresentCapability = $notPresentMatches[0].Matches[0].Groups[1].Value + + # Record initial states for cleanup + $script:initialInstalledState = 'Installed' + $script:initialNotPresentState = 'NotPresent' + } + + AfterAll { + # Restore capabilities to their original states + if ($knownInstalledCapability) { + $restoreJson = '{"capabilities":[{"name":"' + $knownInstalledCapability + '","state":"' + $script:initialInstalledState + '"}]}' + dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $restoreJson 2>&1 | Out-Null + } + if ($knownNotPresentCapability) { + $restoreJson = '{"capabilities":[{"name":"' + $knownNotPresentCapability + '","state":"' + $script:initialNotPresentState + '"}]}' + dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $restoreJson 2>&1 | Out-Null + } + } + It 'returns error when name is missing' -Skip:(!$isElevated) { $inputJson = '{"capabilities":[{"state":"Installed"}]}' $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'name is required' } It 'returns error when state is missing' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0"}]}' + $inputJson = '{"capabilities":[{"name":"' + $knownInstalledCapability + '"}]}' $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'state is required' } It 'returns error when capabilities array is empty' -Skip:(!$isElevated) { $inputJson = '{"capabilities":[]}' $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'cannot be empty' } It 'returns error for unsupported desired state' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Staged"}]}' + $inputJson = '{"capabilities":[{"name":"' + $knownInstalledCapability + '","state":"Staged"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'Unsupported desired state' + } + + It 'returns error for malformed JSON input' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{invalid' + $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when installing a non-existent capability' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"name":"NonExistent-Capability-1234567890","state":"Installed"}]}' $testError = & { dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson 2>&1 } $LASTEXITCODE | Should -Not -Be 0 + "$testError" | Should -Match 'not found' } - It 'can install a capability and returns updated state' -Skip:(!$isElevated) { - # Use a capability known to exist on most Windows systems - $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Installed"}]}' + It 'can install a capability and returns updated state' -Skip:(!$isElevated) -Tag 'Mutating' { + $inputJson = '{"capabilities":[{"name":"' + $knownInstalledCapability + '","state":"Installed"}]}' $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.afterState.capabilities | Should -Not -BeNullOrEmpty $output.afterState.capabilities.Count | Should -Be 1 $cap = $output.afterState.capabilities[0] - $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.name | Should -BeExactly $knownInstalledCapability $cap.state | Should -BeIn @('Installed', 'InstallPending') $cap.displayName | Should -Not -BeNullOrEmpty } - It 'can remove a capability and returns updated state' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"NotPresent"}]}' + It 'can remove a capability and returns updated state' -Skip:(!$isElevated) -Tag 'Mutating' { + $inputJson = '{"capabilities":[{"name":"' + $knownNotPresentCapability + '","state":"NotPresent"}]}' $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.afterState.capabilities | Should -Not -BeNullOrEmpty $output.afterState.capabilities.Count | Should -Be 1 $cap = $output.afterState.capabilities[0] - $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' - # Installed is accepted because some base capabilities cannot be removed by DISM - $cap.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged', 'Installed') + $cap.name | Should -BeExactly $knownNotPresentCapability + $cap.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') } - It 'sets state to Installed for an already installed capability' -Skip:(!$isElevated) { - # First ensure the capability is installed - $installJson = '{"capabilities":[{"name":"Language.Basic~~~en-US~0.0.1.0","state":"Installed"}]}' - dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $installJson | Out-Null - $LASTEXITCODE | Should -Be 0 - - # Set Installed again — should succeed idempotently + It 'sets state to Installed for an already installed capability' -Skip:(!$isElevated) -Tag 'Mutating' { + # Set Installed on an already-installed capability — should succeed idempotently + $installJson = '{"capabilities":[{"name":"' + $knownInstalledCapability + '","state":"Installed"}]}' $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $installJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.afterState.capabilities | Should -Not -BeNullOrEmpty $cap = $output.afterState.capabilities[0] - $cap.name | Should -BeExactly 'Language.Basic~~~en-US~0.0.1.0' + $cap.name | Should -BeExactly $knownInstalledCapability $cap.state | Should -Be 'Installed' } - It 'sets state to NotPresent for an already not-present capability' -Skip:(!$isElevated) { - # Use a capability name that does not exist on the system, so it is already NotPresent - $notPresentJson = '{"capabilities":[{"name":"Test.NonExistent.Capability~~~0.0.1.0","state":"NotPresent"}]}' - - # Set NotPresent — should succeed idempotently since it is already not present + It 'sets state to NotPresent for an already not-present capability' -Skip:(!$isElevated) -Tag 'Mutating' { + # Set NotPresent on an already not-present capability — should succeed idempotently + $notPresentJson = '{"capabilities":[{"name":"' + $knownNotPresentCapability + '","state":"NotPresent"}]}' $output = dsc resource set -r Microsoft.Windows/FeatureOnDemandList -i $notPresentJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.afterState.capabilities | Should -Not -BeNullOrEmpty $cap = $output.afterState.capabilities[0] - $cap.name | Should -BeExactly 'Test.NonExistent.Capability~~~0.0.1.0' + $cap.name | Should -BeExactly $knownNotPresentCapability $cap.state | Should -BeIn @('NotPresent', 'Removed', 'Staged') # Set NotPresent again — should still succeed idempotently