From e18e4795f23a91f1ba866b8f1877afc6797d7d61 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 21:48:02 -0700 Subject: [PATCH 1/8] Add `Microsoft.Windows/OptionalFeatureList` resource --- Cargo.lock | 9 + Cargo.toml | 5 +- resources/dism_dsc/.project.data.json | 10 + resources/dism_dsc/Cargo.toml | 13 + resources/dism_dsc/locales/en-us.toml | 18 ++ .../optionalfeature.dsc.resource.json | 92 +++++++ resources/dism_dsc/src/main.rs | 81 ++++++ .../dism_dsc/src/optional_feature/dism.rs | 241 ++++++++++++++++++ .../dism_dsc/src/optional_feature/export.rs | 155 +++++++++++ .../dism_dsc/src/optional_feature/get.rs | 33 +++ .../dism_dsc/src/optional_feature/mod.rs | 10 + .../dism_dsc/src/optional_feature/types.rs | 71 ++++++ .../tests/optionalFeature_export.tests.ps1 | 117 +++++++++ .../tests/optionalFeature_get.tests.ps1 | 54 ++++ 14 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 resources/dism_dsc/.project.data.json create mode 100644 resources/dism_dsc/Cargo.toml create mode 100644 resources/dism_dsc/locales/en-us.toml create mode 100644 resources/dism_dsc/optionalfeature.dsc.resource.json create mode 100644 resources/dism_dsc/src/main.rs create mode 100644 resources/dism_dsc/src/optional_feature/dism.rs create mode 100644 resources/dism_dsc/src/optional_feature/export.rs create mode 100644 resources/dism_dsc/src/optional_feature/get.rs create mode 100644 resources/dism_dsc/src/optional_feature/mod.rs create mode 100644 resources/dism_dsc/src/optional_feature/types.rs create mode 100644 resources/dism_dsc/tests/optionalFeature_export.tests.ps1 create mode 100644 resources/dism_dsc/tests/optionalFeature_get.tests.ps1 diff --git a/Cargo.lock b/Cargo.lock index 969561253..6337bbd73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -676,6 +676,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dism_dsc" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", +] + [[package]] name = "dispatch" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index f5301114c..b49f425d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "lib/dsc-lib-registry", "resources/runcommandonset", "lib/dsc-lib-security_context", + "resources/dism_dsc", "resources/sshdconfig", "resources/WindowsUpdate", "tools/dsctest", @@ -44,6 +45,7 @@ default-members = [ "lib/dsc-lib-registry", "resources/runcommandonset", "lib/dsc-lib-security_context", + "resources/dism_dsc", "resources/sshdconfig", "resources/WindowsUpdate", "tools/dsctest", @@ -80,7 +82,8 @@ Windows = [ "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" + "xtask", + "resources/dism_dsc" ] macOS = [ "dsc", diff --git a/resources/dism_dsc/.project.data.json b/resources/dism_dsc/.project.data.json new file mode 100644 index 000000000..9471d7ed4 --- /dev/null +++ b/resources/dism_dsc/.project.data.json @@ -0,0 +1,10 @@ +{ + "Name": "dism_dsc", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": ["dism_dsc"], + "CopyFiles": { + "Windows": ["optionalfeature.dsc.resource.json"] + } +} diff --git a/resources/dism_dsc/Cargo.toml b/resources/dism_dsc/Cargo.toml new file mode 100644 index 000000000..006dd1796 --- /dev/null +++ b/resources/dism_dsc/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "dism_dsc" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "dism_dsc" +path = "src/main.rs" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml new file mode 100644 index 000000000..7c8c84348 --- /dev/null +++ b/resources/dism_dsc/locales/en-us.toml @@ -0,0 +1,18 @@ +_version = 1 + +[main] +missingOperation = "Missing operation argument" +usage = "Usage: dism_dsc " +windowsOnly = "This resource is only supported on Windows" +unknownOperation = "Unknown operation '%{operation}'" +errorReadingInput = "Error reading input: %{err}" + +[get] +failedParseInput = "Failed to parse input: %{err}" +featuresArrayEmpty = "Features array cannot be empty for get operation" +featureNameRequired = "featureName is required for get operation" +failedSerializeOutput = "Failed to serialize output: %{err}" + +[export] +failedParseInput = "Failed to parse input: %{err}" +failedSerializeOutput = "Failed to serialize output: %{err}" diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json new file mode 100644 index 000000000..3b1d40e2c --- /dev/null +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage Windows Optional Features using the DISM API.", + "tags": [ + "windows", + "dism", + "optionalfeature", + "feature" + ], + "type": "Microsoft.Windows/OptionalFeatureList", + "version": "0.1.0", + "get": { + "executable": "dism_dsc", + "args": [ + "get" + ], + "input": "stdin", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "dism_dsc", + "args": [ + "export" + ], + "input": "stdin", + "requireSecurityContext": "elevated" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/OptionalFeatureList/v0.1.0/schema.json", + "title": "Windows Optional Feature", + "description": "Query information about Windows Optional Features using the DISM API.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", + "markdownDescription": "The `Microsoft.Windows/OptionalFeatureList` resource enables you to query information about Windows Optional Features using the DISM API.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", + "type": "object", + "additionalProperties": false, + "properties": { + "features": { + "type": "array", + "title": "Features", + "description": "An array of optional feature filters or feature information objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "featureName": { + "type": "string", + "title": "Feature name", + "description": "The name of the Windows optional feature. Required for get operation. For export operation, this is optional and wildcards (*) are supported for case-insensitive filtering." + }, + "state": { + "type": "string", + "enum": [ + "NotPresent", + "UninstallPending", + "Staged", + "Removed", + "Installed", + "InstallPending", + "Superseded", + "PartiallyInstalled" + ], + "title": "Feature state", + "description": "The current state of the optional feature." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the optional feature. 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 optional feature. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + }, + "restartRequired": { + "type": "string", + "enum": [ + "No", + "Possible", + "Required" + ], + "title": "Restart required", + "description": "Whether a restart is required after enabling or disabling the feature. This is a read-only property returned by get and export operations." + } + } + } + } + } + } + } +} diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs new file mode 100644 index 000000000..345fb05f4 --- /dev/null +++ b/resources/dism_dsc/src/main.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(windows)] +mod optional_feature; + +use rust_i18n::t; +use std::io::{self, Read, IsTerminal}; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Error: {}", t!("main.missingOperation")); + eprintln!("{}", t!("main.usage")); + std::process::exit(1); + } + + let operation = args[1].as_str(); + + match operation { + "export" => { + // Read optional input from stdin (only if stdin is not a terminal/TTY) + let mut buffer = String::new(); + if !io::stdin().is_terminal() { + let _ = io::stdin().read_to_string(&mut buffer); + } + + #[cfg(windows)] + match optional_feature::handle_export(&buffer) { + Ok(output) => { + println!("{output}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: {}", t!("main.windowsOnly")); + std::process::exit(1); + } + } + "get" => { + // Read input from stdin + let mut buffer = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut buffer) { + eprintln!("{}", t!("main.errorReadingInput", err = e)); + std::process::exit(1); + } + + #[cfg(windows)] + match optional_feature::handle_get(&buffer) { + Ok(output) => { + println!("{output}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: {}", t!("main.windowsOnly")); + std::process::exit(1); + } + } + _ => { + eprintln!("{}", t!("main.unknownOperation", operation = operation)); + eprintln!("{}", t!("main.usage")); + std::process::exit(1); + } + } +} diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs new file mode 100644 index 000000000..0585b9531 --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; + +use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, RestartType}; + +const DISM_ONLINE_IMAGE: &str = "DISM_{53BFAE52-B167-4E2F-A258-0A37B57FF845}"; +const DISM_LOG_ERRORS: i32 = 0; +const DISM_PACKAGE_NONE: i32 = 0; + +#[repr(C, packed)] +struct DismFeature { + feature_name: *const u16, + state: i32, +} + +#[repr(C, packed)] +struct DismFeatureInfo { + feature_name: *const u16, + state: i32, + display_name: *const u16, + description: *const u16, + restart_required: i32, + custom_property: *const c_void, + custom_property_count: u32, +} + +// Function pointer types for the DISM API +type DismInitializeFn = + unsafe extern "system" fn(i32, *const u16, *const u16) -> i32; +type DismOpenSessionFn = + unsafe extern "system" fn(*const u16, *const u16, *const u16, *mut u32) -> i32; +type DismGetFeaturesFn = + unsafe extern "system" fn(u32, *const u16, i32, *mut *mut DismFeature, *mut u32) -> i32; +type DismGetFeatureInfoFn = + unsafe extern "system" fn(u32, *const u16, *const u16, i32, *mut *mut DismFeatureInfo) -> 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; + +// Kernel32 functions for dynamic loading +extern "system" { + fn LoadLibraryW(lp_lib_file_name: *const u16) -> *mut c_void; + fn GetProcAddress(h_module: *mut c_void, lp_proc_name: *const u8) -> *mut c_void; + fn FreeLibrary(h_lib_module: *mut c_void) -> i32; +} + +fn to_wide_null(s: &str) -> Vec { + std::ffi::OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +unsafe fn from_wide_ptr(ptr: *const u16) -> String { + if ptr.is_null() { + return String::new(); + } + let len = (0..).take_while(|&i| *ptr.add(i) != 0).count(); + let slice = std::slice::from_raw_parts(ptr, len); + String::from_utf16_lossy(slice) +} + +unsafe fn load_fn(lib: *mut c_void, name: &[u8]) -> Result { + let ptr = GetProcAddress(lib, name.as_ptr()); + if ptr.is_null() { + return Err(format!( + "Failed to find function '{}' in dismapi.dll", + std::str::from_utf8(&name[..name.len() - 1]).unwrap_or("?") + )); + } + Ok(std::mem::transmute_copy(&ptr)) +} + +struct DismApi { + lib: *mut c_void, + close_session: DismCloseSessionFn, + shutdown: DismShutdownFn, + get_features: DismGetFeaturesFn, + get_feature_info: DismGetFeatureInfoFn, + delete: DismDeleteFn, +} + +impl DismApi { + fn load() -> Result { + let lib_name = to_wide_null("dismapi.dll"); + let lib = unsafe { LoadLibraryW(lib_name.as_ptr()) }; + if lib.is_null() { + return Err("Failed to load dismapi.dll. Ensure DISM is available on this system.".to_string()); + } + + unsafe { + Ok(DismApi { + lib, + close_session: load_fn(lib, b"DismCloseSession\0")?, + shutdown: load_fn(lib, b"DismShutdown\0")?, + get_features: load_fn(lib, b"DismGetFeatures\0")?, + get_feature_info: load_fn(lib, b"DismGetFeatureInfo\0")?, + delete: load_fn(lib, b"DismDelete\0")?, + }) + } + } +} + +impl Drop for DismApi { + fn drop(&mut self) { + unsafe { + FreeLibrary(self.lib); + } + } +} + +pub struct DismSessionHandle { + handle: u32, + api: DismApi, +} + +impl DismSessionHandle { + pub fn open() -> Result { + let api = DismApi::load()?; + + // Load DismInitialize and DismOpenSession (only needed during open) + let dism_initialize: DismInitializeFn = + unsafe { load_fn(api.lib, b"DismInitialize\0")? }; + let dism_open_session: DismOpenSessionFn = + unsafe { load_fn(api.lib, b"DismOpenSession\0")? }; + + unsafe { + let hr = dism_initialize(DISM_LOG_ERRORS, std::ptr::null(), std::ptr::null()); + if hr < 0 { + return Err(format!( + "DismInitialize failed: HRESULT 0x{:08X}", + hr as u32 + )); + } + + let image_path = to_wide_null(DISM_ONLINE_IMAGE); + let mut session: u32 = 0; + let hr = dism_open_session( + image_path.as_ptr(), + std::ptr::null(), + std::ptr::null(), + &mut session, + ); + if hr < 0 { + (api.shutdown)(); + return Err(format!( + "DismOpenSession failed: HRESULT 0x{:08X}", + hr as u32 + )); + } + + Ok(DismSessionHandle { + handle: session, + api, + }) + } + } + + pub fn get_feature_info(&self, feature_name: &str) -> Result { + let wide_name = to_wide_null(feature_name); + let mut info_ptr: *mut DismFeatureInfo = std::ptr::null_mut(); + + let hr = unsafe { + (self.api.get_feature_info)( + self.handle, + wide_name.as_ptr(), + std::ptr::null(), + DISM_PACKAGE_NONE, + &mut info_ptr, + ) + }; + + if hr < 0 { + return Err(format!( + "DismGetFeatureInfo failed for '{}': HRESULT 0x{:08X}", + feature_name, hr as u32 + )); + } + + let result = unsafe { + let info = &*info_ptr; + let feature_info = OptionalFeatureInfo { + feature_name: Some(from_wide_ptr(info.feature_name)), + 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 + }; + + Ok(result) + } + + pub fn get_all_feature_basics(&self) -> Result, String> { + let mut features_ptr: *mut DismFeature = std::ptr::null_mut(); + let mut count: u32 = 0; + + let hr = unsafe { + (self.api.get_features)( + self.handle, + std::ptr::null(), + DISM_PACKAGE_NONE, + &mut features_ptr, + &mut count, + ) + }; + + if hr < 0 { + return Err(format!( + "DismGetFeatures failed: HRESULT 0x{:08X}", + hr as u32 + )); + } + + let mut result = Vec::new(); + unsafe { + for i in 0..count as usize { + 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) + } +} + +impl Drop for DismSessionHandle { + fn drop(&mut self) { + unsafe { + (self.api.close_session)(self.handle); + (self.api.shutdown)(); + } + } +} diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs new file mode 100644 index 000000000..af16c9a5f --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, OptionalFeatureList}; + +pub fn handle_export(input: &str) -> Result { + let filters: Vec = if input.trim().is_empty() { + vec![OptionalFeatureInfo::default()] + } else { + let list: OptionalFeatureList = serde_json::from_str(input) + .map_err(|e| t!("export.failedParseInput", err = e.to_string()).to_string())?; + list.features + }; + + let session = DismSessionHandle::open()?; + let all_basics = session.get_all_feature_basics()?; + + // Check if any filter requires full info (displayName or description) + let needs_full_info = filters + .iter() + .any(|f| f.display_name.is_some() || f.description.is_some()); + + let mut results = Vec::new(); + + for (name, state_val) in &all_basics { + let state = FeatureState::from_dism(*state_val); + + if needs_full_info { + // Get full info first so we can filter on displayName/description + let info = match session.get_feature_info(name) { + Ok(info) => info, + Err(_) => OptionalFeatureInfo { + feature_name: Some(name.clone()), + state, + display_name: None, + description: None, + restart_required: None, + }, + }; + + if matches_any_filter(&info, &filters) { + results.push(info); + } + } else { + // Fast path: only need name and state for filtering + 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()), + state, + display_name: None, + description: None, + restart_required: None, + }); + } + } + } + } + } + + let output = OptionalFeatureList { features: results }; + 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; + } + + // Check suffix (part after last *) + if let Some(last) = parts.last() { + if !last.is_empty() && !text_lower.ends_with(last) { + return false; + } + } + + // Check middle parts appear in order + let mut pos = parts[0].len(); + for part in &parts[1..parts.len().saturating_sub(1)] { + if part.is_empty() { + continue; + } + match text_lower[pos..].find(part) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + + true +} diff --git a/resources/dism_dsc/src/optional_feature/get.rs b/resources/dism_dsc/src/optional_feature/get.rs new file mode 100644 index 000000000..5996bc5ce --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/get.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::optional_feature::types::OptionalFeatureList; + +pub fn handle_get(input: &str) -> Result { + let feature_list: OptionalFeatureList = serde_json::from_str(input) + .map_err(|e| t!("get.failedParseInput", err = e.to_string()).to_string())?; + + if feature_list.features.is_empty() { + return Err(t!("get.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results = Vec::new(); + + for feature_input in &feature_list.features { + let feature_name = feature_input + .feature_name + .as_ref() + .ok_or_else(|| t!("get.featureNameRequired").to_string())?; + + let info = session.get_feature_info(feature_name)?; + results.push(info); + } + + let output = OptionalFeatureList { features: results }; + serde_json::to_string(&output) + .map_err(|e| t!("get.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/optional_feature/mod.rs b/resources/dism_dsc/src/optional_feature/mod.rs new file mode 100644 index 000000000..02163cf6c --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/mod.rs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; +mod dism; +mod get; +mod export; + +pub use get::handle_get; +pub use export::handle_export; diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs new file mode 100644 index 000000000..f98d4563b --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OptionalFeatureList { + pub features: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct OptionalFeatureInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub feature_name: 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 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, + Possible, + Required, +} + +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 { + 0 => Some(RestartType::No), + 1 => Some(RestartType::Possible), + 2 => Some(RestartType::Required), + _ => None, + } + } +} diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 new file mode 100644 index 000000000..d25623d5d --- /dev/null +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + } + + It 'exports all features with no input' -Skip:(!$isElevated) { + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features.Count | Should -BeGreaterThan 0 + $features[0].featureName | Should -Not -BeNullOrEmpty + $features[0].state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + } + + It 'exports features filtered by exact featureName' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features.Count | Should -Be 1 + $feature = $features[0] + $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.displayName | Should -Not -BeNullOrEmpty + $feature.description | Should -Not -BeNullOrEmpty + $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') + } + + It 'exports features filtered by wildcard featureName' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Printing-*"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + foreach ($feature in $features) { + $feature.featureName | Should -BeLike 'Printing-*' + } + } + + It 'exports features filtered by state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + foreach ($feature in $features) { + $feature.state | Should -BeExactly 'Installed' + } + } + + It 'exports features with combined featureName and state filter' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"*","state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + foreach ($feature in $features) { + $feature.state | Should -BeExactly 'Installed' + } + } + + It 'exports features filtered by wildcard displayName' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"displayName":"*Print*"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + foreach ($feature in $features) { + $feature.displayName | Should -BeLike '*Print*' + } + } + + It 'exports features with multiple filters using OR logic' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"Printing-PrintToPDFServices-Features"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $names = $features | ForEach-Object { $_.featureName } + $names | Should -Contain 'Microsoft-Windows-Subsystem-Linux' + $names | Should -Contain 'Printing-PrintToPDFServices-Features' + } + + It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"ZZZNonExistent*"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features.Count | Should -Be 0 + } + + It 'returns complete feature properties in export results' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features.Count | Should -Be 1 + $feature = $features[0] + $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + $feature.displayName | Should -Not -BeNullOrEmpty + $feature.description | Should -Not -BeNullOrEmpty + $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') + } +} diff --git a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 new file mode 100644 index 000000000..8e71741bc --- /dev/null +++ b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + } + + It 'gets a known optional feature by name' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features | Should -Not -BeNullOrEmpty + $output.actualState.features.Count | Should -Be 1 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + $feature.displayName | Should -Not -BeNullOrEmpty + $feature.description | Should -Not -BeNullOrEmpty + $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') + } + + It 'gets multiple features in a single request' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"Printing-PrintToPDFServices-Features"}]}' + $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features.Count | Should -Be 2 + $output.actualState.features[0].featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $output.actualState.features[1].featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + } + + It 'returns error when featureName is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"state":"Installed"}]}' + $testError = & { dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' -Skip:(!$isElevated) { + $inputJson = '{"features":[]}' + $testError = & { dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for a non-existent feature name' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' + $testError = & { dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } +} From 0f31d0167d51ab28d5b98a239889f5ef371062d4 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 22:16:32 -0700 Subject: [PATCH 2/8] Add set operation --- resources/dism_dsc/locales/en-us.toml | 10 +- .../optionalfeature.dsc.resource.json | 14 ++- resources/dism_dsc/src/main.rs | 26 ++++++ .../dism_dsc/src/optional_feature/dism.rs | 74 +++++++++++++++ .../dism_dsc/src/optional_feature/mod.rs | 2 + .../dism_dsc/src/optional_feature/set.rs | 54 +++++++++++ .../tests/optionalFeature_set.tests.ps1 | 93 +++++++++++++++++++ 7 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 resources/dism_dsc/src/optional_feature/set.rs create mode 100644 resources/dism_dsc/tests/optionalFeature_set.tests.ps1 diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index 7c8c84348..c93272ca0 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -2,7 +2,7 @@ _version = 1 [main] missingOperation = "Missing operation argument" -usage = "Usage: dism_dsc " +usage = "Usage: dism_dsc " windowsOnly = "This resource is only supported on Windows" unknownOperation = "Unknown operation '%{operation}'" errorReadingInput = "Error reading input: %{err}" @@ -16,3 +16,11 @@ failedSerializeOutput = "Failed to serialize output: %{err}" [export] failedParseInput = "Failed to parse input: %{err}" failedSerializeOutput = "Failed to serialize output: %{err}" + +[set] +failedParseInput = "Failed to parse input: %{err}" +featuresArrayEmpty = "Features array cannot be empty for set operation" +featureNameRequired = "featureName is required for set operation" +stateRequired = "state is required for set operation" +unsupportedDesiredState = "Unsupported desired state '%{state}'. Supported states for set are: Installed, NotPresent, Removed" +failedSerializeOutput = "Failed to serialize output: %{err}" diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index 3b1d40e2c..c419c5373 100644 --- a/resources/dism_dsc/optionalfeature.dsc.resource.json +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -17,6 +17,16 @@ "input": "stdin", "requireSecurityContext": "elevated" }, + "set": { + "executable": "dism_dsc", + "args": [ + "set" + ], + "input": "stdin", + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, "export": { "executable": "dism_dsc", "args": [ @@ -30,8 +40,8 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/OptionalFeatureList/v0.1.0/schema.json", "title": "Windows Optional Feature", - "description": "Query information about Windows Optional Features using the DISM API.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", - "markdownDescription": "The `Microsoft.Windows/OptionalFeatureList` resource enables you to query information about Windows Optional Features using the DISM API.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", + "description": "Manage Windows Optional Features using the DISM API. Supports get, set, and export operations.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", + "markdownDescription": "The `Microsoft.Windows/OptionalFeatureList` resource enables you to manage Windows Optional Features using the DISM API. Supports enabling (`Installed`) and disabling (`NotPresent`, `Removed`) features.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", "type": "object", "additionalProperties": false, "properties": { diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs index 345fb05f4..6a154745d 100644 --- a/resources/dism_dsc/src/main.rs +++ b/resources/dism_dsc/src/main.rs @@ -72,6 +72,32 @@ fn main() { std::process::exit(1); } } + "set" => { + // Read input from stdin + let mut buffer = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut buffer) { + eprintln!("{}", t!("main.errorReadingInput", err = e)); + std::process::exit(1); + } + + #[cfg(windows)] + match optional_feature::handle_set(&buffer) { + Ok(output) => { + println!("{output}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: {}", t!("main.windowsOnly")); + 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 0585b9531..cd56ea055 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -36,6 +36,28 @@ type DismGetFeaturesFn = unsafe extern "system" fn(u32, *const u16, i32, *mut *mut DismFeature, *mut u32) -> i32; type DismGetFeatureInfoFn = unsafe extern "system" fn(u32, *const u16, *const u16, i32, *mut *mut DismFeatureInfo) -> i32; +type DismEnableFeatureFn = unsafe extern "system" fn( + u32, // Session + *const u16, // FeatureName + *const u16, // Identifier (NULL) + i32, // PackageIdentifier (DismPackageNone) + i32, // LimitAccess (BOOL) + *const *const u16,// SourcePaths (NULL) + u32, // SourcePathCount + i32, // EnableAll (BOOL) + *mut c_void, // CancelEvent (NULL) + *mut c_void, // Progress callback (NULL) + *mut c_void, // UserData (NULL) +) -> i32; +type DismDisableFeatureFn = unsafe extern "system" fn( + u32, // Session + *const u16, // FeatureName + *const u16, // PackageName (NULL) + i32, // RemovePayload (BOOL) + *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; @@ -80,6 +102,8 @@ struct DismApi { shutdown: DismShutdownFn, get_features: DismGetFeaturesFn, get_feature_info: DismGetFeatureInfoFn, + enable_feature: DismEnableFeatureFn, + disable_feature: DismDisableFeatureFn, delete: DismDeleteFn, } @@ -98,6 +122,8 @@ impl DismApi { shutdown: load_fn(lib, b"DismShutdown\0")?, get_features: load_fn(lib, b"DismGetFeatures\0")?, get_feature_info: load_fn(lib, b"DismGetFeatureInfo\0")?, + enable_feature: load_fn(lib, b"DismEnableFeature\0")?, + disable_feature: load_fn(lib, b"DismDisableFeature\0")?, delete: load_fn(lib, b"DismDelete\0")?, }) } @@ -196,6 +222,54 @@ impl DismSessionHandle { Ok(result) } + pub fn enable_feature(&self, feature_name: &str) -> Result<(), String> { + let wide_name = to_wide_null(feature_name); + let hr = unsafe { + (self.api.enable_feature)( + self.handle, + wide_name.as_ptr(), + std::ptr::null(), // Identifier + DISM_PACKAGE_NONE, // PackageIdentifier + 0, // LimitAccess = FALSE + std::ptr::null(), // SourcePaths + 0, // SourcePathCount + 0, // EnableAll = FALSE + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + if hr < 0 { + return Err(format!( + "DismEnableFeature failed for '{}': HRESULT 0x{:08X}", + feature_name, hr as u32 + )); + } + Ok(()) + } + + pub fn disable_feature(&self, feature_name: &str, remove_payload: bool) -> Result<(), String> { + let wide_name = to_wide_null(feature_name); + let hr = unsafe { + (self.api.disable_feature)( + self.handle, + wide_name.as_ptr(), + std::ptr::null(), // PackageName + i32::from(remove_payload), // RemovePayload + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + if hr < 0 { + return Err(format!( + "DismDisableFeature failed for '{}': HRESULT 0x{:08X}", + feature_name, hr as u32 + )); + } + Ok(()) + } + pub fn get_all_feature_basics(&self) -> Result, String> { let mut features_ptr: *mut DismFeature = std::ptr::null_mut(); let mut count: u32 = 0; diff --git a/resources/dism_dsc/src/optional_feature/mod.rs b/resources/dism_dsc/src/optional_feature/mod.rs index 02163cf6c..95af23b42 100644 --- a/resources/dism_dsc/src/optional_feature/mod.rs +++ b/resources/dism_dsc/src/optional_feature/mod.rs @@ -5,6 +5,8 @@ mod types; mod dism; mod get; mod export; +mod set; pub use get::handle_get; pub use export::handle_export; +pub use set::handle_set; diff --git a/resources/dism_dsc/src/optional_feature/set.rs b/resources/dism_dsc/src/optional_feature/set.rs new file mode 100644 index 000000000..313f077ce --- /dev/null +++ b/resources/dism_dsc/src/optional_feature/set.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::optional_feature::dism::DismSessionHandle; +use crate::optional_feature::types::{FeatureState, OptionalFeatureList}; + +pub fn handle_set(input: &str) -> Result { + let feature_list: OptionalFeatureList = serde_json::from_str(input) + .map_err(|e| t!("set.failedParseInput", err = e.to_string()).to_string())?; + + if feature_list.features.is_empty() { + return Err(t!("set.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results = Vec::new(); + + for feature_input in &feature_list.features { + let feature_name = feature_input + .feature_name + .as_ref() + .ok_or_else(|| t!("set.featureNameRequired").to_string())?; + + let desired_state = feature_input + .state + .as_ref() + .ok_or_else(|| t!("set.stateRequired").to_string())?; + + match desired_state { + FeatureState::Installed => { + session.enable_feature(feature_name)?; + } + FeatureState::NotPresent | FeatureState::Removed => { + session.disable_feature(feature_name, true)?; + } + _ => { + return Err(t!( + "set.unsupportedDesiredState", + state = format!("{desired_state:?}") + ) + .to_string()); + } + } + + let info = session.get_feature_info(feature_name)?; + results.push(info); + } + + let output = OptionalFeatureList { features: results }; + serde_json::to_string(&output) + .map_err(|e| t!("set.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 new file mode 100644 index 000000000..27c49fbe8 --- /dev/null +++ b/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/OptionalFeatureList - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + } + + It 'returns error when featureName is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"state":"Installed"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when state is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' -Skip:(!$isElevated) { + $inputJson = '{"features":[]}' + $testError = & { dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for unsupported desired state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features","state":"Staged"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for a non-existent feature name' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890","state":"Installed"}]}' + $testError = & { dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'can enable a feature and returns updated state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features","state":"Installed"}]}' + $output = dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $output.afterState.features.Count | Should -Be 1 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + $feature.state | Should -BeIn @('Installed', 'InstallPending') + $feature.displayName | Should -Not -BeNullOrEmpty + } + + It 'can disable a feature with NotPresent and returns updated state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features","state":"NotPresent"}]}' + $output = dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $output.afterState.features.Count | Should -Be 1 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') + } + + It 'sets state to Installed for an already installed feature' -Skip:(!$isElevated) { + # First ensure the feature is installed + $enableJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features","state":"Installed"}]}' + dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $enableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Now set Installed again — should succeed idempotently + $output = dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $enableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + $feature.state | Should -Be 'Installed' + } + + It 'sets state to NotPresent for an already not-present feature' -Skip:(!$isElevated) { + # First ensure the feature is not present + $disableJson = '{"features":[{"featureName":"Printing-PrintToPDFServices-Features","state":"NotPresent"}]}' + dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $disableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Now set NotPresent again — should succeed idempotently + $output = dsc resource set -r Microsoft.Windows/OptionalFeatureList -i $disableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'Staged') + } +} From bf7bb101ac4dc3fedd5cca09f51fe931b402b5ca Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 22:34:01 -0700 Subject: [PATCH 3/8] code improvements --- resources/dism_dsc/locales/en-us.toml | 10 ++ resources/dism_dsc/src/main.rs | 119 +++++++----------- .../dism_dsc/src/optional_feature/dism.rs | 72 +++++------ .../dism_dsc/src/optional_feature/export.rs | 23 ++-- .../dism_dsc/src/optional_feature/set.rs | 7 +- .../dism_dsc/src/optional_feature/types.rs | 16 +++ 6 files changed, 121 insertions(+), 126 deletions(-) diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index c93272ca0..5e037dff2 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -24,3 +24,13 @@ featureNameRequired = "featureName is required for set operation" stateRequired = "state is required for set operation" unsupportedDesiredState = "Unsupported desired state '%{state}'. Supported states for set are: Installed, NotPresent, Removed" failedSerializeOutput = "Failed to serialize output: %{err}" + +[dism] +failedLoadLibrary = "Failed to load dismapi.dll. Ensure DISM is available on this system." +functionNotFound = "Failed to find function '%{name}' in dismapi.dll" +initializeFailed = "DismInitialize failed: HRESULT %{hr}" +openSessionFailed = "DismOpenSession failed: HRESULT %{hr}" +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}" diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs index 6a154745d..e1cd71ee7 100644 --- a/resources/dism_dsc/src/main.rs +++ b/resources/dism_dsc/src/main.rs @@ -9,6 +9,45 @@ use std::io::{self, Read, IsTerminal}; rust_i18n::i18n!("locales", fallback = "en-us"); +fn read_stdin(required: bool) -> Result { + let mut buffer = String::new(); + if required || !io::stdin().is_terminal() { + io::stdin() + .read_to_string(&mut buffer) + .map_err(|e| t!("main.errorReadingInput", err = e).to_string())?; + } + Ok(buffer) +} + +fn dispatch(handler: impl FnOnce(&str) -> Result, stdin_required: bool) { + let buffer = match read_stdin(stdin_required) { + Ok(b) => b, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + #[cfg(windows)] + match handler(&buffer) { + Ok(output) => { + println!("{output}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + let _ = buffer; + eprintln!("Error: {}", t!("main.windowsOnly")); + std::process::exit(1); + } +} + fn main() { let args: Vec = std::env::args().collect(); @@ -21,83 +60,9 @@ fn main() { let operation = args[1].as_str(); match operation { - "export" => { - // Read optional input from stdin (only if stdin is not a terminal/TTY) - let mut buffer = String::new(); - if !io::stdin().is_terminal() { - let _ = io::stdin().read_to_string(&mut buffer); - } - - #[cfg(windows)] - match optional_feature::handle_export(&buffer) { - Ok(output) => { - println!("{output}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } - - #[cfg(not(windows))] - { - eprintln!("Error: {}", t!("main.windowsOnly")); - std::process::exit(1); - } - } - "get" => { - // Read input from stdin - let mut buffer = String::new(); - if let Err(e) = io::stdin().read_to_string(&mut buffer) { - eprintln!("{}", t!("main.errorReadingInput", err = e)); - std::process::exit(1); - } - - #[cfg(windows)] - match optional_feature::handle_get(&buffer) { - Ok(output) => { - println!("{output}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } - - #[cfg(not(windows))] - { - eprintln!("Error: {}", t!("main.windowsOnly")); - std::process::exit(1); - } - } - "set" => { - // Read input from stdin - let mut buffer = String::new(); - if let Err(e) = io::stdin().read_to_string(&mut buffer) { - eprintln!("{}", t!("main.errorReadingInput", err = e)); - std::process::exit(1); - } - - #[cfg(windows)] - match optional_feature::handle_set(&buffer) { - Ok(output) => { - println!("{output}"); - std::process::exit(0); - } - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } - - #[cfg(not(windows))] - { - eprintln!("Error: {}", t!("main.windowsOnly")); - std::process::exit(1); - } - } + "export" => dispatch(optional_feature::handle_export, false), + "get" => dispatch(optional_feature::handle_get, true), + "set" => dispatch(optional_feature::handle_set, true), _ => { 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 cd56ea055..06d7d6df2 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -4,6 +4,8 @@ use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; +use rust_i18n::t; + use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, RestartType}; const DISM_ONLINE_IMAGE: &str = "DISM_{53BFAE52-B167-4E2F-A258-0A37B57FF845}"; @@ -80,7 +82,7 @@ unsafe fn from_wide_ptr(ptr: *const u16) -> String { if ptr.is_null() { return String::new(); } - let len = (0..).take_while(|&i| *ptr.add(i) != 0).count(); + let len = (0..65536).take_while(|&i| *ptr.add(i) != 0).count(); let slice = std::slice::from_raw_parts(ptr, len); String::from_utf16_lossy(slice) } @@ -88,10 +90,8 @@ unsafe fn from_wide_ptr(ptr: *const u16) -> String { unsafe fn load_fn(lib: *mut c_void, name: &[u8]) -> Result { let ptr = GetProcAddress(lib, name.as_ptr()); if ptr.is_null() { - return Err(format!( - "Failed to find function '{}' in dismapi.dll", - std::str::from_utf8(&name[..name.len() - 1]).unwrap_or("?") - )); + let fn_name = std::str::from_utf8(&name[..name.len() - 1]).unwrap_or("?"); + return Err(t!("dism.functionNotFound", name = fn_name).to_string()); } Ok(std::mem::transmute_copy(&ptr)) } @@ -112,7 +112,7 @@ impl DismApi { let lib_name = to_wide_null("dismapi.dll"); let lib = unsafe { LoadLibraryW(lib_name.as_ptr()) }; if lib.is_null() { - return Err("Failed to load dismapi.dll. Ensure DISM is available on this system.".to_string()); + return Err(t!("dism.failedLoadLibrary").to_string()); } unsafe { @@ -144,6 +144,13 @@ pub struct DismSessionHandle { } impl DismSessionHandle { + /// Opens a new DISM session for the online image. + /// + /// NOTE: `DismInitialize` and `DismShutdown` are per-process globals. + /// Only one `DismSessionHandle` should exist at a time. Creating a + /// second session while one is already open (or after one has been + /// dropped) will call `DismInitialize` again, which returns + /// `DISMAPI_E_DISMAPI_ALREADY_INITIALIZED`. pub fn open() -> Result { let api = DismApi::load()?; @@ -156,10 +163,7 @@ impl DismSessionHandle { unsafe { let hr = dism_initialize(DISM_LOG_ERRORS, std::ptr::null(), std::ptr::null()); if hr < 0 { - return Err(format!( - "DismInitialize failed: HRESULT 0x{:08X}", - hr as u32 - )); + return Err(t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); } let image_path = to_wide_null(DISM_ONLINE_IMAGE); @@ -172,10 +176,7 @@ impl DismSessionHandle { ); if hr < 0 { (api.shutdown)(); - return Err(format!( - "DismOpenSession failed: HRESULT 0x{:08X}", - hr as u32 - )); + return Err(t!("dism.openSessionFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); } Ok(DismSessionHandle { @@ -200,20 +201,21 @@ impl DismSessionHandle { }; if hr < 0 { - return Err(format!( - "DismGetFeatureInfo failed for '{}': HRESULT 0x{:08X}", - feature_name, hr as u32 - )); + return Err(t!("dism.getFeatureInfoFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } let result = unsafe { - let info = &*info_ptr; + 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 feature_info = OptionalFeatureInfo { - feature_name: Some(from_wide_ptr(info.feature_name)), - 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), + feature_name: Some(from_wide_ptr(feature_name_val)), + 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), }; (self.api.delete)(info_ptr as *const c_void); feature_info @@ -240,10 +242,7 @@ impl DismSessionHandle { ) }; if hr < 0 { - return Err(format!( - "DismEnableFeature failed for '{}': HRESULT 0x{:08X}", - feature_name, hr as u32 - )); + return Err(t!("dism.enableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } Ok(()) } @@ -262,10 +261,7 @@ impl DismSessionHandle { ) }; if hr < 0 { - return Err(format!( - "DismDisableFeature failed for '{}': HRESULT 0x{:08X}", - feature_name, hr as u32 - )); + return Err(t!("dism.disableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } Ok(()) } @@ -285,18 +281,16 @@ impl DismSessionHandle { }; if hr < 0 { - return Err(format!( - "DismGetFeatures failed: HRESULT 0x{:08X}", - hr as u32 - )); + return Err(t!("dism.getFeaturesFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); } let mut result = Vec::new(); unsafe { for i in 0..count as usize { - let feature = &*features_ptr.add(i); - let name = from_wide_ptr(feature.feature_name); - result.push((name, feature.state)); + 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)); } (self.api.delete)(features_ptr as *const c_void); } diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index af16c9a5f..b41ea33d3 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -132,24 +132,31 @@ fn matches_wildcard(text: &str, pattern: &str) -> bool { return false; } - // Check suffix (part after last *) - if let Some(last) = parts.last() { - if !last.is_empty() && !text_lower.ends_with(last) { + // 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 - let mut pos = parts[0].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..].find(part) { + match text_lower[pos..end].find(part) { Some(idx) => pos += idx + part.len(), None => return false, } } - true + pos <= end } diff --git a/resources/dism_dsc/src/optional_feature/set.rs b/resources/dism_dsc/src/optional_feature/set.rs index 313f077ce..8ba8a4fed 100644 --- a/resources/dism_dsc/src/optional_feature/set.rs +++ b/resources/dism_dsc/src/optional_feature/set.rs @@ -32,13 +32,16 @@ pub fn handle_set(input: &str) -> Result { FeatureState::Installed => { session.enable_feature(feature_name)?; } - FeatureState::NotPresent | FeatureState::Removed => { + FeatureState::NotPresent => { + session.disable_feature(feature_name, false)?; + } + FeatureState::Removed => { session.disable_feature(feature_name, true)?; } _ => { return Err(t!( "set.unsupportedDesiredState", - state = format!("{desired_state:?}") + state = desired_state.to_string() ) .to_string()); } diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index f98d4563b..2ab598c6b 100644 --- a/resources/dism_dsc/src/optional_feature/types.rs +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use serde::{Deserialize, Serialize}; +use std::fmt; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -43,6 +44,21 @@ 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 { From f37b61c87bb7000c540bec24b44419281e9adad6 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 23:31:20 -0700 Subject: [PATCH 4/8] Add _restartRequired and fix tests for non-Windows --- .../optionalfeature.dsc.resource.json | 9 ++++++ .../dism_dsc/src/optional_feature/dism.rs | 11 +++++--- .../dism_dsc/src/optional_feature/export.rs | 2 +- .../dism_dsc/src/optional_feature/get.rs | 2 +- .../dism_dsc/src/optional_feature/set.rs | 28 +++++++++++++++---- .../dism_dsc/src/optional_feature/types.rs | 3 ++ .../tests/optionalFeature_export.tests.ps1 | 9 ++++-- .../tests/optionalFeature_get.tests.ps1 | 9 ++++-- .../tests/optionalFeature_set.tests.ps1 | 9 ++++-- 9 files changed, 61 insertions(+), 21 deletions(-) diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index c419c5373..d873a5b4c 100644 --- a/resources/dism_dsc/optionalfeature.dsc.resource.json +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -45,6 +45,15 @@ "type": "object", "additionalProperties": false, "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 + } + }, "features": { "type": "array", "title": "Features", diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index 06d7d6df2..a142dce83 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -11,6 +11,7 @@ use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, RestartT const DISM_ONLINE_IMAGE: &str = "DISM_{53BFAE52-B167-4E2F-A258-0A37B57FF845}"; const DISM_LOG_ERRORS: i32 = 0; const DISM_PACKAGE_NONE: i32 = 0; +const ERROR_SUCCESS_REBOOT_REQUIRED: i32 = 3010; #[repr(C, packed)] struct DismFeature { @@ -224,7 +225,8 @@ impl DismSessionHandle { Ok(result) } - pub fn enable_feature(&self, feature_name: &str) -> Result<(), String> { + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). + pub fn enable_feature(&self, feature_name: &str) -> Result { let wide_name = to_wide_null(feature_name); let hr = unsafe { (self.api.enable_feature)( @@ -244,10 +246,11 @@ impl DismSessionHandle { if hr < 0 { return Err(t!("dism.enableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } - Ok(()) + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } - pub fn disable_feature(&self, feature_name: &str, remove_payload: bool) -> Result<(), String> { + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). + pub fn disable_feature(&self, feature_name: &str, remove_payload: bool) -> Result { let wide_name = to_wide_null(feature_name); let hr = unsafe { (self.api.disable_feature)( @@ -263,7 +266,7 @@ impl DismSessionHandle { if hr < 0 { return Err(t!("dism.disableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } - Ok(()) + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } pub fn get_all_feature_basics(&self) -> Result, String> { diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index b41ea33d3..ced3c4d90 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -69,7 +69,7 @@ pub fn handle_export(input: &str) -> Result { } } - let output = OptionalFeatureList { features: results }; + let output = OptionalFeatureList { restart_required_meta: None, features: results }; serde_json::to_string(&output) .map_err(|e| t!("export.failedSerializeOutput", err = e.to_string()).to_string()) } diff --git a/resources/dism_dsc/src/optional_feature/get.rs b/resources/dism_dsc/src/optional_feature/get.rs index 5996bc5ce..4fe8263b7 100644 --- a/resources/dism_dsc/src/optional_feature/get.rs +++ b/resources/dism_dsc/src/optional_feature/get.rs @@ -27,7 +27,7 @@ pub fn handle_get(input: &str) -> Result { results.push(info); } - let output = OptionalFeatureList { features: results }; + let output = OptionalFeatureList { restart_required_meta: None, features: results }; serde_json::to_string(&output) .map_err(|e| t!("get.failedSerializeOutput", err = e.to_string()).to_string()) } diff --git a/resources/dism_dsc/src/optional_feature/set.rs b/resources/dism_dsc/src/optional_feature/set.rs index 8ba8a4fed..4fbfb0f98 100644 --- a/resources/dism_dsc/src/optional_feature/set.rs +++ b/resources/dism_dsc/src/optional_feature/set.rs @@ -2,10 +2,15 @@ // Licensed under the MIT License. use rust_i18n::t; +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()) +} + pub fn handle_set(input: &str) -> Result { let feature_list: OptionalFeatureList = serde_json::from_str(input) .map_err(|e| t!("set.failedParseInput", err = e.to_string()).to_string())?; @@ -16,6 +21,7 @@ pub fn handle_set(input: &str) -> Result { let session = DismSessionHandle::open()?; let mut results = Vec::new(); + let mut reboot_required = false; for feature_input in &feature_list.features { let feature_name = feature_input @@ -28,15 +34,15 @@ pub fn handle_set(input: &str) -> Result { .as_ref() .ok_or_else(|| t!("set.stateRequired").to_string())?; - match desired_state { + let needs_reboot = match desired_state { FeatureState::Installed => { - session.enable_feature(feature_name)?; + session.enable_feature(feature_name)? } FeatureState::NotPresent => { - session.disable_feature(feature_name, false)?; + session.disable_feature(feature_name, false)? } FeatureState::Removed => { - session.disable_feature(feature_name, true)?; + session.disable_feature(feature_name, true)? } _ => { return Err(t!( @@ -45,13 +51,23 @@ pub fn handle_set(input: &str) -> Result { ) .to_string()); } - } + }; + + reboot_required = reboot_required || needs_reboot; let info = session.get_feature_info(feature_name)?; results.push(info); } - let output = OptionalFeatureList { features: results }; + 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 = OptionalFeatureList { restart_required_meta, features: results }; serde_json::to_string(&output) .map_err(|e| t!("set.failedSerializeOutput", err = e.to_string()).to_string()) } diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index 2ab598c6b..03d3b1067 100644 --- a/resources/dism_dsc/src/optional_feature/types.rs +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -2,11 +2,14 @@ // Licensed under the MIT License. use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::fmt; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct OptionalFeatureList { + #[serde(rename = "_restartRequired", skip_serializing_if = "Option::is_none")] + pub restart_required_meta: Option>>, pub features: Vec, } diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 index d25623d5d..bde90b85e 100644 --- a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -3,9 +3,12 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsWindows) { BeforeDiscovery { - $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator - ) + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } } It 'exports all features with no input' -Skip:(!$isElevated) { diff --git a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 index 8e71741bc..36315530f 100644 --- a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 @@ -3,9 +3,12 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWindows) { BeforeDiscovery { - $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator - ) + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } } It 'gets a known optional feature by name' -Skip:(!$isElevated) { diff --git a/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 index 27c49fbe8..72018585a 100644 --- a/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_set.tests.ps1 @@ -3,9 +3,12 @@ Describe 'Microsoft.Windows/OptionalFeatureList - set operation' -Skip:(!$IsWindows) { BeforeDiscovery { - $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator - ) + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } } It 'returns error when featureName is missing' -Skip:(!$isElevated) { From 9345a39955cbdb29e798a465c8c2beda738a3ebe Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 13 Mar 2026 10:31:06 -0700 Subject: [PATCH 5/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- resources/dism_dsc/src/optional_feature/dism.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index a142dce83..e7a9671cf 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -110,7 +110,10 @@ struct DismApi { impl DismApi { fn load() -> Result { - let lib_name = to_wide_null("dismapi.dll"); + // Load dismapi.dll from the trusted System32 directory to avoid DLL search order hijacking. + let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| String::from(r"C:\Windows")); + let dll_path = format!(r"{}\System32\dismapi.dll", system_root); + let lib_name = to_wide_null(&dll_path); let lib = unsafe { LoadLibraryW(lib_name.as_ptr()) }; if lib.is_null() { return Err(t!("dism.failedLoadLibrary").to_string()); From 09e273b39e328e70cb35b3d9c1c065005dd9dac2 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 15:00:38 -0700 Subject: [PATCH 6/8] address copilot feedback --- .../optionalfeature.dsc.resource.json | 11 ++++- resources/dism_dsc/src/main.rs | 13 +++-- .../dism_dsc/src/optional_feature/dism.rs | 10 ++++ .../dism_dsc/src/optional_feature/export.rs | 2 + .../dism_dsc/src/optional_feature/types.rs | 2 + .../tests/optionalFeature_get.tests.ps1 | 47 +++++++++++++++---- 6 files changed, 68 insertions(+), 17 deletions(-) diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index d873a5b4c..1a26dfd24 100644 --- a/resources/dism_dsc/optionalfeature.dsc.resource.json +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -44,6 +44,9 @@ "markdownDescription": "The `Microsoft.Windows/OptionalFeatureList` resource enables you to manage Windows Optional Features using the DISM API. Supports enabling (`Installed`) and disabling (`NotPresent`, `Removed`) features.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/optionalfeaturelist/resource\n", "type": "object", "additionalProperties": false, + "required": [ + "features" + ], "properties": { "_restartRequired": { "type": "array", @@ -67,6 +70,11 @@ "title": "Feature name", "description": "The name of the Windows optional feature. 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 feature exists on the system. Set to false when the requested feature name is not recognized by DISM." + }, "state": { "type": "string", "enum": [ @@ -80,8 +88,7 @@ "PartiallyInstalled" ], "title": "Feature state", - "description": "The current state of the optional feature." - }, + "description": "The current state of the optional feature. For set operations, only Installed, NotPresent, and Removed are accepted; other states are returned by get and export operations and are not valid inputs for set."}, "displayName": { "type": "string", "title": "Display name", diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs index e1cd71ee7..d50052590 100644 --- a/resources/dism_dsc/src/main.rs +++ b/resources/dism_dsc/src/main.rs @@ -28,7 +28,6 @@ fn dispatch(handler: impl FnOnce(&str) -> Result, stdin_required } }; - #[cfg(windows)] match handler(&buffer) { Ok(output) => { println!("{output}"); @@ -39,15 +38,15 @@ fn dispatch(handler: impl FnOnce(&str) -> Result, stdin_required std::process::exit(1); } } +} - #[cfg(not(windows))] - { - let _ = buffer; - eprintln!("Error: {}", t!("main.windowsOnly")); - std::process::exit(1); - } +#[cfg(not(windows))] +fn main() { + eprintln!("Error: {}", t!("main.windowsOnly")); + std::process::exit(1); } +#[cfg(windows)] fn main() { let args: Vec = std::env::args().collect(); diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index e7a9671cf..52463a1e5 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -12,6 +12,7 @@ const DISM_ONLINE_IMAGE: &str = "DISM_{53BFAE52-B167-4E2F-A258-0A37B57FF845}"; 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; #[repr(C, packed)] struct DismFeature { @@ -204,6 +205,14 @@ impl DismSessionHandle { ) }; + if hr == DISMAPI_E_UNKNOWN_FEATURE { + return Ok(OptionalFeatureInfo { + feature_name: Some(feature_name.to_string()), + exist: Some(false), + ..OptionalFeatureInfo::default() + }); + } + if hr < 0 { return Err(t!("dism.getFeatureInfoFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); } @@ -216,6 +225,7 @@ impl DismSessionHandle { let restart_val = std::ptr::addr_of!((*info_ptr).restart_required).read_unaligned(); let feature_info = OptionalFeatureInfo { feature_name: Some(from_wide_ptr(feature_name_val)), + exist: None, state: FeatureState::from_dism(state_val), display_name: Some(from_wide_ptr(display_name_val)), description: Some(from_wide_ptr(description_val)), diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index ced3c4d90..071361fb1 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -34,6 +34,7 @@ pub fn handle_export(input: &str) -> Result { Ok(info) => info, Err(_) => OptionalFeatureInfo { feature_name: Some(name.clone()), + exist: None, state, display_name: None, description: None, @@ -58,6 +59,7 @@ pub fn handle_export(input: &str) -> Result { Err(_) => { results.push(OptionalFeatureInfo { feature_name: Some(name.clone()), + exist: None, state, display_name: None, description: None, diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index 03d3b1067..12c83903c 100644 --- a/resources/dism_dsc/src/optional_feature/types.rs +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -18,6 +18,8 @@ pub struct OptionalFeatureList { pub struct OptionalFeatureInfo { #[serde(skip_serializing_if = "Option::is_none")] pub feature_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")] diff --git a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 index 36315530f..f0a1235e3 100644 --- a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 @@ -11,14 +11,25 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWind } } + BeforeAll { + # Use dism command to get a known feature name + $dismOutput = & dism /Online /Get-Features /Format:Table 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to get features using dism: $dismOutput" + } + # Use the first feature name for tests + $knownFeatureNameOne = ($dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$').Matches[0].Groups[1].Value + $knownFeatureNameTwo = ($dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$').Matches[0].Groups[1].Value + } + It 'gets a known optional feature by name' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.actualState.features | Should -Not -BeNullOrEmpty $output.actualState.features.Count | Should -Be 1 $feature = $output.actualState.features[0] - $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.featureName | Should -BeExactly $knownFeatureNameOne $feature.state | Should -BeIn @( 'NotPresent', 'UninstallPending', 'Staged', 'Removed', 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' @@ -29,12 +40,12 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWind } It 'gets multiple features in a single request' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"Printing-PrintToPDFServices-Features"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"} ,{"featureName":"' + $knownFeatureNameTwo + '"}]}' $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.actualState.features.Count | Should -Be 2 - $output.actualState.features[0].featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' - $output.actualState.features[1].featureName | Should -BeExactly 'Printing-PrintToPDFServices-Features' + $output.actualState.features[0].featureName | Should -BeExactly $knownFeatureNameOne + $output.actualState.features[1].featureName | Should -BeExactly $knownFeatureNameTwo } It 'returns error when featureName is missing' -Skip:(!$isElevated) { @@ -49,9 +60,29 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWind $LASTEXITCODE | Should -Not -Be 0 } - It 'returns error for a non-existent feature name' -Skip:(!$isElevated) { + It 'returns _exist false for a non-existent feature name' -Skip:(!$isElevated) { $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' - $testError = & { dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>&1 } - $LASTEXITCODE | Should -Not -Be 0 + $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features | Should -Not -BeNullOrEmpty + $output.actualState.features.Count | Should -Be 1 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly 'NonExistent-Feature-1234567890' + $feature._exist | Should -BeFalse + $feature.state | Should -BeNullOrEmpty + $feature.displayName | Should -BeNullOrEmpty + $feature.description | Should -BeNullOrEmpty + $feature.restartRequired | Should -BeNullOrEmpty + } + + It 'returns _exist false alongside valid features' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"NonExistent-Feature-1234567890"}]}' + $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features.Count | Should -Be 2 + $output.actualState.features[0].featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $output.actualState.features[0].PSObject.Properties.Name | Should -Not -Contain '_exist' + $output.actualState.features[1].featureName | Should -BeExactly 'NonExistent-Feature-1234567890' + $output.actualState.features[1]._exist | Should -BeFalse } } From ca53f7bbe8d3b9b4ed03e2039fbf12e161baabb2 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Fri, 13 Mar 2026 15:41:30 -0700 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../dism_dsc/src/optional_feature/dism.rs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index 52463a1e5..82d867175 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -13,6 +13,16 @@ 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 LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; + +#[link(name = "kernel32")] +extern "system" { + fn LoadLibraryExW( + lpLibFileName: *const u16, + hFile: *mut c_void, + dwFlags: u32, + ) -> *mut c_void; +} #[repr(C, packed)] struct DismFeature { @@ -112,10 +122,16 @@ struct DismApi { impl DismApi { fn load() -> Result { // Load dismapi.dll from the trusted System32 directory to avoid DLL search order hijacking. - let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| String::from(r"C:\Windows")); - let dll_path = format!(r"{}\System32\dismapi.dll", system_root); - let lib_name = to_wide_null(&dll_path); - let lib = unsafe { LoadLibraryW(lib_name.as_ptr()) }; + // Use LoadLibraryExW with LOAD_LIBRARY_SEARCH_SYSTEM32 so the DLL location cannot be + // redirected via environment variables or the default DLL search order. + let lib_name = to_wide_null("dismapi.dll"); + let lib = unsafe { + LoadLibraryExW( + lib_name.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; if lib.is_null() { return Err(t!("dism.failedLoadLibrary").to_string()); } From a19b6121a0d79f2d76487605e3cf7f6cf712557b Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 15:52:26 -0700 Subject: [PATCH 8/8] address copilot feedack --- .../dism_dsc/src/optional_feature/dism.rs | 1 - .../dism_dsc/src/optional_feature/export.rs | 30 ++++++++++++++++++- .../tests/optionalFeature_export.tests.ps1 | 29 +++++++++++++----- .../tests/optionalFeature_get.tests.ps1 | 16 ++++++---- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/resources/dism_dsc/src/optional_feature/dism.rs b/resources/dism_dsc/src/optional_feature/dism.rs index 82d867175..2ae36f3f0 100644 --- a/resources/dism_dsc/src/optional_feature/dism.rs +++ b/resources/dism_dsc/src/optional_feature/dism.rs @@ -78,7 +78,6 @@ type DismDeleteFn = unsafe extern "system" fn(*const c_void) -> i32; // Kernel32 functions for dynamic loading extern "system" { - fn LoadLibraryW(lp_lib_file_name: *const u16) -> *mut c_void; fn GetProcAddress(h_module: *mut c_void, lp_proc_name: *const u8) -> *mut c_void; fn FreeLibrary(h_lib_module: *mut c_void) -> i32; } diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index 071361fb1..5366cacc5 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -25,11 +25,39 @@ pub fn handle_export(input: &str) -> Result { let mut results = Vec::new(); + // When full info is needed, pre-partition filters by whether they specify a feature_name. + // This lets us skip get_feature_info() for features that cannot match any name-constrained filter. + let (filters_with_name, filters_without_name): (Vec<&OptionalFeatureInfo>, Vec<&OptionalFeatureInfo>) = + if needs_full_info { + filters.iter().partition(|f| f.feature_name.is_some()) + } else { + (Vec::new(), Vec::new()) + }; + for (name, state_val) in &all_basics { let state = FeatureState::from_dism(*state_val); if needs_full_info { - // Get full info first so we can filter on displayName/description + // Decide whether this feature could possibly match any filter based on its name. + // If any filter does not constrain feature_name, we must consider every feature, + // since such filters may match on displayName/description alone. + 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.feature_name { + if filter_name == name { + should_get_full = true; + break; + } + } + } + } + if !should_get_full { + // This feature cannot satisfy any name-constrained filter, and there are + // no filters without a feature_name, so skip the expensive get_feature_info(). + continue; + } + // Get full info so we can filter on displayName/description and other fields. let info = match session.get_feature_info(name) { Ok(info) => info, Err(_) => OptionalFeatureInfo { diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 index bde90b85e..cf1928bc5 100644 --- a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -11,6 +11,21 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } } + BeforeAll { + # Use dism command to get a known feature name + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to get features using dism: $dismOutput" + } + $enabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$' + $disabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$' + if (-not $enabledMatches -or -not $disabledMatches) { + throw "Failed to find both enabled and disabled features in DISM output.`nOutput:`n$dismOutput" + } + $knownFeatureNameOne = $enabledMatches[0].Matches[0].Groups[1].Value + $knownFeatureNameTwo = $disabledMatches[0].Matches[0].Groups[1].Value + } + It 'exports all features with no input' -Skip:(!$isElevated) { $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 @@ -25,14 +40,14 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } It 'exports features filtered by exact featureName' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $features = $output.resources[0].properties.features $features | Should -Not -BeNullOrEmpty $features.Count | Should -Be 1 $feature = $features[0] - $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.featureName | Should -BeExactly $knownFeatureNameOne $feature.displayName | Should -Not -BeNullOrEmpty $feature.description | Should -Not -BeNullOrEmpty $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') @@ -83,14 +98,14 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } It 'exports features with multiple filters using OR logic' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"Printing-PrintToPDFServices-Features"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"},{"featureName":"' + $knownFeatureNameTwo + '"}]}' $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $features = $output.resources[0].properties.features $features | Should -Not -BeNullOrEmpty $names = $features | ForEach-Object { $_.featureName } - $names | Should -Contain 'Microsoft-Windows-Subsystem-Linux' - $names | Should -Contain 'Printing-PrintToPDFServices-Features' + $names | Should -Contain $knownFeatureNameOne + $names | Should -Contain $knownFeatureNameTwo } It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { @@ -102,13 +117,13 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } It 'returns complete feature properties in export results' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $features = $output.resources[0].properties.features $features.Count | Should -Be 1 $feature = $features[0] - $feature.featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $feature.featureName | Should -BeExactly $knownFeatureNameOne $feature.state | Should -BeIn @( 'NotPresent', 'UninstallPending', 'Staged', 'Removed', 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' diff --git a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 index f0a1235e3..5cd4d3f33 100644 --- a/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_get.tests.ps1 @@ -13,13 +13,17 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWind BeforeAll { # Use dism command to get a known feature name - $dismOutput = & dism /Online /Get-Features /Format:Table 2>&1 + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 if ($LASTEXITCODE -ne 0) { throw "Failed to get features using dism: $dismOutput" } - # Use the first feature name for tests - $knownFeatureNameOne = ($dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$').Matches[0].Groups[1].Value - $knownFeatureNameTwo = ($dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$').Matches[0].Groups[1].Value + $enabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$' + $disabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$' + if (-not $enabledMatches -or -not $disabledMatches) { + throw "Failed to find both enabled and disabled features in DISM output.`nOutput:`n$dismOutput" + } + $knownFeatureNameOne = $enabledMatches[0].Matches[0].Groups[1].Value + $knownFeatureNameTwo = $disabledMatches[0].Matches[0].Groups[1].Value } It 'gets a known optional feature by name' -Skip:(!$isElevated) { @@ -76,11 +80,11 @@ Describe 'Microsoft.Windows/OptionalFeatureList - get operation' -Skip:(!$IsWind } It 'returns _exist false alongside valid features' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Microsoft-Windows-Subsystem-Linux"},{"featureName":"NonExistent-Feature-1234567890"}]}' + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"},{"featureName":"NonExistent-Feature-1234567890"}]}' $output = dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $output.actualState.features.Count | Should -Be 2 - $output.actualState.features[0].featureName | Should -BeExactly 'Microsoft-Windows-Subsystem-Linux' + $output.actualState.features[0].featureName | Should -BeExactly $knownFeatureNameOne $output.actualState.features[0].PSObject.Properties.Name | Should -Not -Contain '_exist' $output.actualState.features[1].featureName | Should -BeExactly 'NonExistent-Feature-1234567890' $output.actualState.features[1]._exist | Should -BeFalse