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/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/.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..7cff05168 --- /dev/null +++ b/resources/windows_feature/locales/en-us.toml @@ -0,0 +1,35 @@ +_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 new file mode 100644 index 000000000..adc3e7036 --- /dev/null +++ b/resources/windows_feature/src/dism.rs @@ -0,0 +1,407 @@ +// 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/export.rs b/resources/windows_feature/src/export.rs new file mode 100644 index 000000000..4a56707a4 --- /dev/null +++ b/resources/windows_feature/src/export.rs @@ -0,0 +1,96 @@ +// 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, + ..Default::default() + }, + }; + + 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..62092f738 --- /dev/null +++ b/resources/windows_feature/src/main.rs @@ -0,0 +1,152 @@ +// 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/set.rs b/resources/windows_feature/src/set.rs new file mode 100644 index 000000000..59f19f5c5 --- /dev/null +++ b/resources/windows_feature/src/set.rs @@ -0,0 +1,106 @@ +// 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, Metadata, WindowsFeatureInfo, WindowsFeatureList}; +use crate::util::get_computer_name; + +pub fn handle_set(input: &WindowsFeatureList, what_if: bool) -> 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 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(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_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 + }; + + 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..e80d9390f --- /dev/null +++ b/resources/windows_feature/src/types.rs @@ -0,0 +1,82 @@ +// 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, + /// 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")] + 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/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/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 new file mode 100644 index 000000000..39020017d --- /dev/null +++ b/resources/windows_feature/windows_feature.dsc.resource.json @@ -0,0 +1,162 @@ +{ + "$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 + }, + { + "whatIfArg": "--what-if" + } + ], + "implementsPretest": false, + "return": "state", + "whatIfReturns": "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." + }, + "_metadata": { + "type": "object", + "title": "Metadata", + "description": "Metadata returned by what-if operations.", + "properties": { + "whatIf": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } +}