From 5e3d40b3f6711c1784a937f2e7b09366c5f61537 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:24:24 +0200 Subject: [PATCH 01/12] Initial setup of Microsoft.Windows/WindowsFeatureList --- Cargo.lock | 9 + Cargo.toml | 7 +- data.build.json | 17 + resources/windows_feature/.project.data.json | 10 + resources/windows_feature/Cargo.toml | 18 + resources/windows_feature/locales/en-us.toml | 38 ++ resources/windows_feature/src/dism.rs | 397 ++++++++++++++++++ resources/windows_feature/src/export.rs | 95 +++++ resources/windows_feature/src/get.rs | 31 ++ resources/windows_feature/src/main.rs | 146 +++++++ resources/windows_feature/src/set.rs | 70 +++ resources/windows_feature/src/types.rs | 73 ++++ resources/windows_feature/src/util.rs | 132 ++++++ .../tests/windows_feature_export.tests.ps1 | 82 ++++ .../tests/windows_feature_get.tests.ps1 | 86 ++++ .../tests/windows_feature_set.tests.ps1 | 127 ++++++ .../windows_feature.dsc.resource.json | 147 +++++++ 17 files changed, 1483 insertions(+), 2 deletions(-) create mode 100644 resources/windows_feature/.project.data.json create mode 100644 resources/windows_feature/Cargo.toml create mode 100644 resources/windows_feature/locales/en-us.toml create mode 100644 resources/windows_feature/src/dism.rs create mode 100644 resources/windows_feature/src/export.rs create mode 100644 resources/windows_feature/src/get.rs create mode 100644 resources/windows_feature/src/main.rs create mode 100644 resources/windows_feature/src/set.rs create mode 100644 resources/windows_feature/src/types.rs create mode 100644 resources/windows_feature/src/util.rs create mode 100644 resources/windows_feature/tests/windows_feature_export.tests.ps1 create mode 100644 resources/windows_feature/tests/windows_feature_get.tests.ps1 create mode 100644 resources/windows_feature/tests/windows_feature_set.tests.ps1 create mode 100644 resources/windows_feature/windows_feature.dsc.resource.json diff --git a/Cargo.lock b/Cargo.lock index e64487eee..4b2b83bc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4242,6 +4242,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_feature" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", +] + [[package]] name = "windows_firewall" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 18c31d016..576a004c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", @@ -48,17 +49,18 @@ default-members = [ "lib/dsc-lib-registry", "resources/runcommandonset", "lib/dsc-lib-security_context", - "resources/dism_dsc", "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" + "xtask", + "resources/dism_dsc" ] [workspace.metadata.groups] @@ -83,6 +85,7 @@ Windows = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", diff --git a/data.build.json b/data.build.json index a8d16434d..07c050d19 100644 --- a/data.build.json +++ b/data.build.json @@ -109,6 +109,8 @@ "windows_firewall.exe", "windows_service.exe", "windows_service.dsc.resource.json", + "windows_feature.exe", + "windows_feature.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -478,6 +480,21 @@ ] } }, + { + "Name": "windows_feature", + "Kind": "Resource", + "RelativePath": "resources/windows_feature", + "SupportedPlatformOS": "Windows", + "IsRust": true, + "Binaries": [ + "windows_feature" + ], + "CopyFiles": { + "Windows": [ + "windows_feature.dsc.resource.json" + ] + } + }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/windows_feature/.project.data.json b/resources/windows_feature/.project.data.json new file mode 100644 index 000000000..705925f3c --- /dev/null +++ b/resources/windows_feature/.project.data.json @@ -0,0 +1,10 @@ +{ + "Name": "windows_feature", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": ["windows_feature"], + "CopyFiles": { + "Windows": ["windows_feature.dsc.resource.json"] + } +} diff --git a/resources/windows_feature/Cargo.toml b/resources/windows_feature/Cargo.toml new file mode 100644 index 000000000..0112f85f9 --- /dev/null +++ b/resources/windows_feature/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "windows_feature" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "windows_feature" +path = "src/main.rs" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml new file mode 100644 index 000000000..e2b721059 --- /dev/null +++ b/resources/windows_feature/locales/en-us.toml @@ -0,0 +1,38 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_feature get --input | set --input | export [--input ]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +windowsOnly = "This resource is only supported on Windows" + +[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}" + +[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}" + +[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}" +notSupportedAppx = "This resource is not supported when installed via Appx" +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/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs new file mode 100644 index 000000000..b24b34c43 --- /dev/null +++ b/resources/windows_feature/src/dism.rs @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; + +use rust_i18n::t; + +use crate::types::{FeatureState, RestartType, WindowsFeatureInfo}; + +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; +const REGDB_E_CLASSNOTREG: i32 = 0x80040154u32 as i32; +const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; + +#[link(name = "kernel32")] +unsafe extern "system" { + fn LoadLibraryExW( + lpLibFileName: *const u16, + hFile: *mut c_void, + dwFlags: u32, + ) -> *mut c_void; +} + +#[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 DismEnableFeatureFn = unsafe extern "system" fn( + u32, // Session + *const u16, // FeatureName + *const u16, // Identifier (NULL) + i32, // PackageIdentifier (DismPackageNone) + i32, // LimitAccess (BOOL) + *const *const u16, // SourcePaths + 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; + +// Kernel32 functions for dynamic loading +unsafe extern "system" { + 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(); + } + unsafe { + 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) + } +} + +unsafe fn load_fn(lib: *mut c_void, name: &[u8]) -> Result { + unsafe { + let ptr = GetProcAddress(lib, name.as_ptr()); + if ptr.is_null() { + 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)) + } +} + +struct DismApi { + lib: *mut c_void, + close_session: DismCloseSessionFn, + shutdown: DismShutdownFn, + get_features: DismGetFeaturesFn, + get_feature_info: DismGetFeatureInfoFn, + enable_feature: DismEnableFeatureFn, + disable_feature: DismDisableFeatureFn, + delete: DismDeleteFn, +} + +impl DismApi { + fn load() -> Result { + // Load dismapi.dll from the trusted System32 directory to avoid DLL search order hijacking. + // 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()); + } + + 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")?, + enable_feature: load_fn(lib, b"DismEnableFeature\0")?, + disable_feature: load_fn(lib, b"DismDisableFeature\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 { + /// Opens a new DISM session for the online image. + 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 == REGDB_E_CLASSNOTREG { + return Err(t!("dism.notSupportedAppx").to_string()); + } + if hr < 0 { + return Err( + t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), + ); + } + + 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 == REGDB_E_CLASSNOTREG { + (api.shutdown)(); + return Err(t!("dism.notSupportedAppx").to_string()); + } + if hr < 0 { + (api.shutdown)(); + return Err( + t!("dism.openSessionFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), + ); + } + + 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 == DISMAPI_E_UNKNOWN_FEATURE { + return Ok(WindowsFeatureInfo { + feature_name: Some(feature_name.to_string()), + exist: Some(false), + ..WindowsFeatureInfo::default() + }); + } + + if hr < 0 { + 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_info = WindowsFeatureInfo { + feature_name: Some(from_wide_ptr(info.feature_name)), + exist: None, + 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), + enable_all: None, + source_paths: None, + limit_access: None, + }; + (self.api.delete)(info_ptr as *const c_void); + feature_info + }; + + Ok(result) + } + + /// Enable a Windows feature. + /// + /// * `source_paths` — Optional list of local media paths passed as `SourcePaths` to + /// `DismEnableFeature`. Required on air-gapped systems without access to Windows Update. + /// * `limit_access` — When `true`, prevents DISM from contacting Windows Update + /// (`LimitAccess = TRUE`). + /// * `enable_all` — When `true`, enables all features that the specified feature depends on, + /// including child features (`EnableAll = TRUE`). + /// + /// Returns `Ok(true)` if a reboot is required to complete the operation. + pub fn enable_feature( + &self, + feature_name: &str, + source_paths: &[String], + limit_access: bool, + enable_all: bool, + ) -> Result { + let wide_name = to_wide_null(feature_name); + + // Build wide-string arrays for source paths. The vectors must remain alive for the + // duration of the unsafe call, so they are kept in scope here. + let wide_paths: Vec> = source_paths.iter().map(|p| to_wide_null(p)).collect(); + let wide_ptrs: Vec<*const u16> = wide_paths.iter().map(|p| p.as_ptr()).collect(); + let (paths_ptr, paths_count) = if wide_ptrs.is_empty() { + (std::ptr::null(), 0u32) + } else { + (wide_ptrs.as_ptr(), wide_ptrs.len() as u32) + }; + + let hr = unsafe { + (self.api.enable_feature)( + self.handle, + wide_name.as_ptr(), + std::ptr::null(), // Identifier + DISM_PACKAGE_NONE, // PackageIdentifier + i32::from(limit_access), // LimitAccess + paths_ptr, // SourcePaths + paths_count, // SourcePathCount + i32::from(enable_all), // EnableAll + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + + if hr < 0 { + return Err(t!( + "dism.enableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + /// Disable (uninstall) a Windows feature. + /// + /// * `remove_payload` — When `true`, passes `RemovePayload = TRUE` to `DismDisableFeature`, + /// which removes the feature's payload from disk (equivalent to DISM state `Removed`). + /// + /// Returns `Ok(true)` if a reboot is required to complete the operation. + 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)( + 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(t!( + "dism.disableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + 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( + 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)); + } + (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/windows_feature/src/export.rs b/resources/windows_feature/src/export.rs new file mode 100644 index 000000000..3ac649188 --- /dev/null +++ b/resources/windows_feature/src/export.rs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::dism::DismSessionHandle; +use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::util::{matches_wildcard, WildcardFilterable}; + +pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result { + let filters: Vec = match filter { + None => vec![WindowsFeatureInfo::default()], + Some(list) if list.features.is_empty() => vec![WindowsFeatureInfo::default()], + Some(list) => list.features.clone(), + }; + + let session = DismSessionHandle::open()?; + let all_basics = session.get_all_feature_basics()?; + + // Check if any filter requires full info (displayName or description filtering) + let needs_full_info = filters + .iter() + .any(|f| f.display_name.is_some() || f.description.is_some()); + + 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<&WindowsFeatureInfo>, + Vec<&WindowsFeatureInfo>, + ) = 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 { + // 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 + && matches_wildcard(name, filter_name) + { + should_get_full = true; + break; + } + } + } + if !should_get_full { + 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(_) => WindowsFeatureInfo { + feature_name: Some(name.clone()), + exist: None, + state, + display_name: None, + description: None, + restart_required: None, + enable_all: None, + source_paths: None, + limit_access: None, + }, + }; + + if info.matches_any_filter(&filters) { + results.push(info); + } + } else { + // Fast path: only need name and state for filtering, skip expensive + // per-feature DismGetFeatureInfo calls. + let basic = WindowsFeatureInfo { + feature_name: Some(name.clone()), + state: state.clone(), + ..WindowsFeatureInfo::default() + }; + + if basic.matches_any_filter(&filters) { + results.push(basic); + } + } + } + + Ok(WindowsFeatureList { + restart_required_meta: None, + features: results, + }) +} diff --git a/resources/windows_feature/src/get.rs b/resources/windows_feature/src/get.rs new file mode 100644 index 000000000..7ea58a77c --- /dev/null +++ b/resources/windows_feature/src/get.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::dism::DismSessionHandle; +use crate::types::{WindowsFeatureInfo, WindowsFeatureList}; + +pub fn handle_get(input: &WindowsFeatureList) -> Result { + if input.features.is_empty() { + return Err(t!("get.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results: Vec = Vec::new(); + + for feature_input in &input.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); + } + + Ok(WindowsFeatureList { + restart_required_meta: None, + features: results, + }) +} diff --git a/resources/windows_feature/src/main.rs b/resources/windows_feature/src/main.rs new file mode 100644 index 000000000..25887f36a --- /dev/null +++ b/resources/windows_feature/src/main.rs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod dism; +mod export; +mod get; +mod set; +mod types; +mod util; + +use rust_i18n::t; +use std::process::exit; + +use types::WindowsFeatureList; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_FEATURE_ERROR: i32 = 3; + +/// Write a JSON error object to stderr: `{"error":""}` +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + +/// Deserialize the required JSON input into a `WindowsFeatureList`, or exit with an error. +fn require_input(input_json: Option) -> WindowsFeatureList { + let json = match input_json { + Some(j) => j, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +/// Serialize a value to JSON and print it to stdout, or exit with an error. +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_FEATURE_ERROR); + } + } +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_FEATURE_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + match operation { + "get" => { + let input = require_input(input_json); + match get::handle_get(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + "set" => { + let input = require_input(input_json); + match set::handle_set(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + "export" => { + let filter: Option = match input_json { + Some(json) => match serde_json::from_str(&json) { + Ok(v) => Some(v), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }, + None => None, + }; + + match export::handle_export(filter.as_ref()) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + } +} + +/// Parse the `--input ` argument from the command-line args. +fn parse_input_arg(args: &[String]) -> Option { + let mut i = 2; // skip binary name and operation + while i < args.len() { + if args[i] == "--input" || args[i] == "-i" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + write_error(&t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + i += 1; + } + None +} diff --git a/resources/windows_feature/src/set.rs b/resources/windows_feature/src/set.rs new file mode 100644 index 000000000..2afd828e6 --- /dev/null +++ b/resources/windows_feature/src/set.rs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::{Map, Value}; + +use crate::dism::DismSessionHandle; +use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::util::get_computer_name; + +pub fn handle_set(input: &WindowsFeatureList) -> Result { + if input.features.is_empty() { + return Err(t!("set.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results: Vec = Vec::new(); + let mut reboot_required = false; + + for feature_input in &input.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())?; + + let needs_reboot = match desired_state { + FeatureState::Installed => { + let source_paths = feature_input + .source_paths + .as_deref() + .unwrap_or(&[]); + let limit_access = feature_input.limit_access.unwrap_or(false); + let enable_all = feature_input.enable_all.unwrap_or(false); + session.enable_feature(feature_name, source_paths, limit_access, enable_all)? + } + FeatureState::NotPresent => session.disable_feature(feature_name, false)?, + FeatureState::Removed => session.disable_feature(feature_name, true)?, + _ => { + return Err(t!( + "set.unsupportedDesiredState", + state = desired_state.to_string() + ) + .to_string()); + } + }; + + reboot_required = reboot_required || needs_reboot; + + let info = session.get_feature_info(feature_name)?; + results.push(info); + } + + let restart_required_meta = if reboot_required { + let mut entry = Map::new(); + entry.insert("system".to_string(), Value::String(get_computer_name())); + Some(vec![entry]) + } else { + None + }; + + Ok(WindowsFeatureList { + restart_required_meta, + features: results, + }) +} diff --git a/resources/windows_feature/src/types.rs b/resources/windows_feature/src/types.rs new file mode 100644 index 000000000..3e61f565b --- /dev/null +++ b/resources/windows_feature/src/types.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::util::{DismState, WildcardFilterable, matches_optional_exact, matches_optional_wildcard}; + +pub type FeatureState = DismState; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WindowsFeatureList { + #[serde(rename = "_restartRequired", skip_serializing_if = "Option::is_none")] + pub restart_required_meta: Option>>, + pub features: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct WindowsFeatureInfo { + #[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")] + 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, + /// Enable all features that the specified feature depends on, including child features. + /// Passed as the `EnableAll` parameter to `DismEnableFeature`. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_all: Option, + /// Local source paths (e.g., a mounted Windows ISO or WIM) passed to `DismEnableFeature` + /// as `SourcePaths`. Required on systems that cannot reach Windows Update. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_paths: Option>, + /// When `true`, prevents DISM from contacting Windows Update even when `sourcePaths` is empty. + /// Passed as the `LimitAccess` parameter to `DismEnableFeature`. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_access: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum RestartType { + No, + Possible, + Required, +} + +impl RestartType { + pub fn from_dism(restart: i32) -> Option { + match restart { + 0 => Some(RestartType::No), + 1 => Some(RestartType::Possible), + 2 => Some(RestartType::Required), + _ => None, + } + } +} + +impl WildcardFilterable for WindowsFeatureInfo { + fn matches_filter(&self, filter: &Self) -> bool { + matches_optional_wildcard(&self.feature_name, &filter.feature_name) + && matches_optional_exact(&self.state, &filter.state) + && matches_optional_wildcard(&self.display_name, &filter.display_name) + && matches_optional_wildcard(&self.description, &filter.description) + } +} diff --git a/resources/windows_feature/src/util.rs b/resources/windows_feature/src/util.rs new file mode 100644 index 000000000..50f13f119 --- /dev/null +++ b/resources/windows_feature/src/util.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// DISM package/feature state values. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum DismState { + NotPresent, + UninstallPending, + Staged, + Removed, + Installed, + InstallPending, + Superseded, + PartiallyInstalled, +} + +impl fmt::Display for DismState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DismState::NotPresent => write!(f, "NotPresent"), + DismState::UninstallPending => write!(f, "UninstallPending"), + DismState::Staged => write!(f, "Staged"), + DismState::Removed => write!(f, "Removed"), + DismState::Installed => write!(f, "Installed"), + DismState::InstallPending => write!(f, "InstallPending"), + DismState::Superseded => write!(f, "Superseded"), + DismState::PartiallyInstalled => write!(f, "PartiallyInstalled"), + } + } +} + +impl DismState { + pub fn from_dism(state: i32) -> Option { + match state { + 0 => Some(DismState::NotPresent), + 1 => Some(DismState::UninstallPending), + 2 => Some(DismState::Staged), + 3 => Some(DismState::Removed), + 4 => Some(DismState::Installed), + 5 => Some(DismState::InstallPending), + 6 => Some(DismState::Superseded), + 7 => Some(DismState::PartiallyInstalled), + _ => None, + } + } +} + +/// Match a string against a pattern that supports `*` wildcards (case-insensitive). +pub fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if !pattern_lower.contains('*') { + return text_lower == pattern_lower; + } + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { + return false; + } + + let mut pos = parts[0].len(); + + let suffix = *parts.last().unwrap_or(&""); + let end = if suffix.is_empty() { + text_lower.len() + } else { + if !text_lower.ends_with(suffix) { + return false; + } + text_lower.len() - suffix.len() + }; + + for part in &parts[1..parts.len().saturating_sub(1)] { + if part.is_empty() { + continue; + } + match text_lower.get(pos..end).and_then(|s| s.find(part)) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + + pos <= end +} + +/// Check that an optional string field matches a wildcard filter pattern. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_wildcard(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(pattern) => match info_value { + Some(value) => matches_wildcard(value, pattern), + None => false, + }, + None => true, + } +} + +/// Check that an optional field matches an exact filter value. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_exact(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(expected) => match info_value { + Some(actual) => actual == expected, + None => false, + }, + None => true, + } +} + +/// Trait for types that support wildcard-based filter matching in export operations. +pub trait WildcardFilterable { + /// Returns true if this instance matches the given filter (AND logic within a single filter). + fn matches_filter(&self, filter: &Self) -> bool; + + /// Returns true if this instance matches any of the given filters (OR logic between filters). + fn matches_any_filter(&self, filters: &[Self]) -> bool + where + Self: Sized, + { + filters.iter().any(|filter| self.matches_filter(filter)) + } +} + +/// Returns the computer name from the COMPUTERNAME environment variable, or "localhost" as fallback. +pub fn get_computer_name() -> String { + std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) +} diff --git a/resources/windows_feature/tests/windows_feature_export.tests.ps1 b/resources/windows_feature/tests/windows_feature_export.tests.ps1 new file mode 100644 index 000000000..ccee09718 --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_export.tests.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWindows) { + BeforeAll { + # Discover at least one enabled and one disabled feature using DISM + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate 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" + } + $knownEnabledFeature = $enabledMatches[0].Matches[0].Groups[1].Value + $knownDisabledFeature = $disabledMatches[0].Matches[0].Groups[1].Value + } + + It 'exports all features with no input filter' { + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList | 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' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features.Count | Should -Be 1 + $features[0].featureName | Should -BeExactly $knownEnabledFeature + } + + It 'exports features filtered by state Installed' { + $inputJson = '{"features":[{"state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features | ForEach-Object { $_.state | Should -Be 'Installed' } + } + + It 'returns empty features list for a non-matching filter' { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -BeNullOrEmpty + } + + It 'exports with wildcard featureName filter' { + # Use the first 3 characters of a known feature name as a wildcard prefix + $prefix = $knownEnabledFeature.Substring(0, [Math]::Min(3, $knownEnabledFeature.Length)) + $inputJson = '{"features":[{"featureName":"' + $prefix + '*"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + # At minimum the known feature should be present if its name starts with $prefix + $features | Should -Not -BeNullOrEmpty + $features | ForEach-Object { + $_.featureName.ToLower() | Should -BeLike "$($prefix.ToLower())*" + } + } + + It 'exports multiple feature filters (OR logic)' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"},{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $featureNames = $output.resources[0].properties.features | Select-Object -ExpandProperty featureName + $featureNames | Should -Contain $knownEnabledFeature + $featureNames | Should -Contain $knownDisabledFeature + } +} diff --git a/resources/windows_feature/tests/windows_feature_get.tests.ps1 b/resources/windows_feature/tests/windows_feature_get.tests.ps1 new file mode 100644 index 000000000..daaa88d5b --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_get.tests.ps1 @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - get operation' -Skip:(!$IsWindows) { + BeforeAll { + # Discover at least one enabled and one disabled feature using DISM + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate 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" + } + $knownEnabledFeature = $enabledMatches[0].Matches[0].Groups[1].Value + $knownDisabledFeature = $disabledMatches[0].Matches[0].Groups[1].Value + } + + Context 'Get a single feature by featureName' { + It 'returns feature info for a known enabled feature' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -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 $knownEnabledFeature + $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 'returns feature info for a known disabled feature' { + $inputJson = '{"features":[{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly $knownDisabledFeature + $feature.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + } + + It 'returns _exist false for a non-existent feature name' { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly 'NonExistent-Feature-1234567890' + $feature._exist | Should -BeFalse + $feature.PSObject.Properties.Name | Should -Not -Contain 'state' + $feature.PSObject.Properties.Name | Should -Not -Contain 'displayName' + } + } + + Context 'Get multiple features in one request' { + It 'returns info for both features' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"},{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features.Count | Should -Be 2 + $output.actualState.features[0].featureName | Should -BeExactly $knownEnabledFeature + $output.actualState.features[1].featureName | Should -BeExactly $knownDisabledFeature + } + } + + Context 'Input validation' { + It 'returns error when featureName is missing' { + $inputJson = '{"features":[{"state":"Installed"}]}' + & { dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' { + $inputJson = '{"features":[]}' + & { dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + } +} diff --git a/resources/windows_feature/tests/windows_feature_set.tests.ps1 b/resources/windows_feature/tests/windows_feature_set.tests.ps1 new file mode 100644 index 000000000..144f33977 --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_set.tests.ps1 @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + # TelnetClient is a safe non-critical feature available on most Windows SKUs + # used here to exercise enable/disable without system impact. + + Context 'Input validation' { + It 'returns error when featureName is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"state":"Installed"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when state is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' -Skip:(!$isElevated) { + $inputJson = '{"features":[]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for unsupported desired state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Staged"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for a non-existent feature name' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890","state":"Installed"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Enable and disable TelnetClient' { + It 'can enable TelnetClient and returns Installed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -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 'TelnetClient' + $feature.state | Should -BeIn @('Installed', 'InstallPending') + $feature.displayName | Should -Not -BeNullOrEmpty + } + + It 'can enable TelnetClient with enableAll set to true' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed","enableAll":true}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('Installed', 'InstallPending') + } + + It 'can disable TelnetClient with NotPresent and returns non-Installed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"NotPresent"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') + } + + It 'can disable TelnetClient with Removed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Removed"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'Staged', 'UninstallPending') + } + + It 'set Installed is idempotent for an already installed feature' -Skip:(!$isElevated) { + # First ensure installed + $enableJson = '{"features":[{"featureName":"TelnetClient","state":"Installed"}]}' + dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $enableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set Installed again - should succeed + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $enableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features[0].state | Should -Be 'Installed' + } + + It 'set NotPresent is idempotent for an already disabled feature' -Skip:(!$isElevated) { + # First ensure not present + $disableJson = '{"features":[{"featureName":"TelnetClient","state":"NotPresent"}]}' + dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $disableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set NotPresent again - should succeed + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $disableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features[0].state | Should -BeIn @('NotPresent', 'Removed', 'Staged') + } + } + + Context 'limitAccess parameter' { + It 'can enable TelnetClient with limitAccess true' -Skip:(!$isElevated) { + # TelnetClient payload is present in CBS, so limitAccess should not prevent installation + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed","limitAccess":true}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + # May succeed or fail depending on whether CBS payload is staged; just verify exit code 0 means success + if ($LASTEXITCODE -eq 0) { + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + } + } + } +} diff --git a/resources/windows_feature/windows_feature.dsc.resource.json b/resources/windows_feature/windows_feature.dsc.resource.json new file mode 100644 index 000000000..3d3b465ab --- /dev/null +++ b/resources/windows_feature/windows_feature.dsc.resource.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage Windows Features using the DISM API. Supports enableAll, sourcePaths, and limitAccess parameters for advanced installation scenarios such as Windows Server roles and offline media.", + "tags": [ + "Windows", + "dism", + "feature" + ], + "type": "Microsoft.Windows/WindowsFeatureList", + "version": "0.1.0", + "get": { + "executable": "windows_feature", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_feature", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "windows_feature", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Feature error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Feature List", + "description": "Manage Windows Features using the DISM API. Supports enableAll, sourcePaths, and limitAccess for advanced scenarios such as Windows Server roles and offline media.", + "type": "object", + "additionalProperties": false, + "required": [ + "features" + ], + "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", + "description": "An array of feature filters or feature information objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "featureName": { + "type": "string", + "title": "Feature name", + "description": "The name of the Windows feature as reported by DISM. Required for get and set operations. For export, this is optional and wildcards (*) are supported for case-insensitive filtering." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the feature exists on this system. Set to false when the requested feature name is not recognized by DISM." + }, + "state": { + "type": "string", + "enum": [ + "NotPresent", + "UninstallPending", + "Staged", + "Removed", + "Installed", + "InstallPending", + "Superseded", + "PartiallyInstalled" + ], + "title": "Feature state", + "description": "The current state of the Windows feature. For set operations, only Installed, NotPresent, and Removed are accepted; other states are returned by get and export and are not valid inputs for set." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the 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 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. Read-only; returned by get and export." + }, + "enableAll": { + "type": "boolean", + "title": "Enable all", + "description": "When true, enables all features that the specified feature depends on, including child features. Passed as EnableAll to DismEnableFeature. Only applies to set with state Installed." + }, + "sourcePaths": { + "type": "array", + "title": "Source paths", + "description": "Local paths to Windows installation media (e.g., a mounted ISO or WIM) used as source files for DismEnableFeature. Required on air-gapped systems that cannot reach Windows Update. Only applies to set with state Installed.", + "items": { + "type": "string" + } + }, + "limitAccess": { + "type": "boolean", + "title": "Limit access", + "description": "When true, prevents DISM from contacting Windows Update to download feature payloads, even when sourcePaths is empty. Passed as LimitAccess to DismEnableFeature. Only applies to set with state Installed." + } + } + } + } + } + } + } +} From 806b5649766f870909e4a92cace13ff5bf87337e Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:32:21 +0200 Subject: [PATCH 02/12] Add whatIf support --- lib/dsc-lib-registry/src/config.rs | 4 +- resources/windows_feature/locales/en-us.toml | 5 + resources/windows_feature/src/main.rs | 8 +- resources/windows_feature/src/set.rs | 56 ++++++-- resources/windows_feature/src/types.rs | 9 ++ .../windows_featurelist_whatif.tests.ps1 | 123 ++++++++++++++++++ .../windows_feature.dsc.resource.json | 15 +++ 7 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 372180360..be35fb3f0 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -35,8 +35,8 @@ pub struct Registry { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct Metadata { - #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none")] pub what_if: Option> } diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml index e2b721059..d8807bcfa 100644 --- a/resources/windows_feature/locales/en-us.toml +++ b/resources/windows_feature/locales/en-us.toml @@ -36,3 +36,8 @@ getFeatureInfoFailed = "DismGetFeatureInfo failed for '%{name}': HRESULT %{hr}" enableFeatureFailed = "DismEnableFeature failed for '%{name}': HRESULT %{hr}" disableFeatureFailed = "DismDisableFeature failed for '%{name}': HRESULT %{hr}" getFeaturesFailed = "DismGetFeatures failed: HRESULT %{hr}" + +[windows_feature_helper] +whatIfEnable = "Would enable feature '%{name}'" +whatIfDisable = "Would disable feature '%{name}' (remove payload: false)" +whatIfRemove = "Would remove feature '%{name}' (remove payload: true)" diff --git a/resources/windows_feature/src/main.rs b/resources/windows_feature/src/main.rs index 25887f36a..62092f738 100644 --- a/resources/windows_feature/src/main.rs +++ b/resources/windows_feature/src/main.rs @@ -88,7 +88,8 @@ fn main() { } "set" => { let input = require_input(input_json); - match set::handle_set(&input) { + let what_if = parse_what_if_arg(&args); + match set::handle_set(&input, what_if) { Ok(result) => { print_json(&result); exit(EXIT_SUCCESS); @@ -144,3 +145,8 @@ fn parse_input_arg(args: &[String]) -> Option { } None } + +/// Returns `true` if `-w` or `--what-if` is present in the command-line args. +fn parse_what_if_arg(args: &[String]) -> bool { + args.iter().any(|a| a == "-w" || a == "--what-if") +} diff --git a/resources/windows_feature/src/set.rs b/resources/windows_feature/src/set.rs index 2afd828e6..59f19f5c5 100644 --- a/resources/windows_feature/src/set.rs +++ b/resources/windows_feature/src/set.rs @@ -5,10 +5,10 @@ use rust_i18n::t; use serde_json::{Map, Value}; use crate::dism::DismSessionHandle; -use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::types::{FeatureState, Metadata, WindowsFeatureInfo, WindowsFeatureList}; use crate::util::get_computer_name; -pub fn handle_set(input: &WindowsFeatureList) -> Result { +pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result { if input.features.is_empty() { return Err(t!("set.featuresArrayEmpty").to_string()); } @@ -28,6 +28,8 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result = Vec::new(); + let needs_reboot = match desired_state { FeatureState::Installed => { let source_paths = feature_input @@ -36,10 +38,29 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result { + if what_if { + what_if_metadata.push(t!("windows_feature_helper.whatIfDisable", name = feature_name.as_str()).to_string()); + false + } else { + session.disable_feature(feature_name, false)? + } + } + FeatureState::Removed => { + if what_if { + what_if_metadata.push(t!("windows_feature_helper.whatIfRemove", name = feature_name.as_str()).to_string()); + false + } else { + session.disable_feature(feature_name, true)? + } } - FeatureState::NotPresent => session.disable_feature(feature_name, false)?, - FeatureState::Removed => session.disable_feature(feature_name, true)?, _ => { return Err(t!( "set.unsupportedDesiredState", @@ -49,13 +70,28 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result>, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WindowsFeatureList { @@ -43,6 +50,8 @@ pub struct WindowsFeatureInfo { /// Passed as the `LimitAccess` parameter to `DismEnableFeature`. #[serde(skip_serializing_if = "Option::is_none")] pub limit_access: Option, + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 b/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 new file mode 100644 index 000000000..edef96f56 --- /dev/null +++ b/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { + BeforeAll { + $testFeature = 'TelnetClient' + } + + It 'Can whatif enable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log -ErrorAction SilentlyContinue) + + # Projected state echoes back the requested feature name and state + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Installed' + + # what-if metadata present + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif disable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'NotPresent' + $result.features[0]._metadata.whatIf[0] | Should -Match "Would disable feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif remove a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Removed" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Removed' + $result.features[0]._metadata.whatIf[0] | Should -Match "Would remove feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif enable a feature with enableAll and limitAccess without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed", "enableAll": true, "limitAccess": true } + ] +} +"@ + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Installed' + $result.features[0].enableAll | Should -BeTrue + $result.features[0].limitAccess | Should -BeTrue + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" + + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif multiple features in one call without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" }, + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features | Should -HaveCount 2 + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable" + $result.features[1]._metadata.whatIf[0] | Should -Match "Would disable" + } +} diff --git a/resources/windows_feature/windows_feature.dsc.resource.json b/resources/windows_feature/windows_feature.dsc.resource.json index 3d3b465ab..39020017d 100644 --- a/resources/windows_feature/windows_feature.dsc.resource.json +++ b/resources/windows_feature/windows_feature.dsc.resource.json @@ -25,10 +25,14 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } ], "implementsPretest": false, "return": "state", + "whatIfReturns": "state", "requireSecurityContext": "elevated" }, "export": { @@ -137,6 +141,17 @@ "type": "boolean", "title": "Limit access", "description": "When true, prevents DISM from contacting Windows Update to download feature payloads, even when sourcePaths is empty. Passed as LimitAccess to DismEnableFeature. Only applies to set with state Installed." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations.", + "properties": { + "whatIf": { + "type": "array", + "items": { "type": "string" } + } + } } } } From 2d016de575e4d9344789c0ec0be998ea71134d36 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:51:31 +0200 Subject: [PATCH 03/12] Fix build --- resources/windows_feature/src/dism.rs | 5 +++-- resources/windows_feature/src/export.rs | 1 + resources/windows_feature/src/types.rs | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs index b24b34c43..6ab0cd679 100644 --- a/resources/windows_feature/src/dism.rs +++ b/resources/windows_feature/src/dism.rs @@ -25,13 +25,13 @@ unsafe extern "system" { ) -> *mut c_void; } -#[repr(C, packed)] +#[repr(C)] struct DismFeature { feature_name: *const u16, state: i32, } -#[repr(C, packed)] +#[repr(C)] struct DismFeatureInfo { feature_name: *const u16, state: i32, @@ -259,6 +259,7 @@ impl DismSessionHandle { enable_all: None, source_paths: None, limit_access: None, + ..Default::default() }; (self.api.delete)(info_ptr as *const c_void); feature_info diff --git a/resources/windows_feature/src/export.rs b/resources/windows_feature/src/export.rs index 3ac649188..4a56707a4 100644 --- a/resources/windows_feature/src/export.rs +++ b/resources/windows_feature/src/export.rs @@ -67,6 +67,7 @@ pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result>>, @@ -24,7 +24,7 @@ pub struct WindowsFeatureList { } #[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct WindowsFeatureInfo { #[serde(skip_serializing_if = "Option::is_none")] pub feature_name: Option, From 7c22222860af4057e1abdef2d76e42c192a63028 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 13:12:20 +0200 Subject: [PATCH 04/12] Remove unused strings --- resources/windows_feature/locales/en-us.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml index d8807bcfa..7cff05168 100644 --- a/resources/windows_feature/locales/en-us.toml +++ b/resources/windows_feature/locales/en-us.toml @@ -9,22 +9,14 @@ invalidJson = "Invalid JSON input: %{error}" windowsOnly = "This resource is only supported on Windows" [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}" [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}" [dism] failedLoadLibrary = "Failed to load dismapi.dll. Ensure DISM is available on this system." From 06e58c9fb070116b851e9002c10a98f598d63f93 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 23 May 2026 08:16:42 +0200 Subject: [PATCH 05/12] Fix pointer --- resources/windows_feature/src/dism.rs | 31 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/resources/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs index 6ab0cd679..adc3e7036 100644 --- a/resources/windows_feature/src/dism.rs +++ b/resources/windows_feature/src/dism.rs @@ -25,13 +25,13 @@ unsafe extern "system" { ) -> *mut c_void; } -#[repr(C)] +#[repr(C, packed(4))] struct DismFeature { feature_name: *const u16, state: i32, } -#[repr(C)] +#[repr(C, packed(4))] struct DismFeatureInfo { feature_name: *const u16, state: i32, @@ -248,14 +248,21 @@ impl DismSessionHandle { } let result = unsafe { - let info = &*info_ptr; + // Use addr_of! + read_unaligned because the struct is packed(4): + // pointer fields are only 4-byte aligned, so we cannot create + // Rust references to them (that would be UB on x64). + let feature_name = std::ptr::addr_of!((*info_ptr).feature_name).read_unaligned(); + let state = std::ptr::addr_of!((*info_ptr).state).read_unaligned(); + let display_name = std::ptr::addr_of!((*info_ptr).display_name).read_unaligned(); + let description = std::ptr::addr_of!((*info_ptr).description).read_unaligned(); + let restart = std::ptr::addr_of!((*info_ptr).restart_required).read_unaligned(); let feature_info = WindowsFeatureInfo { - feature_name: Some(from_wide_ptr(info.feature_name)), + feature_name: Some(from_wide_ptr(feature_name)), exist: None, - 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), + state: FeatureState::from_dism(state), + display_name: Some(from_wide_ptr(display_name)), + description: Some(from_wide_ptr(description)), + restart_required: RestartType::from_dism(restart), enable_all: None, source_paths: None, limit_access: None, @@ -377,9 +384,11 @@ impl DismSessionHandle { 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 fp = features_ptr.add(i); + let name_ptr = std::ptr::addr_of!((*fp).feature_name).read_unaligned(); + let state = std::ptr::addr_of!((*fp).state).read_unaligned(); + let name = from_wide_ptr(name_ptr); + result.push((name, state)); } (self.api.delete)(features_ptr as *const c_void); } From 9cec952b4818c28bdf100a216c7862b092d179e6 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:24:24 +0200 Subject: [PATCH 06/12] Initial setup of Microsoft.Windows/WindowsFeatureList --- Cargo.lock | 9 + Cargo.toml | 7 +- data.build.json | 17 + resources/windows_feature/.project.data.json | 10 + resources/windows_feature/Cargo.toml | 18 + resources/windows_feature/locales/en-us.toml | 38 ++ resources/windows_feature/src/dism.rs | 397 ++++++++++++++++++ resources/windows_feature/src/export.rs | 95 +++++ resources/windows_feature/src/get.rs | 31 ++ resources/windows_feature/src/main.rs | 146 +++++++ resources/windows_feature/src/set.rs | 70 +++ resources/windows_feature/src/types.rs | 73 ++++ resources/windows_feature/src/util.rs | 132 ++++++ .../tests/windows_feature_export.tests.ps1 | 82 ++++ .../tests/windows_feature_get.tests.ps1 | 86 ++++ .../tests/windows_feature_set.tests.ps1 | 127 ++++++ .../windows_feature.dsc.resource.json | 147 +++++++ 17 files changed, 1483 insertions(+), 2 deletions(-) create mode 100644 resources/windows_feature/.project.data.json create mode 100644 resources/windows_feature/Cargo.toml create mode 100644 resources/windows_feature/locales/en-us.toml create mode 100644 resources/windows_feature/src/dism.rs create mode 100644 resources/windows_feature/src/export.rs create mode 100644 resources/windows_feature/src/get.rs create mode 100644 resources/windows_feature/src/main.rs create mode 100644 resources/windows_feature/src/set.rs create mode 100644 resources/windows_feature/src/types.rs create mode 100644 resources/windows_feature/src/util.rs create mode 100644 resources/windows_feature/tests/windows_feature_export.tests.ps1 create mode 100644 resources/windows_feature/tests/windows_feature_get.tests.ps1 create mode 100644 resources/windows_feature/tests/windows_feature_set.tests.ps1 create mode 100644 resources/windows_feature/windows_feature.dsc.resource.json diff --git a/Cargo.lock b/Cargo.lock index e64487eee..4b2b83bc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4242,6 +4242,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_feature" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", +] + [[package]] name = "windows_firewall" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 18c31d016..576a004c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", @@ -48,17 +49,18 @@ default-members = [ "lib/dsc-lib-registry", "resources/runcommandonset", "lib/dsc-lib-security_context", - "resources/dism_dsc", "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" + "xtask", + "resources/dism_dsc" ] [workspace.metadata.groups] @@ -83,6 +85,7 @@ Windows = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", + "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", diff --git a/data.build.json b/data.build.json index a8d16434d..07c050d19 100644 --- a/data.build.json +++ b/data.build.json @@ -109,6 +109,8 @@ "windows_firewall.exe", "windows_service.exe", "windows_service.dsc.resource.json", + "windows_feature.exe", + "windows_feature.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -478,6 +480,21 @@ ] } }, + { + "Name": "windows_feature", + "Kind": "Resource", + "RelativePath": "resources/windows_feature", + "SupportedPlatformOS": "Windows", + "IsRust": true, + "Binaries": [ + "windows_feature" + ], + "CopyFiles": { + "Windows": [ + "windows_feature.dsc.resource.json" + ] + } + }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/windows_feature/.project.data.json b/resources/windows_feature/.project.data.json new file mode 100644 index 000000000..705925f3c --- /dev/null +++ b/resources/windows_feature/.project.data.json @@ -0,0 +1,10 @@ +{ + "Name": "windows_feature", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": ["windows_feature"], + "CopyFiles": { + "Windows": ["windows_feature.dsc.resource.json"] + } +} diff --git a/resources/windows_feature/Cargo.toml b/resources/windows_feature/Cargo.toml new file mode 100644 index 000000000..0112f85f9 --- /dev/null +++ b/resources/windows_feature/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "windows_feature" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "windows_feature" +path = "src/main.rs" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml new file mode 100644 index 000000000..e2b721059 --- /dev/null +++ b/resources/windows_feature/locales/en-us.toml @@ -0,0 +1,38 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_feature get --input | set --input | export [--input ]" +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +windowsOnly = "This resource is only supported on Windows" + +[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}" + +[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}" + +[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}" +notSupportedAppx = "This resource is not supported when installed via Appx" +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/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs new file mode 100644 index 000000000..b24b34c43 --- /dev/null +++ b/resources/windows_feature/src/dism.rs @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::ffi::c_void; +use std::os::windows::ffi::OsStrExt; + +use rust_i18n::t; + +use crate::types::{FeatureState, RestartType, WindowsFeatureInfo}; + +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; +const REGDB_E_CLASSNOTREG: i32 = 0x80040154u32 as i32; +const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; + +#[link(name = "kernel32")] +unsafe extern "system" { + fn LoadLibraryExW( + lpLibFileName: *const u16, + hFile: *mut c_void, + dwFlags: u32, + ) -> *mut c_void; +} + +#[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 DismEnableFeatureFn = unsafe extern "system" fn( + u32, // Session + *const u16, // FeatureName + *const u16, // Identifier (NULL) + i32, // PackageIdentifier (DismPackageNone) + i32, // LimitAccess (BOOL) + *const *const u16, // SourcePaths + 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; + +// Kernel32 functions for dynamic loading +unsafe extern "system" { + 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(); + } + unsafe { + 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) + } +} + +unsafe fn load_fn(lib: *mut c_void, name: &[u8]) -> Result { + unsafe { + let ptr = GetProcAddress(lib, name.as_ptr()); + if ptr.is_null() { + 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)) + } +} + +struct DismApi { + lib: *mut c_void, + close_session: DismCloseSessionFn, + shutdown: DismShutdownFn, + get_features: DismGetFeaturesFn, + get_feature_info: DismGetFeatureInfoFn, + enable_feature: DismEnableFeatureFn, + disable_feature: DismDisableFeatureFn, + delete: DismDeleteFn, +} + +impl DismApi { + fn load() -> Result { + // Load dismapi.dll from the trusted System32 directory to avoid DLL search order hijacking. + // 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()); + } + + 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")?, + enable_feature: load_fn(lib, b"DismEnableFeature\0")?, + disable_feature: load_fn(lib, b"DismDisableFeature\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 { + /// Opens a new DISM session for the online image. + 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 == REGDB_E_CLASSNOTREG { + return Err(t!("dism.notSupportedAppx").to_string()); + } + if hr < 0 { + return Err( + t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), + ); + } + + 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 == REGDB_E_CLASSNOTREG { + (api.shutdown)(); + return Err(t!("dism.notSupportedAppx").to_string()); + } + if hr < 0 { + (api.shutdown)(); + return Err( + t!("dism.openSessionFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), + ); + } + + 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 == DISMAPI_E_UNKNOWN_FEATURE { + return Ok(WindowsFeatureInfo { + feature_name: Some(feature_name.to_string()), + exist: Some(false), + ..WindowsFeatureInfo::default() + }); + } + + if hr < 0 { + 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_info = WindowsFeatureInfo { + feature_name: Some(from_wide_ptr(info.feature_name)), + exist: None, + 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), + enable_all: None, + source_paths: None, + limit_access: None, + }; + (self.api.delete)(info_ptr as *const c_void); + feature_info + }; + + Ok(result) + } + + /// Enable a Windows feature. + /// + /// * `source_paths` — Optional list of local media paths passed as `SourcePaths` to + /// `DismEnableFeature`. Required on air-gapped systems without access to Windows Update. + /// * `limit_access` — When `true`, prevents DISM from contacting Windows Update + /// (`LimitAccess = TRUE`). + /// * `enable_all` — When `true`, enables all features that the specified feature depends on, + /// including child features (`EnableAll = TRUE`). + /// + /// Returns `Ok(true)` if a reboot is required to complete the operation. + pub fn enable_feature( + &self, + feature_name: &str, + source_paths: &[String], + limit_access: bool, + enable_all: bool, + ) -> Result { + let wide_name = to_wide_null(feature_name); + + // Build wide-string arrays for source paths. The vectors must remain alive for the + // duration of the unsafe call, so they are kept in scope here. + let wide_paths: Vec> = source_paths.iter().map(|p| to_wide_null(p)).collect(); + let wide_ptrs: Vec<*const u16> = wide_paths.iter().map(|p| p.as_ptr()).collect(); + let (paths_ptr, paths_count) = if wide_ptrs.is_empty() { + (std::ptr::null(), 0u32) + } else { + (wide_ptrs.as_ptr(), wide_ptrs.len() as u32) + }; + + let hr = unsafe { + (self.api.enable_feature)( + self.handle, + wide_name.as_ptr(), + std::ptr::null(), // Identifier + DISM_PACKAGE_NONE, // PackageIdentifier + i32::from(limit_access), // LimitAccess + paths_ptr, // SourcePaths + paths_count, // SourcePathCount + i32::from(enable_all), // EnableAll + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData + ) + }; + + if hr < 0 { + return Err(t!( + "dism.enableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + /// Disable (uninstall) a Windows feature. + /// + /// * `remove_payload` — When `true`, passes `RemovePayload = TRUE` to `DismDisableFeature`, + /// which removes the feature's payload from disk (equivalent to DISM state `Removed`). + /// + /// Returns `Ok(true)` if a reboot is required to complete the operation. + 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)( + 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(t!( + "dism.disableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); + } + Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) + } + + 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( + 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)); + } + (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/windows_feature/src/export.rs b/resources/windows_feature/src/export.rs new file mode 100644 index 000000000..3ac649188 --- /dev/null +++ b/resources/windows_feature/src/export.rs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::dism::DismSessionHandle; +use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::util::{matches_wildcard, WildcardFilterable}; + +pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result { + let filters: Vec = match filter { + None => vec![WindowsFeatureInfo::default()], + Some(list) if list.features.is_empty() => vec![WindowsFeatureInfo::default()], + Some(list) => list.features.clone(), + }; + + let session = DismSessionHandle::open()?; + let all_basics = session.get_all_feature_basics()?; + + // Check if any filter requires full info (displayName or description filtering) + let needs_full_info = filters + .iter() + .any(|f| f.display_name.is_some() || f.description.is_some()); + + 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<&WindowsFeatureInfo>, + Vec<&WindowsFeatureInfo>, + ) = 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 { + // 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 + && matches_wildcard(name, filter_name) + { + should_get_full = true; + break; + } + } + } + if !should_get_full { + 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(_) => WindowsFeatureInfo { + feature_name: Some(name.clone()), + exist: None, + state, + display_name: None, + description: None, + restart_required: None, + enable_all: None, + source_paths: None, + limit_access: None, + }, + }; + + if info.matches_any_filter(&filters) { + results.push(info); + } + } else { + // Fast path: only need name and state for filtering, skip expensive + // per-feature DismGetFeatureInfo calls. + let basic = WindowsFeatureInfo { + feature_name: Some(name.clone()), + state: state.clone(), + ..WindowsFeatureInfo::default() + }; + + if basic.matches_any_filter(&filters) { + results.push(basic); + } + } + } + + Ok(WindowsFeatureList { + restart_required_meta: None, + features: results, + }) +} diff --git a/resources/windows_feature/src/get.rs b/resources/windows_feature/src/get.rs new file mode 100644 index 000000000..7ea58a77c --- /dev/null +++ b/resources/windows_feature/src/get.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::dism::DismSessionHandle; +use crate::types::{WindowsFeatureInfo, WindowsFeatureList}; + +pub fn handle_get(input: &WindowsFeatureList) -> Result { + if input.features.is_empty() { + return Err(t!("get.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results: Vec = Vec::new(); + + for feature_input in &input.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); + } + + Ok(WindowsFeatureList { + restart_required_meta: None, + features: results, + }) +} diff --git a/resources/windows_feature/src/main.rs b/resources/windows_feature/src/main.rs new file mode 100644 index 000000000..25887f36a --- /dev/null +++ b/resources/windows_feature/src/main.rs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod dism; +mod export; +mod get; +mod set; +mod types; +mod util; + +use rust_i18n::t; +use std::process::exit; + +use types::WindowsFeatureList; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_FEATURE_ERROR: i32 = 3; + +/// Write a JSON error object to stderr: `{"error":""}` +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + +/// Deserialize the required JSON input into a `WindowsFeatureList`, or exit with an error. +fn require_input(input_json: Option) -> WindowsFeatureList { + let json = match input_json { + Some(j) => j, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +/// Serialize a value to JSON and print it to stdout, or exit with an error. +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_FEATURE_ERROR); + } + } +} + +#[cfg(not(windows))] +fn main() { + write_error(&t!("main.windowsOnly")); + exit(EXIT_FEATURE_ERROR); +} + +#[cfg(windows)] +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + write_error(&t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + match operation { + "get" => { + let input = require_input(input_json); + match get::handle_get(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + "set" => { + let input = require_input(input_json); + match set::handle_set(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + "export" => { + let filter: Option = match input_json { + Some(json) => match serde_json::from_str(&json) { + Ok(v) => Some(v), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }, + None => None, + }; + + match export::handle_export(filter.as_ref()) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e); + exit(EXIT_FEATURE_ERROR); + } + } + } + _ => { + write_error(&t!("main.unknownOperation", operation = operation)); + exit(EXIT_INVALID_ARGS); + } + } +} + +/// Parse the `--input ` argument from the command-line args. +fn parse_input_arg(args: &[String]) -> Option { + let mut i = 2; // skip binary name and operation + while i < args.len() { + if args[i] == "--input" || args[i] == "-i" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + write_error(&t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + i += 1; + } + None +} diff --git a/resources/windows_feature/src/set.rs b/resources/windows_feature/src/set.rs new file mode 100644 index 000000000..2afd828e6 --- /dev/null +++ b/resources/windows_feature/src/set.rs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::{Map, Value}; + +use crate::dism::DismSessionHandle; +use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::util::get_computer_name; + +pub fn handle_set(input: &WindowsFeatureList) -> Result { + if input.features.is_empty() { + return Err(t!("set.featuresArrayEmpty").to_string()); + } + + let session = DismSessionHandle::open()?; + let mut results: Vec = Vec::new(); + let mut reboot_required = false; + + for feature_input in &input.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())?; + + let needs_reboot = match desired_state { + FeatureState::Installed => { + let source_paths = feature_input + .source_paths + .as_deref() + .unwrap_or(&[]); + let limit_access = feature_input.limit_access.unwrap_or(false); + let enable_all = feature_input.enable_all.unwrap_or(false); + session.enable_feature(feature_name, source_paths, limit_access, enable_all)? + } + FeatureState::NotPresent => session.disable_feature(feature_name, false)?, + FeatureState::Removed => session.disable_feature(feature_name, true)?, + _ => { + return Err(t!( + "set.unsupportedDesiredState", + state = desired_state.to_string() + ) + .to_string()); + } + }; + + reboot_required = reboot_required || needs_reboot; + + let info = session.get_feature_info(feature_name)?; + results.push(info); + } + + let restart_required_meta = if reboot_required { + let mut entry = Map::new(); + entry.insert("system".to_string(), Value::String(get_computer_name())); + Some(vec![entry]) + } else { + None + }; + + Ok(WindowsFeatureList { + restart_required_meta, + features: results, + }) +} diff --git a/resources/windows_feature/src/types.rs b/resources/windows_feature/src/types.rs new file mode 100644 index 000000000..3e61f565b --- /dev/null +++ b/resources/windows_feature/src/types.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::util::{DismState, WildcardFilterable, matches_optional_exact, matches_optional_wildcard}; + +pub type FeatureState = DismState; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WindowsFeatureList { + #[serde(rename = "_restartRequired", skip_serializing_if = "Option::is_none")] + pub restart_required_meta: Option>>, + pub features: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct WindowsFeatureInfo { + #[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")] + 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, + /// Enable all features that the specified feature depends on, including child features. + /// Passed as the `EnableAll` parameter to `DismEnableFeature`. + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_all: Option, + /// Local source paths (e.g., a mounted Windows ISO or WIM) passed to `DismEnableFeature` + /// as `SourcePaths`. Required on systems that cannot reach Windows Update. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_paths: Option>, + /// When `true`, prevents DISM from contacting Windows Update even when `sourcePaths` is empty. + /// Passed as the `LimitAccess` parameter to `DismEnableFeature`. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_access: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum RestartType { + No, + Possible, + Required, +} + +impl RestartType { + pub fn from_dism(restart: i32) -> Option { + match restart { + 0 => Some(RestartType::No), + 1 => Some(RestartType::Possible), + 2 => Some(RestartType::Required), + _ => None, + } + } +} + +impl WildcardFilterable for WindowsFeatureInfo { + fn matches_filter(&self, filter: &Self) -> bool { + matches_optional_wildcard(&self.feature_name, &filter.feature_name) + && matches_optional_exact(&self.state, &filter.state) + && matches_optional_wildcard(&self.display_name, &filter.display_name) + && matches_optional_wildcard(&self.description, &filter.description) + } +} diff --git a/resources/windows_feature/src/util.rs b/resources/windows_feature/src/util.rs new file mode 100644 index 000000000..50f13f119 --- /dev/null +++ b/resources/windows_feature/src/util.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// DISM package/feature state values. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum DismState { + NotPresent, + UninstallPending, + Staged, + Removed, + Installed, + InstallPending, + Superseded, + PartiallyInstalled, +} + +impl fmt::Display for DismState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DismState::NotPresent => write!(f, "NotPresent"), + DismState::UninstallPending => write!(f, "UninstallPending"), + DismState::Staged => write!(f, "Staged"), + DismState::Removed => write!(f, "Removed"), + DismState::Installed => write!(f, "Installed"), + DismState::InstallPending => write!(f, "InstallPending"), + DismState::Superseded => write!(f, "Superseded"), + DismState::PartiallyInstalled => write!(f, "PartiallyInstalled"), + } + } +} + +impl DismState { + pub fn from_dism(state: i32) -> Option { + match state { + 0 => Some(DismState::NotPresent), + 1 => Some(DismState::UninstallPending), + 2 => Some(DismState::Staged), + 3 => Some(DismState::Removed), + 4 => Some(DismState::Installed), + 5 => Some(DismState::InstallPending), + 6 => Some(DismState::Superseded), + 7 => Some(DismState::PartiallyInstalled), + _ => None, + } + } +} + +/// Match a string against a pattern that supports `*` wildcards (case-insensitive). +pub fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if !pattern_lower.contains('*') { + return text_lower == pattern_lower; + } + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { + return false; + } + + let mut pos = parts[0].len(); + + let suffix = *parts.last().unwrap_or(&""); + let end = if suffix.is_empty() { + text_lower.len() + } else { + if !text_lower.ends_with(suffix) { + return false; + } + text_lower.len() - suffix.len() + }; + + for part in &parts[1..parts.len().saturating_sub(1)] { + if part.is_empty() { + continue; + } + match text_lower.get(pos..end).and_then(|s| s.find(part)) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + + pos <= end +} + +/// Check that an optional string field matches a wildcard filter pattern. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_wildcard(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(pattern) => match info_value { + Some(value) => matches_wildcard(value, pattern), + None => false, + }, + None => true, + } +} + +/// Check that an optional field matches an exact filter value. +/// Returns true if the filter has no value (no constraint). +pub fn matches_optional_exact(info_value: &Option, filter_value: &Option) -> bool { + match filter_value { + Some(expected) => match info_value { + Some(actual) => actual == expected, + None => false, + }, + None => true, + } +} + +/// Trait for types that support wildcard-based filter matching in export operations. +pub trait WildcardFilterable { + /// Returns true if this instance matches the given filter (AND logic within a single filter). + fn matches_filter(&self, filter: &Self) -> bool; + + /// Returns true if this instance matches any of the given filters (OR logic between filters). + fn matches_any_filter(&self, filters: &[Self]) -> bool + where + Self: Sized, + { + filters.iter().any(|filter| self.matches_filter(filter)) + } +} + +/// Returns the computer name from the COMPUTERNAME environment variable, or "localhost" as fallback. +pub fn get_computer_name() -> String { + std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) +} diff --git a/resources/windows_feature/tests/windows_feature_export.tests.ps1 b/resources/windows_feature/tests/windows_feature_export.tests.ps1 new file mode 100644 index 000000000..ccee09718 --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_export.tests.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWindows) { + BeforeAll { + # Discover at least one enabled and one disabled feature using DISM + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate 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" + } + $knownEnabledFeature = $enabledMatches[0].Matches[0].Groups[1].Value + $knownDisabledFeature = $disabledMatches[0].Matches[0].Groups[1].Value + } + + It 'exports all features with no input filter' { + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList | 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' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features.Count | Should -Be 1 + $features[0].featureName | Should -BeExactly $knownEnabledFeature + } + + It 'exports features filtered by state Installed' { + $inputJson = '{"features":[{"state":"Installed"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -Not -BeNullOrEmpty + $features | ForEach-Object { $_.state | Should -Be 'Installed' } + } + + It 'returns empty features list for a non-matching filter' { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + $features | Should -BeNullOrEmpty + } + + It 'exports with wildcard featureName filter' { + # Use the first 3 characters of a known feature name as a wildcard prefix + $prefix = $knownEnabledFeature.Substring(0, [Math]::Min(3, $knownEnabledFeature.Length)) + $inputJson = '{"features":[{"featureName":"' + $prefix + '*"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $features = $output.resources[0].properties.features + # At minimum the known feature should be present if its name starts with $prefix + $features | Should -Not -BeNullOrEmpty + $features | ForEach-Object { + $_.featureName.ToLower() | Should -BeLike "$($prefix.ToLower())*" + } + } + + It 'exports multiple feature filters (OR logic)' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"},{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $featureNames = $output.resources[0].properties.features | Select-Object -ExpandProperty featureName + $featureNames | Should -Contain $knownEnabledFeature + $featureNames | Should -Contain $knownDisabledFeature + } +} diff --git a/resources/windows_feature/tests/windows_feature_get.tests.ps1 b/resources/windows_feature/tests/windows_feature_get.tests.ps1 new file mode 100644 index 000000000..daaa88d5b --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_get.tests.ps1 @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - get operation' -Skip:(!$IsWindows) { + BeforeAll { + # Discover at least one enabled and one disabled feature using DISM + $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate 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" + } + $knownEnabledFeature = $enabledMatches[0].Matches[0].Groups[1].Value + $knownDisabledFeature = $disabledMatches[0].Matches[0].Groups[1].Value + } + + Context 'Get a single feature by featureName' { + It 'returns feature info for a known enabled feature' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -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 $knownEnabledFeature + $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 'returns feature info for a known disabled feature' { + $inputJson = '{"features":[{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly $knownDisabledFeature + $feature.state | Should -BeIn @( + 'NotPresent', 'UninstallPending', 'Staged', 'Removed', + 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' + ) + } + + It 'returns _exist false for a non-existent feature name' { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.actualState.features[0] + $feature.featureName | Should -BeExactly 'NonExistent-Feature-1234567890' + $feature._exist | Should -BeFalse + $feature.PSObject.Properties.Name | Should -Not -Contain 'state' + $feature.PSObject.Properties.Name | Should -Not -Contain 'displayName' + } + } + + Context 'Get multiple features in one request' { + It 'returns info for both features' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"},{"featureName":"' + $knownDisabledFeature + '"}]}' + $output = dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.actualState.features.Count | Should -Be 2 + $output.actualState.features[0].featureName | Should -BeExactly $knownEnabledFeature + $output.actualState.features[1].featureName | Should -BeExactly $knownDisabledFeature + } + } + + Context 'Input validation' { + It 'returns error when featureName is missing' { + $inputJson = '{"features":[{"state":"Installed"}]}' + & { dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' { + $inputJson = '{"features":[]}' + & { dsc resource get -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + } +} diff --git a/resources/windows_feature/tests/windows_feature_set.tests.ps1 b/resources/windows_feature/tests/windows_feature_set.tests.ps1 new file mode 100644 index 000000000..144f33977 --- /dev/null +++ b/resources/windows_feature/tests/windows_feature_set.tests.ps1 @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Microsoft.Windows/WindowsFeatureList - set operation' -Skip:(!$IsWindows) { + BeforeDiscovery { + $isElevated = if ($IsWindows) { + ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) + } else { + $false + } + } + + # TelnetClient is a safe non-critical feature available on most Windows SKUs + # used here to exercise enable/disable without system impact. + + Context 'Input validation' { + It 'returns error when featureName is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"state":"Installed"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when state is missing' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error when features array is empty' -Skip:(!$isElevated) { + $inputJson = '{"features":[]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for unsupported desired state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Staged"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'returns error for a non-existent feature name' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890","state":"Installed"}]}' + & { dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>&1 } | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Enable and disable TelnetClient' { + It 'can enable TelnetClient and returns Installed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -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 'TelnetClient' + $feature.state | Should -BeIn @('Installed', 'InstallPending') + $feature.displayName | Should -Not -BeNullOrEmpty + } + + It 'can enable TelnetClient with enableAll set to true' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed","enableAll":true}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('Installed', 'InstallPending') + } + + It 'can disable TelnetClient with NotPresent and returns non-Installed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"NotPresent"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features | Should -Not -BeNullOrEmpty + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'UninstallPending', 'Staged') + } + + It 'can disable TelnetClient with Removed state' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Removed"}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + $feature.state | Should -BeIn @('NotPresent', 'Removed', 'Staged', 'UninstallPending') + } + + It 'set Installed is idempotent for an already installed feature' -Skip:(!$isElevated) { + # First ensure installed + $enableJson = '{"features":[{"featureName":"TelnetClient","state":"Installed"}]}' + dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $enableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set Installed again - should succeed + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $enableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features[0].state | Should -Be 'Installed' + } + + It 'set NotPresent is idempotent for an already disabled feature' -Skip:(!$isElevated) { + # First ensure not present + $disableJson = '{"features":[{"featureName":"TelnetClient","state":"NotPresent"}]}' + dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $disableJson | Out-Null + $LASTEXITCODE | Should -Be 0 + + # Set NotPresent again - should succeed + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $disableJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.afterState.features[0].state | Should -BeIn @('NotPresent', 'Removed', 'Staged') + } + } + + Context 'limitAccess parameter' { + It 'can enable TelnetClient with limitAccess true' -Skip:(!$isElevated) { + # TelnetClient payload is present in CBS, so limitAccess should not prevent installation + $inputJson = '{"features":[{"featureName":"TelnetClient","state":"Installed","limitAccess":true}]}' + $output = dsc resource set -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + # May succeed or fail depending on whether CBS payload is staged; just verify exit code 0 means success + if ($LASTEXITCODE -eq 0) { + $feature = $output.afterState.features[0] + $feature.featureName | Should -BeExactly 'TelnetClient' + } + } + } +} diff --git a/resources/windows_feature/windows_feature.dsc.resource.json b/resources/windows_feature/windows_feature.dsc.resource.json new file mode 100644 index 000000000..3d3b465ab --- /dev/null +++ b/resources/windows_feature/windows_feature.dsc.resource.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Manage Windows Features using the DISM API. Supports enableAll, sourcePaths, and limitAccess parameters for advanced installation scenarios such as Windows Server roles and offline media.", + "tags": [ + "Windows", + "dism", + "feature" + ], + "type": "Microsoft.Windows/WindowsFeatureList", + "version": "0.1.0", + "get": { + "executable": "windows_feature", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_feature", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "windows_feature", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Feature error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Feature List", + "description": "Manage Windows Features using the DISM API. Supports enableAll, sourcePaths, and limitAccess for advanced scenarios such as Windows Server roles and offline media.", + "type": "object", + "additionalProperties": false, + "required": [ + "features" + ], + "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", + "description": "An array of feature filters or feature information objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "featureName": { + "type": "string", + "title": "Feature name", + "description": "The name of the Windows feature as reported by DISM. Required for get and set operations. For export, this is optional and wildcards (*) are supported for case-insensitive filtering." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the feature exists on this system. Set to false when the requested feature name is not recognized by DISM." + }, + "state": { + "type": "string", + "enum": [ + "NotPresent", + "UninstallPending", + "Staged", + "Removed", + "Installed", + "InstallPending", + "Superseded", + "PartiallyInstalled" + ], + "title": "Feature state", + "description": "The current state of the Windows feature. For set operations, only Installed, NotPresent, and Removed are accepted; other states are returned by get and export and are not valid inputs for set." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the 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 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. Read-only; returned by get and export." + }, + "enableAll": { + "type": "boolean", + "title": "Enable all", + "description": "When true, enables all features that the specified feature depends on, including child features. Passed as EnableAll to DismEnableFeature. Only applies to set with state Installed." + }, + "sourcePaths": { + "type": "array", + "title": "Source paths", + "description": "Local paths to Windows installation media (e.g., a mounted ISO or WIM) used as source files for DismEnableFeature. Required on air-gapped systems that cannot reach Windows Update. Only applies to set with state Installed.", + "items": { + "type": "string" + } + }, + "limitAccess": { + "type": "boolean", + "title": "Limit access", + "description": "When true, prevents DISM from contacting Windows Update to download feature payloads, even when sourcePaths is empty. Passed as LimitAccess to DismEnableFeature. Only applies to set with state Installed." + } + } + } + } + } + } + } +} From d252d1304dec509a1b92626cb8c8034a5aed2551 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:32:21 +0200 Subject: [PATCH 07/12] Add whatIf support --- lib/dsc-lib-registry/src/config.rs | 4 +- resources/windows_feature/locales/en-us.toml | 5 + resources/windows_feature/src/main.rs | 8 +- resources/windows_feature/src/set.rs | 56 ++++++-- resources/windows_feature/src/types.rs | 9 ++ .../windows_featurelist_whatif.tests.ps1 | 123 ++++++++++++++++++ .../windows_feature.dsc.resource.json | 15 +++ 7 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 diff --git a/lib/dsc-lib-registry/src/config.rs b/lib/dsc-lib-registry/src/config.rs index 372180360..be35fb3f0 100644 --- a/lib/dsc-lib-registry/src/config.rs +++ b/lib/dsc-lib-registry/src/config.rs @@ -35,8 +35,8 @@ pub struct Registry { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct Metadata { - #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none")] pub what_if: Option> } diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml index e2b721059..d8807bcfa 100644 --- a/resources/windows_feature/locales/en-us.toml +++ b/resources/windows_feature/locales/en-us.toml @@ -36,3 +36,8 @@ getFeatureInfoFailed = "DismGetFeatureInfo failed for '%{name}': HRESULT %{hr}" enableFeatureFailed = "DismEnableFeature failed for '%{name}': HRESULT %{hr}" disableFeatureFailed = "DismDisableFeature failed for '%{name}': HRESULT %{hr}" getFeaturesFailed = "DismGetFeatures failed: HRESULT %{hr}" + +[windows_feature_helper] +whatIfEnable = "Would enable feature '%{name}'" +whatIfDisable = "Would disable feature '%{name}' (remove payload: false)" +whatIfRemove = "Would remove feature '%{name}' (remove payload: true)" diff --git a/resources/windows_feature/src/main.rs b/resources/windows_feature/src/main.rs index 25887f36a..62092f738 100644 --- a/resources/windows_feature/src/main.rs +++ b/resources/windows_feature/src/main.rs @@ -88,7 +88,8 @@ fn main() { } "set" => { let input = require_input(input_json); - match set::handle_set(&input) { + let what_if = parse_what_if_arg(&args); + match set::handle_set(&input, what_if) { Ok(result) => { print_json(&result); exit(EXIT_SUCCESS); @@ -144,3 +145,8 @@ fn parse_input_arg(args: &[String]) -> Option { } None } + +/// Returns `true` if `-w` or `--what-if` is present in the command-line args. +fn parse_what_if_arg(args: &[String]) -> bool { + args.iter().any(|a| a == "-w" || a == "--what-if") +} diff --git a/resources/windows_feature/src/set.rs b/resources/windows_feature/src/set.rs index 2afd828e6..59f19f5c5 100644 --- a/resources/windows_feature/src/set.rs +++ b/resources/windows_feature/src/set.rs @@ -5,10 +5,10 @@ use rust_i18n::t; use serde_json::{Map, Value}; use crate::dism::DismSessionHandle; -use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; +use crate::types::{FeatureState, Metadata, WindowsFeatureInfo, WindowsFeatureList}; use crate::util::get_computer_name; -pub fn handle_set(input: &WindowsFeatureList) -> Result { +pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result { if input.features.is_empty() { return Err(t!("set.featuresArrayEmpty").to_string()); } @@ -28,6 +28,8 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result = Vec::new(); + let needs_reboot = match desired_state { FeatureState::Installed => { let source_paths = feature_input @@ -36,10 +38,29 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result { + if what_if { + what_if_metadata.push(t!("windows_feature_helper.whatIfDisable", name = feature_name.as_str()).to_string()); + false + } else { + session.disable_feature(feature_name, false)? + } + } + FeatureState::Removed => { + if what_if { + what_if_metadata.push(t!("windows_feature_helper.whatIfRemove", name = feature_name.as_str()).to_string()); + false + } else { + session.disable_feature(feature_name, true)? + } } - FeatureState::NotPresent => session.disable_feature(feature_name, false)?, - FeatureState::Removed => session.disable_feature(feature_name, true)?, _ => { return Err(t!( "set.unsupportedDesiredState", @@ -49,13 +70,28 @@ pub fn handle_set(input: &WindowsFeatureList) -> Result>, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct WindowsFeatureList { @@ -43,6 +50,8 @@ pub struct WindowsFeatureInfo { /// Passed as the `LimitAccess` parameter to `DismEnableFeature`. #[serde(skip_serializing_if = "Option::is_none")] pub limit_access: Option, + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] diff --git a/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 b/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 new file mode 100644 index 000000000..edef96f56 --- /dev/null +++ b/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { + BeforeAll { + $testFeature = 'TelnetClient' + } + + It 'Can whatif enable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log -ErrorAction SilentlyContinue) + + # Projected state echoes back the requested feature name and state + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Installed' + + # what-if metadata present + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif disable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'NotPresent' + $result.features[0]._metadata.whatIf[0] | Should -Match "Would disable feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif remove a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Removed" } + ] +} +"@ + # Capture pre-state + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + # Run what-if + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Removed' + $result.features[0]._metadata.whatIf[0] | Should -Match "Would remove feature '$testFeature'" + + # No mutation occurred + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif enable a feature with enableAll and limitAccess without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed", "enableAll": true, "limitAccess": true } + ] +} +"@ + $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features[0].featureName | Should -Be $testFeature + $result.features[0].state | Should -Be 'Installed' + $result.features[0].enableAll | Should -BeTrue + $result.features[0].limitAccess | Should -BeTrue + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" + + $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can whatif multiple features in one call without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" }, + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + + $result.features | Should -HaveCount 2 + $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable" + $result.features[1]._metadata.whatIf[0] | Should -Match "Would disable" + } +} diff --git a/resources/windows_feature/windows_feature.dsc.resource.json b/resources/windows_feature/windows_feature.dsc.resource.json index 3d3b465ab..39020017d 100644 --- a/resources/windows_feature/windows_feature.dsc.resource.json +++ b/resources/windows_feature/windows_feature.dsc.resource.json @@ -25,10 +25,14 @@ { "jsonInputArg": "--input", "mandatory": true + }, + { + "whatIfArg": "--what-if" } ], "implementsPretest": false, "return": "state", + "whatIfReturns": "state", "requireSecurityContext": "elevated" }, "export": { @@ -137,6 +141,17 @@ "type": "boolean", "title": "Limit access", "description": "When true, prevents DISM from contacting Windows Update to download feature payloads, even when sourcePaths is empty. Passed as LimitAccess to DismEnableFeature. Only applies to set with state Installed." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations.", + "properties": { + "whatIf": { + "type": "array", + "items": { "type": "string" } + } + } } } } From 6c850393b4756e8c05168e7e3d595e4ba5732627 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 10:51:31 +0200 Subject: [PATCH 08/12] Fix build --- resources/windows_feature/src/dism.rs | 5 +++-- resources/windows_feature/src/export.rs | 1 + resources/windows_feature/src/types.rs | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/resources/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs index b24b34c43..6ab0cd679 100644 --- a/resources/windows_feature/src/dism.rs +++ b/resources/windows_feature/src/dism.rs @@ -25,13 +25,13 @@ unsafe extern "system" { ) -> *mut c_void; } -#[repr(C, packed)] +#[repr(C)] struct DismFeature { feature_name: *const u16, state: i32, } -#[repr(C, packed)] +#[repr(C)] struct DismFeatureInfo { feature_name: *const u16, state: i32, @@ -259,6 +259,7 @@ impl DismSessionHandle { enable_all: None, source_paths: None, limit_access: None, + ..Default::default() }; (self.api.delete)(info_ptr as *const c_void); feature_info diff --git a/resources/windows_feature/src/export.rs b/resources/windows_feature/src/export.rs index 3ac649188..4a56707a4 100644 --- a/resources/windows_feature/src/export.rs +++ b/resources/windows_feature/src/export.rs @@ -67,6 +67,7 @@ pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result>>, @@ -24,7 +24,7 @@ pub struct WindowsFeatureList { } #[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct WindowsFeatureInfo { #[serde(skip_serializing_if = "Option::is_none")] pub feature_name: Option, From ac5acebb1b69ac5b5239bba20072c29f0203449b Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Fri, 22 May 2026 13:12:20 +0200 Subject: [PATCH 09/12] Remove unused strings --- resources/windows_feature/locales/en-us.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml index d8807bcfa..7cff05168 100644 --- a/resources/windows_feature/locales/en-us.toml +++ b/resources/windows_feature/locales/en-us.toml @@ -9,22 +9,14 @@ invalidJson = "Invalid JSON input: %{error}" windowsOnly = "This resource is only supported on Windows" [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}" [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}" [dism] failedLoadLibrary = "Failed to load dismapi.dll. Ensure DISM is available on this system." From e78ffd96c6655d00db002e85cabd8093289b7d80 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 23 May 2026 08:16:42 +0200 Subject: [PATCH 10/12] Fix pointer --- resources/windows_feature/src/dism.rs | 31 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/resources/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs index 6ab0cd679..adc3e7036 100644 --- a/resources/windows_feature/src/dism.rs +++ b/resources/windows_feature/src/dism.rs @@ -25,13 +25,13 @@ unsafe extern "system" { ) -> *mut c_void; } -#[repr(C)] +#[repr(C, packed(4))] struct DismFeature { feature_name: *const u16, state: i32, } -#[repr(C)] +#[repr(C, packed(4))] struct DismFeatureInfo { feature_name: *const u16, state: i32, @@ -248,14 +248,21 @@ impl DismSessionHandle { } let result = unsafe { - let info = &*info_ptr; + // Use addr_of! + read_unaligned because the struct is packed(4): + // pointer fields are only 4-byte aligned, so we cannot create + // Rust references to them (that would be UB on x64). + let feature_name = std::ptr::addr_of!((*info_ptr).feature_name).read_unaligned(); + let state = std::ptr::addr_of!((*info_ptr).state).read_unaligned(); + let display_name = std::ptr::addr_of!((*info_ptr).display_name).read_unaligned(); + let description = std::ptr::addr_of!((*info_ptr).description).read_unaligned(); + let restart = std::ptr::addr_of!((*info_ptr).restart_required).read_unaligned(); let feature_info = WindowsFeatureInfo { - feature_name: Some(from_wide_ptr(info.feature_name)), + feature_name: Some(from_wide_ptr(feature_name)), exist: None, - 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), + state: FeatureState::from_dism(state), + display_name: Some(from_wide_ptr(display_name)), + description: Some(from_wide_ptr(description)), + restart_required: RestartType::from_dism(restart), enable_all: None, source_paths: None, limit_access: None, @@ -377,9 +384,11 @@ impl DismSessionHandle { 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 fp = features_ptr.add(i); + let name_ptr = std::ptr::addr_of!((*fp).feature_name).read_unaligned(); + let state = std::ptr::addr_of!((*fp).state).read_unaligned(); + let name = from_wide_ptr(name_ptr); + result.push((name, state)); } (self.api.delete)(features_ptr as *const c_void); } From 15ccd6f3ec22e7790fa882e15e314a6cfd131075 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:27:15 +0200 Subject: [PATCH 11/12] Move content under dism_dsc --- Cargo.toml | 3 - data.build.json | 18 +- resources/dism_dsc/.project.data.json | 2 +- resources/dism_dsc/locales/en-us.toml | 9 +- resources/dism_dsc/src/dism.rs | 300 +++++++++---- resources/dism_dsc/src/main.rs | 21 +- .../src/windows_feature}/export.rs | 50 ++- resources/dism_dsc/src/windows_feature/get.rs | 36 ++ resources/dism_dsc/src/windows_feature/mod.rs | 11 + .../src/windows_feature}/set.rs | 61 ++- .../src/windows_feature}/types.rs | 10 +- .../tests/windowsFeature_export.tests.ps1} | 0 .../tests/windowsFeature_get.tests.ps1} | 0 .../tests/windowsFeature_set.tests.ps1} | 0 .../tests/windowsFeature_whatif.tests.ps1} | 38 +- .../windows_feature.dsc.resource.json | 32 +- .../registry/registry.dsc.manifests.json | 3 +- resources/windows_feature/.project.data.json | 10 - resources/windows_feature/Cargo.toml | 18 - resources/windows_feature/locales/en-us.toml | 35 -- resources/windows_feature/src/dism.rs | 407 ------------------ resources/windows_feature/src/get.rs | 31 -- resources/windows_feature/src/main.rs | 152 ------- resources/windows_feature/src/util.rs | 132 ------ 24 files changed, 388 insertions(+), 991 deletions(-) rename resources/{windows_feature/src => dism_dsc/src/windows_feature}/export.rs (56%) create mode 100644 resources/dism_dsc/src/windows_feature/get.rs create mode 100644 resources/dism_dsc/src/windows_feature/mod.rs rename resources/{windows_feature/src => dism_dsc/src/windows_feature}/set.rs (58%) rename resources/{windows_feature/src => dism_dsc/src/windows_feature}/types.rs (81%) rename resources/{windows_feature/tests/windows_feature_export.tests.ps1 => dism_dsc/tests/windowsFeature_export.tests.ps1} (100%) rename resources/{windows_feature/tests/windows_feature_get.tests.ps1 => dism_dsc/tests/windowsFeature_get.tests.ps1} (100%) rename resources/{windows_feature/tests/windows_feature_set.tests.ps1 => dism_dsc/tests/windowsFeature_set.tests.ps1} (100%) rename resources/{windows_feature/tests/windows_featurelist_whatif.tests.ps1 => dism_dsc/tests/windowsFeature_whatif.tests.ps1} (68%) rename resources/{windows_feature => dism_dsc}/windows_feature.dsc.resource.json (92%) delete mode 100644 resources/windows_feature/.project.data.json delete mode 100644 resources/windows_feature/Cargo.toml delete mode 100644 resources/windows_feature/locales/en-us.toml delete mode 100644 resources/windows_feature/src/dism.rs delete mode 100644 resources/windows_feature/src/get.rs delete mode 100644 resources/windows_feature/src/main.rs delete mode 100644 resources/windows_feature/src/util.rs diff --git a/Cargo.toml b/Cargo.toml index 576a004c3..1dec69170 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", - "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", @@ -52,7 +51,6 @@ default-members = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", - "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", @@ -85,7 +83,6 @@ Windows = [ "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", - "resources/windows_feature", "resources/windows_firewall", "tools/dsctest", "tools/test_group_resource", diff --git a/data.build.json b/data.build.json index 07c050d19..9a6d58d03 100644 --- a/data.build.json +++ b/data.build.json @@ -109,7 +109,6 @@ "windows_firewall.exe", "windows_service.exe", "windows_service.dsc.resource.json", - "windows_feature.exe", "windows_feature.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", @@ -182,7 +181,7 @@ "SupportedPlatformOS": "Windows", "Binaries": ["dism_dsc"], "CopyFiles": { - "Windows": ["optionalfeature.dsc.resource.json", "featureondemand.dsc.resource.json"] + "Windows": ["optionalfeature.dsc.resource.json", "featureondemand.dsc.resource.json", "windows_feature.dsc.resource.json"] } }, { @@ -480,21 +479,6 @@ ] } }, - { - "Name": "windows_feature", - "Kind": "Resource", - "RelativePath": "resources/windows_feature", - "SupportedPlatformOS": "Windows", - "IsRust": true, - "Binaries": [ - "windows_feature" - ], - "CopyFiles": { - "Windows": [ - "windows_feature.dsc.resource.json" - ] - } - }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/dism_dsc/.project.data.json b/resources/dism_dsc/.project.data.json index 9e456e3b9..c21c4ed57 100644 --- a/resources/dism_dsc/.project.data.json +++ b/resources/dism_dsc/.project.data.json @@ -5,6 +5,6 @@ "SupportedPlatformOS": "Windows", "Binaries": ["dism_dsc"], "CopyFiles": { - "Windows": ["optionalfeature.dsc.resource.json", "featureondemand.dsc.resource.json"] + "Windows": ["optionalfeature.dsc.resource.json", "featureondemand.dsc.resource.json", "windows_feature.dsc.resource.json"] } } diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index 19a945a67..3127bfa8d 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -2,10 +2,10 @@ _version = 1 [main] missingArguments = "Missing operation and resource type arguments" -usage = "Usage: dism_dsc " +usage = "Usage: dism_dsc " windowsOnly = "This resource is only supported on Windows" unknownOperation = "Unknown operation '%{operation}'" -unknownResourceType = "Unknown resource type '%{resource_type}'. Expected 'optional-feature' or 'feature-on-demand'" +unknownResourceType = "Unknown resource type '%{resource_type}'. Expected 'optional-feature', 'feature-on-demand', or 'windows-feature'" errorReadingInput = "Error reading input: %{err}" [get] @@ -60,3 +60,8 @@ getCapabilitiesFailed = "DismGetCapabilities failed: HRESULT %{hr}" getCapabilityInfoFailed = "DismGetCapabilityInfo failed for '%{name}': HRESULT %{hr}" addCapabilityFailed = "DismAddCapability failed for '%{name}': HRESULT %{hr}" removeCapabilityFailed = "DismRemoveCapability failed for '%{name}': HRESULT %{hr}" + +[windows_feature_helper] +whatIfEnable = "Would enable feature '%{name}'" +whatIfDisable = "Would disable feature '%{name}' (remove payload: false)" +whatIfRemove = "Would remove feature '%{name}' (remove payload: true)" diff --git a/resources/dism_dsc/src/dism.rs b/resources/dism_dsc/src/dism.rs index aa693d8b0..5c447c378 100644 --- a/resources/dism_dsc/src/dism.rs +++ b/resources/dism_dsc/src/dism.rs @@ -7,6 +7,10 @@ use std::os::windows::ffi::OsStrExt; use rust_i18n::t; use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, RestartType}; +use crate::windows_feature::types::{ + FeatureState as WindowsFeatureState, RestartType as WindowsFeatureRestartType, + WindowsFeatureInfo, +}; const DISM_ONLINE_IMAGE: &str = "DISM_{53BFAE52-B167-4E2F-A258-0A37B57FF845}"; const DISM_LOG_ERRORS: i32 = 0; @@ -19,11 +23,7 @@ const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; #[link(name = "kernel32")] unsafe extern "system" { - fn LoadLibraryExW( - lpLibFileName: *const u16, - hFile: *mut c_void, - dwFlags: u32, - ) -> *mut c_void; + fn LoadLibraryExW(lpLibFileName: *const u16, hFile: *mut c_void, dwFlags: u32) -> *mut c_void; } #[repr(C, packed)] @@ -71,8 +71,7 @@ pub struct DismCapabilityResult { } // Function pointer types for the DISM API -type DismInitializeFn = - unsafe extern "system" fn(i32, *const u16, *const u16) -> i32; +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 = @@ -80,47 +79,47 @@ type DismGetFeaturesFn = 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) + 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) + 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 DismGetCapabilitiesFn = unsafe extern "system" fn(u32, *mut *mut DismCapability, *mut u32) -> i32; type DismGetCapabilityInfoFn = unsafe extern "system" fn(u32, *const u16, *mut *mut DismCapabilityDetail) -> i32; type DismAddCapabilityFn = unsafe extern "system" fn( - u32, // Session - *const u16, // Name - i32, // LimitAccess (BOOL) - *const *const u16,// SourcePaths (NULL) - u32, // SourcePathCount - *mut c_void, // CancelEvent (NULL) - *mut c_void, // Progress callback (NULL) - *mut c_void, // UserData (NULL) + u32, // Session + *const u16, // Name + i32, // LimitAccess (BOOL) + *const *const u16, // SourcePaths (NULL) + u32, // SourcePathCount + *mut c_void, // CancelEvent (NULL) + *mut c_void, // Progress callback (NULL) + *mut c_void, // UserData (NULL) ) -> i32; type DismRemoveCapabilityFn = unsafe extern "system" fn( - u32, // Session - *const u16, // Name - *mut c_void, // CancelEvent (NULL) - *mut c_void, // Progress callback (NULL) - *mut c_void, // UserData (NULL) + u32, // Session + *const u16, // Name + *mut c_void, // CancelEvent (NULL) + *mut c_void, // Progress callback (NULL) + *mut c_void, // UserData (NULL) ) -> i32; type DismCloseSessionFn = unsafe extern "system" fn(u32) -> i32; type DismShutdownFn = unsafe extern "system" fn() -> i32; @@ -237,8 +236,7 @@ impl DismSessionHandle { 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_initialize: DismInitializeFn = unsafe { load_fn(api.lib, b"DismInitialize\0")? }; let dism_open_session: DismOpenSessionFn = unsafe { load_fn(api.lib, b"DismOpenSession\0")? }; @@ -248,7 +246,9 @@ impl DismSessionHandle { return Err(t!("dism.notSupportedAppx").to_string()); } if hr < 0 { - return Err(t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err( + t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), + ); } let image_path = to_wide_null(DISM_ONLINE_IMAGE); @@ -265,7 +265,11 @@ impl DismSessionHandle { } if hr < 0 { (api.shutdown)(); - return Err(t!("dism.openSessionFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.openSessionFailed", + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } Ok(DismSessionHandle { @@ -298,7 +302,12 @@ impl DismSessionHandle { } if hr < 0 { - return Err(t!("dism.getFeatureInfoFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.getFeatureInfoFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } let result = unsafe { @@ -318,46 +327,136 @@ impl DismSessionHandle { Ok(result) } + pub fn get_windows_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 == DISMAPI_E_UNKNOWN_FEATURE { + return Ok(WindowsFeatureInfo { + feature_name: Some(feature_name.to_string()), + exist: Some(false), + ..WindowsFeatureInfo::default() + }); + } + + if hr < 0 { + 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_info = WindowsFeatureInfo { + feature_name: Some(from_wide_ptr(info.feature_name)), + exist: None, + state: WindowsFeatureState::from_dism(info.state), + display_name: Some(from_wide_ptr(info.display_name)), + description: Some(from_wide_ptr(info.description)), + restart_required: WindowsFeatureRestartType::from_dism(info.restart_required), + enable_all: None, + source_paths: None, + limit_access: None, + ..Default::default() + }; + (self.api.delete)(info_ptr as *const c_void); + feature_info + }; + + Ok(result) + } + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). pub fn enable_feature(&self, feature_name: &str) -> Result { + self.enable_feature_with_options(feature_name, &[], false, false) + } + + /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). + pub fn enable_feature_with_options( + &self, + feature_name: &str, + source_paths: &[String], + limit_access: bool, + enable_all: bool, + ) -> Result { let wide_name = to_wide_null(feature_name); + + let wide_paths: Vec> = + source_paths.iter().map(|path| to_wide_null(path)).collect(); + let wide_ptrs: Vec<*const u16> = wide_paths.iter().map(|path| path.as_ptr()).collect(); + let (paths_ptr, paths_count) = if wide_ptrs.is_empty() { + (std::ptr::null(), 0u32) + } else { + (wide_ptrs.as_ptr(), wide_ptrs.len() as u32) + }; + 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 + std::ptr::null(), // Identifier + DISM_PACKAGE_NONE, // PackageIdentifier + i32::from(limit_access), + paths_ptr, + paths_count, + i32::from(enable_all), + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData ) }; if hr < 0 { - return Err(t!("dism.enableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.enableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). - pub fn disable_feature(&self, feature_name: &str, remove_payload: bool) -> Result { + 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)( self.handle, wide_name.as_ptr(), - std::ptr::null(), // PackageName + std::ptr::null(), // PackageName i32::from(remove_payload), // RemovePayload - std::ptr::null_mut(), // CancelEvent - std::ptr::null_mut(), // Progress - std::ptr::null_mut(), // UserData + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData ) }; if hr < 0 { - return Err(t!("dism.disableFeatureFailed", name = feature_name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.disableFeatureFailed", + name = feature_name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } @@ -377,7 +476,11 @@ impl DismSessionHandle { }; if hr < 0 { - return Err(t!("dism.getFeaturesFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.getFeaturesFailed", + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } let mut result = Vec::new(); @@ -394,19 +497,15 @@ impl DismSessionHandle { } pub fn get_capability_info(&self, name: &str) -> Result { - let get_cap_info = self.api.get_capability_info + let get_cap_info = self + .api + .get_capability_info .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; let wide_name = to_wide_null(name); let mut info_ptr: *mut DismCapabilityDetail = std::ptr::null_mut(); - let hr = unsafe { - get_cap_info( - self.handle, - wide_name.as_ptr(), - &mut info_ptr, - ) - }; + let hr = unsafe { get_cap_info(self.handle, wide_name.as_ptr(), &mut info_ptr) }; if hr == DISMAPI_E_UNKNOWN_FEATURE { return Ok(DismCapabilityResult { @@ -421,7 +520,12 @@ impl DismSessionHandle { } if hr < 0 { - return Err(t!("dism.getCapabilityInfoFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.getCapabilityInfoFailed", + name = name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } let result = unsafe { @@ -458,7 +562,9 @@ impl DismSessionHandle { /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). pub fn add_capability(&self, name: &str) -> Result { - let add_cap = self.api.add_capability + let add_cap = self + .api + .add_capability .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; let wide_name = to_wide_null(name); @@ -466,23 +572,30 @@ impl DismSessionHandle { add_cap( self.handle, wide_name.as_ptr(), - 0, // LimitAccess = FALSE - std::ptr::null(), // SourcePaths - 0, // SourcePathCount - std::ptr::null_mut(), // CancelEvent - std::ptr::null_mut(), // Progress - std::ptr::null_mut(), // UserData + 0, // LimitAccess = FALSE + std::ptr::null(), // SourcePaths + 0, // SourcePathCount + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData ) }; if hr < 0 { - return Err(t!("dism.addCapabilityFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.addCapabilityFailed", + name = name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } /// Returns `Ok(true)` if DISM reports a reboot is required (HRESULT 3010). pub fn remove_capability(&self, name: &str) -> Result { - let remove_cap = self.api.remove_capability + let remove_cap = self + .api + .remove_capability .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; let wide_name = to_wide_null(name); @@ -490,37 +603,42 @@ impl DismSessionHandle { remove_cap( self.handle, wide_name.as_ptr(), - std::ptr::null_mut(), // CancelEvent - std::ptr::null_mut(), // Progress - std::ptr::null_mut(), // UserData + std::ptr::null_mut(), // CancelEvent + std::ptr::null_mut(), // Progress + std::ptr::null_mut(), // UserData ) }; if hr == DISMAPI_E_CAPABILITY_NOT_APPLICABLE { return Ok(false); // Already not present — nothing to do } if hr < 0 { - return Err(t!("dism.removeCapabilityFailed", name = name, hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.removeCapabilityFailed", + name = name, + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) } pub fn get_all_capability_basics(&self) -> Result, String> { - let get_caps = self.api.get_capabilities + let get_caps = self + .api + .get_capabilities .ok_or_else(|| t!("dism.capabilitiesNotSupported").to_string())?; let mut caps_ptr: *mut DismCapability = std::ptr::null_mut(); let mut count: u32 = 0; - let hr = unsafe { - get_caps( - self.handle, - &mut caps_ptr, - &mut count, - ) - }; + let hr = unsafe { get_caps(self.handle, &mut caps_ptr, &mut count) }; if hr < 0 { - return Err(t!("dism.getCapabilitiesFailed", hr = format!("0x{:08X}", hr as u32)).to_string()); + return Err(t!( + "dism.getCapabilitiesFailed", + hr = format!("0x{:08X}", hr as u32) + ) + .to_string()); } let mut result = Vec::new(); diff --git a/resources/dism_dsc/src/main.rs b/resources/dism_dsc/src/main.rs index f54ee290f..4690df64f 100644 --- a/resources/dism_dsc/src/main.rs +++ b/resources/dism_dsc/src/main.rs @@ -1,17 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#[cfg(windows)] -mod util; #[cfg(windows)] pub(crate) mod dism; #[cfg(windows)] +mod feature_on_demand; +#[cfg(windows)] mod optional_feature; #[cfg(windows)] -mod feature_on_demand; +mod util; +#[cfg(windows)] +mod windows_feature; use rust_i18n::t; -use std::io::{self, Read, IsTerminal}; +use std::io::{self, IsTerminal, Read}; rust_i18n::i18n!("locales", fallback = "en-us"); @@ -72,8 +74,17 @@ fn main() { ("get", "feature-on-demand") => dispatch(feature_on_demand::handle_get), ("set", "feature-on-demand") => dispatch(feature_on_demand::handle_set), ("export", "feature-on-demand") => dispatch(feature_on_demand::handle_export), + ("get", "windows-feature") => dispatch(windows_feature::handle_get), + ("set", "windows-feature") => { + let what_if = args.iter().any(|arg| arg == "-w" || arg == "--what-if"); + dispatch(|input| windows_feature::handle_set(input, what_if)); + } + ("export", "windows-feature") => dispatch(windows_feature::handle_export), ("get" | "set" | "export", _) => { - eprintln!("{}", t!("main.unknownResourceType", resource_type = resource_type)); + eprintln!( + "{}", + t!("main.unknownResourceType", resource_type = resource_type) + ); eprintln!("{}", t!("main.usage")); std::process::exit(1); } diff --git a/resources/windows_feature/src/export.rs b/resources/dism_dsc/src/windows_feature/export.rs similarity index 56% rename from resources/windows_feature/src/export.rs rename to resources/dism_dsc/src/windows_feature/export.rs index 4a56707a4..f7ee2205f 100644 --- a/resources/windows_feature/src/export.rs +++ b/resources/dism_dsc/src/windows_feature/export.rs @@ -1,34 +1,41 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use rust_i18n::t; + use crate::dism::DismSessionHandle; -use crate::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; -use crate::util::{matches_wildcard, WildcardFilterable}; +use crate::util::{WildcardFilterable, matches_wildcard}; +use crate::windows_feature::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; -pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result { - let filters: Vec = match filter { - None => vec![WindowsFeatureInfo::default()], - Some(list) if list.features.is_empty() => vec![WindowsFeatureInfo::default()], - Some(list) => list.features.clone(), +pub fn handle_export(input: &str) -> Result { + let filters: Vec = if input.trim().is_empty() { + vec![WindowsFeatureInfo::default()] + } else { + let list: WindowsFeatureList = serde_json::from_str(input) + .map_err(|e| t!("export.failedParseInput", err = e.to_string()).to_string())?; + if list.features.is_empty() { + vec![WindowsFeatureInfo::default()] + } else { + list.features + } }; let session = DismSessionHandle::open()?; let all_basics = session.get_all_feature_basics()?; - // Check if any filter requires full info (displayName or description filtering) let needs_full_info = filters .iter() - .any(|f| f.display_name.is_some() || f.description.is_some()); + .any(|filter| filter.display_name.is_some() || filter.description.is_some()); 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<&WindowsFeatureInfo>, Vec<&WindowsFeatureInfo>, ) = if needs_full_info { - filters.iter().partition(|f| f.feature_name.is_some()) + filters + .iter() + .partition(|filter| filter.feature_name.is_some()) } else { (Vec::new(), Vec::new()) }; @@ -37,13 +44,10 @@ pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result) -> Result info, Err(_) => WindowsFeatureInfo { feature_name: Some(name.clone()), @@ -75,8 +79,6 @@ pub fn handle_export(filter: Option<&WindowsFeatureList>) -> Result) -> Result Result { + let feature_list: WindowsFeatureList = 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_windows_feature_info(feature_name)?; + results.push(info); + } + + let output = WindowsFeatureList { + 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/windows_feature/mod.rs b/resources/dism_dsc/src/windows_feature/mod.rs new file mode 100644 index 000000000..cd88d459c --- /dev/null +++ b/resources/dism_dsc/src/windows_feature/mod.rs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod export; +mod get; +mod set; +pub(crate) mod types; + +pub use export::handle_export; +pub use get::handle_get; +pub use set::handle_set; diff --git a/resources/windows_feature/src/set.rs b/resources/dism_dsc/src/windows_feature/set.rs similarity index 58% rename from resources/windows_feature/src/set.rs rename to resources/dism_dsc/src/windows_feature/set.rs index 59f19f5c5..f8c1f05aa 100644 --- a/resources/windows_feature/src/set.rs +++ b/resources/dism_dsc/src/windows_feature/set.rs @@ -5,11 +5,16 @@ use rust_i18n::t; use serde_json::{Map, Value}; use crate::dism::DismSessionHandle; -use crate::types::{FeatureState, Metadata, WindowsFeatureInfo, WindowsFeatureList}; use crate::util::get_computer_name; +use crate::windows_feature::types::{ + FeatureState, Metadata, WindowsFeatureInfo, WindowsFeatureList, +}; -pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result { - if input.features.is_empty() { +pub fn handle_set(input: &str, what_if: bool) -> Result { + let feature_list: WindowsFeatureList = 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()); } @@ -17,7 +22,7 @@ pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result = Vec::new(); let mut reboot_required = false; - for feature_input in &input.features { + for feature_input in &feature_list.features { let feature_name = feature_input .feature_name .as_ref() @@ -32,22 +37,36 @@ pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result { - let source_paths = feature_input - .source_paths - .as_deref() - .unwrap_or(&[]); + let source_paths = feature_input.source_paths.as_deref().unwrap_or(&[]); let limit_access = feature_input.limit_access.unwrap_or(false); let enable_all = feature_input.enable_all.unwrap_or(false); if what_if { - what_if_metadata.push(t!("windows_feature_helper.whatIfEnable", name = feature_name.as_str()).to_string()); + what_if_metadata.push( + t!( + "windows_feature_helper.whatIfEnable", + name = feature_name.as_str() + ) + .to_string(), + ); false } else { - session.enable_feature(feature_name, source_paths, limit_access, enable_all)? + session.enable_feature_with_options( + feature_name, + source_paths, + limit_access, + enable_all, + )? } } FeatureState::NotPresent => { if what_if { - what_if_metadata.push(t!("windows_feature_helper.whatIfDisable", name = feature_name.as_str()).to_string()); + what_if_metadata.push( + t!( + "windows_feature_helper.whatIfDisable", + name = feature_name.as_str() + ) + .to_string(), + ); false } else { session.disable_feature(feature_name, false)? @@ -55,7 +74,13 @@ pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result { if what_if { - what_if_metadata.push(t!("windows_feature_helper.whatIfRemove", name = feature_name.as_str()).to_string()); + what_if_metadata.push( + t!( + "windows_feature_helper.whatIfRemove", + name = feature_name.as_str() + ) + .to_string(), + ); false } else { session.disable_feature(feature_name, true)? @@ -80,13 +105,15 @@ pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> Result Result, #[serde(skip_serializing_if = "Option::is_none")] pub restart_required: Option, - /// Enable all features that the specified feature depends on, including child features. - /// Passed as the `EnableAll` parameter to `DismEnableFeature`. #[serde(skip_serializing_if = "Option::is_none")] pub enable_all: Option, - /// Local source paths (e.g., a mounted Windows ISO or WIM) passed to `DismEnableFeature` - /// as `SourcePaths`. Required on systems that cannot reach Windows Update. #[serde(skip_serializing_if = "Option::is_none")] pub source_paths: Option>, - /// When `true`, prevents DISM from contacting Windows Update even when `sourcePaths` is empty. - /// Passed as the `LimitAccess` parameter to `DismEnableFeature`. #[serde(skip_serializing_if = "Option::is_none")] pub limit_access: Option, #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] diff --git a/resources/windows_feature/tests/windows_feature_export.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 similarity index 100% rename from resources/windows_feature/tests/windows_feature_export.tests.ps1 rename to resources/dism_dsc/tests/windowsFeature_export.tests.ps1 diff --git a/resources/windows_feature/tests/windows_feature_get.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_get.tests.ps1 similarity index 100% rename from resources/windows_feature/tests/windows_feature_get.tests.ps1 rename to resources/dism_dsc/tests/windowsFeature_get.tests.ps1 diff --git a/resources/windows_feature/tests/windows_feature_set.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_set.tests.ps1 similarity index 100% rename from resources/windows_feature/tests/windows_feature_set.tests.ps1 rename to resources/dism_dsc/tests/windowsFeature_set.tests.ps1 diff --git a/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_whatif.tests.ps1 similarity index 68% rename from resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 rename to resources/dism_dsc/tests/windowsFeature_whatif.tests.ps1 index edef96f56..42f43d72a 100644 --- a/resources/windows_feature/tests/windows_featurelist_whatif.tests.ps1 +++ b/resources/dism_dsc/tests/windowsFeature_whatif.tests.ps1 @@ -1,12 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { +Describe 'WindowsFeatureList what-if tests' -Skip:(!$IsWindows) { BeforeAll { $testFeature = 'TelnetClient' } - It 'Can whatif enable a feature without mutating state' { + It 'Can what-if enable a feature without mutating state' { $json = @" { "features": [ @@ -15,10 +15,10 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { } "@ # Capture pre-state - $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json # Run what-if - $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $result = $json | dism_dsc set windows-feature -w 2>$null | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $TestDrive/error.log -ErrorAction SilentlyContinue) # Projected state echoes back the requested feature name and state @@ -29,11 +29,11 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" # No mutation occurred - $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $after = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) } - It 'Can whatif disable a feature without mutating state' { + It 'Can what-if disable a feature without mutating state' { $json = @" { "features": [ @@ -42,10 +42,10 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { } "@ # Capture pre-state - $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json # Run what-if - $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $result = $json | dism_dsc set windows-feature -w 2>$null | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $result.features[0].featureName | Should -Be $testFeature @@ -53,11 +53,11 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { $result.features[0]._metadata.whatIf[0] | Should -Match "Would disable feature '$testFeature'" # No mutation occurred - $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $after = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) } - It 'Can whatif remove a feature without mutating state' { + It 'Can what-if remove a feature without mutating state' { $json = @" { "features": [ @@ -66,10 +66,10 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { } "@ # Capture pre-state - $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json # Run what-if - $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $result = $json | dism_dsc set windows-feature -w 2>$null | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $result.features[0].featureName | Should -Be $testFeature @@ -77,11 +77,11 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { $result.features[0]._metadata.whatIf[0] | Should -Match "Would remove feature '$testFeature'" # No mutation occurred - $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $after = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) } - It 'Can whatif enable a feature with enableAll and limitAccess without mutating state' { + It 'Can what-if enable a feature with enableAll and limitAccess without mutating state' { $json = @" { "features": [ @@ -89,9 +89,9 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { ] } "@ - $before = windows_feature get --input $json 2>$null | ConvertFrom-Json + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json - $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $result = $json | dism_dsc set windows-feature -w 2>$null | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $result.features[0].featureName | Should -Be $testFeature @@ -100,11 +100,11 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { $result.features[0].limitAccess | Should -BeTrue $result.features[0]._metadata.whatIf[0] | Should -Match "Would enable feature '$testFeature'" - $after = windows_feature get --input $json 2>$null | ConvertFrom-Json + $after = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) } - It 'Can whatif multiple features in one call without mutating state' { + It 'Can what-if multiple features in one call without mutating state' { $json = @" { "features": [ @@ -113,7 +113,7 @@ Describe 'windows_feature list whatif tests' -Skip:(!$IsWindows) { ] } "@ - $result = windows_feature set -w --input $json 2>$null | ConvertFrom-Json + $result = $json | dism_dsc set windows-feature -w 2>$null | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $result.features | Should -HaveCount 2 diff --git a/resources/windows_feature/windows_feature.dsc.resource.json b/resources/dism_dsc/windows_feature.dsc.resource.json similarity index 92% rename from resources/windows_feature/windows_feature.dsc.resource.json rename to resources/dism_dsc/windows_feature.dsc.resource.json index 39020017d..6be6db92d 100644 --- a/resources/windows_feature/windows_feature.dsc.resource.json +++ b/resources/dism_dsc/windows_feature.dsc.resource.json @@ -9,47 +9,39 @@ "type": "Microsoft.Windows/WindowsFeatureList", "version": "0.1.0", "get": { - "executable": "windows_feature", + "executable": "dism_dsc", "args": [ "get", - { - "jsonInputArg": "--input", - "mandatory": true - } - ] + "windows-feature" + ], + "input": "stdin" }, "set": { - "executable": "windows_feature", + "executable": "dism_dsc", "args": [ "set", - { - "jsonInputArg": "--input", - "mandatory": true - }, + "windows-feature", { "whatIfArg": "--what-if" } ], + "input": "stdin", "implementsPretest": false, "return": "state", "whatIfReturns": "state", "requireSecurityContext": "elevated" }, "export": { - "executable": "windows_feature", + "executable": "dism_dsc", "args": [ "export", - { - "jsonInputArg": "--input", - "mandatory": false - } - ] + "windows-feature" + ], + "input": "stdin" }, "exitCodes": { "0": "Success", - "1": "Invalid arguments", - "2": "Invalid input", - "3": "Feature error" + "1": "Invalid arguments" }, "schema": { "embedded": { diff --git a/resources/registry/registry.dsc.manifests.json b/resources/registry/registry.dsc.manifests.json index 260b860d8..2cb9fbbc8 100644 --- a/resources/registry/registry.dsc.manifests.json +++ b/resources/registry/registry.dsc.manifests.json @@ -112,8 +112,7 @@ "command": { "executable": "registry", "args": [ - "schema", - "--list" + "schema" ] } } diff --git a/resources/windows_feature/.project.data.json b/resources/windows_feature/.project.data.json deleted file mode 100644 index 705925f3c..000000000 --- a/resources/windows_feature/.project.data.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Name": "windows_feature", - "Kind": "Resource", - "IsRust": true, - "SupportedPlatformOS": "Windows", - "Binaries": ["windows_feature"], - "CopyFiles": { - "Windows": ["windows_feature.dsc.resource.json"] - } -} diff --git a/resources/windows_feature/Cargo.toml b/resources/windows_feature/Cargo.toml deleted file mode 100644 index 0112f85f9..000000000 --- a/resources/windows_feature/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "windows_feature" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "windows_feature" -path = "src/main.rs" - -[package.metadata.i18n] -available-locales = ["en-us"] -default-locale = "en-us" -load-path = "locales" - -[dependencies] -rust-i18n = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } diff --git a/resources/windows_feature/locales/en-us.toml b/resources/windows_feature/locales/en-us.toml deleted file mode 100644 index 7cff05168..000000000 --- a/resources/windows_feature/locales/en-us.toml +++ /dev/null @@ -1,35 +0,0 @@ -_version = 1 - -[main] -missingOperation = "Missing operation. Usage: windows_feature get --input | set --input | export [--input ]" -unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" -missingInput = "Missing --input argument" -missingInputValue = "Missing value for --input argument" -invalidJson = "Invalid JSON input: %{error}" -windowsOnly = "This resource is only supported on Windows" - -[get] -featuresArrayEmpty = "Features array cannot be empty for get operation" -featureNameRequired = "featureName is required for get operation" - -[set] -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" - -[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}" -notSupportedAppx = "This resource is not supported when installed via Appx" -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}" - -[windows_feature_helper] -whatIfEnable = "Would enable feature '%{name}'" -whatIfDisable = "Would disable feature '%{name}' (remove payload: false)" -whatIfRemove = "Would remove feature '%{name}' (remove payload: true)" diff --git a/resources/windows_feature/src/dism.rs b/resources/windows_feature/src/dism.rs deleted file mode 100644 index adc3e7036..000000000 --- a/resources/windows_feature/src/dism.rs +++ /dev/null @@ -1,407 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use std::ffi::c_void; -use std::os::windows::ffi::OsStrExt; - -use rust_i18n::t; - -use crate::types::{FeatureState, RestartType, WindowsFeatureInfo}; - -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; -const REGDB_E_CLASSNOTREG: i32 = 0x80040154u32 as i32; -const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; - -#[link(name = "kernel32")] -unsafe extern "system" { - fn LoadLibraryExW( - lpLibFileName: *const u16, - hFile: *mut c_void, - dwFlags: u32, - ) -> *mut c_void; -} - -#[repr(C, packed(4))] -struct DismFeature { - feature_name: *const u16, - state: i32, -} - -#[repr(C, packed(4))] -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 DismEnableFeatureFn = unsafe extern "system" fn( - u32, // Session - *const u16, // FeatureName - *const u16, // Identifier (NULL) - i32, // PackageIdentifier (DismPackageNone) - i32, // LimitAccess (BOOL) - *const *const u16, // SourcePaths - 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; - -// Kernel32 functions for dynamic loading -unsafe extern "system" { - 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(); - } - unsafe { - 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) - } -} - -unsafe fn load_fn(lib: *mut c_void, name: &[u8]) -> Result { - unsafe { - let ptr = GetProcAddress(lib, name.as_ptr()); - if ptr.is_null() { - 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)) - } -} - -struct DismApi { - lib: *mut c_void, - close_session: DismCloseSessionFn, - shutdown: DismShutdownFn, - get_features: DismGetFeaturesFn, - get_feature_info: DismGetFeatureInfoFn, - enable_feature: DismEnableFeatureFn, - disable_feature: DismDisableFeatureFn, - delete: DismDeleteFn, -} - -impl DismApi { - fn load() -> Result { - // Load dismapi.dll from the trusted System32 directory to avoid DLL search order hijacking. - // 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()); - } - - 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")?, - enable_feature: load_fn(lib, b"DismEnableFeature\0")?, - disable_feature: load_fn(lib, b"DismDisableFeature\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 { - /// Opens a new DISM session for the online image. - 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 == REGDB_E_CLASSNOTREG { - return Err(t!("dism.notSupportedAppx").to_string()); - } - if hr < 0 { - return Err( - t!("dism.initializeFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), - ); - } - - 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 == REGDB_E_CLASSNOTREG { - (api.shutdown)(); - return Err(t!("dism.notSupportedAppx").to_string()); - } - if hr < 0 { - (api.shutdown)(); - return Err( - t!("dism.openSessionFailed", hr = format!("0x{:08X}", hr as u32)).to_string(), - ); - } - - 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 == DISMAPI_E_UNKNOWN_FEATURE { - return Ok(WindowsFeatureInfo { - feature_name: Some(feature_name.to_string()), - exist: Some(false), - ..WindowsFeatureInfo::default() - }); - } - - if hr < 0 { - return Err(t!( - "dism.getFeatureInfoFailed", - name = feature_name, - hr = format!("0x{:08X}", hr as u32) - ) - .to_string()); - } - - let result = unsafe { - // Use addr_of! + read_unaligned because the struct is packed(4): - // pointer fields are only 4-byte aligned, so we cannot create - // Rust references to them (that would be UB on x64). - let feature_name = std::ptr::addr_of!((*info_ptr).feature_name).read_unaligned(); - let state = std::ptr::addr_of!((*info_ptr).state).read_unaligned(); - let display_name = std::ptr::addr_of!((*info_ptr).display_name).read_unaligned(); - let description = std::ptr::addr_of!((*info_ptr).description).read_unaligned(); - let restart = std::ptr::addr_of!((*info_ptr).restart_required).read_unaligned(); - let feature_info = WindowsFeatureInfo { - feature_name: Some(from_wide_ptr(feature_name)), - exist: None, - state: FeatureState::from_dism(state), - display_name: Some(from_wide_ptr(display_name)), - description: Some(from_wide_ptr(description)), - restart_required: RestartType::from_dism(restart), - enable_all: None, - source_paths: None, - limit_access: None, - ..Default::default() - }; - (self.api.delete)(info_ptr as *const c_void); - feature_info - }; - - Ok(result) - } - - /// Enable a Windows feature. - /// - /// * `source_paths` — Optional list of local media paths passed as `SourcePaths` to - /// `DismEnableFeature`. Required on air-gapped systems without access to Windows Update. - /// * `limit_access` — When `true`, prevents DISM from contacting Windows Update - /// (`LimitAccess = TRUE`). - /// * `enable_all` — When `true`, enables all features that the specified feature depends on, - /// including child features (`EnableAll = TRUE`). - /// - /// Returns `Ok(true)` if a reboot is required to complete the operation. - pub fn enable_feature( - &self, - feature_name: &str, - source_paths: &[String], - limit_access: bool, - enable_all: bool, - ) -> Result { - let wide_name = to_wide_null(feature_name); - - // Build wide-string arrays for source paths. The vectors must remain alive for the - // duration of the unsafe call, so they are kept in scope here. - let wide_paths: Vec> = source_paths.iter().map(|p| to_wide_null(p)).collect(); - let wide_ptrs: Vec<*const u16> = wide_paths.iter().map(|p| p.as_ptr()).collect(); - let (paths_ptr, paths_count) = if wide_ptrs.is_empty() { - (std::ptr::null(), 0u32) - } else { - (wide_ptrs.as_ptr(), wide_ptrs.len() as u32) - }; - - let hr = unsafe { - (self.api.enable_feature)( - self.handle, - wide_name.as_ptr(), - std::ptr::null(), // Identifier - DISM_PACKAGE_NONE, // PackageIdentifier - i32::from(limit_access), // LimitAccess - paths_ptr, // SourcePaths - paths_count, // SourcePathCount - i32::from(enable_all), // EnableAll - std::ptr::null_mut(), // CancelEvent - std::ptr::null_mut(), // Progress - std::ptr::null_mut(), // UserData - ) - }; - - if hr < 0 { - return Err(t!( - "dism.enableFeatureFailed", - name = feature_name, - hr = format!("0x{:08X}", hr as u32) - ) - .to_string()); - } - Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) - } - - /// Disable (uninstall) a Windows feature. - /// - /// * `remove_payload` — When `true`, passes `RemovePayload = TRUE` to `DismDisableFeature`, - /// which removes the feature's payload from disk (equivalent to DISM state `Removed`). - /// - /// Returns `Ok(true)` if a reboot is required to complete the operation. - 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)( - 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(t!( - "dism.disableFeatureFailed", - name = feature_name, - hr = format!("0x{:08X}", hr as u32) - ) - .to_string()); - } - Ok(hr == ERROR_SUCCESS_REBOOT_REQUIRED) - } - - 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( - 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 fp = features_ptr.add(i); - let name_ptr = std::ptr::addr_of!((*fp).feature_name).read_unaligned(); - let state = std::ptr::addr_of!((*fp).state).read_unaligned(); - let name = from_wide_ptr(name_ptr); - result.push((name, 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/windows_feature/src/get.rs b/resources/windows_feature/src/get.rs deleted file mode 100644 index 7ea58a77c..000000000 --- a/resources/windows_feature/src/get.rs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use rust_i18n::t; - -use crate::dism::DismSessionHandle; -use crate::types::{WindowsFeatureInfo, WindowsFeatureList}; - -pub fn handle_get(input: &WindowsFeatureList) -> Result { - if input.features.is_empty() { - return Err(t!("get.featuresArrayEmpty").to_string()); - } - - let session = DismSessionHandle::open()?; - let mut results: Vec = Vec::new(); - - for feature_input in &input.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); - } - - Ok(WindowsFeatureList { - restart_required_meta: None, - features: results, - }) -} diff --git a/resources/windows_feature/src/main.rs b/resources/windows_feature/src/main.rs deleted file mode 100644 index 62092f738..000000000 --- a/resources/windows_feature/src/main.rs +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -mod dism; -mod export; -mod get; -mod set; -mod types; -mod util; - -use rust_i18n::t; -use std::process::exit; - -use types::WindowsFeatureList; - -rust_i18n::i18n!("locales", fallback = "en-us"); - -const EXIT_SUCCESS: i32 = 0; -const EXIT_INVALID_ARGS: i32 = 1; -const EXIT_INVALID_INPUT: i32 = 2; -const EXIT_FEATURE_ERROR: i32 = 3; - -/// Write a JSON error object to stderr: `{"error":""}` -fn write_error(message: &str) { - eprintln!("{}", serde_json::json!({"error": message})); -} - -/// Deserialize the required JSON input into a `WindowsFeatureList`, or exit with an error. -fn require_input(input_json: Option) -> WindowsFeatureList { - let json = match input_json { - Some(j) => j, - None => { - write_error(&t!("main.missingInput")); - exit(EXIT_INVALID_ARGS); - } - }; - match serde_json::from_str(&json) { - Ok(v) => v, - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_INVALID_INPUT); - } - } -} - -/// Serialize a value to JSON and print it to stdout, or exit with an error. -fn print_json(value: &impl serde::Serialize) { - match serde_json::to_string(value) { - Ok(json) => println!("{json}"), - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_FEATURE_ERROR); - } - } -} - -#[cfg(not(windows))] -fn main() { - write_error(&t!("main.windowsOnly")); - exit(EXIT_FEATURE_ERROR); -} - -#[cfg(windows)] -fn main() { - let args: Vec = std::env::args().collect(); - - if args.len() < 2 { - write_error(&t!("main.missingOperation")); - exit(EXIT_INVALID_ARGS); - } - - let operation = args[1].as_str(); - let input_json = parse_input_arg(&args); - - match operation { - "get" => { - let input = require_input(input_json); - match get::handle_get(&input) { - Ok(result) => { - print_json(&result); - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e); - exit(EXIT_FEATURE_ERROR); - } - } - } - "set" => { - let input = require_input(input_json); - let what_if = parse_what_if_arg(&args); - match set::handle_set(&input, what_if) { - Ok(result) => { - print_json(&result); - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e); - exit(EXIT_FEATURE_ERROR); - } - } - } - "export" => { - let filter: Option = match input_json { - Some(json) => match serde_json::from_str(&json) { - Ok(v) => Some(v), - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_INVALID_INPUT); - } - }, - None => None, - }; - - match export::handle_export(filter.as_ref()) { - Ok(result) => { - print_json(&result); - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e); - exit(EXIT_FEATURE_ERROR); - } - } - } - _ => { - write_error(&t!("main.unknownOperation", operation = operation)); - exit(EXIT_INVALID_ARGS); - } - } -} - -/// Parse the `--input ` argument from the command-line args. -fn parse_input_arg(args: &[String]) -> Option { - let mut i = 2; // skip binary name and operation - while i < args.len() { - if args[i] == "--input" || args[i] == "-i" { - if i + 1 < args.len() { - return Some(args[i + 1].clone()); - } - write_error(&t!("main.missingInputValue")); - exit(EXIT_INVALID_ARGS); - } - i += 1; - } - None -} - -/// Returns `true` if `-w` or `--what-if` is present in the command-line args. -fn parse_what_if_arg(args: &[String]) -> bool { - args.iter().any(|a| a == "-w" || a == "--what-if") -} diff --git a/resources/windows_feature/src/util.rs b/resources/windows_feature/src/util.rs deleted file mode 100644 index 50f13f119..000000000 --- a/resources/windows_feature/src/util.rs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use serde::{Deserialize, Serialize}; -use std::fmt; - -/// DISM package/feature state values. -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub enum DismState { - NotPresent, - UninstallPending, - Staged, - Removed, - Installed, - InstallPending, - Superseded, - PartiallyInstalled, -} - -impl fmt::Display for DismState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - DismState::NotPresent => write!(f, "NotPresent"), - DismState::UninstallPending => write!(f, "UninstallPending"), - DismState::Staged => write!(f, "Staged"), - DismState::Removed => write!(f, "Removed"), - DismState::Installed => write!(f, "Installed"), - DismState::InstallPending => write!(f, "InstallPending"), - DismState::Superseded => write!(f, "Superseded"), - DismState::PartiallyInstalled => write!(f, "PartiallyInstalled"), - } - } -} - -impl DismState { - pub fn from_dism(state: i32) -> Option { - match state { - 0 => Some(DismState::NotPresent), - 1 => Some(DismState::UninstallPending), - 2 => Some(DismState::Staged), - 3 => Some(DismState::Removed), - 4 => Some(DismState::Installed), - 5 => Some(DismState::InstallPending), - 6 => Some(DismState::Superseded), - 7 => Some(DismState::PartiallyInstalled), - _ => None, - } - } -} - -/// Match a string against a pattern that supports `*` wildcards (case-insensitive). -pub fn matches_wildcard(text: &str, pattern: &str) -> bool { - let text_lower = text.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - - if !pattern_lower.contains('*') { - return text_lower == pattern_lower; - } - - let parts: Vec<&str> = pattern_lower.split('*').collect(); - - if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { - return false; - } - - let mut pos = parts[0].len(); - - let suffix = *parts.last().unwrap_or(&""); - let end = if suffix.is_empty() { - text_lower.len() - } else { - if !text_lower.ends_with(suffix) { - return false; - } - text_lower.len() - suffix.len() - }; - - for part in &parts[1..parts.len().saturating_sub(1)] { - if part.is_empty() { - continue; - } - match text_lower.get(pos..end).and_then(|s| s.find(part)) { - Some(idx) => pos += idx + part.len(), - None => return false, - } - } - - pos <= end -} - -/// Check that an optional string field matches a wildcard filter pattern. -/// Returns true if the filter has no value (no constraint). -pub fn matches_optional_wildcard(info_value: &Option, filter_value: &Option) -> bool { - match filter_value { - Some(pattern) => match info_value { - Some(value) => matches_wildcard(value, pattern), - None => false, - }, - None => true, - } -} - -/// Check that an optional field matches an exact filter value. -/// Returns true if the filter has no value (no constraint). -pub fn matches_optional_exact(info_value: &Option, filter_value: &Option) -> bool { - match filter_value { - Some(expected) => match info_value { - Some(actual) => actual == expected, - None => false, - }, - None => true, - } -} - -/// Trait for types that support wildcard-based filter matching in export operations. -pub trait WildcardFilterable { - /// Returns true if this instance matches the given filter (AND logic within a single filter). - fn matches_filter(&self, filter: &Self) -> bool; - - /// Returns true if this instance matches any of the given filters (OR logic between filters). - fn matches_any_filter(&self, filters: &[Self]) -> bool - where - Self: Sized, - { - filters.iter().any(|filter| self.matches_filter(filter)) - } -} - -/// Returns the computer name from the COMPUTERNAME environment variable, or "localhost" as fallback. -pub fn get_computer_name() -> String { - std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) -} From 5f1011892f070ca448a740ce921ecbbb29dc79c1 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:21:59 +0200 Subject: [PATCH 12/12] Revert registry file --- resources/registry/registry.dsc.manifests.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/registry/registry.dsc.manifests.json b/resources/registry/registry.dsc.manifests.json index 2cb9fbbc8..987084d18 100644 --- a/resources/registry/registry.dsc.manifests.json +++ b/resources/registry/registry.dsc.manifests.json @@ -112,10 +112,11 @@ "command": { "executable": "registry", "args": [ - "schema" + "schema", + "--list" ] } } } ] -} +} \ No newline at end of file