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..1dec69170 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,6 @@ default-members = [ "lib/dsc-lib-registry", "resources/runcommandonset", "lib/dsc-lib-security_context", - "resources/dism_dsc", "resources/sshdconfig", "resources/WindowsUpdate", "resources/windows_service", @@ -58,7 +57,8 @@ default-members = [ "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" + "xtask", + "resources/dism_dsc" ] [workspace.metadata.groups] diff --git a/data.build.json b/data.build.json index a8d16434d..9a6d58d03 100644 --- a/data.build.json +++ b/data.build.json @@ -109,6 +109,7 @@ "windows_firewall.exe", "windows_service.exe", "windows_service.dsc.resource.json", + "windows_feature.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -180,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"] } }, { 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/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/dism_dsc/src/windows_feature/export.rs b/resources/dism_dsc/src/windows_feature/export.rs new file mode 100644 index 000000000..f7ee2205f --- /dev/null +++ b/resources/dism_dsc/src/windows_feature/export.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::dism::DismSessionHandle; +use crate::util::{WildcardFilterable, matches_wildcard}; +use crate::windows_feature::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; + +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()?; + + let needs_full_info = filters + .iter() + .any(|filter| filter.display_name.is_some() || filter.description.is_some()); + + let mut results = Vec::new(); + + let (filters_with_name, filters_without_name): ( + Vec<&WindowsFeatureInfo>, + Vec<&WindowsFeatureInfo>, + ) = if needs_full_info { + filters + .iter() + .partition(|filter| filter.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 { + let mut should_get_full = !filters_without_name.is_empty(); + if !should_get_full { + for filter in &filters_with_name { + if let Some(ref filter_name) = filter.feature_name + && matches_wildcard(name, filter_name) + { + should_get_full = true; + break; + } + } + } + if !should_get_full { + continue; + } + + let info = match session.get_windows_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, + ..Default::default() + }, + }; + + if info.matches_any_filter(&filters) { + results.push(info); + } + } else { + let basic = WindowsFeatureInfo { + feature_name: Some(name.clone()), + state: state.clone(), + ..WindowsFeatureInfo::default() + }; + + if basic.matches_any_filter(&filters) { + results.push(basic); + } + } + } + + let output = WindowsFeatureList { + restart_required_meta: None, + features: results, + }; + serde_json::to_string(&output) + .map_err(|e| t!("export.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/windows_feature/get.rs b/resources/dism_dsc/src/windows_feature/get.rs new file mode 100644 index 000000000..033b0b8ea --- /dev/null +++ b/resources/dism_dsc/src/windows_feature/get.rs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; + +use crate::dism::DismSessionHandle; +use crate::windows_feature::types::WindowsFeatureList; + +pub fn handle_get(input: &str) -> 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/dism_dsc/src/windows_feature/set.rs b/resources/dism_dsc/src/windows_feature/set.rs new file mode 100644 index 000000000..f8c1f05aa --- /dev/null +++ b/resources/dism_dsc/src/windows_feature/set.rs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::{Map, Value}; + +use crate::dism::DismSessionHandle; +use crate::util::get_computer_name; +use crate::windows_feature::types::{ + FeatureState, Metadata, WindowsFeatureInfo, WindowsFeatureList, +}; + +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()); + } + + let session = DismSessionHandle::open()?; + let mut results: Vec = Vec::new(); + let mut reboot_required = false; + + for feature_input in &feature_list.features { + let feature_name = feature_input + .feature_name + .as_ref() + .ok_or_else(|| t!("set.featureNameRequired").to_string())?; + + let desired_state = feature_input + .state + .as_ref() + .ok_or_else(|| t!("set.stateRequired").to_string())?; + + let mut what_if_metadata: Vec = Vec::new(); + + 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); + if what_if { + what_if_metadata.push( + t!( + "windows_feature_helper.whatIfEnable", + name = feature_name.as_str() + ) + .to_string(), + ); + false + } else { + 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(), + ); + 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)? + } + } + _ => { + return Err(t!( + "set.unsupportedDesiredState", + state = desired_state.to_string() + ) + .to_string()); + } + }; + + if what_if { + results.push(WindowsFeatureInfo { + feature_name: feature_input.feature_name.clone(), + state: feature_input.state.clone(), + enable_all: feature_input.enable_all, + source_paths: feature_input.source_paths.clone(), + limit_access: feature_input.limit_access, + metadata: if what_if_metadata.is_empty() { + None + } else { + Some(Metadata { + what_if: Some(what_if_metadata), + }) + }, + ..Default::default() + }); + } else { + reboot_required = reboot_required || needs_reboot; + let info = session.get_windows_feature_info(feature_name)?; + results.push(info); + } + } + + let restart_required_meta = if !what_if && reboot_required { + let mut entry = Map::new(); + entry.insert("system".to_string(), Value::String(get_computer_name())); + Some(vec![entry]) + } else { + None + }; + + let output = WindowsFeatureList { + restart_required_meta, + features: results, + }; + serde_json::to_string(&output) + .map_err(|e| t!("set.failedSerializeOutput", err = e.to_string()).to_string()) +} diff --git a/resources/dism_dsc/src/windows_feature/types.rs b/resources/dism_dsc/src/windows_feature/types.rs new file mode 100644 index 000000000..774b3c409 --- /dev/null +++ b/resources/dism_dsc/src/windows_feature/types.rs @@ -0,0 +1,78 @@ +// 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, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Metadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub what_if: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields, 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(deny_unknown_fields, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_paths: Option>, + #[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)] +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/dism_dsc/tests/windowsFeature_export.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 new file mode 100644 index 000000000..ccee09718 --- /dev/null +++ b/resources/dism_dsc/tests/windowsFeature_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/dism_dsc/tests/windowsFeature_get.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_get.tests.ps1 new file mode 100644 index 000000000..daaa88d5b --- /dev/null +++ b/resources/dism_dsc/tests/windowsFeature_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/dism_dsc/tests/windowsFeature_set.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_set.tests.ps1 new file mode 100644 index 000000000..144f33977 --- /dev/null +++ b/resources/dism_dsc/tests/windowsFeature_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/dism_dsc/tests/windowsFeature_whatif.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_whatif.tests.ps1 new file mode 100644 index 000000000..42f43d72a --- /dev/null +++ b/resources/dism_dsc/tests/windowsFeature_whatif.tests.ps1 @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'WindowsFeatureList what-if tests' -Skip:(!$IsWindows) { + BeforeAll { + $testFeature = 'TelnetClient' + } + + It 'Can what-if enable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" } + ] +} +"@ + # Capture pre-state + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + + # Run what-if + $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 + $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 = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can what-if disable a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + # Capture pre-state + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + + # Run what-if + $result = $json | dism_dsc set windows-feature -w 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 = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can what-if remove a feature without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Removed" } + ] +} +"@ + # Capture pre-state + $before = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + + # Run what-if + $result = $json | dism_dsc set windows-feature -w 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 = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can what-if enable a feature with enableAll and limitAccess without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed", "enableAll": true, "limitAccess": true } + ] +} +"@ + $before = $json | dism_dsc get windows-feature 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 + $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 = $json | dism_dsc get windows-feature 2>$null | ConvertFrom-Json + $before | ConvertTo-Json -Depth 10 | Should -Be ($after | ConvertTo-Json -Depth 10) + } + + It 'Can what-if multiple features in one call without mutating state' { + $json = @" +{ + "features": [ + { "featureName": "$testFeature", "state": "Installed" }, + { "featureName": "$testFeature", "state": "NotPresent" } + ] +} +"@ + $result = $json | dism_dsc set windows-feature -w 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/dism_dsc/windows_feature.dsc.resource.json b/resources/dism_dsc/windows_feature.dsc.resource.json new file mode 100644 index 000000000..6be6db92d --- /dev/null +++ b/resources/dism_dsc/windows_feature.dsc.resource.json @@ -0,0 +1,154 @@ +{ + "$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": "dism_dsc", + "args": [ + "get", + "windows-feature" + ], + "input": "stdin" + }, + "set": { + "executable": "dism_dsc", + "args": [ + "set", + "windows-feature", + { + "whatIfArg": "--what-if" + } + ], + "input": "stdin", + "implementsPretest": false, + "return": "state", + "whatIfReturns": "state", + "requireSecurityContext": "elevated" + }, + "export": { + "executable": "dism_dsc", + "args": [ + "export", + "windows-feature" + ], + "input": "stdin" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments" + }, + "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." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations.", + "properties": { + "whatIf": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } +} diff --git a/resources/registry/registry.dsc.manifests.json b/resources/registry/registry.dsc.manifests.json index 260b860d8..987084d18 100644 --- a/resources/registry/registry.dsc.manifests.json +++ b/resources/registry/registry.dsc.manifests.json @@ -119,4 +119,4 @@ } } ] -} +} \ No newline at end of file