diff --git a/Cargo.lock b/Cargo.lock index a5462091c..4388cf58d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,6 +855,15 @@ dependencies = [ "tree-sitter-ssh-server-config", ] +[[package]] +name = "dsc-resource-windows-update" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "windows 0.62.2", +] + [[package]] name = "dsctest" version = "0.1.0" @@ -3731,11 +3740,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -3747,6 +3768,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -3794,7 +3824,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -3863,6 +3904,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -3996,6 +4047,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 7099b4d29..cf640b638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "resources/runcommandonset", "lib/dsc-lib-security_context", "resources/sshdconfig", + "resources/WindowsUpdate", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -41,6 +42,7 @@ default-members = [ "resources/runcommandonset", "lib/dsc-lib-security_context", "resources/sshdconfig", + "resources/WindowsUpdate", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -67,6 +69,7 @@ Windows = [ "resources/runcommandonset", "lib/dsc-lib-security_context", "resources/sshdconfig", + "resources/WindowsUpdate", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", @@ -219,6 +222,14 @@ urlencoding = { version = "2.1" } which = { version = "8.0" } # dsc-lib ipnetwork = { version = "0.21" } +# WindowsUpdate +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Variant", + "Win32_System_UpdateAgent" +] } # build-only dependencies # dsc-lib, dsc-lib-registry, sshdconfig, tree-sitter-dscexpression, tree-sitter-ssh-server-config diff --git a/build.data.json b/build.data.json index 0c37b289c..4b93c135b 100644 --- a/build.data.json +++ b/build.data.json @@ -82,6 +82,8 @@ "sshd-windows.dsc.resource.json", "sshd_config.dsc.resource.json", "windowspowershell.dsc.resource.json", + "windowsupdate.dsc.resource.json", + "wu_dsc.exe", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -387,6 +389,21 @@ ] } }, + { + "Name": "windowsupdate", + "Kind": "Resource", + "RelativePath": "resources/WindowsUpdate", + "SupportedPlatformOS": "Windows", + "IsRust": true, + "Binaries": [ + "wu_dsc" + ], + "CopyFiles": { + "Windows": [ + "windowsupdate.dsc.resource.json" + ] + } + }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/WindowsUpdate/.project.data.json b/resources/WindowsUpdate/.project.data.json new file mode 100644 index 000000000..c447c1c56 --- /dev/null +++ b/resources/WindowsUpdate/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "windowsupdate", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "wu_dsc" + ], + "CopyFiles": { + "Windows": [ + "windowsupdate.dsc.resource.json" + ] + } +} diff --git a/resources/WindowsUpdate/Cargo.toml b/resources/WindowsUpdate/Cargo.toml new file mode 100644 index 000000000..2731ec63e --- /dev/null +++ b/resources/WindowsUpdate/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dsc-resource-windows-update" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "wu_dsc" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/resources/WindowsUpdate/README.md b/resources/WindowsUpdate/README.md new file mode 100644 index 000000000..51687a73b --- /dev/null +++ b/resources/WindowsUpdate/README.md @@ -0,0 +1,143 @@ +# Microsoft.Windows/UpdateList DSC Resource + +## Overview + +The `Microsoft.Windows/UpdateList` resource enables querying information about Windows Updates using the Windows Update Agent COM APIs. This resource allows you to retrieve detailed information about specific updates available on or installed on a Windows system. + +## Features + +- Query Windows Update information by title +- Retrieve comprehensive update details including: + - Installation status + - Update description + - Unique update identifier + - KB article IDs + - Recommended hard disk space + - Security severity rating + - Security bulletin IDs + - Update type (Software or Driver) + +## Requirements + +- Windows operating system +- Windows Update Agent (built into Windows) +- Administrator privileges may be required for certain update queries + +## Usage + +### Get Operation + +The `get` operation searches for a Windows Update by title or id (as exact match) and returns detailed information about the update. + +#### Input Schema + +```json +{ + "updates": [{ + "title": "Security Update" + }] +} +``` + +#### Example DSC Configuration + +```yaml +# windows-update-query.dsc.yaml +$schema: https://aka.ms/dsc/schemas/v3/configuration.json +resources: +- name: QuerySecurityUpdate + type: Microsoft.Windows/UpdateList + properties: + updates: + - title: "Security Update for Windows" +``` + +#### Output Example + +```json +{ + "updates": [{ + "title": "2024-01 Security Update for Windows 11 Version 22H2 for x64-based Systems (KB5034123)", + "isInstalled": true, + "description": "Install this update to resolve issues in Windows...", + "id": "12345678-1234-1234-1234-123456789abc", + "isUninstallable": true, + "kbArticleIds": ["5034123"], + "recommendedHardDiskSpace": 512, + "msrcSeverity": "Critical", + "securityBulletinIds": ["MS24-001"], + "updateType": "Software" + }] +} +``` + +## Properties + +### Input/Output Properties + +The resource returns an UpdateList object containing an array of updates: + +| Property | Type | Description | +|-----------------------|-----------------|-------------------------------------------------------| +| updates | array | Array of update objects | +| updates[].title | string | The full title of the Windows Update | +| updates[].isInstalled | boolean | Whether the update is currently installed | +| updates[].description | string | Detailed description of the update | +| updates[].id | string | Unique identifier (GUID) for the update | +| updates[].isUninstallable | boolean | Whether the update can be uninstalled | +| updates[].kbArticleIds | array[string] | Knowledge Base article identifiers | +| updates[].recommendedHardDiskSpace | integer (int64) | Recommended hard disk space in megabytes (MB) | +| updates[].msrcSeverity | enum | MSRC severity: Critical, Important, Moderate, or Low | +| updates[].securityBulletinIds | array[string] | Security bulletin identifiers | +| updates[].updateType | enum | Type of update: Software or Driver | + +## Implementation Details + +- **Language**: Rust +- **Executable**: `wu_dsc` +- **COM APIs Used**: Windows Update Agent (WUA) COM interfaces + - `IUpdateSession` + - `IUpdateSearcher` + - `IUpdateCollection` + - `IUpdate` + +## Limitations + +- Requires Windows operating system +- Search is case-insensitive and matches partial titles + +## Building + +To build the resource: + +```powershell +cd resources/WindowsUpdate +cargo build --release +``` + +The compiled executable will be located at `target/release/wu_dsc.exe`. + +## Testing + +To test the resource manually: + +```powershell +# Create input JSON +$input = @{ updates = @(@{ title = "Security Update" }) } | ConvertTo-Json -Depth 3 + +# Query for an update +$input | .\wu_dsc.exe get +``` + +## Error Handling + +The resource will return an error if: +- No update matching the specified title is found +- COM initialization fails +- The Windows Update service is unavailable +- Invalid input is provided + +## License + +Copyright (c) Microsoft Corporation. +Licensed under the MIT License. diff --git a/resources/WindowsUpdate/src/main.rs b/resources/WindowsUpdate/src/main.rs new file mode 100644 index 000000000..ef2c5db5a --- /dev/null +++ b/resources/WindowsUpdate/src/main.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(windows)] +mod windows_update; + +use std::io::{self, Read, IsTerminal}; + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Error: Missing operation argument"); + eprintln!("Usage: wu_dsc "); + std::process::exit(1); + } + + let operation = args[1].as_str(); + + match operation { + "export" => { + // Read optional input from stdin (only if stdin is not a terminal/TTY) + let mut buffer = String::new(); + if !io::stdin().is_terminal() { + let _ = io::stdin().read_to_string(&mut buffer); + } + + #[cfg(windows)] + match windows_update::handle_export(&buffer) { + Ok(output) => { + println!("{}", output); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: Windows Update resource is only supported on Windows"); + std::process::exit(1); + } + } + "get" => { + // Read input from stdin + let mut buffer = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut buffer) { + eprintln!("Error reading input: {}", e); + std::process::exit(1); + } + + #[cfg(windows)] + match windows_update::handle_get(&buffer) { + Ok(output) => { + println!("{}", output); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: Windows Update resource is only supported on Windows"); + std::process::exit(1); + } + } + "set" => { + // Read input from stdin + let mut buffer = String::new(); + if let Err(e) = io::stdin().read_to_string(&mut buffer) { + eprintln!("Error reading input: {}", e); + std::process::exit(1); + } + + #[cfg(windows)] + match windows_update::handle_set(&buffer) { + Ok(output) => { + println!("{}", output); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("Error: Windows Update resource is only supported on Windows"); + std::process::exit(1); + } + } + _ => { + eprintln!("Error: Unknown operation '{}'", operation); + eprintln!("Usage: wu_dsc "); + std::process::exit(1); + } + } +} diff --git a/resources/WindowsUpdate/src/windows_update/export.rs b/resources/WindowsUpdate/src/windows_update/export.rs new file mode 100644 index 000000000..ce0c373da --- /dev/null +++ b/resources/WindowsUpdate/src/windows_update/export.rs @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use windows::{ + core::*, + Win32::Foundation::*, + Win32::System::Com::*, + Win32::System::UpdateAgent::*, +}; + +use std::collections::HashSet; +use crate::windows_update::types::{UpdateList, UpdateInfo, extract_update_info}; + +pub fn handle_export(input: &str) -> Result { + // Parse optional filter input as UpdateList + let update_list: UpdateList = if input.trim().is_empty() { + UpdateList { + updates: vec![UpdateInfo { + title: None, + id: None, + is_installed: None, + description: None, + is_uninstallable: None, + kb_article_ids: None, + recommended_hard_disk_space: None, + msrc_severity: None, + security_bulletin_ids: None, + update_type: None, + }] + } + } else { + serde_json::from_str(input) + .map_err(|e| Error::new(E_INVALIDARG, format!("Failed to parse input: {}", e)))? + }; + + let filters = &update_list.updates; + + // Initialize COM + let com_initialized = unsafe { + CoInitializeEx(Some(std::ptr::null()), COINIT_MULTITHREADED).is_ok() + }; + + let result = unsafe { + // Create update session + let update_session: IUpdateSession = CoCreateInstance( + &UpdateSession, + None, + CLSCTX_INPROC_SERVER, + )?; + + // Create update searcher + let searcher = update_session.CreateUpdateSearcher()?; + + // Use the broadest search criteria to get all updates once + // We'll filter in-memory for each filter in the array + let search_result = searcher.Search(&BSTR::from("IsInstalled=0 or IsInstalled=1"))?; + + // Get updates collection + let updates = search_result.Updates()?; + let count = updates.Count()?; + + // Use HashSet to track unique update IDs (for OR logic across filters) + let mut matched_update_ids: HashSet = HashSet::new(); + let mut all_found_updates: Vec = Vec::new(); + + // Process each filter in the array (OR logic between filters) + for (filter_index, filter) in filters.iter().enumerate() { + let mut filter_found_match = false; + + // Collect matching updates for this specific filter + for i in 0..count { + let update = updates.get_Item(i)?; + let identity = update.Identity()?; + let update_id = identity.UpdateID()?.to_string(); + + // Extract all update information for filtering + let update_info = extract_update_info(&update)?; + + // Apply all filters (AND logic within a single filter) + let mut matches = true; + + // Filter by is_installed + if let Some(installed_filter) = filter.is_installed { + matches = matches && (update_info.is_installed == Some(installed_filter)); + } + + // Filter by title with wildcard support + if let Some(title_filter) = &filter.title { + if let Some(ref title) = update_info.title { + matches = matches && matches_wildcard(title, title_filter); + } else { + matches = false; + } + } + + // Filter by id + if let Some(id_filter) = &filter.id { + if let Some(ref id) = update_info.id { + matches = matches && id.eq_ignore_ascii_case(id_filter); + } else { + matches = false; + } + } + + // Filter by description with wildcard support + if let Some(desc_filter) = &filter.description { + if let Some(ref description) = update_info.description { + matches = matches && matches_wildcard(description, desc_filter); + } else { + matches = false; + } + } + + // Filter by is_uninstallable + if let Some(uninstallable_filter) = filter.is_uninstallable { + matches = matches && (update_info.is_uninstallable == Some(uninstallable_filter)); + } + + // Filter by KB article IDs (match if any KB ID in the filter is present) + if let Some(kb_filter) = &filter.kb_article_ids { + if !kb_filter.is_empty() { + if let Some(ref kb_article_ids) = update_info.kb_article_ids { + let kb_matches = kb_filter.iter().any(|filter_kb| { + kb_article_ids.iter().any(|update_kb| update_kb.eq_ignore_ascii_case(filter_kb)) + }); + matches = matches && kb_matches; + } else { + matches = false; + } + } + } + + // Filter by recommended_hard_disk_space (if specified, update space must be >= filter space) + if let Some(space_filter) = filter.recommended_hard_disk_space { + if let Some(recommended_hard_disk_space) = update_info.recommended_hard_disk_space { + matches = matches && (recommended_hard_disk_space >= space_filter); + } else { + matches = false; + } + } + + // Filter by MSRC severity + if let Some(severity_filter) = &filter.msrc_severity { + matches = matches && (update_info.msrc_severity.as_ref() == Some(severity_filter)); + } + + // Filter by security bulletin IDs (match if any bulletin ID in the filter is present) + if let Some(bulletin_filter) = &filter.security_bulletin_ids { + if !bulletin_filter.is_empty() { + if let Some(ref security_bulletin_ids) = update_info.security_bulletin_ids { + let bulletin_matches = bulletin_filter.iter().any(|filter_bulletin| { + security_bulletin_ids.iter().any(|update_bulletin| update_bulletin.eq_ignore_ascii_case(filter_bulletin)) + }); + matches = matches && bulletin_matches; + } else { + matches = false; + } + } + } + + // Filter by update type + if let Some(type_filter) = &filter.update_type { + matches = matches && (update_info.update_type.as_ref() == Some(type_filter)); + } + + if matches { + filter_found_match = true; + // Only add to results if we haven't seen this update ID before + if !matched_update_ids.contains(&update_id) { + matched_update_ids.insert(update_id); + all_found_updates.push(update_info); + } + } + } + + // Check if this filter found at least one match + if !filter_found_match { + // Only check if the filter has at least one criterion specified + let has_criteria = filter.title.is_some() + || filter.id.is_some() + || filter.is_installed.is_some() + || filter.description.is_some() + || filter.is_uninstallable.is_some() + || filter.kb_article_ids.is_some() + || filter.recommended_hard_disk_space.is_some() + || filter.msrc_severity.is_some() + || filter.security_bulletin_ids.is_some() + || filter.update_type.is_some(); + + if has_criteria { + // Construct error message with filter criteria + let mut criteria_parts = Vec::new(); + if let Some(title) = &filter.title { + criteria_parts.push(format!("title '{}'", title)); + } + if let Some(id) = &filter.id { + criteria_parts.push(format!("id '{}'", id)); + } + if let Some(is_installed) = filter.is_installed { + criteria_parts.push(format!("is_installed {}", is_installed)); + } + if let Some(description) = &filter.description { + criteria_parts.push(format!("description '{}'", description)); + } + if let Some(is_uninstallable) = filter.is_uninstallable { + criteria_parts.push(format!("is_uninstallable {}", is_uninstallable)); + } + if let Some(kb_ids) = &filter.kb_article_ids { + criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + } + if let Some(space) = filter.recommended_hard_disk_space { + criteria_parts.push(format!("recommended_hard_disk_space {}", space)); + } + if let Some(severity) = &filter.msrc_severity { + criteria_parts.push(format!("msrc_severity {:?}", severity)); + } + if let Some(bulletin_ids) = &filter.security_bulletin_ids { + criteria_parts.push(format!("security_bulletin_ids {:?}", bulletin_ids)); + } + if let Some(update_type) = &filter.update_type { + criteria_parts.push(format!("update_type {:?}", update_type)); + } + + let criteria_str = criteria_parts.join(", "); + let error_msg = format!("No matching update found for filter {}: {}", filter_index, criteria_str); + + // Emit JSON error to stderr + eprintln!("{{\"error\":\"{}\"}}", error_msg); + + return Err(Error::new(E_FAIL, error_msg)); + } + } + } + + Ok(all_found_updates) + }; + + // Ensure COM is uninitialized if it was initialized + if com_initialized { + unsafe { + CoUninitialize(); + } + } + + match result { + Ok(updates) => { + let result = UpdateList { + updates + }; + serde_json::to_string(&result) + .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + } + Err(e) => Err(e), + } +} + +// Helper function to match string against pattern with wildcard (*) +fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + // Split pattern by asterisks + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + // If no wildcard, it's an exact match (case-insensitive) + if parts.len() == 1 { + return text_lower == pattern_lower; + } + + // If pattern is just asterisk(s), match everything + if parts.is_empty() { + return true; + } + + // Check if pattern starts with asterisk + let starts_with_wildcard = pattern_lower.starts_with('*'); + // Check if pattern ends with asterisk + let ends_with_wildcard = pattern_lower.ends_with('*'); + + let mut pos = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + // For the first part, check if it should be at the start + if i == 0 && !starts_with_wildcard { + if !text_lower.starts_with(part) { + return false; + } + pos = part.len(); + } else { + // Find the part in the remaining text + if let Some(found_pos) = text_lower[pos..].find(part) { + pos += found_pos + part.len(); + } else { + return false; + } + } + } + + // For the last part, check if it should be at the end + if !ends_with_wildcard && !parts.is_empty() { + if let Some(last_part) = parts.last() { + if !last_part.is_empty() && !text_lower.ends_with(last_part) { + return false; + } + } + } + + true +} diff --git a/resources/WindowsUpdate/src/windows_update/get.rs b/resources/WindowsUpdate/src/windows_update/get.rs new file mode 100644 index 000000000..17171db88 --- /dev/null +++ b/resources/WindowsUpdate/src/windows_update/get.rs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use windows::{ + core::*, + Win32::Foundation::*, + Win32::System::Com::*, + Win32::System::UpdateAgent::*, +}; + +use crate::windows_update::types::{UpdateList, extract_update_info}; + +pub fn handle_get(input: &str) -> Result { + // Parse input as UpdateList + let update_list: UpdateList = serde_json::from_str(input) + .map_err(|e| Error::new(E_INVALIDARG, format!("Failed to parse input: {}", e)))?; + + if update_list.updates.is_empty() { + return Err(Error::new(E_INVALIDARG, "Updates array cannot be empty for get operation")); + } + + // Initialize COM + let com_initialized = unsafe { + CoInitializeEx(Some(std::ptr::null()), COINIT_MULTITHREADED).is_ok() + }; + + let result = unsafe { + // Create update session using the proper interface + let update_session: IUpdateSession = CoCreateInstance( + &UpdateSession, + None, + CLSCTX_INPROC_SERVER, + )?; + + // Create update searcher + let searcher = update_session.CreateUpdateSearcher()?; + + // Search for updates + let search_result = searcher.Search(&BSTR::from("IsInstalled=0 or IsInstalled=1"))?; + + // Get updates collection + let all_updates = search_result.Updates()?; + let count = all_updates.Count()?; + + // Process each input filter + let mut matched_updates = Vec::new(); + + for update_input in &update_list.updates { + // Validate that at least one search criterion is provided + if update_input.title.is_none() + && update_input.id.is_none() + && update_input.kb_article_ids.is_none() + && update_input.is_installed.is_none() + && update_input.update_type.is_none() + && update_input.msrc_severity.is_none() { + return Err(Error::new(E_INVALIDARG, "At least one search criterion must be specified for get operation")); + } + + // Find the update matching ALL provided criteria (logical AND) + let mut found_update = None; + for i in 0..count { + let update = all_updates.get_Item(i)?; + + // Check title match + if let Some(search_title) = &update_input.title { + let title = update.Title()?.to_string(); + if !title.eq_ignore_ascii_case(search_title) { + continue; // Title doesn't match, skip this update + } + } + + // Check id match + if let Some(search_id) = &update_input.id { + let identity = update.Identity()?; + let update_id = identity.UpdateID()?.to_string(); + if !update_id.eq_ignore_ascii_case(search_id) { + continue; // ID doesn't match, skip this update + } + } + + // Check is_installed match + if let Some(search_installed) = update_input.is_installed { + let is_installed = update.IsInstalled()?.as_bool(); + if is_installed != search_installed { + continue; // Installation state doesn't match, skip this update + } + } + + // Check KB article IDs match + if let Some(search_kb_ids) = &update_input.kb_article_ids { + let kb_articles = update.KBArticleIDs()?; + let kb_count = kb_articles.Count()?; + let mut kb_article_ids = Vec::new(); + for j in 0..kb_count { + if let Ok(kb_str) = kb_articles.get_Item(j) { + kb_article_ids.push(kb_str.to_string()); + } + } + + // Check if all search KB IDs are present + let mut all_match = true; + for search_kb in search_kb_ids { + if !kb_article_ids.iter().any(|kb| kb.eq_ignore_ascii_case(search_kb)) { + all_match = false; + break; + } + } + if !all_match { + continue; // KB articles don't match, skip this update + } + } + + // Check update type match + if let Some(search_type) = &update_input.update_type { + use windows::Win32::System::UpdateAgent::UpdateType as WinUpdateType; + let ut = update.Type()?; + let update_type = if ut == WinUpdateType(2) { + crate::windows_update::types::UpdateType::Driver + } else { + crate::windows_update::types::UpdateType::Software + }; + + if &update_type != search_type { + continue; // Update type doesn't match, skip this update + } + } + + // Check MSRC severity match + if let Some(search_severity) = &update_input.msrc_severity { + let msrc_severity = if let Ok(severity_str) = update.MsrcSeverity() { + match severity_str.to_string().as_str() { + "Critical" => Some(crate::windows_update::types::MsrcSeverity::Critical), + "Important" => Some(crate::windows_update::types::MsrcSeverity::Important), + "Moderate" => Some(crate::windows_update::types::MsrcSeverity::Moderate), + "Low" => Some(crate::windows_update::types::MsrcSeverity::Low), + _ => None, + } + } else { + None + }; + + if msrc_severity.as_ref() != Some(search_severity) { + continue; // Severity doesn't match, skip this update + } + } + + // All criteria matched - extract and store the update + found_update = Some(extract_update_info(&update)?); + break; + } + + if let Some(update_info) = found_update { + matched_updates.push(update_info); + } else { + // No match found for this input - construct error message and return + let mut criteria_parts = Vec::new(); + if let Some(title) = &update_input.title { + criteria_parts.push(format!("title '{}'", title)); + } + if let Some(id) = &update_input.id { + criteria_parts.push(format!("id '{}'", id)); + } + if let Some(is_installed) = update_input.is_installed { + criteria_parts.push(format!("is_installed {}", is_installed)); + } + if let Some(kb_ids) = &update_input.kb_article_ids { + criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + } + if let Some(update_type) = &update_input.update_type { + criteria_parts.push(format!("update_type {:?}", update_type)); + } + if let Some(severity) = &update_input.msrc_severity { + criteria_parts.push(format!("msrc_severity {:?}", severity)); + } + + let criteria_str = criteria_parts.join(", "); + let error_msg = format!("No matching update found for criteria: {}", criteria_str); + + // Emit JSON error to stderr + eprintln!("{{\"error\":\"{}\"}}", error_msg); + + return Err(Error::new(E_FAIL, error_msg)); + } + } + + Ok(matched_updates) + }; + + // Ensure COM is uninitialized if it was initialized + if com_initialized { + unsafe { + CoUninitialize(); + } + } + + match result { + Ok(updates) => { + let result = UpdateList { + updates + }; + serde_json::to_string(&result) + .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + } + Err(e) => Err(e), + } +} diff --git a/resources/WindowsUpdate/src/windows_update/mod.rs b/resources/WindowsUpdate/src/windows_update/mod.rs new file mode 100644 index 000000000..76d5dc65e --- /dev/null +++ b/resources/WindowsUpdate/src/windows_update/mod.rs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; +mod get; +mod set; +mod export; + +pub use get::handle_get; +pub use set::handle_set; +pub use export::handle_export; diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs new file mode 100644 index 000000000..8807d86d0 --- /dev/null +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -0,0 +1,259 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use windows::{ + core::*, + Win32::Foundation::*, + Win32::System::Com::*, + Win32::System::UpdateAgent::*, +}; + +use crate::windows_update::types::{UpdateList, UpdateInfo, extract_update_info}; + +pub fn handle_set(input: &str) -> Result { + // Parse input as UpdateList + let update_list: UpdateList = serde_json::from_str(input) + .map_err(|e| Error::new(E_INVALIDARG, format!("Failed to parse input: {}", e)))?; + + if update_list.updates.is_empty() { + return Err(Error::new(E_INVALIDARG, "Updates array cannot be empty for set operation")); + } + + // Initialize COM + let com_initialized = unsafe { + CoInitializeEx(Some(std::ptr::null()), COINIT_MULTITHREADED).is_ok() + }; + + let result: Result> = unsafe { + // Create update session + let update_session: IUpdateSession = CoCreateInstance( + &UpdateSession, + None, + CLSCTX_INPROC_SERVER, + )?; + + // Create update searcher + let searcher = update_session.CreateUpdateSearcher()?; + + // Search for all updates (installed and not installed) + let search_result = searcher.Search(&BSTR::from("IsInstalled=0 or IsInstalled=1"))?; + + // Get updates collection + let all_updates = search_result.Updates()?; + let count = all_updates.Count()?; + + // First pass: Verify all input objects have matches + let mut matched_updates: Vec<(IUpdate, bool)> = Vec::new(); + + for update_input in &update_list.updates { + // Validate that at least one search criterion is provided + if update_input.title.is_none() + && update_input.id.is_none() + && update_input.kb_article_ids.is_none() + && update_input.is_installed.is_none() + && update_input.update_type.is_none() + && update_input.msrc_severity.is_none() { + return Err(Error::new(E_INVALIDARG, "At least one search criterion must be specified for set operation")); + } + + // Find the update matching ALL provided criteria (logical AND) + let mut found_update: Option<(IUpdate, bool)> = None; + for i in 0..count { + let update = all_updates.get_Item(i)?; + + // Check title match + if let Some(search_title) = &update_input.title { + let title = update.Title()?.to_string(); + if !title.eq_ignore_ascii_case(search_title) { + continue; // Title doesn't match, skip this update + } + } + + // Check id match + if let Some(search_id) = &update_input.id { + let identity = update.Identity()?; + let update_id = identity.UpdateID()?.to_string(); + if !update_id.eq_ignore_ascii_case(search_id) { + continue; // ID doesn't match, skip this update + } + } + + // Check is_installed match + if let Some(search_installed) = update_input.is_installed { + let is_installed = update.IsInstalled()?.as_bool(); + if is_installed != search_installed { + continue; // Installation state doesn't match, skip this update + } + } + + // Check KB article IDs match + if let Some(search_kb_ids) = &update_input.kb_article_ids { + let kb_articles = update.KBArticleIDs()?; + let kb_count = kb_articles.Count()?; + let mut kb_article_ids = Vec::new(); + for j in 0..kb_count { + if let Ok(kb_str) = kb_articles.get_Item(j) { + kb_article_ids.push(kb_str.to_string()); + } + } + + // Check if all search KB IDs are present + let mut all_match = true; + for search_kb in search_kb_ids { + if !kb_article_ids.iter().any(|kb| kb.eq_ignore_ascii_case(search_kb)) { + all_match = false; + break; + } + } + if !all_match { + continue; // KB articles don't match, skip this update + } + } + + // Check update type match + if let Some(search_type) = &update_input.update_type { + use windows::Win32::System::UpdateAgent::UpdateType as WinUpdateType; + let ut = update.Type()?; + let update_type = if ut == WinUpdateType(2) { + crate::windows_update::types::UpdateType::Driver + } else { + crate::windows_update::types::UpdateType::Software + }; + + if &update_type != search_type { + continue; // Update type doesn't match, skip this update + } + } + + // Check MSRC severity match + if let Some(search_severity) = &update_input.msrc_severity { + let msrc_severity = if let Ok(severity_str) = update.MsrcSeverity() { + match severity_str.to_string().as_str() { + "Critical" => Some(crate::windows_update::types::MsrcSeverity::Critical), + "Important" => Some(crate::windows_update::types::MsrcSeverity::Important), + "Moderate" => Some(crate::windows_update::types::MsrcSeverity::Moderate), + "Low" => Some(crate::windows_update::types::MsrcSeverity::Low), + _ => None, + } + } else { + None + }; + + if msrc_severity.as_ref() != Some(search_severity) { + continue; // Severity doesn't match, skip this update + } + } + + // All criteria matched + let is_installed = update.IsInstalled()?.as_bool(); + found_update = Some((update.clone(), is_installed)); + break; + } + + if let Some(matched) = found_update { + matched_updates.push(matched); + } else { + // No match found for this input - construct error message and return + let mut criteria_parts = Vec::new(); + if let Some(title) = &update_input.title { + criteria_parts.push(format!("title '{}'", title)); + } + if let Some(id) = &update_input.id { + criteria_parts.push(format!("id '{}'", id)); + } + if let Some(is_installed) = update_input.is_installed { + criteria_parts.push(format!("is_installed {}", is_installed)); + } + if let Some(kb_ids) = &update_input.kb_article_ids { + criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + } + if let Some(update_type) = &update_input.update_type { + criteria_parts.push(format!("update_type {:?}", update_type)); + } + if let Some(severity) = &update_input.msrc_severity { + criteria_parts.push(format!("msrc_severity {:?}", severity)); + } + + let criteria_str = criteria_parts.join(", "); + let error_msg = format!("No matching update found for criteria: {}", criteria_str); + + // Emit JSON error to stderr + eprintln!("{{\"error\":\"{}\"}}", error_msg); + + return Err(Error::new(E_FAIL, error_msg)); + } + } + + // All inputs have matches - now proceed with installation/uninstallation + let mut result_updates = Vec::new(); + + for (update, is_installed) in matched_updates { + let update_info = if is_installed { + // Already installed, just return current state + extract_update_info(&update)? + } else { + // Not installed - proceed with installation + // Create update collection for download/install + let updates_to_install: IUpdateCollection = CoCreateInstance( + &UpdateCollection, + None, + CLSCTX_INPROC_SERVER, + )?; + updates_to_install.Add(&update)?; + + // Download the update if needed + if !update.IsDownloaded()?.as_bool() { + let downloader = update_session.CreateUpdateDownloader()?; + downloader.SetUpdates(&updates_to_install)?; + let download_result = downloader.Download()?; + + use windows::Win32::System::UpdateAgent::OperationResultCode; + let result_code = download_result.ResultCode()?; + // Check if download was successful (orcSucceeded = 2) + if result_code != OperationResultCode(2) { + let hresult = download_result.HResult()?; + return Err(Error::new(HRESULT(hresult), format!("Failed to download update. Result code: {}", result_code.0))); + } + } + + // Install the update + let installer = update_session.CreateUpdateInstaller()?; + installer.SetUpdates(&updates_to_install)?; + let install_result = installer.Install()?; + + use windows::Win32::System::UpdateAgent::OperationResultCode; + let result_code = install_result.ResultCode()?; + // Check if installation was successful (orcSucceeded = 2) + if result_code != OperationResultCode(2) { + let hresult = install_result.HResult()?; + return Err(Error::new(HRESULT(hresult), format!("Failed to install update. Result code: {}", result_code.0))); + } + + // Get full details now that it's installed + extract_update_info(&update)? + }; + + result_updates.push(update_info); + } + + Ok(result_updates) + }; + + // Ensure COM is uninitialized if it was initialized + if com_initialized { + unsafe { + CoUninitialize(); + } + } + + match result { + Ok(updates) => { + let results = UpdateList { + updates + }; + serde_json::to_string(&results) + .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + } + Err(e) => Err(e), + } +} diff --git a/resources/WindowsUpdate/src/windows_update/types.rs b/resources/WindowsUpdate/src/windows_update/types.rs new file mode 100644 index 000000000..9c98642a3 --- /dev/null +++ b/resources/WindowsUpdate/src/windows_update/types.rs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateList { + pub updates: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_installed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_uninstallable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kb_article_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub recommended_hard_disk_space: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub msrc_severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub security_bulletin_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_type: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum MsrcSeverity { + Critical, + Important, + Moderate, + Low, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum UpdateType { + Software, + Driver, +} + +impl std::fmt::Display for MsrcSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MsrcSeverity::Critical => write!(f, "Critical"), + MsrcSeverity::Important => write!(f, "Important"), + MsrcSeverity::Moderate => write!(f, "Moderate"), + MsrcSeverity::Low => write!(f, "Low"), + } + } +} + +impl std::fmt::Display for UpdateType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UpdateType::Software => write!(f, "Software"), + UpdateType::Driver => write!(f, "Driver"), + } + } +} + +#[cfg(windows)] +use windows::{ + core::*, + Win32::System::UpdateAgent::*, +}; + +/// Extract complete update information from an IUpdate interface +#[cfg(windows)] +pub fn extract_update_info(update: &IUpdate) -> Result { + + unsafe { + let title = update.Title()?.to_string(); + let identity = update.Identity()?; + let update_id = identity.UpdateID()?.to_string(); + let is_installed = update.IsInstalled()?.as_bool(); + let description = update.Description()?.to_string(); + let is_uninstallable = update.IsUninstallable()?.as_bool(); + + // Get KB Article IDs + let kb_articles = update.KBArticleIDs()?; + let kb_count = kb_articles.Count()?; + let mut kb_article_ids = Vec::new(); + for j in 0..kb_count { + if let Ok(kb_str) = kb_articles.get_Item(j) { + kb_article_ids.push(kb_str.to_string()); + } + } + + // Get recommended hard disk space in MB + let recommended_hard_disk_space = match update.RecommendedHardDiskSpace() { + Ok(space) => space as i64, + Err(_) => 0i64, + }; + + // Get MSRC Severity + let msrc_severity = if let Ok(severity_str) = update.MsrcSeverity() { + match severity_str.to_string().as_str() { + "Critical" => Some(MsrcSeverity::Critical), + "Important" => Some(MsrcSeverity::Important), + "Moderate" => Some(MsrcSeverity::Moderate), + "Low" => Some(MsrcSeverity::Low), + _ => None, + } + } else { + None + }; + + // Get Security Bulletin IDs + let security_bulletins = update.SecurityBulletinIDs()?; + let bulletin_count = security_bulletins.Count()?; + let mut security_bulletin_ids = Vec::new(); + for j in 0..bulletin_count { + if let Ok(bulletin_str) = security_bulletins.get_Item(j) { + security_bulletin_ids.push(bulletin_str.to_string()); + } + } + + // Determine update type using proper enum comparison + let update_type = { + use windows::Win32::System::UpdateAgent::UpdateType as WinUpdateType; + let ut = update.Type()?; + // utDriver = 2, utSoftware = 1 + if ut == WinUpdateType(2) { + UpdateType::Driver + } else { + UpdateType::Software + } + }; + + Ok(UpdateInfo { + title: Some(title), + is_installed: Some(is_installed), + description: Some(description), + id: Some(update_id), + is_uninstallable: Some(is_uninstallable), + kb_article_ids: Some(kb_article_ids), + recommended_hard_disk_space: Some(recommended_hard_disk_space), + msrc_severity, + security_bulletin_ids: Some(security_bulletin_ids), + update_type: Some(update_type), + }) + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate.executable.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate.executable.tests.ps1 new file mode 100644 index 000000000..6de1a3887 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate.executable.tests.ps1 @@ -0,0 +1,280 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update resource executable tests' -Skip:(!$IsWindows) { + BeforeAll { + $exeName = 'wu_dsc' + $resourceDir = Join-Path $PSScriptRoot ".." + + # Try to find the executable + $exePaths = @( + (Join-Path $resourceDir "target\release\wu_dsc.exe"), + (Join-Path $resourceDir "target\debug\wu_dsc.exe") + ) + + $exePath = $null + foreach ($path in $exePaths) { + if (Test-Path $path) { + $exePath = $path + break + } + } + + # If not found in target dirs, try to find in PATH + if ($null -eq $exePath) { + $exePath = (Get-Command wu_dsc.exe -ErrorAction SilentlyContinue).Source + } + + $skipTests = (-not $IsWindows) -or ($null -eq $exePath) + + if ($skipTests -and $IsWindows) { + Write-Warning "wu_dsc executable not found. Run 'cargo build' to build the resource." + } + } + + Context 'Executable file properties' { + It 'executable should exist' -Skip:$skipTests { + Test-Path $exePath | Should -Be $true + } + + It 'executable should be a PE file' -Skip:$skipTests { + $bytes = [System.IO.File]::ReadAllBytes($exePath) + # Check for MZ header (PE executable) + $bytes[0] | Should -Be 0x4D # 'M' + $bytes[1] | Should -Be 0x5A # 'Z' + } + + It 'executable should have .exe extension' -Skip:$skipTests { + $exePath | Should -Match '\.exe$' + } + } + + Context 'Command line interface' { + It 'should fail without arguments' -Skip:$skipTests { + $result = & $exePath 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $result | Should -Match 'Usage|Error' + } + + It 'should display usage information when called without args' -Skip:$skipTests { + $result = & $exePath 2>&1 + $result | Should -Match 'Usage|operation|get|set|test' + } + + It 'should fail with unknown operation' -Skip:$skipTests { + $json = '[{"title": "test"}]' + $result = $json | & $exePath 'invalid_operation' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $result | Should -Match 'Unknown operation|Error|Usage' + } + } + + Context 'Get operation input handling' { + It 'should accept JSON input via stdin' -Skip:$skipTests { + $json = '[{"title": "Windows Defender"}]' + $result = $json | & $exePath 'get' 2>&1 + # May succeed or fail depending on updates, but should process the input + $result | Should -Not -BeNullOrEmpty + } + + It 'should fail with invalid JSON input' -Skip:$skipTests { + $invalidJson = 'not valid json' + $result = $invalidJson | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should fail when title is missing from JSON' -Skip:$skipTests { + $json = '[{}]' + $result = $json | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should handle empty input gracefully' -Skip:$skipTests { + $result = '' | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should fail when no input is provided' -Skip:$skipTests { + # Simulate no stdin by closing stdin immediately + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $exePath + $psi.Arguments = 'get' + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + $process.StandardInput.Close() + $process.WaitForExit() + + $process.ExitCode | Should -Not -Be 0 + } + } + + Context 'Get operation output' { + It 'should return valid JSON when update is found' -Skip:$skipTests { + # Try to find a common update (Defender definitions are updated frequently) + $json = '[{"title": "Windows"}]' + $result = $json | & $exePath 'get' 2>&1 + + if ($LASTEXITCODE -eq 0) { + { $result | ConvertFrom-Json } | Should -Not -Throw + $output = $result | ConvertFrom-Json + $output[0].title | Should -Not -BeNullOrEmpty + $output[0].id | Should -Not -BeNullOrEmpty + } + else { + # No matching update found, which is acceptable + Write-Host "No matching update found for testing" + $true | Should -Be $true + } + } + + It 'should return error when update is not found' -Skip:$skipTests { + $json = '[{"title": "ThisUpdateDoesNotExist999888777"}]' + $result = $json | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + $result | Should -Match 'not found|Error' + } + + It 'should output to stdout for success' -Skip:$skipTests { + $json = '[{"title": "Windows Defender"}]' + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $exePath + $psi.Arguments = 'get' + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + $process.StandardInput.WriteLine($json) + $process.StandardInput.Close() + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + if ($process.ExitCode -eq 0) { + $stdout | Should -Not -BeNullOrEmpty + } + else { + $stderr | Should -Not -BeNullOrEmpty + } + } + + It 'should output to stderr for errors' -Skip:$skipTests { + $json = '[{"title": "NonExistentUpdate12345"}]' + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $exePath + $psi.Arguments = 'get' + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + $process.Start() | Out-Null + $process.StandardInput.WriteLine($json) + $process.StandardInput.Close() + + $stdout = $process.StandardOutput.ReadToEnd() + $stderr = $process.StandardError.ReadToEnd() + $process.WaitForExit() + + $process.ExitCode | Should -Not -Be 0 + $stderr | Should -Not -BeNullOrEmpty + } + } + + Context 'Exit codes' { + It 'should exit with 0 on success' -Skip:$skipTests { + # Try with a broad search that's likely to find something + $json = '[{"title": "Windows"}]' + $result = $json | & $exePath 'get' 2>&1 + + if ($LASTEXITCODE -eq 0) { + $LASTEXITCODE | Should -Be 0 + } + else { + Write-Host "No update found, exit code check skipped" + $true | Should -Be $true + } + } + + It 'should exit with non-zero on error' -Skip:$skipTests { + $json = '[{"title": "NonExistentUpdate99999"}]' + $result = $json | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should exit with non-zero on invalid input' -Skip:$skipTests { + $invalidJson = 'not json' + $result = $invalidJson | & $exePath 'get' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should exit with non-zero on unimplemented operation' -Skip:$skipTests { + $json = '[{"title": "test"}]' + $result = $json | & $exePath 'set' 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Performance and reliability' { + It 'should not crash with malformed JSON' -Skip:$skipTests { + $malformedInputs = @( + '{"title":', + '{"title": "test"', + '{title: "test"}', + '{"title": }', + 'null', + '[]', + '""' + ) + + foreach ($input in $malformedInputs) { + $result = $input | & $exePath 'get' 2>&1 + # Should fail gracefully, not crash + $LASTEXITCODE | Should -Not -Be 0 + } + } + + It 'should handle very long title strings' -Skip:$skipTests { + $longTitle = 'A' * 1000 + $json = "[{`"title`": `"$longTitle`"}]" + $result = $json | & $exePath 'get' 2>&1 + # Should handle gracefully (either find nothing or error properly) + $result | Should -Not -BeNullOrEmpty + } + + It 'should handle special characters in title' -Skip:$skipTests { + $specialTitle = 'Test & Update <2024> "Special"' + $json = "[{`"title`": `"$specialTitle`"}]" + $result = $json | & $exePath 'get' 2>&1 + # Should not crash + $result | Should -Not -BeNullOrEmpty + } + + It 'should complete within reasonable time' -Skip:$skipTests { + $json = '[{"title": "Windows Defender"}]' + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $result = $json | & $exePath 'get' 2>&1 + $stopwatch.Stop() + + # Should complete within 60 seconds (Windows Update can be slow) + $stopwatch.Elapsed.TotalSeconds | Should -BeLessThan 60 + } + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 new file mode 100644 index 000000000..e4cd7cce0 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 @@ -0,0 +1,202 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update resource schema validation' { + BeforeAll { + $resourceType = 'Microsoft.Windows/UpdateList' + $manifestPath = Join-Path $PSScriptRoot "..\windowsupdate.dsc.resource.json" + } + + Context 'Manifest validation' { + It 'manifest file should exist' { + Test-Path $manifestPath | Should -Be $true + } + + It 'manifest should be valid JSON' { + { Get-Content $manifestPath | ConvertFrom-Json } | Should -Not -Throw + } + + It 'manifest should have correct type' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.type | Should -BeExactly $resourceType + } + + It 'manifest should have version' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.version | Should -Not -BeNullOrEmpty + $manifest.version | Should -Match '^\d+\.\d+\.\d+$' + } + + It 'manifest should have description' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.description | Should -Not -BeNullOrEmpty + } + + It 'manifest should have get operation' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.get | Should -Not -BeNullOrEmpty + $manifest.get.executable | Should -BeExactly 'wu_dsc' + $manifest.get.args | Should -Contain 'get' + $manifest.get.input | Should -BeExactly 'stdin' + } + } + + Context 'Schema validation' { + It 'should have embedded schema' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded | Should -Not -BeNullOrEmpty + } + + It 'schema should have correct JSON schema version' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.'$schema' | Should -Match 'json-schema.org' + } + + It 'schema should have title property' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.title | Should -Not -BeNullOrEmpty + } + + It 'schema should require updates property' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.required | Should -Contain 'updates' + } + + It 'schema should define updates property as array' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $properties = $manifest.schema.embedded.properties + $properties.updates | Should -Not -BeNullOrEmpty + $properties.updates.type | Should -BeExactly 'array' + } + + It 'schema should define all expected properties in updates items' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $itemProperties = $manifest.schema.embedded.properties.updates.items.properties + + $expectedProperties = @( + 'title', + 'isInstalled', + 'description', + 'id', + 'isUninstallable', + 'kbArticleIds', + 'recommendedHardDiskSpace', + 'msrcSeverity', + 'securityBulletinIds', + 'updateType' + ) + + foreach ($prop in $expectedProperties) { + $itemProperties.$prop | Should -Not -BeNullOrEmpty -Because "Property '$prop' should be defined" + } + } + + It 'title property should be string type' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.properties.updates.items.properties.title.type | Should -BeExactly 'string' + } + + It 'isInstalled property should be boolean' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $isInstalled = $manifest.schema.embedded.properties.updates.items.properties.isInstalled + $isInstalled.type | Should -BeExactly 'boolean' + } + + It 'description property should be string' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $description = $manifest.schema.embedded.properties.updates.items.properties.description + $description.type | Should -BeExactly 'string' + } + + It 'id property should be string' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $id = $manifest.schema.embedded.properties.updates.items.properties.id + $id.type | Should -BeExactly 'string' + } + + It 'isUninstallable property should be boolean' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $isUninstallable = $manifest.schema.embedded.properties.updates.items.properties.isUninstallable + $isUninstallable.type | Should -BeExactly 'boolean' + } + + It 'kbArticleIds property should be array' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $kbArticles = $manifest.schema.embedded.properties.updates.items.properties.kbArticleIds + $kbArticles.type | Should -BeExactly 'array' + $kbArticles.items.type | Should -BeExactly 'string' + } + + It 'recommendedHardDiskSpace property should be integer int64' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $recommendedHardDiskSpace = $manifest.schema.embedded.properties.updates.items.properties.recommendedHardDiskSpace + $recommendedHardDiskSpace.type | Should -BeExactly 'integer' + $recommendedHardDiskSpace.format | Should -BeExactly 'int64' + } + + It 'msrcSeverity property should be enum with correct values' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $msrcSeverity = $manifest.schema.embedded.properties.updates.items.properties.msrcSeverity + $msrcSeverity.type | Should -BeExactly 'string' + $msrcSeverity.enum | Should -Contain 'Critical' + $msrcSeverity.enum | Should -Contain 'Important' + $msrcSeverity.enum | Should -Contain 'Moderate' + $msrcSeverity.enum | Should -Contain 'Low' + } + + It 'securityBulletinIds property should be array' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $bulletinIds = $manifest.schema.embedded.properties.updates.items.properties.securityBulletinIds + $bulletinIds.type | Should -BeExactly 'array' + $bulletinIds.items.type | Should -BeExactly 'string' + } + + It 'updateType property should be enum with correct values' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $updateType = $manifest.schema.embedded.properties.updates.items.properties.updateType + $updateType.type | Should -BeExactly 'string' + $updateType.enum | Should -Contain 'Software' + $updateType.enum | Should -Contain 'Driver' + } + + It 'schema should not allow additional properties' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.additionalProperties | Should -Be $false + } + + It 'all properties should have descriptions' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $properties = $manifest.schema.embedded.properties + + foreach ($propName in $properties.PSObject.Properties.Name) { + $prop = $properties.$propName + $prop.description | Should -Not -BeNullOrEmpty -Because "Property '$propName' should have a description" + } + } + + It 'all properties should have titles' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $properties = $manifest.schema.embedded.properties + + foreach ($propName in $properties.PSObject.Properties.Name) { + $prop = $properties.$propName + $prop.title | Should -Not -BeNullOrEmpty -Because "Property '$propName' should have a title" + } + } + } + + Context 'Documentation links' { + It 'schema should have valid schema ID URL' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $schemaId = $manifest.schema.embedded.'$id' + $schemaId | Should -Not -BeNullOrEmpty + $schemaId | Should -Match '^https://' + $schemaId | Should -Match 'Microsoft\.Windows/UpdateList' + } + + It 'description should reference documentation URL' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $manifest.schema.embedded.description | Should -Match 'https://' + } + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 new file mode 100644 index 000000000..fe13a1df7 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 @@ -0,0 +1,310 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update Export operation tests' { + BeforeAll { + $resourceType = 'Microsoft.Windows/UpdateList' + } + + Context 'Export operation' { + It 'should return UpdateList with array of updates' -Skip:(!$IsWindows) { + $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + $result.updates | Should -Not -BeNullOrEmpty + @($result.updates).Count | Should -BeGreaterThan 0 + } + + It 'should work without input filter' -Skip:(!$IsWindows) { + $out = '' | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + $result.updates.Count | Should -BeGreaterThan 0 + } + + It 'should filter by isInstalled=true' -Skip:(!$IsWindows) { + $json = '{"updates":[{"isInstalled": true}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + foreach ($update in $result.updates) { + $update.isInstalled | Should -Be $true + } + } + } + + It 'should filter by isInstalled=false' -Skip:(!$IsWindows) { + $json = '{"updates":[{"isInstalled": false}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + foreach ($update in $result.updates) { + $update.isInstalled | Should -Be $false + } + } + } + + It 'should filter by title with wildcard in middle' -Skip:(!$IsWindows) { + $json = '{"updates":[{"title": "*Windows*"}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + foreach ($update in $result.updates) { + $update.title | Should -Match 'Windows' + } + } + } + } + + It 'should return proper structure for each update' -Skip:(!$IsWindows) { + $out = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + $update = $result.updates[0] + $update.PSObject.Properties.Name | Should -Contain 'title' + $update.PSObject.Properties.Name | Should -Contain 'id' + $update.PSObject.Properties.Name | Should -Contain 'isInstalled' + $update.PSObject.Properties.Name | Should -Contain 'description' + $update.PSObject.Properties.Name | Should -Contain 'isUninstallable' + $update.PSObject.Properties.Name | Should -Contain 'kbArticleIds' + $update.PSObject.Properties.Name | Should -Contain 'recommendedHardDiskSpace' + $update.PSObject.Properties.Name | Should -Contain 'updateType' + $update.kbArticleIds | Should -Not -BeNull + @($update.kbArticleIds).Count | Should -BeGreaterOrEqual 0 + } + } + + It 'should fail when wildcard filter has no matches' -Skip:(!$IsWindows) { + $json = '{"updates":[{"title": "ThisUpdateShouldNeverExist99999*"}]}' + $stderr = $json | dsc resource export -r $resourceType -o json 2>&1 + + # Should fail because the filter has criteria but no matches + $LASTEXITCODE | Should -Not -Be 0 + + # Check for error message in stderr + $errorText = $stderr | Out-String + $errorText | Should -Match 'No matching update found' + } + + It 'should fail if filter with specific exact title has no matches' -Skip:(!$IsWindows) { + # Use a very specific title that won't match (no wildcard) + $json = @{ + updates = @( + @{ + title = 'ThisUpdateShouldNeverExist12345XYZ' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource export -r $resourceType 2>&1 + + # Should fail because the filter has criteria but no matches + $LASTEXITCODE | Should -Not -Be 0 + + # Check for error message in stderr + $errorText = $stderr | Out-String + $errorText | Should -Match 'No matching update found' + } + + It 'should succeed with empty filter object (no criteria)' -Skip:(!$IsWindows) { + # Empty filter should match all updates + $json = @{ + updates = @( + @{} + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + $result.updates.Count | Should -BeGreaterThan 0 + } + + It 'should fail if any filter with criteria has no matches' -Skip:(!$IsWindows) { + # Get an actual update + $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $allConfig = $allOut | ConvertFrom-Json + $allResult = $allConfig.resources[0].properties + if ($allResult.updates.Count -gt 0) { + $update1 = $allResult.updates[0] + + # One valid filter, one invalid filter + $json = @{ + updates = @( + @{ + id = $update1.id + }, + @{ + title = 'ThisUpdateShouldNeverExist12345XYZ' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource export -r $resourceType 2>&1 + + # Should fail because second filter has no matches + $LASTEXITCODE | Should -Not -Be 0 + + # Check for error message in stderr + $errorText = $stderr | Out-String + $errorText | Should -Match 'No matching update found' + } + } + } + + It 'should return results when all filters find matches' -Skip:(!$IsWindows) { + # Get actual updates + $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $allConfig = $allOut | ConvertFrom-Json + $allResult = $allConfig.resources[0].properties + if ($allResult.updates.Count -ge 2) { + $update1 = $allResult.updates[0] + $update2 = $allResult.updates[1] + + $json = @{ + updates = @( + @{ + id = $update1.id + }, + @{ + id = $update2.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + $result.updates.Count | Should -BeGreaterOrEqual 2 + } else { + Write-Host "Need at least 2 updates for this test, skipping" + $true | Should -Be $true + } + } + } + + It 'should filter by msrcSeverity' -Skip:(!$IsWindows) { + $json = '{"updates":[{"msrcSeverity": "Critical"}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + foreach ($update in $result.updates) { + $update.msrcSeverity | Should -Be 'Critical' + } + } + } + } + + It 'should filter by updateType Software' -Skip:(!$IsWindows) { + $json = '{"updates":[{"updateType": "Software"}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + foreach ($update in $result.updates) { + $update.updateType | Should -Be 'Software' + } + } + } + } + + It 'should support OR logic with multiple filters in array' -Skip:(!$IsWindows) { + # Get some updates to use as filters + $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $allConfig = $allOut | ConvertFrom-Json + $allResult = $allConfig.resources[0].properties + if ($allResult.updates.Count -ge 2) { + # Use two specific update IDs as filters (OR logic) + $id1 = $allResult.updates[0].id + $id2 = $allResult.updates[1].id + $json = "{`"updates`":[{`"id`": `"$id1`"}, {`"id`": `"$id2`"}]}" + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + + # Should return both updates (OR logic) + $result.updates.Count | Should -BeGreaterOrEqual 2 + $foundIds = $result.updates.id + $foundIds | Should -Contain $id1 + $foundIds | Should -Contain $id2 + } + else { + Write-Host "Need at least 2 updates for OR logic test, skipping" + $true | Should -Be $true + } + } + } + + It 'should support AND logic within single filter object' -Skip:(!$IsWindows) { + # Multiple properties in one filter = AND logic + $json = '{"updates":[{"isInstalled": true, "updateType": "Software"}]}' + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + if ($result.updates.Count -gt 0) { + # All results must match BOTH conditions + foreach ($update in $result.updates) { + $update.isInstalled | Should -Be $true + $update.updateType | Should -Be 'Software' + } + } + } + } + + It 'should not return duplicates when multiple filters match same update' -Skip:(!$IsWindows) { + # Get an update with known properties + $allOut = '{"updates":[{}]}' | dsc resource export -r $resourceType -o json 2>&1 + + if ($LASTEXITCODE -eq 0) { + $allConfig = $allOut | ConvertFrom-Json + $allResult = $allConfig.resources[0].properties + if ($allResult.updates.Count -gt 0) { + $testUpdate = $allResult.updates[0] + # Use the same ID in both filters - this should only return one update + # Even though technically both filters specify the same criteria + $json = "{`"updates`":[{`"id`": `"$($testUpdate.id)`"}, {`"id`": `"$($testUpdate.id)`"}]}" + $out = $json | dsc resource export -r $resourceType -o json 2>&1 + + $LASTEXITCODE | Should -Be 0 -Because $out + $config = $out | ConvertFrom-Json + $result = $config.resources[0].properties + + # Should return the update only once (no duplicates) + $matchingUpdates = $result.updates | Where-Object { $_.id -eq $testUpdate.id } + $matchingUpdates.Count | Should -Be 1 + } + } + } + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 new file mode 100644 index 000000000..0e9027f78 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 @@ -0,0 +1,369 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update Get operation tests' { + BeforeAll { + $resourceType = 'Microsoft.Windows/UpdateList' + $result = dsc resource export -r $resourceType | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $exportOut = $result.resources[0].properties + $exportOut.updates.Count | Should -BeGreaterThan 0 + } + + Context 'Get operation' { + It 'should return proper JSON structure for existing update with exact title' -Skip:(!$IsWindows) { + $exactTitle = $exportOut.updates[0].title + $json = @{ + updates = @( + @{ + title = $exactTitle + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $getResult = $out | ConvertFrom-Json + $getResult.actualState | Should -Not -BeNullOrEmpty + $getResult.actualState.updates[0].title | Should -BeExactly $exactTitle + $getResult.actualState.updates[0].id | Should -Not -BeNullOrEmpty + $getResult.actualState.updates[0].isInstalled | Should -BeIn ($true, $false) + $getResult.actualState.updates[0].description | Should -Not -BeNullOrEmpty + $getResult.actualState.updates[0].isUninstallable | Should -BeIn ($true, $false) + $getResult.actualState.updates[0].recommendedHardDiskSpace | Should -BeGreaterOrEqual 0 + $getResult.actualState.updates[0].updateType | Should -BeIn @('Software', 'Driver') + } + + It 'should handle case-insensitive exact title match' -Skip:(!$IsWindows) { + $exactTitle = $exportOut.updates[0].title + + # Test with lowercase version + $jsonLower = @{ + updates = @( + @{ + title = $exactTitle.ToLower() + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $outLower = $jsonLower | dsc resource get -r $resourceType 2>&1 + + # Test with uppercase version + $jsonUpper = @{ + updates = @( + @{ + title = $exactTitle.ToUpper() + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $outUpper = $jsonUpper | dsc resource get -r $resourceType 2>&1 + + # Both should succeed + if ($outLower -and $outUpper) { + $resultLower = $outLower | ConvertFrom-Json + $resultUpper = $outUpper | ConvertFrom-Json + $resultLower.actualState.updates[0].id | Should -Be $resultUpper.actualState.updates[0].id + } + } + + It 'should fail when partial title is provided' -Skip:(!$IsWindows) { + # Get operation now requires exact match, so partial should fail + $json = @{ + updates = @( + @{ + title = 'Windows' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource get -r $resourceType 2>&1 + # This will likely fail unless there's an update with exact title "Windows" + # which is unlikely + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should fail when update is not found' -Skip:(!$IsWindows) { + # Use a very unlikely update title + $json = @{ + updates = @( + @{ + title = 'ThisUpdateShouldNeverExist12345XYZ' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should match when both title and id are correct' -Skip:(!$IsWindows) { + $testUpdate = $exportOut.updates[0] + $json = @{ + updates = @( + @{ + title = $testUpdate.title + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].title | Should -Be $testUpdate.title + $result.actualState.updates[0].id | Should -Be $testUpdate.id + } + + It 'should fail when title matches but id does not' -Skip:(!$IsWindows) { + $testUpdate = $exportOut.updates[0] + $json = @{ + updates = @( + @{ + title = $testUpdate.title + id = '00000000-0000-0000-0000-000000000000' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource get -r $resourceType 2>&1 + + # Should fail because id doesn't match + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should fail when id matches but title does not' -Skip:(!$IsWindows) { + $testUpdate = $exportOut.updates[0] + $json = @{ + updates = @( + @{ + title = 'ThisWrongTitle99999' + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $null = $json | dsc resource get -r $resourceType 2>&1 + + # Should fail because title doesn't match + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'should return valid boolean for isInstalled' -Skip:(!$IsWindows) { + $json = @{ + updates = @( + @{ + title = $exportOut.updates[0].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].isInstalled | Should -BeOfType [bool] + } + + It 'should return valid integer for recommendedHardDiskSpace' -Skip:(!$IsWindows) { + $json = @{ + updates = @( + @{ + title = $exportOut.updates[0].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].recommendedHardDiskSpace | Should -BeGreaterOrEqual 0 + } + + It 'should return valid array for KBArticleIDs' -Skip:(!$IsWindows) { + $json = @{ + updates = @( + @{ + title = $exportOut.updates[0].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].kbArticleIds.GetType().BaseType.Name | Should -Be 'Array' + } + + It 'should return valid enum value for updateType' -Skip:(!$IsWindows) { + $json = @{ + updates = @( + @{ + title = $exportOut.updates[0].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].updateType | Should -BeIn @('Software', 'Driver') + } + + It 'should return valid enum value for msrcSeverity when present' -Skip:(!$IsWindows) { + $updateWithSeverity = $exportOut.updates | Where-Object { $null -ne $_.msrcSeverity } | Select-Object -First 1 + + if ($updateWithSeverity) { + $json = @{ + updates = @( + @{ + title = $updateWithSeverity.title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].msrcSeverity | Should -BeExactly $updateWithSeverity.msrcSeverity + } + } + + It 'should include GUID format for update ID' -Skip:(!$IsWindows) { + $json = @{ + updates = @( + @{ + title = $exportOut.updates[0].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + # Basic GUID format check (8-4-4-4-12 hex digits) + $result.actualState.updates[0].id | Should -Match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + } + + It 'should support lookup by id' -Skip:(!$IsWindows) { + $updateId = $exportOut.updates[0].id + $json = @{ + updates = @( + @{ + id = $updateId + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.actualState.updates[0].id | Should -Be $updateId + } + + It 'should process multiple input objects and return all matches' -Skip:(!$IsWindows) { + # Get at least 2 updates to test with + if ($exportOut.updates.Count -ge 2) { + $update1 = $exportOut.updates[0] + $update2 = $exportOut.updates[1] + + $json = @{ + updates = @( + @{ + title = $update1.title + }, + @{ + title = $update2.title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $getResult = $out | ConvertFrom-Json + $getResult.actualState.updates.Count | Should -Be 2 + $getResult.actualState.updates[0].title | Should -BeIn @($update1.title, $update2.title) + $getResult.actualState.updates[1].title | Should -BeIn @($update1.title, $update2.title) + } else { + Set-ItResult -Skipped -Because "Need at least 2 updates for this test" + } + } + + It 'should fail if any input object does not have a match' -Skip:(!$IsWindows) { + $update1 = $exportOut.updates[0] + + $json = @{ + updates = @( + @{ + title = $update1.title + }, + @{ + title = 'ThisUpdateShouldNeverExist12345XYZ' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource get -r $resourceType 2>&1 + + # Should fail because second input has no match + $LASTEXITCODE | Should -Not -Be 0 + + # Check for error message in stderr + $errorText = $stderr | Out-String + $errorText | Should -Match 'No matching update found' + } + + It 'should support filtering by KB article IDs' -Skip:(!$IsWindows) { + # Find an update with KB article IDs + $updateWithKB = $exportOut.updates | Where-Object { $_.kbArticleIds.Count -gt 0 } | Select-Object -First 1 + + if ($updateWithKB) { + $json = @{ + updates = @( + @{ + kbArticleIds = @($updateWithKB.kbArticleIds[0]) + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $getResult = $out | ConvertFrom-Json + $getResult.actualState.updates[0].kbArticleIds | Should -Contain $updateWithKB.kbArticleIds[0] + } else { + Set-ItResult -Skipped -Because "No updates with KB article IDs found" + } + } + + It 'should support filtering by update type' -Skip:(!$IsWindows) { + $softwareUpdate = $exportOut.updates | Where-Object { $_.updateType -eq 'Software' } | Select-Object -First 1 + + if ($softwareUpdate) { + $json = @{ + updates = @( + @{ + id = $softwareUpdate.id + updateType = 'Software' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $getResult = $out | ConvertFrom-Json + $getResult.actualState.updates[0].updateType | Should -Be 'Software' + } else { + Set-ItResult -Skipped -Because "No software updates found" + } + } + + It 'should support filtering by MSRC severity with AND logic' -Skip:(!$IsWindows) { + $updateWithSeverity = $exportOut.updates | Where-Object { $null -ne $_.msrcSeverity } | Select-Object -First 1 + + if ($updateWithSeverity) { + $json = @{ + updates = @( + @{ + id = $updateWithSeverity.id + msrcSeverity = $updateWithSeverity.msrcSeverity + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource get -r $resourceType 2>&1 + + $LASTEXITCODE | Should -Be 0 + $getResult = $out | ConvertFrom-Json + $getResult.actualState.updates[0].msrcSeverity | Should -Be $updateWithSeverity.msrcSeverity + } else { + Set-ItResult -Skipped -Because "No updates with MSRC severity found" + } + } + } +} diff --git a/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 new file mode 100644 index 000000000..64266de58 --- /dev/null +++ b/resources/WindowsUpdate/tests/windowsupdate_set.tests.ps1 @@ -0,0 +1,210 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Update Set operation tests' { + BeforeDiscovery { + $resourceType = 'Microsoft.Windows/UpdateList' + + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + Context 'Set operation' -Skip:(!$isAdmin -or !$IsWindows) { + It 'should match when both title and id are correct' { + # Get an actual installed update with both title and id + $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + if ($result.updates.Count -gt 0) { + $testUpdate = $result.updates[0] + $json = @{ + updates = @( + @{ + title = $testUpdate.title + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + # Try to set (should detect already installed) + $out = $json | dsc resource set -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $out | ConvertFrom-Json + $result.afterState.updates[0].title | Should -Be $testUpdate.title + $result.afterState.updates[0].id | Should -Be $testUpdate.id + $result.afterState.updates[0].isInstalled | Should -Be $true + } + } + else { + Write-Host "No installed updates found, skipping test" + $true | Should -Be $true + } + } + } + + It 'should fail when title matches but id does not' { + # Get an actual update + $exportOut = '{"updates": []}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + if ($result.updates.Count -gt 0) { + $testUpdate = $result.updates[0] + $json = @{ + updates = @( + @{ + title = $testUpdate.title + id = '00000000-0000-0000-0000-000000000000' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource set -r $resourceType 2>&1 + + # Should fail because id doesn't match + $LASTEXITCODE | Should -Not -Be 0 + } + else { + Write-Host "No updates found, skipping test" + $true | Should -Be $true + } + } + } + + It 'should fail when id matches but title does not' { + # Get an actual update + $exportOut = '{"updates": []}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + if ($result.updates.Count -gt 0) { + $testUpdate = $result.updates[0] + $json = @{ + updates = @( + @{ + title = 'ThisWrongTitle99999' + id = $testUpdate.id + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource set -r $resourceType 2>&1 + + # Should fail because title doesn't match + $LASTEXITCODE | Should -Not -Be 0 + } + else { + Write-Host "No updates found, skipping test" + $true | Should -Be $true + } + } + } + + It 'should verify all inputs have matches before installing' { + # Get an actual update + $exportOut = '{"updates": []}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + if ($result.updates.Count -gt 0) { + $update1 = $result.updates[0] + + # One valid, one invalid - should fail before attempting any installation + $json = @{ + updates = @( + @{ + title = $update1.title + }, + @{ + title = 'ThisUpdateShouldNeverExist12345XYZ' + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource set -r $resourceType 2>&1 + + # Should fail before attempting any installation + $LASTEXITCODE | Should -Not -Be 0 + + # Check for error message in stderr + $errorText = $stderr | Out-String + $errorText | Should -Match 'No matching update found' + } + else { + Write-Host "No updates found, skipping test" + $true | Should -Be $true + } + } + } + + It 'should process multiple valid input objects' { + # Get an actual update + $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + # Get two installed updates + $installedUpdates = $result.updates | Where-Object { $_.isInstalled -eq $true } | Select-Object -First 2 + + if ($installedUpdates.Count -ge 2) { + $json = @{ + updates = @( + @{ + title = $installedUpdates[0].title + }, + @{ + title = $installedUpdates[1].title + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource set -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $setResult = $out | ConvertFrom-Json + $setResult.afterState.updates.Count | Should -Be 2 + } + } else { + Write-Host "Need at least 2 installed updates for this test, skipping" + $true | Should -Be $true + } + } + } + + It 'should apply logical AND for all criteria in each input' { + # Get an actual update + $exportOut = '{"updates": [{"isInstalled": true}]}' | dsc resource export -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $result = $exportOut | ConvertFrom-Json + $installedUpdate = $result.updates | Where-Object { $_.isInstalled -eq $true } | Select-Object -First 1 + + if ($installedUpdate) { + # Multiple criteria - all must match + $json = @{ + updates = @( + @{ + title = $installedUpdate.title + id = $installedUpdate.id + isInstalled = $true + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $out = $json | dsc resource set -r $resourceType 2>&1 + + if ($LASTEXITCODE -eq 0) { + $setResult = $out | ConvertFrom-Json + $setResult.afterState.updates[0].title | Should -Be $installedUpdate.title + $setResult.afterState.updates[0].id | Should -Be $installedUpdate.id + $setResult.afterState.updates[0].isInstalled | Should -Be $true + } + } else { + Write-Host "No installed updates found, skipping test" + $true | Should -Be $true + } + } + } + } +} diff --git a/resources/WindowsUpdate/windowsupdate.dsc.resource.json b/resources/WindowsUpdate/windowsupdate.dsc.resource.json new file mode 100644 index 000000000..0e81349c6 --- /dev/null +++ b/resources/WindowsUpdate/windowsupdate.dsc.resource.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "description": "Query Windows Update information for specific updates.", + "tags": [ + "windows", + "update", + "patch", + "security" + ], + "type": "Microsoft.Windows/UpdateList", + "version": "0.1.0", + "get": { + "executable": "wu_dsc", + "args": [ + "get" + ], + "input": "stdin" + }, + "set": { + "executable": "wu_dsc", + "args": [ + "set" + ], + "input": "stdin", + "preTest": true, + "return": "state" + }, + "export": { + "executable": "wu_dsc", + "args": [ + "export" + ], + "input": "stdin" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/resources/Microsoft.Windows/UpdateList/v0.1.0/schema.json", + "title": "Windows Update List", + "description": "Query information about Windows Updates.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource\n", + "markdownDescription": "The `Microsoft.Windows/UpdateList` resource enables you to query information about Windows Updates using the Windows Update Agent COM APIs.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource\n", + "type": "object", + "required": ["updates"], + "additionalProperties": false, + "properties": { + "updates": { + "type": "array", + "title": "Updates", + "description": "An array of update filters or update information objects.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "title": "Update title", + "description": "The exact title of the Windows Update to search for (for get operation) or a title pattern with wildcards (* supported) for filtering (for export operation). Either title or id must be specified for get operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#title\n", + "markdownDescription": "The exact title of the Windows Update to search for (for get operation) or a title pattern with wildcards (* supported) for filtering (for export operation). Either title or id must be specified for get operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#title\n" + }, + "id": { + "type": "string", + "title": "Update ID", + "description": "The unique identifier (GUID) for the Windows Update to search for (for get operation) or filter by (for export operation). Either title or id must be specified for get operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#id\n", + "markdownDescription": "The unique identifier (GUID) for the Windows Update to search for (for get operation) or filter by (for export operation). Either title or id must be specified for get operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#id\n" + }, + "isInstalled": { + "type": "boolean", + "title": "Is installed", + "description": "Indicates whether the update is currently installed on the system. For export operation, this can be used as a filter.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#isinstalled\n", + "markdownDescription": "Indicates whether the update is currently installed on the system. For export operation, this can be used as a filter.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#isinstalled\n" + }, + "description": { + "type": "string", + "title": "Update description", + "description": "The detailed description of the Windows Update. Can be used with wildcards for filtering in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#description\n", + "markdownDescription": "The detailed description of the Windows Update. Can be used with wildcards for filtering in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#description\n" + }, + "isUninstallable": { + "type": "boolean", + "title": "Is uninstallable", + "description": "Indicates whether the update can be uninstalled from the system. Can be used as a filter in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#isuninstallable\n", + "markdownDescription": "Indicates whether the update can be uninstalled from the system. Can be used as a filter in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#isuninstallable\n" + }, + "kbArticleIds": { + "type": "array", + "items": { + "type": "string" + }, + "title": "KB Article IDs", + "description": "The Knowledge Base (KB) article identifiers associated with the update. Can be used as a filter in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#kbarticleids\n", + "markdownDescription": "The Knowledge Base (KB) article identifiers associated with the update. Can be used as a filter in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#kbarticleids\n" + }, + "recommendedHardDiskSpace": { + "type": "integer", + "format": "int64", + "title": "Recommended hard disk space", + "description": "The recommended free hard disk space in megabytes (MB) that should be available before installing the update. Can be used as a filter in export operation (updates with space >= this value will be returned).\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#recommendedharddiskspace\n", + "markdownDescription": "The recommended free hard disk space in megabytes (MB) that should be available before installing the update. Can be used as a filter in export operation (updates with space >= this value will be returned).\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#recommendedharddiskspace\n" + }, + "msrcSeverity": { + "type": "string", + "enum": [ + "Critical", + "Important", + "Moderate", + "Low" + ], + "title": "MSRC severity rating", + "description": "The Microsoft Security Response Center (MSRC) severity rating for the update. Can be used as a filter in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#msrcseverity\n", + "markdownDescription": "The Microsoft Security Response Center (MSRC) severity rating for the update. Can be used as a filter in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#msrcseverity\n" + }, + "securityBulletinIds": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Security bulletin IDs", + "description": "The security bulletin identifiers associated with the update. Can be used as a filter in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#securitybulletinids\n", + "markdownDescription": "The security bulletin identifiers associated with the update. Can be used as a filter in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#securitybulletinids\n" + }, + "updateType": { + "type": "string", + "enum": [ + "Software", + "Driver" + ], + "title": "Update type", + "description": "The type of the update (Software or Driver). Can be used as a filter in export operation.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#updatetype\n", + "markdownDescription": "The type of the update (Software or Driver). Can be used as a filter in export operation.\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#updatetype\n" + } + } + } + } + } + } + } +}