From 5613f4bd3332d96c119da06c9b51dfd6ebaf7957 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Mon, 19 Jan 2026 12:36:30 -0800 Subject: [PATCH 1/7] Make strings localizable, add metadata field --- Cargo.lock | 1 + resources/WindowsUpdate/Cargo.toml | 1 + resources/WindowsUpdate/locales/en-us.toml | 53 +++++++++++++++++++ resources/WindowsUpdate/src/main.rs | 21 ++++---- .../src/windows_update/export.rs | 33 ++++++------ .../WindowsUpdate/src/windows_update/get.rs | 46 ++++++++++------ .../WindowsUpdate/src/windows_update/set.rs | 50 +++++++++++------ .../WindowsUpdate/src/windows_update/types.rs | 3 ++ 8 files changed, 152 insertions(+), 56 deletions(-) create mode 100644 resources/WindowsUpdate/locales/en-us.toml diff --git a/Cargo.lock b/Cargo.lock index 4388cf58d..621c8bbc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,6 +859,7 @@ dependencies = [ name = "dsc-resource-windows-update" version = "0.1.0" dependencies = [ + "rust-i18n", "serde", "serde_json", "windows 0.62.2", diff --git a/resources/WindowsUpdate/Cargo.toml b/resources/WindowsUpdate/Cargo.toml index 2731ec63e..24b57ebfb 100644 --- a/resources/WindowsUpdate/Cargo.toml +++ b/resources/WindowsUpdate/Cargo.toml @@ -8,6 +8,7 @@ name = "wu_dsc" path = "src/main.rs" [dependencies] +rust-i18n = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/resources/WindowsUpdate/locales/en-us.toml b/resources/WindowsUpdate/locales/en-us.toml new file mode 100644 index 000000000..ca611e7c9 --- /dev/null +++ b/resources/WindowsUpdate/locales/en-us.toml @@ -0,0 +1,53 @@ +_version = 1 + +[main] +missingOperation = "Missing operation argument" +usage = "Usage: wu_dsc " +windowsUpdateOnlySupported = "Windows Update resource is only supported on Windows" +unknownOperation = "Unknown operation '%{operation}'" +errorReadingInput = "Error reading input: %{err}" + +[export] +failedParseInput = "Failed to parse input: %{err}" +noMatchingUpdateForFilter = "No matching update found for filter %{index}: %{criteria}" +failedSerializeOutput = "Failed to serialize output: %{err}" +criteriaTitle = "title '%{value}'" +criteriaId = "id '%{value}'" +criteriaIsInstalled = "is_installed %{value}" +criteriaDescription = "description '%{value}'" +criteriaIsUninstallable = "is_uninstallable %{value}" +criteriaKbArticleIds = "kb_article_ids %{value}" +criteriaRecommendedHardDiskSpace = "recommended_hard_disk_space %{value}" +criteriaMsrcSeverity = "msrc_severity %{value}" +criteriaSecurityBulletinIds = "security_bulletin_ids %{value}" +criteriaUpdateType = "update_type %{value}" + +[get] +failedParseInput = "Failed to parse input: %{err}" +updatesArrayEmpty = "Updates array cannot be empty for get operation" +atLeastOneCriterionRequired = "At least one search criterion must be specified for get operation" +titleMatchedMultipleUpdates = "Title '%{title}' matched %{count} updates. Please use a more specific identifier such as 'id' or 'kb_article_ids' to uniquely identify the update." +noMatchingUpdateForCriteria = "No matching update found for criteria: %{criteria}" +failedSerializeOutput = "Failed to serialize output: %{err}" +criteriaTitle = "title '%{value}'" +criteriaId = "id '%{value}'" +criteriaIsInstalled = "is_installed %{value}" +criteriaKbArticleIds = "kb_article_ids %{value}" +criteriaUpdateType = "update_type %{value}" +criteriaMsrcSeverity = "msrc_severity %{value}" + +[set] +failedParseInput = "Failed to parse input: %{err}" +updatesArrayEmpty = "Updates array cannot be empty for set operation" +atLeastOneCriterionRequired = "At least one search criterion must be specified for set operation" +titleMatchedMultipleUpdates = "Title '%{title}' matched %{count} updates. Please use a more specific identifier such as 'id' or 'kb_article_ids' to uniquely identify the update." +noMatchingUpdateForCriteria = "No matching update found for criteria: %{criteria}" +failedDownloadUpdate = "Failed to download update. Result code: %{code}" +failedInstallUpdate = "Failed to install update. Result code: %{code}" +failedSerializeOutput = "Failed to serialize output: %{err}" +criteriaTitle = "title '%{value}'" +criteriaId = "id '%{value}'" +criteriaIsInstalled = "is_installed %{value}" +criteriaKbArticleIds = "kb_article_ids %{value}" +criteriaUpdateType = "update_type %{value}" +criteriaMsrcSeverity = "msrc_severity %{value}" diff --git a/resources/WindowsUpdate/src/main.rs b/resources/WindowsUpdate/src/main.rs index ef2c5db5a..be51553e1 100644 --- a/resources/WindowsUpdate/src/main.rs +++ b/resources/WindowsUpdate/src/main.rs @@ -4,14 +4,17 @@ #[cfg(windows)] mod windows_update; +use rust_i18n::t; use std::io::{self, Read, IsTerminal}; +rust_i18n::i18n!("locales", fallback = "en-us"); + fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { - eprintln!("Error: Missing operation argument"); - eprintln!("Usage: wu_dsc "); + eprintln!("Error: {}", t!("main.missingOperation")); + eprintln!("{}", t!("main.usage")); std::process::exit(1); } @@ -39,7 +42,7 @@ fn main() { #[cfg(not(windows))] { - eprintln!("Error: Windows Update resource is only supported on Windows"); + eprintln!("Error: {}", t!("main.windowsUpdateOnlySupported")); std::process::exit(1); } } @@ -47,7 +50,7 @@ fn main() { // 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); + eprintln!("{}", t!("main.errorReadingInput", err = e)); std::process::exit(1); } @@ -65,7 +68,7 @@ fn main() { #[cfg(not(windows))] { - eprintln!("Error: Windows Update resource is only supported on Windows"); + eprintln!("Error: {}", t!("main.windowsUpdateOnlySupported")); std::process::exit(1); } } @@ -73,7 +76,7 @@ fn main() { // 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); + eprintln!("{}", t!("main.errorReadingInput", err = e)); std::process::exit(1); } @@ -91,13 +94,13 @@ fn main() { #[cfg(not(windows))] { - eprintln!("Error: Windows Update resource is only supported on Windows"); + eprintln!("Error: {}", t!("main.windowsUpdateOnlySupported")); std::process::exit(1); } } _ => { - eprintln!("Error: Unknown operation '{}'", operation); - eprintln!("Usage: wu_dsc "); + eprintln!("{}", t!("main.unknownOperation", operation = operation)); + eprintln!("{}", t!("main.usage")); std::process::exit(1); } } diff --git a/resources/WindowsUpdate/src/windows_update/export.rs b/resources/WindowsUpdate/src/windows_update/export.rs index ce0c373da..6cdec9f00 100644 --- a/resources/WindowsUpdate/src/windows_update/export.rs +++ b/resources/WindowsUpdate/src/windows_update/export.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use rust_i18n::t; use windows::{ core::*, Win32::Foundation::*, @@ -15,11 +16,12 @@ pub fn handle_export(input: &str) -> Result { // Parse optional filter input as UpdateList let update_list: UpdateList = if input.trim().is_empty() { UpdateList { + metadata: None, updates: vec![UpdateInfo { title: None, id: None, - is_installed: None, description: None, + is_installed: None, is_uninstallable: None, kb_article_ids: None, recommended_hard_disk_space: None, @@ -30,7 +32,7 @@ pub fn handle_export(input: &str) -> Result { } } else { serde_json::from_str(input) - .map_err(|e| Error::new(E_INVALIDARG, format!("Failed to parse input: {}", e)))? + .map_err(|e| Error::new(E_INVALIDARG.into(), t!("export.failedParseInput", err = e.to_string()).to_string()))? }; let filters = &update_list.updates; @@ -191,43 +193,43 @@ pub fn handle_export(input: &str) -> Result { // Construct error message with filter criteria let mut criteria_parts = Vec::new(); if let Some(title) = &filter.title { - criteria_parts.push(format!("title '{}'", title)); + criteria_parts.push(t!("export.criteriaTitle", value = title).to_string()); } if let Some(id) = &filter.id { - criteria_parts.push(format!("id '{}'", id)); + criteria_parts.push(t!("export.criteriaId", value = id).to_string()); } if let Some(is_installed) = filter.is_installed { - criteria_parts.push(format!("is_installed {}", is_installed)); + criteria_parts.push(t!("export.criteriaIsInstalled", value = is_installed).to_string()); } if let Some(description) = &filter.description { - criteria_parts.push(format!("description '{}'", description)); + criteria_parts.push(t!("export.criteriaDescription", value = description).to_string()); } if let Some(is_uninstallable) = filter.is_uninstallable { - criteria_parts.push(format!("is_uninstallable {}", is_uninstallable)); + criteria_parts.push(t!("export.criteriaIsUninstallable", value = is_uninstallable).to_string()); } if let Some(kb_ids) = &filter.kb_article_ids { - criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + criteria_parts.push(t!("export.criteriaKbArticleIds", value = format!("{:?}", kb_ids)).to_string()); } if let Some(space) = filter.recommended_hard_disk_space { - criteria_parts.push(format!("recommended_hard_disk_space {}", space)); + criteria_parts.push(t!("export.criteriaRecommendedHardDiskSpace", value = space).to_string()); } if let Some(severity) = &filter.msrc_severity { - criteria_parts.push(format!("msrc_severity {:?}", severity)); + criteria_parts.push(t!("export.criteriaMsrcSeverity", value = format!("{:?}", severity)).to_string()); } if let Some(bulletin_ids) = &filter.security_bulletin_ids { - criteria_parts.push(format!("security_bulletin_ids {:?}", bulletin_ids)); + criteria_parts.push(t!("export.criteriaSecurityBulletinIds", value = format!("{:?}", bulletin_ids)).to_string()); } if let Some(update_type) = &filter.update_type { - criteria_parts.push(format!("update_type {:?}", update_type)); + criteria_parts.push(t!("export.criteriaUpdateType", value = format!("{:?}", update_type)).to_string()); } let criteria_str = criteria_parts.join(", "); - let error_msg = format!("No matching update found for filter {}: {}", filter_index, criteria_str); + let error_msg = t!("export.noMatchingUpdateForFilter", index = filter_index, criteria = criteria_str).to_string(); // Emit JSON error to stderr eprintln!("{{\"error\":\"{}\"}}", error_msg); - return Err(Error::new(E_FAIL, error_msg)); + return Err(Error::new(E_FAIL.into(), error_msg)); } } } @@ -245,10 +247,11 @@ pub fn handle_export(input: &str) -> Result { match result { Ok(updates) => { let result = UpdateList { + metadata: None, updates }; serde_json::to_string(&result) - .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + .map_err(|e| Error::new(E_FAIL.into(), t!("export.failedSerializeOutput", err = e.to_string()).to_string())) } Err(e) => Err(e), } diff --git a/resources/WindowsUpdate/src/windows_update/get.rs b/resources/WindowsUpdate/src/windows_update/get.rs index 17171db88..66994e86a 100644 --- a/resources/WindowsUpdate/src/windows_update/get.rs +++ b/resources/WindowsUpdate/src/windows_update/get.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use rust_i18n::t; use windows::{ core::*, Win32::Foundation::*, @@ -13,10 +14,10 @@ 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)))?; + .map_err(|e| Error::new(E_INVALIDARG.into(), t!("get.failedParseInput", err = e.to_string()).to_string()))?; if update_list.updates.is_empty() { - return Err(Error::new(E_INVALIDARG, "Updates array cannot be empty for get operation")); + return Err(Error::new(E_INVALIDARG.into(), t!("get.updatesArrayEmpty").to_string())); } // Initialize COM @@ -53,11 +54,12 @@ pub fn handle_get(input: &str) -> Result { && 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")); + return Err(Error::new(E_INVALIDARG.into(), t!("get.atLeastOneCriterionRequired").to_string())); } // Find the update matching ALL provided criteria (logical AND) let mut found_update = None; + let mut matching_updates: Vec = Vec::new(); for i in 0..count { let update = all_updates.get_Item(i)?; @@ -144,9 +146,22 @@ pub fn handle_get(input: &str) -> Result { } } - // All criteria matched - extract and store the update - found_update = Some(extract_update_info(&update)?); - break; + // All criteria matched - collect this update + matching_updates.push(update.clone()); + } + + // Check if title matched multiple updates + if let Some(search_title) = &update_input.title { + if matching_updates.len() > 1 { + let error_msg = t!("get.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string(); + eprintln!("{{\"error\":\"{}\"}}", error_msg); + return Err(Error::new(E_INVALIDARG.into(), error_msg)); + } + } + + // Get the first (and should be only) match + if !matching_updates.is_empty() { + found_update = Some(extract_update_info(&matching_updates[0])?); } if let Some(update_info) = found_update { @@ -155,31 +170,31 @@ pub fn handle_get(input: &str) -> Result { // 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)); + criteria_parts.push(t!("get.criteriaTitle", value = title).to_string()); } if let Some(id) = &update_input.id { - criteria_parts.push(format!("id '{}'", id)); + criteria_parts.push(t!("get.criteriaId", value = id).to_string()); } if let Some(is_installed) = update_input.is_installed { - criteria_parts.push(format!("is_installed {}", is_installed)); + criteria_parts.push(t!("get.criteriaIsInstalled", value = is_installed).to_string()); } if let Some(kb_ids) = &update_input.kb_article_ids { - criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + criteria_parts.push(t!("get.criteriaKbArticleIds", value = format!("{:?}", kb_ids)).to_string()); } if let Some(update_type) = &update_input.update_type { - criteria_parts.push(format!("update_type {:?}", update_type)); + criteria_parts.push(t!("get.criteriaUpdateType", value = format!("{:?}", update_type)).to_string()); } if let Some(severity) = &update_input.msrc_severity { - criteria_parts.push(format!("msrc_severity {:?}", severity)); + criteria_parts.push(t!("get.criteriaMsrcSeverity", value = format!("{:?}", severity)).to_string()); } let criteria_str = criteria_parts.join(", "); - let error_msg = format!("No matching update found for criteria: {}", criteria_str); + let error_msg = t!("get.noMatchingUpdateForCriteria", criteria = criteria_str).to_string(); // Emit JSON error to stderr eprintln!("{{\"error\":\"{}\"}}", error_msg); - return Err(Error::new(E_FAIL, error_msg)); + return Err(Error::new(E_FAIL.into(), error_msg)); } } @@ -196,10 +211,11 @@ pub fn handle_get(input: &str) -> Result { match result { Ok(updates) => { let result = UpdateList { + metadata: None, updates }; serde_json::to_string(&result) - .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + .map_err(|e| Error::new(E_FAIL.into(), t!("get.failedSerializeOutput", err = e.to_string()).to_string())) } Err(e) => Err(e), } diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index 8807d86d0..ee2eb7083 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use rust_i18n::t; use windows::{ core::*, Win32::Foundation::*, @@ -13,10 +14,10 @@ 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)))?; + .map_err(|e| Error::new(E_INVALIDARG.into(), t!("set.failedParseInput", err = e.to_string()).to_string()))?; if update_list.updates.is_empty() { - return Err(Error::new(E_INVALIDARG, "Updates array cannot be empty for set operation")); + return Err(Error::new(E_INVALIDARG.into(), t!("set.updatesArrayEmpty").to_string())); } // Initialize COM @@ -53,11 +54,12 @@ pub fn handle_set(input: &str) -> Result { && 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")); + return Err(Error::new(E_INVALIDARG.into(), t!("set.atLeastOneCriterionRequired").to_string())); } // Find the update matching ALL provided criteria (logical AND) let mut found_update: Option<(IUpdate, bool)> = None; + let mut matching_updates: Vec<(IUpdate, bool)> = Vec::new(); for i in 0..count { let update = all_updates.get_Item(i)?; @@ -144,10 +146,23 @@ pub fn handle_set(input: &str) -> Result { } } - // All criteria matched + // All criteria matched - collect this update let is_installed = update.IsInstalled()?.as_bool(); - found_update = Some((update.clone(), is_installed)); - break; + matching_updates.push((update.clone(), is_installed)); + } + + // Check if title matched multiple updates + if let Some(search_title) = &update_input.title { + if matching_updates.len() > 1 { + let error_msg = t!("set.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string(); + eprintln!("{{\"error\":\"{}\"}}", error_msg); + return Err(Error::new(E_INVALIDARG.into(), error_msg)); + } + } + + // Get the first (and should be only) match + if !matching_updates.is_empty() { + found_update = Some(matching_updates[0].clone()); } if let Some(matched) = found_update { @@ -156,31 +171,31 @@ pub fn handle_set(input: &str) -> Result { // 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)); + criteria_parts.push(t!("set.criteriaTitle", value = title).to_string()); } if let Some(id) = &update_input.id { - criteria_parts.push(format!("id '{}'", id)); + criteria_parts.push(t!("set.criteriaId", value = id).to_string()); } if let Some(is_installed) = update_input.is_installed { - criteria_parts.push(format!("is_installed {}", is_installed)); + criteria_parts.push(t!("set.criteriaIsInstalled", value = is_installed).to_string()); } if let Some(kb_ids) = &update_input.kb_article_ids { - criteria_parts.push(format!("kb_article_ids {:?}", kb_ids)); + criteria_parts.push(t!("set.criteriaKbArticleIds", value = format!("{:?}", kb_ids)).to_string()); } if let Some(update_type) = &update_input.update_type { - criteria_parts.push(format!("update_type {:?}", update_type)); + criteria_parts.push(t!("set.criteriaUpdateType", value = format!("{:?}", update_type)).to_string()); } if let Some(severity) = &update_input.msrc_severity { - criteria_parts.push(format!("msrc_severity {:?}", severity)); + criteria_parts.push(t!("set.criteriaMsrcSeverity", value = format!("{:?}", severity)).to_string()); } let criteria_str = criteria_parts.join(", "); - let error_msg = format!("No matching update found for criteria: {}", criteria_str); + let error_msg = t!("set.noMatchingUpdateForCriteria", criteria = criteria_str).to_string(); // Emit JSON error to stderr eprintln!("{{\"error\":\"{}\"}}", error_msg); - return Err(Error::new(E_FAIL, error_msg)); + return Err(Error::new(E_FAIL.into(), error_msg)); } } @@ -212,7 +227,7 @@ pub fn handle_set(input: &str) -> Result { // 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))); + return Err(Error::new(HRESULT(hresult).into(), t!("set.failedDownloadUpdate", code = result_code.0).to_string())); } } @@ -226,7 +241,7 @@ pub fn handle_set(input: &str) -> Result { // 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))); + return Err(Error::new(HRESULT(hresult).into(), t!("set.failedInstallUpdate", code = result_code.0).to_string())); } // Get full details now that it's installed @@ -249,10 +264,11 @@ pub fn handle_set(input: &str) -> Result { match result { Ok(updates) => { let results = UpdateList { + metadata: None, updates }; serde_json::to_string(&results) - .map_err(|e| Error::new(E_FAIL, format!("Failed to serialize output: {}", e))) + .map_err(|e| Error::new(E_FAIL.into(), t!("set.failedSerializeOutput", err = e.to_string()).to_string())) } Err(e) => Err(e), } diff --git a/resources/WindowsUpdate/src/windows_update/types.rs b/resources/WindowsUpdate/src/windows_update/types.rs index 9c98642a3..aec25c11e 100644 --- a/resources/WindowsUpdate/src/windows_update/types.rs +++ b/resources/WindowsUpdate/src/windows_update/types.rs @@ -2,10 +2,13 @@ // Licensed under the MIT License. use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct UpdateList { + #[serde(rename = "_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option>, pub updates: Vec, } From 01fe4a3c4e9182d4e3a72ffc928f132c7b4959dc Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Mon, 19 Jan 2026 13:18:29 -0800 Subject: [PATCH 2/7] set _restartRequired --- .../WindowsUpdate/src/windows_update/set.rs | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index ee2eb7083..063e7f97d 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use rust_i18n::t; +use serde_json::{Map, Value}; use windows::{ core::*, Win32::Foundation::*, @@ -11,6 +12,28 @@ use windows::{ use crate::windows_update::types::{UpdateList, UpdateInfo, extract_update_info}; +/// Gets the computer name using the COMPUTERNAME environment variable +fn get_computer_name() -> String { + std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) +} + +/// Checks if a reboot is or might be required for the given update based on InstallationBehavior +/// Returns true if RebootBehavior indicates reboot is always required (2) or can request reboot (1) +fn check_reboot_behavior(update: &IUpdate) -> bool { + unsafe { + if let Ok(behavior) = update.InstallationBehavior() { + if let Ok(reboot_behavior) = behavior.RebootBehavior() { + // InstallRebootBehavior values: + // 0 = irbNeverReboots - Never requires reboot + // 1 = irbAlwaysRequiresReboot - Always requires reboot + // 2 = irbCanRequestReboot - Can request reboot + return reboot_behavior.0 == 1 || reboot_behavior.0 == 2; + } + } + false + } +} + pub fn handle_set(input: &str) -> Result { // Parse input as UpdateList let update_list: UpdateList = serde_json::from_str(input) @@ -25,7 +48,7 @@ pub fn handle_set(input: &str) -> Result { CoInitializeEx(Some(std::ptr::null()), COINIT_MULTITHREADED).is_ok() }; - let result: Result> = unsafe { + let result: Result<(Vec, bool)> = unsafe { // Create update session let update_session: IUpdateSession = CoCreateInstance( &UpdateSession, @@ -201,6 +224,7 @@ pub fn handle_set(input: &str) -> Result { // All inputs have matches - now proceed with installation/uninstallation let mut result_updates = Vec::new(); + let mut reboot_required = false; for (update, is_installed) in matched_updates { let update_info = if is_installed { @@ -208,6 +232,11 @@ pub fn handle_set(input: &str) -> Result { extract_update_info(&update)? } else { // Not installed - proceed with installation + // Check if this update requires or might require a reboot + if !reboot_required && check_reboot_behavior(&update) { + reboot_required = true; + } + // Create update collection for download/install let updates_to_install: IUpdateCollection = CoCreateInstance( &UpdateCollection, @@ -251,7 +280,7 @@ pub fn handle_set(input: &str) -> Result { result_updates.push(update_info); } - Ok(result_updates) + Ok((result_updates, reboot_required)) }; // Ensure COM is uninitialized if it was initialized @@ -262,9 +291,24 @@ pub fn handle_set(input: &str) -> Result { } match result { - Ok(updates) => { + Ok((updates, reboot_required)) => { + // Build metadata if reboot is required + let metadata = if reboot_required { + let computer_name = get_computer_name(); + let mut restart_required_item = Map::new(); + restart_required_item.insert("system".to_string(), Value::String(computer_name)); + + let restart_required_array = Value::Array(vec![Value::Object(restart_required_item)]); + + let mut metadata_map = Map::new(); + metadata_map.insert("_restartRequired".to_string(), restart_required_array); + Some(metadata_map) + } else { + None + }; + let results = UpdateList { - metadata: None, + metadata, updates }; serde_json::to_string(&results) From a720253123ce78a29693e57b462ca58370286d06 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 20 Jan 2026 12:59:27 -0800 Subject: [PATCH 3/7] Update resources/WindowsUpdate/src/windows_update/set.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WindowsUpdate/src/windows_update/set.rs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index 063e7f97d..c11924ac8 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -174,13 +174,26 @@ pub fn handle_set(input: &str) -> Result { matching_updates.push((update.clone(), is_installed)); } - // Check if title matched multiple updates - if let Some(search_title) = &update_input.title { - if matching_updates.len() > 1 { - let error_msg = t!("set.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string(); - eprintln!("{{\"error\":\"{}\"}}", error_msg); - return Err(Error::new(E_INVALIDARG.into(), error_msg)); - } + // Check if multiple updates matched the provided criteria + if matching_updates.len() > 1 { + // Prefer the existing localized message when a title is provided, + // otherwise fall back to a generic message that does not assume title was used. + let error_msg = if let Some(search_title) = &update_input.title { + t!( + "set.titleMatchedMultipleUpdates", + title = search_title, + count = matching_updates.len() + ) + .to_string() + } else { + format!( + "Multiple updates ({}) matched the provided criteria. \ + Please refine your criteria to uniquely identify a single update.", + matching_updates.len() + ) + }; + eprintln!("{{\"error\":\"{}\"}}", error_msg); + return Err(Error::new(E_INVALIDARG.into(), error_msg)); } // Get the first (and should be only) match From 22954e138cb5043a5d7d1955998446a78a452d7c Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 20 Jan 2026 12:59:45 -0800 Subject: [PATCH 4/7] Update resources/WindowsUpdate/src/windows_update/get.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WindowsUpdate/src/windows_update/get.rs | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/resources/WindowsUpdate/src/windows_update/get.rs b/resources/WindowsUpdate/src/windows_update/get.rs index 66994e86a..db01543d8 100644 --- a/resources/WindowsUpdate/src/windows_update/get.rs +++ b/resources/WindowsUpdate/src/windows_update/get.rs @@ -150,13 +150,29 @@ pub fn handle_get(input: &str) -> Result { matching_updates.push(update.clone()); } - // Check if title matched multiple updates - if let Some(search_title) = &update_input.title { - if matching_updates.len() > 1 { - let error_msg = t!("get.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string(); - eprintln!("{{\"error\":\"{}\"}}", error_msg); - return Err(Error::new(E_INVALIDARG.into(), error_msg)); - } + // Check if multiple updates matched the provided criteria + if matching_updates.len() > 1 { + // Determine if title was the only search criterion + let title_only = update_input.title.is_some() + && 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(); + + let error_msg = if title_only { + let search_title = update_input.title.as_ref().unwrap(); + t!("get.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string() + } else { + // General message that does not assume which criterion caused ambiguity + format!( + "Multiple updates ({}) matched the specified criteria; please refine your search.", + matching_updates.len() + ) + }; + + eprintln!("{{\"error\":\"{}\"}}", error_msg); + return Err(Error::new(E_INVALIDARG.into(), error_msg)); } // Get the first (and should be only) match From e57935dfcd6286f3393b161998125bd1a448b988 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 20 Jan 2026 13:02:16 -0800 Subject: [PATCH 5/7] Fix detection of requiring reboot --- .../src/windows_update/export.rs | 5 +- .../WindowsUpdate/src/windows_update/set.rs | 31 ++++-------- .../WindowsUpdate/src/windows_update/types.rs | 47 ++++++++++++++++--- 3 files changed, 53 insertions(+), 30 deletions(-) diff --git a/resources/WindowsUpdate/src/windows_update/export.rs b/resources/WindowsUpdate/src/windows_update/export.rs index 6cdec9f00..93eeee8e8 100644 --- a/resources/WindowsUpdate/src/windows_update/export.rs +++ b/resources/WindowsUpdate/src/windows_update/export.rs @@ -18,15 +18,16 @@ pub fn handle_export(input: &str) -> Result { UpdateList { metadata: None, updates: vec![UpdateInfo { - title: None, - id: None, description: None, + id: None, + installation_behavior: None, is_installed: None, is_uninstallable: None, kb_article_ids: None, recommended_hard_disk_space: None, msrc_severity: None, security_bulletin_ids: None, + title: None, update_type: None, }] } diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index c11924ac8..452a6e650 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -17,23 +17,6 @@ fn get_computer_name() -> String { std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()) } -/// Checks if a reboot is or might be required for the given update based on InstallationBehavior -/// Returns true if RebootBehavior indicates reboot is always required (2) or can request reboot (1) -fn check_reboot_behavior(update: &IUpdate) -> bool { - unsafe { - if let Ok(behavior) = update.InstallationBehavior() { - if let Ok(reboot_behavior) = behavior.RebootBehavior() { - // InstallRebootBehavior values: - // 0 = irbNeverReboots - Never requires reboot - // 1 = irbAlwaysRequiresReboot - Always requires reboot - // 2 = irbCanRequestReboot - Can request reboot - return reboot_behavior.0 == 1 || reboot_behavior.0 == 2; - } - } - false - } -} - pub fn handle_set(input: &str) -> Result { // Parse input as UpdateList let update_list: UpdateList = serde_json::from_str(input) @@ -245,11 +228,6 @@ pub fn handle_set(input: &str) -> Result { extract_update_info(&update)? } else { // Not installed - proceed with installation - // Check if this update requires or might require a reboot - if !reboot_required && check_reboot_behavior(&update) { - reboot_required = true; - } - // Create update collection for download/install let updates_to_install: IUpdateCollection = CoCreateInstance( &UpdateCollection, @@ -286,6 +264,15 @@ pub fn handle_set(input: &str) -> Result { return Err(Error::new(HRESULT(hresult).into(), t!("set.failedInstallUpdate", code = result_code.0).to_string())); } + // Check if installation result indicates a reboot is required + if !reboot_required { + if let Ok(reboot_req) = install_result.RebootRequired() { + if reboot_req.as_bool() { + reboot_required = true; + } + } + } + // Get full details now that it's installed extract_update_info(&update)? }; diff --git a/resources/WindowsUpdate/src/windows_update/types.rs b/resources/WindowsUpdate/src/windows_update/types.rs index aec25c11e..f6db67697 100644 --- a/resources/WindowsUpdate/src/windows_update/types.rs +++ b/resources/WindowsUpdate/src/windows_update/types.rs @@ -15,25 +15,27 @@ pub struct UpdateList { #[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 installation_behavior: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_installed: 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 recommended_hard_disk_space: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub security_bulletin_ids: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub update_type: Option, } @@ -51,6 +53,18 @@ pub enum UpdateType { Driver, } +/// Represents the installation behavior reboot options from Windows Update +/// These values indicate what reboot behavior can be expected from an update +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum InstallationBehavior { + /// Never requires a reboot + NeverReboots, + /// Always requires a reboot + AlwaysRequiresReboot, + /// Can request a reboot + CanRequestReboot, +} + impl std::fmt::Display for MsrcSeverity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -140,6 +154,26 @@ pub fn extract_update_info(update: &IUpdate) -> Result { } }; + // Get installation behavior reboot setting + let installation_behavior = if let Ok(behavior) = update.InstallationBehavior() { + if let Ok(reboot_behavior) = behavior.RebootBehavior() { + // InstallRebootBehavior values: + // 0 = irbNeverReboots - Never requires reboot + // 1 = irbAlwaysRequiresReboot - Always requires reboot + // 2 = irbCanRequestReboot - Can request reboot + match reboot_behavior.0 { + 0 => Some(InstallationBehavior::NeverReboots), + 1 => Some(InstallationBehavior::AlwaysRequiresReboot), + 2 => Some(InstallationBehavior::CanRequestReboot), + _ => None, + } + } else { + None + } + } else { + None + }; + Ok(UpdateInfo { title: Some(title), is_installed: Some(is_installed), @@ -151,6 +185,7 @@ pub fn extract_update_info(update: &IUpdate) -> Result { msrc_severity, security_bulletin_ids: Some(security_bulletin_ids), update_type: Some(update_type), + installation_behavior, }) } } From 49c09e25b0b49e448f394ede12ea3a1ceacebfb6 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 20 Jan 2026 14:17:15 -0800 Subject: [PATCH 6/7] fix localization --- resources/WindowsUpdate/locales/en-us.toml | 2 ++ resources/WindowsUpdate/src/windows_update/get.rs | 6 +----- resources/WindowsUpdate/src/windows_update/set.rs | 13 ++----------- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/resources/WindowsUpdate/locales/en-us.toml b/resources/WindowsUpdate/locales/en-us.toml index ca611e7c9..4c2129bd7 100644 --- a/resources/WindowsUpdate/locales/en-us.toml +++ b/resources/WindowsUpdate/locales/en-us.toml @@ -27,6 +27,7 @@ failedParseInput = "Failed to parse input: %{err}" updatesArrayEmpty = "Updates array cannot be empty for get operation" atLeastOneCriterionRequired = "At least one search criterion must be specified for get operation" titleMatchedMultipleUpdates = "Title '%{title}' matched %{count} updates. Please use a more specific identifier such as 'id' or 'kb_article_ids' to uniquely identify the update." +criteriaMatchedMultipleUpdates = "Criteria matched %{count} updates. Please use more specific identifiers to uniquely identify the update." noMatchingUpdateForCriteria = "No matching update found for criteria: %{criteria}" failedSerializeOutput = "Failed to serialize output: %{err}" criteriaTitle = "title '%{value}'" @@ -41,6 +42,7 @@ failedParseInput = "Failed to parse input: %{err}" updatesArrayEmpty = "Updates array cannot be empty for set operation" atLeastOneCriterionRequired = "At least one search criterion must be specified for set operation" titleMatchedMultipleUpdates = "Title '%{title}' matched %{count} updates. Please use a more specific identifier such as 'id' or 'kb_article_ids' to uniquely identify the update." +criteriaMatchedMultipleUpdates = "Criteria matched %{count} updates. Please use more specific identifiers to uniquely identify the update." noMatchingUpdateForCriteria = "No matching update found for criteria: %{criteria}" failedDownloadUpdate = "Failed to download update. Result code: %{code}" failedInstallUpdate = "Failed to install update. Result code: %{code}" diff --git a/resources/WindowsUpdate/src/windows_update/get.rs b/resources/WindowsUpdate/src/windows_update/get.rs index db01543d8..83a7bd3c1 100644 --- a/resources/WindowsUpdate/src/windows_update/get.rs +++ b/resources/WindowsUpdate/src/windows_update/get.rs @@ -164,11 +164,7 @@ pub fn handle_get(input: &str) -> Result { let search_title = update_input.title.as_ref().unwrap(); t!("get.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string() } else { - // General message that does not assume which criterion caused ambiguity - format!( - "Multiple updates ({}) matched the specified criteria; please refine your search.", - matching_updates.len() - ) + t!("get.criteriaMatchedMultipleUpdates", count = matching_updates.len()).to_string() }; eprintln!("{{\"error\":\"{}\"}}", error_msg); diff --git a/resources/WindowsUpdate/src/windows_update/set.rs b/resources/WindowsUpdate/src/windows_update/set.rs index 452a6e650..9d2dbce41 100644 --- a/resources/WindowsUpdate/src/windows_update/set.rs +++ b/resources/WindowsUpdate/src/windows_update/set.rs @@ -162,18 +162,9 @@ pub fn handle_set(input: &str) -> Result { // Prefer the existing localized message when a title is provided, // otherwise fall back to a generic message that does not assume title was used. let error_msg = if let Some(search_title) = &update_input.title { - t!( - "set.titleMatchedMultipleUpdates", - title = search_title, - count = matching_updates.len() - ) - .to_string() + t!("set.titleMatchedMultipleUpdates", title = search_title, count = matching_updates.len()).to_string() } else { - format!( - "Multiple updates ({}) matched the provided criteria. \ - Please refine your criteria to uniquely identify a single update.", - matching_updates.len() - ) + t!("set.criteriaMatchedMultipleUpdates", count = matching_updates.len()).to_string() }; eprintln!("{{\"error\":\"{}\"}}", error_msg); return Err(Error::new(E_INVALIDARG.into(), error_msg)); From d23812b1446ca1a79724e0ca9ef21b3c3b574750 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 20 Jan 2026 15:01:02 -0800 Subject: [PATCH 7/7] update tests --- .../tests/windowsupdate.schema.tests.ps1 | 12 +- .../tests/windowsupdate_export.tests.ps1 | 32 ++++++ .../tests/windowsupdate_get.tests.ps1 | 108 ++++++++++++++++++ .../windowsupdate.dsc.resource.json | 11 ++ 4 files changed, 162 insertions(+), 1 deletion(-) diff --git a/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 index e4cd7cce0..3fb55770f 100644 --- a/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate.schema.tests.ps1 @@ -83,7 +83,8 @@ Describe 'Windows Update resource schema validation' { 'recommendedHardDiskSpace', 'msrcSeverity', 'securityBulletinIds', - 'updateType' + 'updateType', + 'installationBehavior' ) foreach ($prop in $expectedProperties) { @@ -159,6 +160,15 @@ Describe 'Windows Update resource schema validation' { $updateType.enum | Should -Contain 'Driver' } + It 'installationBehavior property should be enum with correct values' { + $manifest = Get-Content $manifestPath | ConvertFrom-Json + $installationBehavior = $manifest.schema.embedded.properties.updates.items.properties.installationBehavior + $installationBehavior.type | Should -BeExactly 'string' + $installationBehavior.enum | Should -Contain 'NeverReboots' + $installationBehavior.enum | Should -Contain 'AlwaysRequiresReboot' + $installationBehavior.enum | Should -Contain 'CanRequestReboot' + } + It 'schema should not allow additional properties' { $manifest = Get-Content $manifestPath | ConvertFrom-Json $manifest.schema.embedded.additionalProperties | Should -Be $false diff --git a/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 index fe13a1df7..c12a3a54b 100644 --- a/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate_export.tests.ps1 @@ -306,5 +306,37 @@ Describe 'Windows Update Export operation tests' { } } } + + It 'should return installationBehavior property when present' -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) { + # Check if any update has installationBehavior property + $updateWithBehavior = $result.updates | Where-Object { $null -ne $_.installationBehavior } | Select-Object -First 1 + + if ($updateWithBehavior) { + # Verify the value is one of the valid enum values + $updateWithBehavior.installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') + } + } + } + + It 'should return valid installationBehavior enum values for all 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 + + foreach ($update in $result.updates) { + if ($null -ne $update.installationBehavior) { + $update.installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') -Because "Update '$($update.title)' has invalid installationBehavior" + } + } + } } } diff --git a/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 index 0e9027f78..ffba6b2c2 100644 --- a/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 +++ b/resources/WindowsUpdate/tests/windowsupdate_get.tests.ps1 @@ -365,5 +365,113 @@ Describe 'Windows Update Get operation tests' { Set-ItResult -Skipped -Because "No updates with MSRC severity found" } } + + It 'should return installationBehavior property when present' -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 + # installationBehavior should be one of the valid enum values if present + if ($null -ne $result.actualState.updates[0].installationBehavior) { + $result.actualState.updates[0].installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') + } + } + + It 'should return valid enum value for installationBehavior' -Skip:(!$IsWindows) { + # Find an update that has installationBehavior set + $updateWithBehavior = $exportOut.updates | Where-Object { $null -ne $_.installationBehavior } | Select-Object -First 1 + + if ($updateWithBehavior) { + $json = @{ + updates = @( + @{ + id = $updateWithBehavior.id + } + ) + } | 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].installationBehavior | Should -BeIn @('NeverReboots', 'AlwaysRequiresReboot', 'CanRequestReboot') + } else { + Set-ItResult -Skipped -Because "No updates with installationBehavior found" + } + } + + It 'should fail when title matches multiple updates' -Skip:(!$IsWindows) { + # Find a title pattern that might match multiple updates + # Using isInstalled filter with a common partial title like 'Windows' could match multiple + # This test verifies the new multiple-match detection behavior + + # First, check if there are multiple updates with similar titles + $windowsUpdates = $exportOut.updates | Where-Object { $_.title -like '*Windows*' } + + if ($windowsUpdates.Count -ge 2) { + # Find a common substring that appears in multiple update titles + # Try to use a very generic criteria that would match multiple + $json = @{ + updates = @( + @{ + isInstalled = $true + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource get -r $resourceType 2>&1 + + # If multiple updates match isInstalled=true, it should error + $installedCount = ($exportOut.updates | Where-Object { $_.isInstalled -eq $true }).Count + if ($installedCount -gt 1) { + $LASTEXITCODE | Should -Not -Be 0 + $errorText = $stderr | Out-String + $errorText | Should -Match 'matched.*updates|multiple' + } else { + # Only one installed update, so it should succeed + $LASTEXITCODE | Should -Be 0 + } + } else { + Set-ItResult -Skipped -Because "Need multiple updates to test multiple match detection" + } + } + + It 'should provide helpful error message when multiple updates match title criteria' -Skip:(!$IsWindows) { + # Find a case where using title-only might match multiple updates + # Group updates by similar starting titles + $titleGroups = $exportOut.updates | Group-Object { ($_.title -split ' ')[0..2] -join ' ' } | Where-Object { $_.Count -gt 1 } + + if ($titleGroups.Count -gt 0) { + # Use the first duplicate-ish title group + $firstGroup = $titleGroups[0].Group + if ($firstGroup.Count -ge 2) { + # There might be multiple updates with same starting title + # The error message should mention using more specific identifiers + $json = @{ + updates = @( + @{ + isInstalled = $firstGroup[0].isInstalled + updateType = $firstGroup[0].updateType + } + ) + } | ConvertTo-Json -Depth 10 -Compress + $stderr = $json | dsc resource get -r $resourceType 2>&1 + + # This may or may not fail depending on uniqueness + if ($LASTEXITCODE -ne 0) { + $errorText = $stderr | Out-String + # Should contain helpful guidance + $errorText | Should -Match 'specific|identifier|criteria' + } + } + } else { + Set-ItResult -Skipped -Because "No duplicate title patterns found for testing" + } + } } } diff --git a/resources/WindowsUpdate/windowsupdate.dsc.resource.json b/resources/WindowsUpdate/windowsupdate.dsc.resource.json index 0e81349c6..853f9ca56 100644 --- a/resources/WindowsUpdate/windowsupdate.dsc.resource.json +++ b/resources/WindowsUpdate/windowsupdate.dsc.resource.json @@ -127,6 +127,17 @@ "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" + }, + "installationBehavior": { + "type": "string", + "enum": [ + "NeverReboots", + "AlwaysRequiresReboot", + "CanRequestReboot" + ], + "title": "Installation behavior", + "description": "Indicates the reboot behavior expected from installing this update. NeverReboots means the update never requires a reboot, AlwaysRequiresReboot means it always requires one, and CanRequestReboot means it may request a reboot.\n\nhttps://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#installationbehavior\n", + "markdownDescription": "Indicates the reboot behavior expected from installing this update.\n\n- **NeverReboots**: The update never requires a reboot\n- **AlwaysRequiresReboot**: The update always requires a reboot\n- **CanRequestReboot**: The update may request a reboot\n\n[Online documentation][01]\n\n[01]: https://learn.microsoft.com/powershell/dsc/reference/microsoft.windows/updatelist/resource#installationbehavior\n" } } }