From ea2a73d31cc3ecff0cdc0622536c59b84d310c77 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 10 Mar 2026 17:24:54 -0700 Subject: [PATCH 01/15] Add Microsoft.Windows/Service resource --- Cargo.lock | 10 + Cargo.toml | 7 +- resources/windows_service/.project.data.json | 14 + resources/windows_service/Cargo.toml | 17 + resources/windows_service/locales/en-us.toml | 38 + resources/windows_service/src/main.rs | 163 ++++ resources/windows_service/src/service.rs | 834 ++++++++++++++++++ resources/windows_service/src/types.rs | 145 +++ .../tests/windows_service_export.tests.ps1 | 246 ++++++ .../tests/windows_service_get.tests.ps1 | 118 +++ .../tests/windows_service_set.tests.ps1 | 238 +++++ .../windows_service.dsc.resource.json | 115 +++ 12 files changed, 1942 insertions(+), 3 deletions(-) create mode 100644 resources/windows_service/.project.data.json create mode 100644 resources/windows_service/Cargo.toml create mode 100644 resources/windows_service/locales/en-us.toml create mode 100644 resources/windows_service/src/main.rs create mode 100644 resources/windows_service/src/service.rs create mode 100644 resources/windows_service/src/types.rs create mode 100644 resources/windows_service/tests/windows_service_export.tests.ps1 create mode 100644 resources/windows_service/tests/windows_service_get.tests.ps1 create mode 100644 resources/windows_service/tests/windows_service_set.tests.ps1 create mode 100644 resources/windows_service/windows_service.dsc.resource.json diff --git a/Cargo.lock b/Cargo.lock index 969561253..18d7b49ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4297,6 +4297,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_service" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "serde", + "serde_json", + "windows 0.62.2", +] + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index f5301114c..9198c2bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ "grammars/tree-sitter-ssh-server-config", "y2j", "xtask" -] +, "resources/windows_service"] # This value is modified by the `Set-DefaultWorkspaceMember` helper. # Be sure to use `Reset-DefaultWorkspaceMember` before committing to # avoid unintentionally modifying this value. @@ -52,7 +52,7 @@ default-members = [ "grammars/tree-sitter-ssh-server-config", "y2j", "xtask" -] +, "resources/windows_service"] [workspace.metadata.groups] # The entries in this table map crates by operating system. Use the helper @@ -246,11 +246,12 @@ urlencoding = { version = "2.1" } which = { version = "8.0" } # dsc-lib ipnetwork = { version = "0.21" } -# WindowsUpdate +# WindowsUpdate, windows_service windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_System_Com", "Win32_System_Ole", + "Win32_System_Services", "Win32_System_Variant", "Win32_System_UpdateAgent" ] } diff --git a/resources/windows_service/.project.data.json b/resources/windows_service/.project.data.json new file mode 100644 index 000000000..3ad65d929 --- /dev/null +++ b/resources/windows_service/.project.data.json @@ -0,0 +1,14 @@ +{ + "Name": "windows_service", + "Kind": "Resource", + "IsRust": true, + "SupportedPlatformOS": "Windows", + "Binaries": [ + "windows_service" + ], + "CopyFiles": { + "Windows": [ + "windows_service.dsc.resource.json" + ] + } +} diff --git a/resources/windows_service/Cargo.toml b/resources/windows_service/Cargo.toml new file mode 100644 index 000000000..fc06300af --- /dev/null +++ b/resources/windows_service/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "windows_service" +version = "0.1.0" +edition = "2024" + +[package.metadata.i18n] +available-locales = ["en-us"] +default-locale = "en-us" +load-path = "locales" + +[dependencies] +rust-i18n = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true } diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml new file mode 100644 index 000000000..2296bb15f --- /dev/null +++ b/resources/windows_service/locales/en-us.toml @@ -0,0 +1,38 @@ +_version = 1 + +[main] +missingOperation = "Missing operation. Usage: windows_service --input " +unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" +missingInput = "Missing --input argument" +missingInputValue = "Missing value for --input argument" +invalidJson = "Invalid JSON input: %{error}" +setNotImplemented = "The 'set' operation is not yet implemented" +exportNotImplemented = "The 'export' operation is not yet implemented" +windowsOnly = "This resource is only supported on Windows" + +[get] +nameOrDisplayNameRequired = "At least one of 'name' or 'displayName' must be provided" +openScmFailed = "Failed to open Service Control Manager: %{error}" +queryConfigFailed = "Failed to query service configuration: %{error}" +queryStatusFailed = "Failed to query service status: %{error}" +getKeyNameFailed = "Failed to resolve service name from display name: %{error}" +displayNameMismatch = "Service display name mismatch: expected '%{expected}', got '%{actual}'" + +[export] +enumServicesFailed = "Failed to enumerate services: %{error}" +openServiceFailed = "Failed to open service '%{name}': %{error}" +serializeFailed = "Failed to serialize service: %{error}" + +[set] +nameRequired = "'name' is required for the set operation" +openScmFailed = "Failed to open Service Control Manager: %{error}" +openServiceFailed = "Failed to open service: %{error}" +changeConfigFailed = "Failed to change service configuration: %{error}" +changeConfig2Failed = "Failed to change service extended configuration: %{error}" +startFailed = "Failed to start service: %{error}" +stopFailed = "Failed to stop service: %{error}" +pauseFailed = "Failed to pause service: %{error}" +continueFailed = "Failed to continue service: %{error}" +unsupportedTransition = "Unsupported status transition from '%{current}' to '%{desired}'" +unsupportedStatus = "Cannot set service to status '%{status}'; only Running, Stopped, and Paused are supported" +statusTimeout = "Timed out waiting for service to reach status '%{expected}'; current status is '%{actual}'" diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs new file mode 100644 index 000000000..9b45ca45d --- /dev/null +++ b/resources/windows_service/src/main.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod types; + +#[cfg(windows)] +mod service; + +use rust_i18n::t; +use std::process::exit; + +use types::WindowsService; + +rust_i18n::i18n!("locales", fallback = "en-us"); + +const EXIT_SUCCESS: i32 = 0; +const EXIT_INVALID_ARGS: i32 = 1; +const EXIT_INVALID_INPUT: i32 = 2; +const EXIT_SERVICE_ERROR: i32 = 3; + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Error: {}", t!("main.missingOperation")); + exit(EXIT_INVALID_ARGS); + } + + let operation = args[1].as_str(); + let input_json = parse_input_arg(&args); + + match operation { + "get" => { + let json = match input_json { + Some(j) => j, + None => { + eprintln!("Error: {}", t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + + let input: WindowsService = match serde_json::from_str(&json) { + Ok(s) => s, + Err(e) => { + eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }; + + #[cfg(windows)] + { + match service::get_service(&input) { + Ok(result) => { + println!("{}", serde_json::to_string(&result).unwrap()); + exit(EXIT_SUCCESS); + } + Err(e) => { + eprintln!("Error: {e}"); + exit(EXIT_SERVICE_ERROR); + } + } + } + + #[cfg(not(windows))] + { + let _ = input; + eprintln!("Error: {}", t!("main.windowsOnly")); + exit(EXIT_SERVICE_ERROR); + } + } + "set" => { + let json = match input_json { + Some(j) => j, + None => { + eprintln!("Error: {}", t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + + let input: WindowsService = match serde_json::from_str(&json) { + Ok(s) => s, + Err(e) => { + eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }; + + #[cfg(windows)] + { + match service::set_service(&input) { + Ok(result) => { + println!("{}", serde_json::to_string(&result).unwrap()); + exit(EXIT_SUCCESS); + } + Err(e) => { + eprintln!("Error: {e}"); + exit(EXIT_SERVICE_ERROR); + } + } + } + + #[cfg(not(windows))] + { + let _ = input; + eprintln!("Error: {}", t!("main.windowsOnly")); + exit(EXIT_SERVICE_ERROR); + } + } + "export" => { + let filter: Option = match input_json { + Some(json) => match serde_json::from_str(&json) { + Ok(s) => Some(s), + Err(e) => { + eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + }, + None => None, + }; + + #[cfg(windows)] + { + match service::export_services(filter.as_ref()) { + Ok(()) => exit(EXIT_SUCCESS), + Err(e) => { + eprintln!("Error: {e}"); + exit(EXIT_SERVICE_ERROR); + } + } + } + + #[cfg(not(windows))] + { + let _ = filter; + eprintln!("Error: {}", t!("main.windowsOnly")); + exit(EXIT_SERVICE_ERROR); + } + } + _ => { + eprintln!( + "Error: {}", + t!("main.unknownOperation", operation = operation) + ); + exit(EXIT_INVALID_ARGS); + } + } +} + +/// Parse the `--input ` argument from the command-line args. +fn parse_input_arg(args: &[String]) -> Option { + let mut i = 2; // skip binary name and operation + while i < args.len() { + if args[i] == "--input" || args[i] == "-i" { + if i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + eprintln!("Error: {}", t!("main.missingInputValue")); + exit(EXIT_INVALID_ARGS); + } + i += 1; + } + None +} diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs new file mode 100644 index 000000000..4d6263d4e --- /dev/null +++ b/resources/windows_service/src/service.rs @@ -0,0 +1,834 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use std::mem; +use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::System::Services::*; + +const SERVICE_NO_CHANGE_VALUE: u32 = 0xFFFF_FFFF; +const STATUS_WAIT_TIMEOUT_SECS: u64 = 30; +const STATUS_POLL_INTERVAL_MS: u64 = 250; + +use crate::types::*; + +/// Encode a Rust string as a null-terminated UTF-16 buffer. +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Convert a `PWSTR` to an `Option`. +/// +/// # Safety +/// +/// The pointer must be valid or null. +unsafe fn pwstr_to_string(p: PWSTR) -> Option { + if p.is_null() { + None + } else { + unsafe { p.to_string().ok() } + } +} + +/// Parse a double-null-terminated multi-string into individual strings. +/// +/// # Safety +/// +/// The pointer must point to a valid double-null-terminated buffer, or be null. +unsafe fn parse_multi_string(ptr: PWSTR) -> Vec { + if ptr.is_null() { + return Vec::new(); + } + let mut result = Vec::new(); + let mut current = ptr.0; + unsafe { + loop { + if *current == 0 { + break; + } + let pcwstr = PCWSTR(current); + match pcwstr.to_string() { + Ok(s) => { + current = current.add(s.len() + 1); + result.push(s); + } + Err(_) => break, + } + } + } + result +} + +/// RAII wrapper for `SC_HANDLE` that calls `CloseServiceHandle` on drop. +struct ScHandle(SC_HANDLE); + +impl Drop for ScHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseServiceHandle(self.0); + } + } +} + +/// Look up a service by `name` and/or `display_name` and return the full service info. +/// +/// - If only `name` is provided, the service is looked up by name. +/// - If only `display_name` is provided, the service key name is resolved first. +/// - If both are provided, the service is looked up by name and the display name is verified. +/// +/// If the service does not exist, returns a `WindowsService` with `_exist: false`. +pub fn get_service(input: &WindowsService) -> Result { + if input.name.is_none() && input.display_name.is_none() { + return Err(t!("get.nameOrDisplayNameRequired").to_string()); + } + + unsafe { + // Open Service Control Manager + let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT) + .map_err(|e| t!("get.openScmFailed", error = e.to_string()).to_string())?; + let scm = ScHandle(scm); + + // Resolve the service key name + let service_key_name = match &input.name { + Some(name) => Some(name.clone()), + None => { + let display_name = input.display_name.as_ref().unwrap(); + resolve_key_name_from_display_name(scm.0, display_name).ok() + } + }; + + // If we couldn't resolve a key name, the service doesn't exist + let service_key_name = match service_key_name { + Some(n) => n, + None => { + return Ok(WindowsService { + name: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + }; + + // Open the service + let name_wide = to_wide(&service_key_name); + let service_handle = match OpenServiceW( + scm.0, + PCWSTR(name_wide.as_ptr()), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) { + Ok(h) => ScHandle(h), + Err(_) => { + return Ok(WindowsService { + name: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + }; + + // Query basic configuration + let mut bytes_needed: u32 = 0; + let _ = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); + let mut config_buffer = vec![0u8; bytes_needed as usize]; + let config_ptr = config_buffer.as_mut_ptr().cast::(); + QueryServiceConfigW( + service_handle.0, + Some(&mut *config_ptr), + bytes_needed, + &mut bytes_needed, + ) + .map_err(|e| t!("get.queryConfigFailed", error = e.to_string()).to_string())?; + + let config = &*config_ptr; + let actual_display_name = pwstr_to_string(config.lpDisplayName); + + // If both name and display_name were provided, verify they match + if input.name.is_some() && input.display_name.is_some() { + let expected_dn = input.display_name.as_ref().unwrap(); + let actual_dn = actual_display_name.as_deref().unwrap_or(""); + if !actual_dn.eq_ignore_ascii_case(expected_dn) { + return Err( + t!("get.displayNameMismatch", expected = expected_dn, actual = actual_dn) + .to_string(), + ); + } + } + + // Determine start type (with delayed auto-start check) + let start_type = match config.dwStartType { + SERVICE_AUTO_START => { + if is_delayed_auto_start(service_handle.0) { + Some(StartType::AutomaticDelayedStart) + } else { + Some(StartType::Automatic) + } + } + SERVICE_DEMAND_START => Some(StartType::Manual), + SERVICE_DISABLED => Some(StartType::Disabled), + _ => None, + }; + + // Determine error control + let error_control = match config.dwErrorControl { + SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), + SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), + SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), + SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), + _ => None, + }; + + let executable_path = pwstr_to_string(config.lpBinaryPathName); + let logon_account = pwstr_to_string(config.lpServiceStartName); + let deps = parse_multi_string(config.lpDependencies); + let dependencies = if deps.is_empty() { None } else { Some(deps) }; + + // Query description + let description = query_description(service_handle.0); + + // Query current status + let status = query_status(service_handle.0)?; + + Ok(WindowsService { + name: Some(service_key_name), + display_name: actual_display_name, + description, + exist: Some(true), + status: Some(status), + start_type, + executable_path, + logon_account, + error_control, + dependencies, + }) + } +} + +/// Resolve a service key name from its display name via SCM. +unsafe fn resolve_key_name_from_display_name( + scm: SC_HANDLE, + display_name: &str, +) -> Result { + let dn_wide = to_wide(display_name); + let mut size: u32 = 0; + + // First call to determine the required buffer size + let _ = unsafe { + GetServiceKeyNameW(scm, PCWSTR(dn_wide.as_ptr()), None, &mut size) + }; + + if size == 0 { + return Err( + t!("get.getKeyNameFailed", error = "service not found").to_string(), + ); + } + + size += 1; // null terminator + let mut buffer = vec![0u16; size as usize]; + + unsafe { + GetServiceKeyNameW( + scm, + PCWSTR(dn_wide.as_ptr()), + Some(PWSTR(buffer.as_mut_ptr())), + &mut size, + ) + .map_err(|e| t!("get.getKeyNameFailed", error = e.to_string()).to_string())?; + } + + Ok(String::from_utf16_lossy(&buffer[..size as usize])) +} + +/// Check whether the service is configured for delayed automatic start. +unsafe fn is_delayed_auto_start(service_handle: SC_HANDLE) -> bool { + let mut bytes_needed: u32 = 0; + let _ = unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + None, + &mut bytes_needed, + ) + }; + + if bytes_needed == 0 { + return false; + } + + let mut buffer = vec![0u8; bytes_needed as usize]; + if unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + Some(&mut buffer), + &mut bytes_needed, + ) + } + .is_ok() + { + let info = + unsafe { &*(buffer.as_ptr().cast::()) }; + info.fDelayedAutostart.as_bool() + } else { + false + } +} + +/// Query the service description string. +unsafe fn query_description(service_handle: SC_HANDLE) -> Option { + let mut bytes_needed: u32 = 0; + let _ = unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DESCRIPTION, + None, + &mut bytes_needed, + ) + }; + + if bytes_needed == 0 { + return None; + } + + let mut buffer = vec![0u8; bytes_needed as usize]; + if unsafe { + QueryServiceConfig2W( + service_handle, + SERVICE_CONFIG_DESCRIPTION, + Some(&mut buffer), + &mut bytes_needed, + ) + } + .is_ok() + { + let desc = unsafe { &*(buffer.as_ptr().cast::()) }; + if desc.lpDescription.is_null() { + None + } else { + unsafe { desc.lpDescription.to_string().ok() } + } + } else { + None + } +} + +/// Query the current runtime status of a service. +unsafe fn query_status(service_handle: SC_HANDLE) -> Result { + let mut buffer = vec![0u8; mem::size_of::()]; + let mut bytes_needed: u32 = 0; + + unsafe { + QueryServiceStatusEx( + service_handle, + SC_STATUS_PROCESS_INFO, + Some(&mut buffer), + &mut bytes_needed, + ) + .map_err(|e| t!("get.queryStatusFailed", error = e.to_string()).to_string())?; + } + + let status = unsafe { &*(buffer.as_ptr().cast::()) }; + + match status.dwCurrentState { + SERVICE_STOPPED => Ok(ServiceStatus::Stopped), + SERVICE_START_PENDING => Ok(ServiceStatus::StartPending), + SERVICE_STOP_PENDING => Ok(ServiceStatus::StopPending), + SERVICE_RUNNING => Ok(ServiceStatus::Running), + SERVICE_CONTINUE_PENDING => Ok(ServiceStatus::ContinuePending), + SERVICE_PAUSE_PENDING => Ok(ServiceStatus::PausePending), + SERVICE_PAUSED => Ok(ServiceStatus::Paused), + other => Err( + t!("get.queryStatusFailed", error = format!("unknown state: {}", other.0)) + .to_string(), + ), + } +} + +/// Export (enumerate) all services, optionally filtering by the provided criteria. +/// Each matching service is printed as a JSON line to stdout. +pub fn export_services(filter: Option<&WindowsService>) -> Result<(), String> { + unsafe { + let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE) + .map_err(|e| t!("get.openScmFailed", error = e.to_string()).to_string())?; + let scm = ScHandle(scm); + + let service_names = enumerate_service_names(scm.0)?; + + for service_name in &service_names { + let svc = match get_service_details(scm.0, service_name) { + Ok(s) => s, + Err(_) => continue, // skip services we can't query + }; + + if let Some(f) = filter { + if !matches_filter(&svc, f) { + continue; + } + } + + match serde_json::to_string(&svc) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("{}", t!("export.serializeFailed", error = e.to_string())); + } + } + } + + Ok(()) + } +} + +/// Enumerate all Win32 service names using `EnumServicesStatusExW`. +unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, String> { + unsafe { + let mut bytes_needed: u32 = 0; + let mut services_returned: u32 = 0; + let mut resume_handle: u32 = 0; + + // First call to get required buffer size + let _ = EnumServicesStatusExW( + scm, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + None, + &mut bytes_needed, + &mut services_returned, + Some(&mut resume_handle), + None, + ); + + if bytes_needed == 0 { + return Ok(Vec::new()); + } + + let mut buffer = vec![0u8; bytes_needed as usize]; + resume_handle = 0; + + EnumServicesStatusExW( + scm, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + Some(&mut buffer), + &mut bytes_needed, + &mut services_returned, + Some(&mut resume_handle), + None, + ) + .map_err(|e| t!("export.enumServicesFailed", error = e.to_string()).to_string())?; + + let entries = std::slice::from_raw_parts( + buffer.as_ptr().cast::(), + services_returned as usize, + ); + + let mut names = Vec::with_capacity(services_returned as usize); + for entry in entries { + if let Ok(name) = entry.lpServiceName.to_string() { + names.push(name); + } + } + + Ok(names) + } +} + +/// Get full service details given an SCM handle and a service key name. +unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result { + unsafe { + let name_wide = to_wide(service_name); + let service_handle = OpenServiceW( + scm, + PCWSTR(name_wide.as_ptr()), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) + .map_err(|e| t!("export.openServiceFailed", name = service_name, error = e.to_string()).to_string())?; + let service_handle = ScHandle(service_handle); + + // Query basic configuration + let mut bytes_needed: u32 = 0; + let _ = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); + let mut config_buffer = vec![0u8; bytes_needed as usize]; + let config_ptr = config_buffer.as_mut_ptr().cast::(); + QueryServiceConfigW( + service_handle.0, + Some(&mut *config_ptr), + bytes_needed, + &mut bytes_needed, + ) + .map_err(|e| t!("get.queryConfigFailed", error = e.to_string()).to_string())?; + + let config = &*config_ptr; + let display_name = pwstr_to_string(config.lpDisplayName); + + let start_type = match config.dwStartType { + SERVICE_AUTO_START => { + if is_delayed_auto_start(service_handle.0) { + Some(StartType::AutomaticDelayedStart) + } else { + Some(StartType::Automatic) + } + } + SERVICE_DEMAND_START => Some(StartType::Manual), + SERVICE_DISABLED => Some(StartType::Disabled), + _ => None, + }; + + let error_control = match config.dwErrorControl { + SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), + SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), + SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), + SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), + _ => None, + }; + + let executable_path = pwstr_to_string(config.lpBinaryPathName); + let logon_account = pwstr_to_string(config.lpServiceStartName); + let deps = parse_multi_string(config.lpDependencies); + let dependencies = if deps.is_empty() { None } else { Some(deps) }; + + let description = query_description(service_handle.0); + let status = query_status(service_handle.0).ok(); + + Ok(WindowsService { + name: Some(service_name.to_string()), + display_name, + description, + exist: Some(true), + status, + start_type, + executable_path, + logon_account, + error_control, + dependencies, + }) + } +} + +/// Match a string against a pattern supporting `*` wildcards. +/// If no wildcard is present, performs an exact case-insensitive comparison. +fn matches_wildcard(text: &str, pattern: &str) -> bool { + let text_lower = text.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + let parts: Vec<&str> = pattern_lower.split('*').collect(); + + // No wildcard → exact match + if parts.len() == 1 { + return text_lower == pattern_lower; + } + + let starts_with_wildcard = pattern_lower.starts_with('*'); + let ends_with_wildcard = pattern_lower.ends_with('*'); + + let mut pos = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + if i == 0 && !starts_with_wildcard { + if !text_lower.starts_with(part) { + return false; + } + pos = part.len(); + } else if let Some(found) = text_lower[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + + if !ends_with_wildcard { + if let Some(last) = parts.last() { + if !last.is_empty() && !text_lower.ends_with(last) { + return false; + } + } + } + + true +} + +/// Build a double-null-terminated UTF-16 multi-string from a list of dependency names. +fn deps_to_multi_string(deps: &[String]) -> Vec { + let mut buf = Vec::new(); + for dep in deps { + buf.extend(dep.encode_utf16()); + buf.push(0); + } + buf.push(0); // double-null terminator + buf +} + +/// Apply the desired service configuration and status changes, then return the final state. +pub fn set_service(input: &WindowsService) -> Result { + let name = input.name.as_deref() + .ok_or_else(|| t!("set.nameRequired").to_string())?; + + unsafe { + let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT) + .map_err(|e| t!("set.openScmFailed", error = e.to_string()).to_string())?; + let scm = ScHandle(scm); + + let name_wide = to_wide(name); + let mut access = SERVICE_CHANGE_CONFIG | SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS; + if input.status.is_some() { + access |= SERVICE_START | SERVICE_STOP | SERVICE_PAUSE_CONTINUE; + } + + let service_handle = OpenServiceW(scm.0, PCWSTR(name_wide.as_ptr()), access) + .map_err(|e| t!("set.openServiceFailed", error = e.to_string()).to_string())?; + let service_handle = ScHandle(service_handle); + + // 1. Apply configuration changes via ChangeServiceConfigW + let has_config = input.start_type.is_some() + || input.executable_path.is_some() + || input.logon_account.is_some() + || input.error_control.is_some() + || input.display_name.is_some() + || input.dependencies.is_some(); + + if has_config { + let dw_start_type = match &input.start_type { + Some(StartType::Automatic | StartType::AutomaticDelayedStart) => SERVICE_AUTO_START, + Some(StartType::Manual) => SERVICE_DEMAND_START, + Some(StartType::Disabled) => SERVICE_DISABLED, + None => SERVICE_START_TYPE(SERVICE_NO_CHANGE_VALUE), + }; + + let dw_error_control = match &input.error_control { + Some(ErrorControl::Ignore) => SERVICE_ERROR_IGNORE, + Some(ErrorControl::Normal) => SERVICE_ERROR_NORMAL, + Some(ErrorControl::Severe) => SERVICE_ERROR_SEVERE, + Some(ErrorControl::Critical) => SERVICE_ERROR_CRITICAL, + None => SERVICE_ERROR(SERVICE_NO_CHANGE_VALUE), + }; + + // Build wide strings; they must live through the API call. + let exe_wide = input.executable_path.as_ref().map(|s| to_wide(s)); + let logon_wide = input.logon_account.as_ref().map(|s| to_wide(s)); + let display_wide = input.display_name.as_ref().map(|s| to_wide(s)); + let deps_wide = input.dependencies.as_ref().map(|d| deps_to_multi_string(d)); + + let exe_ptr = exe_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let logon_ptr = logon_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let display_ptr = display_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + let deps_ptr = deps_wide.as_ref().map_or(PCWSTR::null(), |w| PCWSTR(w.as_ptr())); + + ChangeServiceConfigW( + service_handle.0, + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE), // service type unchanged + dw_start_type, + dw_error_control, + exe_ptr, + PCWSTR::null(), // load order group unchanged + None, // tag id unchanged + deps_ptr, + logon_ptr, + PCWSTR::null(), // password unchanged + display_ptr, + ) + .map_err(|e| t!("set.changeConfigFailed", error = e.to_string()).to_string())?; + } + + // 2. Set delayed auto-start flag when start type is specified + if let Some(ref start_type) = input.start_type { + let delayed = matches!(start_type, StartType::AutomaticDelayedStart); + let mut info = SERVICE_DELAYED_AUTO_START_INFO { + fDelayedAutostart: delayed.into(), + }; + ChangeServiceConfig2W( + service_handle.0, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + Some(std::ptr::from_mut(&mut info).cast()), + ) + .map_err(|e| t!("set.changeConfig2Failed", error = e.to_string()).to_string())?; + } + + // 3. Set description + if let Some(ref desc) = input.description { + let mut desc_wide = to_wide(desc); + let mut info = SERVICE_DESCRIPTIONW { + lpDescription: PWSTR(desc_wide.as_mut_ptr()), + }; + ChangeServiceConfig2W( + service_handle.0, + SERVICE_CONFIG_DESCRIPTION, + Some(std::ptr::from_mut(&mut info).cast()), + ) + .map_err(|e| t!("set.changeConfig2Failed", error = e.to_string()).to_string())?; + } + + // 4. Handle service status transitions + if let Some(ref desired_status) = input.status { + let mut current = query_status(service_handle.0)?; + + // Wait for any pending state to resolve first + current = match current { + ServiceStatus::StopPending => { + wait_for_status(service_handle.0, &ServiceStatus::Stopped)?; + ServiceStatus::Stopped + } + ServiceStatus::StartPending | ServiceStatus::ContinuePending => { + wait_for_status(service_handle.0, &ServiceStatus::Running)?; + ServiceStatus::Running + } + ServiceStatus::PausePending => { + wait_for_status(service_handle.0, &ServiceStatus::Paused)?; + ServiceStatus::Paused + } + other => other, + }; + + if current != *desired_status { + match desired_status { + ServiceStatus::Running => { + match current { + ServiceStatus::Stopped => { + StartServiceW(service_handle.0, None) + .map_err(|e| t!("set.startFailed", error = e.to_string()).to_string())?; + } + ServiceStatus::Paused => { + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_CONTINUE, &mut svc_status) + .map_err(|e| t!("set.continueFailed", error = e.to_string()).to_string())?; + } + _ => { + return Err(t!("set.unsupportedTransition", + current = current.to_string(), + desired = desired_status.to_string() + ).to_string()); + } + } + wait_for_status(service_handle.0, desired_status)?; + } + ServiceStatus::Stopped => { + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_STOP, &mut svc_status) + .map_err(|e| t!("set.stopFailed", error = e.to_string()).to_string())?; + wait_for_status(service_handle.0, desired_status)?; + } + ServiceStatus::Paused => { + if current != ServiceStatus::Running { + return Err(t!("set.unsupportedTransition", + current = current.to_string(), + desired = desired_status.to_string() + ).to_string()); + } + let mut svc_status = SERVICE_STATUS::default(); + ControlService(service_handle.0, SERVICE_CONTROL_PAUSE, &mut svc_status) + .map_err(|e| t!("set.pauseFailed", error = e.to_string()).to_string())?; + wait_for_status(service_handle.0, desired_status)?; + } + _ => { + return Err(t!("set.unsupportedStatus", + status = desired_status.to_string() + ).to_string()); + } + } + } + } + + // Return final state + drop(service_handle); + get_service(&WindowsService::new(name)) + } +} + +/// Wait for a service to reach the desired status, with a timeout. +unsafe fn wait_for_status( + service_handle: SC_HANDLE, + desired: &ServiceStatus, +) -> Result<(), String> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(STATUS_WAIT_TIMEOUT_SECS); + loop { + let current = unsafe { query_status(service_handle)? }; + if current == *desired { + return Ok(()); + } + if start.elapsed() > timeout { + return Err(t!("set.statusTimeout", + expected = desired.to_string(), + actual = current.to_string() + ).to_string()); + } + std::thread::sleep(std::time::Duration::from_millis(STATUS_POLL_INTERVAL_MS)); + } +} + +/// Check whether `service` matches all non-`None` fields in `filter`. +fn matches_filter(service: &WindowsService, filter: &WindowsService) -> bool { + // name — wildcard match + if let Some(ref pattern) = filter.name { + let name = service.name.as_deref().unwrap_or(""); + if !matches_wildcard(name, pattern) { + return false; + } + } + + // display_name — wildcard match + if let Some(ref pattern) = filter.display_name { + let dn = service.display_name.as_deref().unwrap_or(""); + if !matches_wildcard(dn, pattern) { + return false; + } + } + + // description — wildcard match + if let Some(ref pattern) = filter.description { + let desc = service.description.as_deref().unwrap_or(""); + if !matches_wildcard(desc, pattern) { + return false; + } + } + + // exist — exact match + if let Some(expected_exist) = filter.exist { + let actual_exist = service.exist.unwrap_or(false); + if actual_exist != expected_exist { + return false; + } + } + + // status — exact match + if let Some(ref expected_status) = filter.status { + match &service.status { + Some(actual_status) if actual_status == expected_status => {} + _ => return false, + } + } + + // start_type — exact match + if let Some(ref expected_start) = filter.start_type { + match &service.start_type { + Some(actual_start) if actual_start == expected_start => {} + _ => return false, + } + } + + // logon_account — exact case-insensitive match + if let Some(ref expected_account) = filter.logon_account { + let actual = service.logon_account.as_deref().unwrap_or(""); + if !actual.eq_ignore_ascii_case(expected_account) { + return false; + } + } + + // dependencies — service must have at least all specified dependencies + if let Some(ref expected_deps) = filter.dependencies { + let actual_deps = service.dependencies.as_deref().unwrap_or(&[]); + for dep in expected_deps { + let dep_lower = dep.to_lowercase(); + if !actual_deps.iter().any(|d| d.to_lowercase() == dep_lower) { + return false; + } + } + } + + true +} diff --git a/resources/windows_service/src/types.rs b/resources/windows_service/src/types.rs new file mode 100644 index 000000000..2a15773f0 --- /dev/null +++ b/resources/windows_service/src/types.rs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +/// Represents the start type of a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum StartType { + /// The service is started automatically by the Service Control Manager during system startup. + Automatic, + /// The service is started automatically, with a delayed start after other auto-start services. + AutomaticDelayedStart, + /// The service is started only manually (e.g., via `sc start` or the Services console). + Manual, + /// The service is disabled and cannot be started. + Disabled, +} + +/// Represents the current status of a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum ServiceStatus { + Running, + Stopped, + Paused, + StartPending, + StopPending, + PausePending, + ContinuePending, +} + +/// Represents the error control level for a Windows service. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub enum ErrorControl { + /// The startup program ignores the error and continues. + Ignore, + /// The startup program logs the error and continues. + Normal, + /// The startup program logs the error. If the last-known-good configuration is being started, + /// startup continues. Otherwise, the system is restarted with the last-known-good configuration. + Severe, + /// The startup program logs the error. If the last-known-good configuration is being started, + /// the startup operation fails. Otherwise, the system is restarted with the last-known-good + /// configuration. + Critical, +} + +/// Represents a Windows service resource. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WindowsService { + /// The name of the service (used to identify the service in the Service Control Manager). + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// The display name of the service shown in the Services console. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + + /// A description of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Indicates if the service exists. + #[serde(rename = "_exist", skip_serializing_if = "Option::is_none")] + pub exist: Option, + + /// The current status of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + + /// The start type of the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub start_type: Option, + + /// The fully qualified path to the service binary. + #[serde(skip_serializing_if = "Option::is_none")] + pub executable_path: Option, + + /// The account under which the service runs (e.g., `LocalSystem`, `NT AUTHORITY\NetworkService`). + #[serde(skip_serializing_if = "Option::is_none")] + pub logon_account: Option, + + /// The error control level for the service. + #[serde(skip_serializing_if = "Option::is_none")] + pub error_control: Option, + + /// A list of service names that this service depends on. + #[serde(skip_serializing_if = "Option::is_none")] + pub dependencies: Option>, +} + +impl WindowsService { + #[must_use] + #[allow(dead_code)] + pub fn new(name: &str) -> Self { + Self { + name: Some(name.to_string()), + display_name: None, + description: None, + exist: None, + status: None, + start_type: None, + executable_path: None, + logon_account: None, + error_control: None, + dependencies: None, + } + } +} + +impl std::fmt::Display for StartType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StartType::Automatic => write!(f, "Automatic"), + StartType::AutomaticDelayedStart => write!(f, "AutomaticDelayedStart"), + StartType::Manual => write!(f, "Manual"), + StartType::Disabled => write!(f, "Disabled"), + } + } +} + +impl std::fmt::Display for ServiceStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ServiceStatus::Running => write!(f, "Running"), + ServiceStatus::Stopped => write!(f, "Stopped"), + ServiceStatus::Paused => write!(f, "Paused"), + ServiceStatus::StartPending => write!(f, "StartPending"), + ServiceStatus::StopPending => write!(f, "StopPending"), + ServiceStatus::PausePending => write!(f, "PausePending"), + ServiceStatus::ContinuePending => write!(f, "ContinuePending"), + } + } +} + +impl std::fmt::Display for ErrorControl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ErrorControl::Ignore => write!(f, "Ignore"), + ErrorControl::Normal => write!(f, "Normal"), + ErrorControl::Severe => write!(f, "Severe"), + ErrorControl::Critical => write!(f, "Critical"), + } + } +} diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 new file mode 100644 index 000000000..7b7a05eaf --- /dev/null +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -0,0 +1,246 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service export tests' { + BeforeAll { + $resourceType = 'Microsoft.Windows/Service' + + function Invoke-DscExport { + param( + [string]$InputJson + ) + if ($InputJson) { + $raw = $InputJson | dsc resource export -r $resourceType -f - 2>$null + } else { + $raw = dsc resource export -r $resourceType 2>$null + } + $parsed = $raw | ConvertFrom-Json + return $parsed + } + } + + Context 'Export without filter' { + It 'Returns multiple services' -Skip:(!$IsWindows) { + $result = Invoke-DscExport + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 10 + } + + It 'Each exported service has required properties' -Skip:(!$IsWindows) { + $result = Invoke-DscExport + foreach ($resource in $result.resources | Select-Object -First 5) { + $svc = $resource.properties + $svc.name | Should -Not -BeNullOrEmpty + $svc.displayName | Should -Not -BeNullOrEmpty + $svc._exist | Should -BeTrue + $svc.status | Should -Not -BeNullOrEmpty + $svc.startType | Should -Not -BeNullOrEmpty + $svc.executablePath | Should -Not -BeNullOrEmpty + $svc.logonAccount | Should -Not -BeNullOrEmpty + $svc.errorControl | Should -Not -BeNullOrEmpty + } + } + + It 'Sets the correct resource type on each entry' -Skip:(!$IsWindows) { + $result = Invoke-DscExport + foreach ($resource in $result.resources | Select-Object -First 5) { + $resource.type | Should -BeExactly $resourceType + } + } + } + + Context 'Export with name filter' { + It 'Filters by exact service name' -Skip:(!$IsWindows) { + $json = @{ name = 'wuauserv' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -Be 1 + $result.resources[0].properties.name | Should -BeExactly 'wuauserv' + } + + It 'Filters by name with leading wildcard' -Skip:(!$IsWindows) { + $json = @{ name = '*serv' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike '*serv' + } + } + + It 'Filters by name with trailing wildcard' -Skip:(!$IsWindows) { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike 'w*' + } + } + + It 'Filters by name with surrounding wildcards' -Skip:(!$IsWindows) { + $json = @{ name = '*update*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike '*update*' + } + } + + It 'Returns empty when name filter matches nothing' -Skip:(!$IsWindows) { + $json = @{ name = 'nonexistent_service_xyz_12345' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -Be 0 + } + } + + Context 'Export with displayName filter' { + It 'Filters by display name with wildcard' -Skip:(!$IsWindows) { + $json = @{ displayName = '*Update*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.displayName | Should -BeLike '*Update*' + } + } + + It 'Filters by exact display name' -Skip:(!$IsWindows) { + $json = @{ displayName = 'Windows Update' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -Be 1 + $result.resources[0].properties.displayName | Should -BeExactly 'Windows Update' + } + } + + Context 'Export with status filter' { + It 'Filters by Running status' -Skip:(!$IsWindows) { + $json = @{ status = 'Running' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Running' + } + } + + It 'Filters by Stopped status' -Skip:(!$IsWindows) { + $json = @{ status = 'Stopped' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Stopped' + } + } + } + + Context 'Export with startType filter' { + It 'Filters by Automatic start type' -Skip:(!$IsWindows) { + $json = @{ startType = 'Automatic' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Automatic' + } + } + + It 'Filters by Manual start type' -Skip:(!$IsWindows) { + $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Manual' + } + } + + It 'Filters by Disabled start type' -Skip:(!$IsWindows) { + $json = @{ startType = 'Disabled' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterOrEqual 0 + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeExactly 'Disabled' + } + } + } + + Context 'Export with multi-field filter' { + It 'Filters by status AND startType together' -Skip:(!$IsWindows) { + $json = @{ status = 'Running'; startType = 'Automatic' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeExactly 'Running' + $resource.properties.startType | Should -BeExactly 'Automatic' + } + } + + It 'Filters by name wildcard AND status' -Skip:(!$IsWindows) { + $json = @{ name = 'w*'; status = 'Stopped' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + foreach ($resource in $result.resources) { + $resource.properties.name | Should -BeLike 'w*' + $resource.properties.status | Should -BeExactly 'Stopped' + } + } + } + + Context 'Export with dependencies filter' { + It 'Filters by a single dependency' -Skip:(!$IsWindows) { + $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $LASTEXITCODE | Should -Be 0 + $result.resources.Count | Should -BeGreaterThan 0 + foreach ($resource in $result.resources) { + $resource.properties.dependencies | Should -Not -BeNullOrEmpty + ($resource.properties.dependencies | ForEach-Object { $_.ToLower() }) | Should -Contain 'rpcss' + } + } + } + + Context 'Export property validation' { + It 'All exported services have valid startType values' -Skip:(!$IsWindows) { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validStartTypes = @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') + foreach ($resource in $result.resources) { + $resource.properties.startType | Should -BeIn $validStartTypes + } + } + + It 'All exported services have valid status values' -Skip:(!$IsWindows) { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validStatuses = @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') + foreach ($resource in $result.resources) { + $resource.properties.status | Should -BeIn $validStatuses + } + } + + It 'All exported services have valid errorControl values' -Skip:(!$IsWindows) { + $json = @{ name = 'w*' } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + $validErrorControls = @('Ignore', 'Normal', 'Severe', 'Critical') + foreach ($resource in $result.resources) { + $resource.properties.errorControl | Should -BeIn $validErrorControls + } + } + + It 'Dependencies is an array when present' -Skip:(!$IsWindows) { + $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress + $result = Invoke-DscExport -InputJson $json + foreach ($resource in $result.resources | Select-Object -First 3) { + $resource.properties.dependencies | Should -BeOfType [System.Object] + $resource.properties.dependencies.Count | Should -BeGreaterThan 0 + } + } + } +} diff --git a/resources/windows_service/tests/windows_service_get.tests.ps1 b/resources/windows_service/tests/windows_service_get.tests.ps1 new file mode 100644 index 000000000..0b36488db --- /dev/null +++ b/resources/windows_service/tests/windows_service_get.tests.ps1 @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service get tests' { + BeforeAll { + # Use a well-known Windows service that exists on all Windows machines + $resourceType = 'Microsoft.Windows/Service' + $knownServiceName = 'wuauserv' + $knownDisplayName = 'Windows Update' + } + + Context 'Get by name' { + It 'Returns service info for an existing service' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + $result.status | Should -Not -BeNullOrEmpty + $result.startType | Should -Not -BeNullOrEmpty + $result.executablePath | Should -Not -BeNullOrEmpty + $result.logonAccount | Should -Not -BeNullOrEmpty + $result.errorControl | Should -Not -BeNullOrEmpty + } + + It 'Returns _exist false for a non-existent service' -Skip:(!$IsWindows) { + $json = @{ name = 'nonexistent_service_xyz' } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly 'nonexistent_service_xyz' + $result._exist | Should -BeFalse + $result.PSObject.Properties.Name | Should -Not -Contain 'status' + } + } + + Context 'Get by displayName' { + It 'Returns service info when only displayName is provided' -Skip:(!$IsWindows) { + $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + } + + It 'Returns _exist false for a non-existent display name' -Skip:(!$IsWindows) { + $json = @{ displayName = 'Nonexistent Display Name XYZ' } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result._exist | Should -BeFalse + } + } + + Context 'Get by both name and displayName' { + It 'Returns service info when both name and displayName match' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName; displayName = $knownDisplayName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.name | Should -BeExactly $knownServiceName + $result.displayName | Should -BeExactly $knownDisplayName + $result._exist | Should -BeTrue + } + + It 'Returns error when name and displayName do not match' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName; displayName = 'Wrong Display Name' } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Service properties' { + It 'Returns valid startType values' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.startType | Should -BeIn @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') + } + + It 'Returns valid status values' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.status | Should -BeIn @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') + } + + It 'Returns valid errorControl values' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $output = $out | ConvertFrom-Json + $result = $output.actualState + $result.errorControl | Should -BeIn @('Ignore', 'Normal', 'Severe', 'Critical') + } + + It 'Returns dependencies as an array when present' -Skip:(!$IsWindows) { + $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $output = $out | ConvertFrom-Json + $result = $output.actualState + if ($null -ne $result.dependencies) { + $result.dependencies | Should -BeOfType [System.Object] + $result.dependencies.Count | Should -BeGreaterThan 0 + } + } + } +} diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 new file mode 100644 index 000000000..500d875ef --- /dev/null +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Windows Service set tests' { + BeforeDiscovery { + $isAdmin = if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]$identity + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + else { + $false + } + } + + BeforeAll { + $resourceType = 'Microsoft.Windows/Service' + # Use the Print Spooler service for set tests — it exists on all Windows + # machines and is safe to reconfigure briefly. + $testServiceName = 'Spooler' + + function Get-ServiceState { + param([string]$Name) + $json = @{ name = $Name } | ConvertTo-Json -Compress + $out = $json | dsc resource get -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + return ($out | ConvertFrom-Json).actualState + } + } + + Context 'Input validation' -Skip:(!$IsWindows -or !$isAdmin) { + It 'Fails when name is not provided' { + $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Fails when input JSON is invalid' { + $out = 'not-json' | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + } + + Context 'Set startType' -Skip:(!$IsWindows -or !$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original start type + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$null + } + } + + It 'Can set startType to Disabled' { + $json = @{ name = $testServiceName; startType = 'Disabled' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testServiceName + $result.startType | Should -BeExactly 'Disabled' + } + + It 'Can set startType to Manual' { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Manual' + } + + It 'Can set startType to Automatic' { + $json = @{ name = $testServiceName; startType = 'Automatic' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Automatic' + } + + It 'Can set startType to AutomaticDelayedStart' { + $json = @{ name = $testServiceName; startType = 'AutomaticDelayedStart' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'AutomaticDelayedStart' + } + } + + Context 'Set description' -Skip:(!$IsWindows -or !$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original description + if ($script:originalState -and $script:originalState.description) { + $restoreJson = @{ + name = $testServiceName + description = $script:originalState.description + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$null + } + } + + It 'Can set description' { + $testDesc = 'DSC test description' + $json = @{ name = $testServiceName; description = $testDesc } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.description | Should -BeExactly $testDesc + } + } + + Context 'Set service status' -Skip:(!$IsWindows -or !$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + # Ensure the service is startable (not disabled) + if ($script:originalState.startType -eq 'Disabled') { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$null + } + } + + AfterAll { + # Restore original status and start type + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + status = $script:originalState.status + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$null + } + } + + It 'Can start a stopped service' { + # First ensure it is stopped + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$null + + # Now start it + $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Running' + } + + It 'Can stop a running service' { + # First ensure it is running + $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$null + + # Now stop it + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Stopped' + } + + It 'Is idempotent when service is already in desired status' { + # Ensure it is stopped + $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress + $json | dsc resource set -r $resourceType -f - 2>$null + + # Set to Stopped again — should succeed without error + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.status | Should -BeExactly 'Stopped' + } + } + + Context 'Set multiple properties at once' -Skip:(!$IsWindows -or !$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original state + if ($script:originalState) { + $restoreJson = @{ + name = $testServiceName + startType = $script:originalState.startType + description = $script:originalState.description + status = $script:originalState.status + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$null + } + } + + It 'Can set startType and description together' { + $json = @{ + name = $testServiceName + startType = 'Manual' + description = 'DSC combined test' + } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.startType | Should -BeExactly 'Manual' + $result.description | Should -BeExactly 'DSC combined test' + } + + It 'Returns full service state after set' { + $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -Not -BeNullOrEmpty + $result.displayName | Should -Not -BeNullOrEmpty + $result._exist | Should -BeTrue + $result.status | Should -Not -BeNullOrEmpty + $result.startType | Should -Not -BeNullOrEmpty + $result.executablePath | Should -Not -BeNullOrEmpty + $result.logonAccount | Should -Not -BeNullOrEmpty + $result.errorControl | Should -Not -BeNullOrEmpty + } + } + + Context 'Set with no changes (idempotent)' -Skip:(!$IsWindows -or !$isAdmin) { + It 'Succeeds when only name is provided (no properties to change)' { + $json = @{ name = $testServiceName } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$null + $LASTEXITCODE | Should -Be 0 + $result = ($out | ConvertFrom-Json).afterState + $result.name | Should -BeExactly $testServiceName + $result._exist | Should -BeTrue + } + } +} diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json new file mode 100644 index 000000000..6a6b59e3d --- /dev/null +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Windows/Service", + "description": "Manage Windows services", + "tags": [ + "Windows" + ], + "version": "0.1.0", + "get": { + "executable": "windows_service", + "args": [ + "get", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "windows_service", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "implementsPretest": false, + "return": "state" + }, + "export": { + "executable": "windows_service", + "args": [ + "export", + { + "jsonInputArg": "--input", + "mandatory": false + } + ] + }, + "exitCodes": { + "0": "Success", + "1": "Invalid arguments", + "2": "Invalid input", + "3": "Service error" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Windows Service", + "description": "Manage Windows services.", + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Service name", + "description": "The name of the service in the Service Control Manager." + }, + "displayName": { + "type": "string", + "title": "Display name", + "description": "The display name of the service shown in the Services console." + }, + "description": { + "type": "string", + "title": "Description", + "description": "A description of the service." + }, + "_exist": { + "type": "boolean", + "title": "Exists", + "description": "Indicates whether the service exists.", + "readOnly": true + }, + "status": { + "type": "string", + "title": "Status", + "description": "The current status of the service.", + "enum": ["Running", "Stopped", "Paused", "StartPending", "StopPending", "PausePending", "ContinuePending"] + }, + "startType": { + "type": "string", + "title": "Start type", + "description": "The start type of the service.", + "enum": ["Automatic", "AutomaticDelayedStart", "Manual", "Disabled"] + }, + "executablePath": { + "type": "string", + "title": "Executable path", + "description": "The fully qualified path to the service binary." + }, + "logonAccount": { + "type": "string", + "title": "Logon account", + "description": "The account under which the service runs." + }, + "errorControl": { + "type": "string", + "title": "Error control", + "description": "The error control level for the service.", + "enum": ["Ignore", "Normal", "Severe", "Critical"] + }, + "dependencies": { + "type": "array", + "title": "Dependencies", + "description": "A list of service names that this service depends on.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} From 6793d4e66dd13074ce17bcaf21202091cf393bbb Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 10 Mar 2026 17:27:49 -0700 Subject: [PATCH 02/15] specify set as requiring elevation --- resources/windows_service/windows_service.dsc.resource.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json index 6a6b59e3d..89fe1025f 100644 --- a/resources/windows_service/windows_service.dsc.resource.json +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -26,7 +26,8 @@ } ], "implementsPretest": false, - "return": "state" + "return": "state", + "requireSecurityContext": "elevated" }, "export": { "executable": "windows_service", From 04ac70801420f0c8ae7fe000046b35cd00c22860 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Tue, 10 Mar 2026 23:18:45 -0700 Subject: [PATCH 03/15] remove unused strings --- resources/windows_service/locales/en-us.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml index 2296bb15f..5b12cc26d 100644 --- a/resources/windows_service/locales/en-us.toml +++ b/resources/windows_service/locales/en-us.toml @@ -6,8 +6,6 @@ unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or ex missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" invalidJson = "Invalid JSON input: %{error}" -setNotImplemented = "The 'set' operation is not yet implemented" -exportNotImplemented = "The 'export' operation is not yet implemented" windowsOnly = "This resource is only supported on Windows" [get] From 26e118d4052b91699bae62d3d5688d2271fc0cd8 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Wed, 11 Mar 2026 12:59:37 -0700 Subject: [PATCH 04/15] fix error handling, setting delayed start, tests --- Cargo.toml | 1 + data.build.json | 17 +++++ resources/windows_service/src/main.rs | 43 +++++++----- resources/windows_service/src/service.rs | 66 +++++++++++-------- resources/windows_service/src/types.rs | 20 ++++++ .../tests/windows_service_export.tests.ps1 | 36 +++++----- .../tests/windows_service_get.tests.ps1 | 28 ++++---- .../tests/windows_service_set.tests.ps1 | 64 +++++++++--------- 8 files changed, 165 insertions(+), 110 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9198c2bee..195991d9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ Windows = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", diff --git a/data.build.json b/data.build.json index 8f9f63e1e..4eacde32a 100644 --- a/data.build.json +++ b/data.build.json @@ -84,6 +84,8 @@ "windowspowershell.dsc.resource.json", "windowsupdate.dsc.resource.json", "wu_dsc.exe", + "windows_service.exe", + "windows_service.dsc.resource.json", "wmi.dsc.resource.json", "wmi.resource.ps1", "wmiAdapter.psd1", @@ -402,6 +404,21 @@ ] } }, + { + "Name": "windows_service", + "Kind": "Resource", + "RelativePath": "resources/windows_service", + "SupportedPlatformOS": "Windows", + "IsRust": true, + "Binaries": [ + "windows_service" + ], + "CopyFiles": { + "Windows": [ + "windows_service.dsc.resource.json" + ] + } + }, { "Name": "dsctest", "Kind": "Resource", diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index 9b45ca45d..5a1958554 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -11,6 +11,11 @@ use std::process::exit; use types::WindowsService; +/// Write a JSON error object to stderr: `{"error":""}` +fn write_error(message: &str) { + eprintln!("{}", serde_json::json!({"error": message})); +} + rust_i18n::i18n!("locales", fallback = "en-us"); const EXIT_SUCCESS: i32 = 0; @@ -22,7 +27,7 @@ fn main() { let args: Vec = std::env::args().collect(); if args.len() < 2 { - eprintln!("Error: {}", t!("main.missingOperation")); + write_error(&t!("main.missingOperation")); exit(EXIT_INVALID_ARGS); } @@ -34,7 +39,7 @@ fn main() { let json = match input_json { Some(j) => j, None => { - eprintln!("Error: {}", t!("main.missingInput")); + write_error(&t!("main.missingInput")); exit(EXIT_INVALID_ARGS); } }; @@ -42,7 +47,7 @@ fn main() { let input: WindowsService = match serde_json::from_str(&json) { Ok(s) => s, Err(e) => { - eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + write_error(&t!("main.invalidJson", error = e.to_string())); exit(EXIT_INVALID_INPUT); } }; @@ -55,7 +60,7 @@ fn main() { exit(EXIT_SUCCESS); } Err(e) => { - eprintln!("Error: {e}"); + write_error(&e.to_string()); exit(EXIT_SERVICE_ERROR); } } @@ -64,7 +69,7 @@ fn main() { #[cfg(not(windows))] { let _ = input; - eprintln!("Error: {}", t!("main.windowsOnly")); + write_error(&t!("main.windowsOnly")); exit(EXIT_SERVICE_ERROR); } } @@ -72,7 +77,7 @@ fn main() { let json = match input_json { Some(j) => j, None => { - eprintln!("Error: {}", t!("main.missingInput")); + write_error(&t!("main.missingInput")); exit(EXIT_INVALID_ARGS); } }; @@ -80,7 +85,7 @@ fn main() { let input: WindowsService = match serde_json::from_str(&json) { Ok(s) => s, Err(e) => { - eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + write_error(&t!("main.invalidJson", error = e.to_string())); exit(EXIT_INVALID_INPUT); } }; @@ -93,7 +98,7 @@ fn main() { exit(EXIT_SUCCESS); } Err(e) => { - eprintln!("Error: {e}"); + write_error(&e.to_string()); exit(EXIT_SERVICE_ERROR); } } @@ -102,7 +107,7 @@ fn main() { #[cfg(not(windows))] { let _ = input; - eprintln!("Error: {}", t!("main.windowsOnly")); + write_error(&t!("main.windowsOnly")); exit(EXIT_SERVICE_ERROR); } } @@ -111,7 +116,7 @@ fn main() { Some(json) => match serde_json::from_str(&json) { Ok(s) => Some(s), Err(e) => { - eprintln!("Error: {}", t!("main.invalidJson", error = e.to_string())); + write_error(&t!("main.invalidJson", error = e.to_string())); exit(EXIT_INVALID_INPUT); } }, @@ -121,9 +126,14 @@ fn main() { #[cfg(windows)] { match service::export_services(filter.as_ref()) { - Ok(()) => exit(EXIT_SUCCESS), + Ok(services) => { + for svc in &services { + println!("{}", serde_json::to_string(svc).unwrap()); + } + exit(EXIT_SUCCESS); + } Err(e) => { - eprintln!("Error: {e}"); + write_error(&e.to_string()); exit(EXIT_SERVICE_ERROR); } } @@ -132,15 +142,12 @@ fn main() { #[cfg(not(windows))] { let _ = filter; - eprintln!("Error: {}", t!("main.windowsOnly")); + write_error(&t!("main.windowsOnly")); exit(EXIT_SERVICE_ERROR); } } _ => { - eprintln!( - "Error: {}", - t!("main.unknownOperation", operation = operation) - ); + write_error(&t!("main.unknownOperation", operation = operation)); exit(EXIT_INVALID_ARGS); } } @@ -154,7 +161,7 @@ fn parse_input_arg(args: &[String]) -> Option { if i + 1 < args.len() { return Some(args[i + 1].clone()); } - eprintln!("Error: {}", t!("main.missingInputValue")); + write_error(&t!("main.missingInputValue")); exit(EXIT_INVALID_ARGS); } i += 1; diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index 4d6263d4e..50ce63dfe 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -77,15 +77,15 @@ impl Drop for ScHandle { /// - If both are provided, the service is looked up by name and the display name is verified. /// /// If the service does not exist, returns a `WindowsService` with `_exist: false`. -pub fn get_service(input: &WindowsService) -> Result { +pub fn get_service(input: &WindowsService) -> Result { if input.name.is_none() && input.display_name.is_none() { - return Err(t!("get.nameOrDisplayNameRequired").to_string()); + return Err(t!("get.nameOrDisplayNameRequired").to_string().into()); } unsafe { // Open Service Control Manager let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT) - .map_err(|e| t!("get.openScmFailed", error = e.to_string()).to_string())?; + .map_err(|e| ServiceError::from(t!("get.openScmFailed", error = e.to_string()).to_string()))?; let scm = ScHandle(scm); // Resolve the service key name @@ -139,7 +139,7 @@ pub fn get_service(input: &WindowsService) -> Result { bytes_needed, &mut bytes_needed, ) - .map_err(|e| t!("get.queryConfigFailed", error = e.to_string()).to_string())?; + .map_err(|e| ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string()))?; let config = &*config_ptr; let actual_display_name = pwstr_to_string(config.lpDisplayName); @@ -151,7 +151,8 @@ pub fn get_service(input: &WindowsService) -> Result { if !actual_dn.eq_ignore_ascii_case(expected_dn) { return Err( t!("get.displayNameMismatch", expected = expected_dn, actual = actual_dn) - .to_string(), + .to_string() + .into(), ); } } @@ -209,7 +210,7 @@ pub fn get_service(input: &WindowsService) -> Result { unsafe fn resolve_key_name_from_display_name( scm: SC_HANDLE, display_name: &str, -) -> Result { +) -> Result { let dn_wide = to_wide(display_name); let mut size: u32 = 0; @@ -220,7 +221,7 @@ unsafe fn resolve_key_name_from_display_name( if size == 0 { return Err( - t!("get.getKeyNameFailed", error = "service not found").to_string(), + t!("get.getKeyNameFailed", error = "service not found").to_string().into(), ); } @@ -314,7 +315,7 @@ unsafe fn query_description(service_handle: SC_HANDLE) -> Option { } /// Query the current runtime status of a service. -unsafe fn query_status(service_handle: SC_HANDLE) -> Result { +unsafe fn query_status(service_handle: SC_HANDLE) -> Result { let mut buffer = vec![0u8; mem::size_of::()]; let mut bytes_needed: u32 = 0; @@ -340,20 +341,22 @@ unsafe fn query_status(service_handle: SC_HANDLE) -> Result Ok(ServiceStatus::Paused), other => Err( t!("get.queryStatusFailed", error = format!("unknown state: {}", other.0)) - .to_string(), + .to_string() + .into(), ), } } /// Export (enumerate) all services, optionally filtering by the provided criteria. -/// Each matching service is printed as a JSON line to stdout. -pub fn export_services(filter: Option<&WindowsService>) -> Result<(), String> { +/// Returns a list of matching services. +pub fn export_services(filter: Option<&WindowsService>) -> Result, ServiceError> { unsafe { let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE) .map_err(|e| t!("get.openScmFailed", error = e.to_string()).to_string())?; let scm = ScHandle(scm); let service_names = enumerate_service_names(scm.0)?; + let mut results = Vec::new(); for service_name in &service_names { let svc = match get_service_details(scm.0, service_name) { @@ -367,20 +370,15 @@ pub fn export_services(filter: Option<&WindowsService>) -> Result<(), String> { } } - match serde_json::to_string(&svc) { - Ok(json) => println!("{json}"), - Err(e) => { - eprintln!("{}", t!("export.serializeFailed", error = e.to_string())); - } - } + results.push(svc); } - Ok(()) + Ok(results) } } /// Enumerate all Win32 service names using `EnumServicesStatusExW`. -unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, String> { +unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, ServiceError> { unsafe { let mut bytes_needed: u32 = 0; let mut services_returned: u32 = 0; @@ -436,7 +434,7 @@ unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, String> } /// Get full service details given an SCM handle and a service key name. -unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result { +unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result { unsafe { let name_wide = to_wide(service_name); let service_handle = OpenServiceW( @@ -565,7 +563,7 @@ fn deps_to_multi_string(deps: &[String]) -> Vec { } /// Apply the desired service configuration and status changes, then return the final state. -pub fn set_service(input: &WindowsService) -> Result { +pub fn set_service(input: &WindowsService) -> Result { let name = input.name.as_deref() .ok_or_else(|| t!("set.nameRequired").to_string())?; @@ -608,6 +606,18 @@ pub fn set_service(input: &WindowsService) -> Result { None => SERVICE_ERROR(SERVICE_NO_CHANGE_VALUE), }; + // When setting AutomaticDelayedStart, the service must not belong to a load + // order group — Windows rejects ChangeServiceConfig2W for the delayed auto-start + // flag with ERROR_INVALID_PARAMETER if one is set. Clear the group by passing an + // empty string instead of null (which means "no change"). + let clear_group_wide; + let load_order_group_ptr = if matches!(&input.start_type, Some(StartType::AutomaticDelayedStart)) { + clear_group_wide = to_wide(""); + PCWSTR(clear_group_wide.as_ptr()) + } else { + PCWSTR::null() + }; + // Build wide strings; they must live through the API call. let exe_wide = input.executable_path.as_ref().map(|s| to_wide(s)); let logon_wide = input.logon_account.as_ref().map(|s| to_wide(s)); @@ -625,8 +635,8 @@ pub fn set_service(input: &WindowsService) -> Result { dw_start_type, dw_error_control, exe_ptr, - PCWSTR::null(), // load order group unchanged - None, // tag id unchanged + load_order_group_ptr, + None, // tag id unchanged deps_ptr, logon_ptr, PCWSTR::null(), // password unchanged @@ -701,7 +711,7 @@ pub fn set_service(input: &WindowsService) -> Result { return Err(t!("set.unsupportedTransition", current = current.to_string(), desired = desired_status.to_string() - ).to_string()); + ).to_string().into()); } } wait_for_status(service_handle.0, desired_status)?; @@ -717,7 +727,7 @@ pub fn set_service(input: &WindowsService) -> Result { return Err(t!("set.unsupportedTransition", current = current.to_string(), desired = desired_status.to_string() - ).to_string()); + ).to_string().into()); } let mut svc_status = SERVICE_STATUS::default(); ControlService(service_handle.0, SERVICE_CONTROL_PAUSE, &mut svc_status) @@ -727,7 +737,7 @@ pub fn set_service(input: &WindowsService) -> Result { _ => { return Err(t!("set.unsupportedStatus", status = desired_status.to_string() - ).to_string()); + ).to_string().into()); } } } @@ -743,7 +753,7 @@ pub fn set_service(input: &WindowsService) -> Result { unsafe fn wait_for_status( service_handle: SC_HANDLE, desired: &ServiceStatus, -) -> Result<(), String> { +) -> Result<(), ServiceError> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(STATUS_WAIT_TIMEOUT_SECS); loop { @@ -755,7 +765,7 @@ unsafe fn wait_for_status( return Err(t!("set.statusTimeout", expected = desired.to_string(), actual = current.to_string() - ).to_string()); + ).to_string().into()); } std::thread::sleep(std::time::Duration::from_millis(STATUS_POLL_INTERVAL_MS)); } diff --git a/resources/windows_service/src/types.rs b/resources/windows_service/src/types.rs index 2a15773f0..69241c58a 100644 --- a/resources/windows_service/src/types.rs +++ b/resources/windows_service/src/types.rs @@ -143,3 +143,23 @@ impl std::fmt::Display for ErrorControl { } } } + +/// Represents an error from a Windows service operation. +#[derive(Debug)] +pub struct ServiceError { + pub message: String, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ServiceError {} + +impl From for ServiceError { + fn from(message: String) -> Self { + Self { message } + } +} diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 index 7b7a05eaf..47bb64562 100644 --- a/resources/windows_service/tests/windows_service_export.tests.ps1 +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -10,9 +10,9 @@ Describe 'Windows Service export tests' { [string]$InputJson ) if ($InputJson) { - $raw = $InputJson | dsc resource export -r $resourceType -f - 2>$null + $raw = $InputJson | dsc resource export -r $resourceType -f - 2>$testdrive/error.log } else { - $raw = dsc resource export -r $resourceType 2>$null + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log } $parsed = $raw | ConvertFrom-Json return $parsed @@ -22,7 +22,7 @@ Describe 'Windows Service export tests' { Context 'Export without filter' { It 'Returns multiple services' -Skip:(!$IsWindows) { $result = Invoke-DscExport - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 10 } @@ -53,7 +53,7 @@ Describe 'Windows Service export tests' { It 'Filters by exact service name' -Skip:(!$IsWindows) { $json = @{ name = 'wuauserv' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -Be 1 $result.resources[0].properties.name | Should -BeExactly 'wuauserv' } @@ -61,7 +61,7 @@ Describe 'Windows Service export tests' { It 'Filters by name with leading wildcard' -Skip:(!$IsWindows) { $json = @{ name = '*serv' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.name | Should -BeLike '*serv' @@ -71,7 +71,7 @@ Describe 'Windows Service export tests' { It 'Filters by name with trailing wildcard' -Skip:(!$IsWindows) { $json = @{ name = 'w*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.name | Should -BeLike 'w*' @@ -81,7 +81,7 @@ Describe 'Windows Service export tests' { It 'Filters by name with surrounding wildcards' -Skip:(!$IsWindows) { $json = @{ name = '*update*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.name | Should -BeLike '*update*' @@ -91,7 +91,7 @@ Describe 'Windows Service export tests' { It 'Returns empty when name filter matches nothing' -Skip:(!$IsWindows) { $json = @{ name = 'nonexistent_service_xyz_12345' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -Be 0 } } @@ -100,7 +100,7 @@ Describe 'Windows Service export tests' { It 'Filters by display name with wildcard' -Skip:(!$IsWindows) { $json = @{ displayName = '*Update*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.displayName | Should -BeLike '*Update*' @@ -110,7 +110,7 @@ Describe 'Windows Service export tests' { It 'Filters by exact display name' -Skip:(!$IsWindows) { $json = @{ displayName = 'Windows Update' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -Be 1 $result.resources[0].properties.displayName | Should -BeExactly 'Windows Update' } @@ -120,7 +120,7 @@ Describe 'Windows Service export tests' { It 'Filters by Running status' -Skip:(!$IsWindows) { $json = @{ status = 'Running' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.status | Should -BeExactly 'Running' @@ -130,7 +130,7 @@ Describe 'Windows Service export tests' { It 'Filters by Stopped status' -Skip:(!$IsWindows) { $json = @{ status = 'Stopped' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.status | Should -BeExactly 'Stopped' @@ -142,7 +142,7 @@ Describe 'Windows Service export tests' { It 'Filters by Automatic start type' -Skip:(!$IsWindows) { $json = @{ startType = 'Automatic' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.startType | Should -BeExactly 'Automatic' @@ -152,7 +152,7 @@ Describe 'Windows Service export tests' { It 'Filters by Manual start type' -Skip:(!$IsWindows) { $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.startType | Should -BeExactly 'Manual' @@ -162,7 +162,7 @@ Describe 'Windows Service export tests' { It 'Filters by Disabled start type' -Skip:(!$IsWindows) { $json = @{ startType = 'Disabled' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterOrEqual 0 foreach ($resource in $result.resources) { $resource.properties.startType | Should -BeExactly 'Disabled' @@ -174,7 +174,7 @@ Describe 'Windows Service export tests' { It 'Filters by status AND startType together' -Skip:(!$IsWindows) { $json = @{ status = 'Running'; startType = 'Automatic' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.status | Should -BeExactly 'Running' @@ -185,7 +185,7 @@ Describe 'Windows Service export tests' { It 'Filters by name wildcard AND status' -Skip:(!$IsWindows) { $json = @{ name = 'w*'; status = 'Stopped' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) foreach ($resource in $result.resources) { $resource.properties.name | Should -BeLike 'w*' $resource.properties.status | Should -BeExactly 'Stopped' @@ -197,7 +197,7 @@ Describe 'Windows Service export tests' { It 'Filters by a single dependency' -Skip:(!$IsWindows) { $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 0 foreach ($resource in $result.resources) { $resource.properties.dependencies | Should -Not -BeNullOrEmpty diff --git a/resources/windows_service/tests/windows_service_get.tests.ps1 b/resources/windows_service/tests/windows_service_get.tests.ps1 index 0b36488db..db8b2ca9e 100644 --- a/resources/windows_service/tests/windows_service_get.tests.ps1 +++ b/resources/windows_service/tests/windows_service_get.tests.ps1 @@ -12,8 +12,8 @@ Describe 'Windows Service get tests' { Context 'Get by name' { It 'Returns service info for an existing service' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $output = $out | ConvertFrom-Json $result = $output.actualState $result.name | Should -BeExactly $knownServiceName @@ -28,8 +28,8 @@ Describe 'Windows Service get tests' { It 'Returns _exist false for a non-existent service' -Skip:(!$IsWindows) { $json = @{ name = 'nonexistent_service_xyz' } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $output = $out | ConvertFrom-Json $result = $output.actualState $result.name | Should -BeExactly 'nonexistent_service_xyz' @@ -41,8 +41,8 @@ Describe 'Windows Service get tests' { Context 'Get by displayName' { It 'Returns service info when only displayName is provided' -Skip:(!$IsWindows) { $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $output = $out | ConvertFrom-Json $result = $output.actualState $result.name | Should -BeExactly $knownServiceName @@ -52,8 +52,8 @@ Describe 'Windows Service get tests' { It 'Returns _exist false for a non-existent display name' -Skip:(!$IsWindows) { $json = @{ displayName = 'Nonexistent Display Name XYZ' } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $output = $out | ConvertFrom-Json $result = $output.actualState $result._exist | Should -BeFalse @@ -63,8 +63,8 @@ Describe 'Windows Service get tests' { Context 'Get by both name and displayName' { It 'Returns service info when both name and displayName match' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName; displayName = $knownDisplayName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $output = $out | ConvertFrom-Json $result = $output.actualState $result.name | Should -BeExactly $knownServiceName @@ -82,7 +82,7 @@ Describe 'Windows Service get tests' { Context 'Service properties' { It 'Returns valid startType values' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json $result = $output.actualState $result.startType | Should -BeIn @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') @@ -90,7 +90,7 @@ Describe 'Windows Service get tests' { It 'Returns valid status values' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json $result = $output.actualState $result.status | Should -BeIn @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') @@ -98,7 +98,7 @@ Describe 'Windows Service get tests' { It 'Returns valid errorControl values' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json $result = $output.actualState $result.errorControl | Should -BeIn @('Ignore', 'Normal', 'Severe', 'Critical') @@ -106,7 +106,7 @@ Describe 'Windows Service get tests' { It 'Returns dependencies as an array when present' -Skip:(!$IsWindows) { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json $result = $output.actualState if ($null -ne $result.dependencies) { diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 index 500d875ef..3527f463e 100644 --- a/resources/windows_service/tests/windows_service_set.tests.ps1 +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -22,8 +22,8 @@ Describe 'Windows Service set tests' { function Get-ServiceState { param([string]$Name) $json = @{ name = $Name } | ConvertTo-Json -Compress - $out = $json | dsc resource get -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) return ($out | ConvertFrom-Json).actualState } } @@ -53,14 +53,14 @@ Describe 'Windows Service set tests' { name = $testServiceName startType = $script:originalState.startType } | ConvertTo-Json -Compress - $restoreJson | dsc resource set -r $resourceType -f - 2>$null + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log } } It 'Can set startType to Disabled' { $json = @{ name = $testServiceName; startType = 'Disabled' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.name | Should -BeExactly $testServiceName $result.startType | Should -BeExactly 'Disabled' @@ -68,24 +68,24 @@ Describe 'Windows Service set tests' { It 'Can set startType to Manual' { $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.startType | Should -BeExactly 'Manual' } It 'Can set startType to Automatic' { $json = @{ name = $testServiceName; startType = 'Automatic' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.startType | Should -BeExactly 'Automatic' } It 'Can set startType to AutomaticDelayedStart' { $json = @{ name = $testServiceName; startType = 'AutomaticDelayedStart' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.startType | Should -BeExactly 'AutomaticDelayedStart' } @@ -103,15 +103,15 @@ Describe 'Windows Service set tests' { name = $testServiceName description = $script:originalState.description } | ConvertTo-Json -Compress - $restoreJson | dsc resource set -r $resourceType -f - 2>$null + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log } } It 'Can set description' { $testDesc = 'DSC test description' $json = @{ name = $testServiceName; description = $testDesc } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.description | Should -BeExactly $testDesc } @@ -123,7 +123,7 @@ Describe 'Windows Service set tests' { # Ensure the service is startable (not disabled) if ($script:originalState.startType -eq 'Disabled') { $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress - $json | dsc resource set -r $resourceType -f - 2>$null + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log } } @@ -135,19 +135,19 @@ Describe 'Windows Service set tests' { startType = $script:originalState.startType status = $script:originalState.status } | ConvertTo-Json -Compress - $restoreJson | dsc resource set -r $resourceType -f - 2>$null + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log } } It 'Can start a stopped service' { # First ensure it is stopped $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress - $json | dsc resource set -r $resourceType -f - 2>$null + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log # Now start it $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.status | Should -BeExactly 'Running' } @@ -155,12 +155,12 @@ Describe 'Windows Service set tests' { It 'Can stop a running service' { # First ensure it is running $json = @{ name = $testServiceName; status = 'Running' } | ConvertTo-Json -Compress - $json | dsc resource set -r $resourceType -f - 2>$null + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log # Now stop it $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.status | Should -BeExactly 'Stopped' } @@ -168,11 +168,11 @@ Describe 'Windows Service set tests' { It 'Is idempotent when service is already in desired status' { # Ensure it is stopped $json = @{ name = $testServiceName; status = 'Stopped' } | ConvertTo-Json -Compress - $json | dsc resource set -r $resourceType -f - 2>$null + $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log # Set to Stopped again — should succeed without error - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.status | Should -BeExactly 'Stopped' } @@ -192,7 +192,7 @@ Describe 'Windows Service set tests' { description = $script:originalState.description status = $script:originalState.status } | ConvertTo-Json -Compress - $restoreJson | dsc resource set -r $resourceType -f - 2>$null + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log } } @@ -202,8 +202,8 @@ Describe 'Windows Service set tests' { startType = 'Manual' description = 'DSC combined test' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.startType | Should -BeExactly 'Manual' $result.description | Should -BeExactly 'DSC combined test' @@ -211,8 +211,8 @@ Describe 'Windows Service set tests' { It 'Returns full service state after set' { $json = @{ name = $testServiceName; startType = 'Manual' } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.name | Should -Not -BeNullOrEmpty $result.displayName | Should -Not -BeNullOrEmpty @@ -228,8 +228,8 @@ Describe 'Windows Service set tests' { Context 'Set with no changes (idempotent)' -Skip:(!$IsWindows -or !$isAdmin) { It 'Succeeds when only name is provided (no properties to change)' { $json = @{ name = $testServiceName } | ConvertTo-Json -Compress - $out = $json | dsc resource set -r $resourceType -f - 2>$null - $LASTEXITCODE | Should -Be 0 + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result = ($out | ConvertFrom-Json).afterState $result.name | Should -BeExactly $testServiceName $result._exist | Should -BeTrue From f305c260410df6fa00e164cf22ebb6ef9fdd1cb8 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Wed, 11 Mar 2026 13:18:40 -0700 Subject: [PATCH 05/15] remove unused string --- resources/windows_service/locales/en-us.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml index 5b12cc26d..6aa57fc97 100644 --- a/resources/windows_service/locales/en-us.toml +++ b/resources/windows_service/locales/en-us.toml @@ -19,7 +19,6 @@ displayNameMismatch = "Service display name mismatch: expected '%{expected}', go [export] enumServicesFailed = "Failed to enumerate services: %{error}" openServiceFailed = "Failed to open service '%{name}': %{error}" -serializeFailed = "Failed to serialize service: %{error}" [set] nameRequired = "'name' is required for the set operation" From 9deb464826b8482fdb2a0d9fdbb5abee956f0ef4 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Wed, 11 Mar 2026 16:29:54 -0700 Subject: [PATCH 06/15] address initial copilot feedback --- Cargo.toml | 9 ++-- .../tests/windows_service_export.tests.ps1 | 51 ++++++++++--------- .../tests/windows_service_get.tests.ps1 | 25 ++++----- .../tests/windows_service_set.tests.ps1 | 14 ++--- 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 195991d9b..fb92fa7ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,9 @@ members = [ "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" -, "resources/windows_service"] + "xtask", "resources/windows_service" +] + # This value is modified by the `Set-DefaultWorkspaceMember` helper. # Be sure to use `Reset-DefaultWorkspaceMember` before committing to # avoid unintentionally modifying this value. @@ -51,8 +52,8 @@ default-members = [ "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask" -, "resources/windows_service"] + "xtask", "resources/windows_service" +] [workspace.metadata.groups] # The entries in this table map crates by operating system. Use the helper diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 index 47bb64562..c9d4c3922 100644 --- a/resources/windows_service/tests/windows_service_export.tests.ps1 +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Service export tests' { +Describe 'Windows Service export tests' -Skip:(!$IsWindows) { BeforeAll { $resourceType = 'Microsoft.Windows/Service' @@ -20,7 +20,7 @@ Describe 'Windows Service export tests' { } Context 'Export without filter' { - It 'Returns multiple services' -Skip:(!$IsWindows) { + It 'Returns multiple services' { $result = Invoke-DscExport $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -BeGreaterThan 10 @@ -41,7 +41,7 @@ Describe 'Windows Service export tests' { } } - It 'Sets the correct resource type on each entry' -Skip:(!$IsWindows) { + It 'Sets the correct resource type on each entry' { $result = Invoke-DscExport foreach ($resource in $result.resources | Select-Object -First 5) { $resource.type | Should -BeExactly $resourceType @@ -50,7 +50,7 @@ Describe 'Windows Service export tests' { } Context 'Export with name filter' { - It 'Filters by exact service name' -Skip:(!$IsWindows) { + It 'Filters by exact service name' { $json = @{ name = 'wuauserv' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -58,7 +58,7 @@ Describe 'Windows Service export tests' { $result.resources[0].properties.name | Should -BeExactly 'wuauserv' } - It 'Filters by name with leading wildcard' -Skip:(!$IsWindows) { + It 'Filters by name with leading wildcard' { $json = @{ name = '*serv' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -68,7 +68,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by name with trailing wildcard' -Skip:(!$IsWindows) { + It 'Filters by name with trailing wildcard' { $json = @{ name = 'w*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -78,7 +78,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by name with surrounding wildcards' -Skip:(!$IsWindows) { + It 'Filters by name with surrounding wildcards' { $json = @{ name = '*update*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -88,7 +88,7 @@ Describe 'Windows Service export tests' { } } - It 'Returns empty when name filter matches nothing' -Skip:(!$IsWindows) { + It 'Returns empty when name filter matches nothing' { $json = @{ name = 'nonexistent_service_xyz_12345' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -97,7 +97,7 @@ Describe 'Windows Service export tests' { } Context 'Export with displayName filter' { - It 'Filters by display name with wildcard' -Skip:(!$IsWindows) { + It 'Filters by display name with wildcard' { $json = @{ displayName = '*Update*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -107,17 +107,20 @@ Describe 'Windows Service export tests' { } } - It 'Filters by exact display name' -Skip:(!$IsWindows) { - $json = @{ displayName = 'Windows Update' } | ConvertTo-Json -Compress + It 'Filters by exact display name' { + $service = Get-Service -Name 'wuauserv' -ErrorAction Stop + $knownDisplayName = $service.DisplayName + $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) $result.resources.Count | Should -Be 1 - $result.resources[0].properties.displayName | Should -BeExactly 'Windows Update' + $result.resources[0].properties.displayName | Should -BeExactly $knownDisplayName + $result.resources[0].properties.name | Should -BeExactly 'wuauserv' } } Context 'Export with status filter' { - It 'Filters by Running status' -Skip:(!$IsWindows) { + It 'Filters by Running status' { $json = @{ status = 'Running' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -127,7 +130,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by Stopped status' -Skip:(!$IsWindows) { + It 'Filters by Stopped status' { $json = @{ status = 'Stopped' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -139,7 +142,7 @@ Describe 'Windows Service export tests' { } Context 'Export with startType filter' { - It 'Filters by Automatic start type' -Skip:(!$IsWindows) { + It 'Filters by Automatic start type' { $json = @{ startType = 'Automatic' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -149,7 +152,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by Manual start type' -Skip:(!$IsWindows) { + It 'Filters by Manual start type' { $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -159,7 +162,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by Disabled start type' -Skip:(!$IsWindows) { + It 'Filters by Disabled start type' { $json = @{ startType = 'Disabled' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -171,7 +174,7 @@ Describe 'Windows Service export tests' { } Context 'Export with multi-field filter' { - It 'Filters by status AND startType together' -Skip:(!$IsWindows) { + It 'Filters by status AND startType together' { $json = @{ status = 'Running'; startType = 'Automatic' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -182,7 +185,7 @@ Describe 'Windows Service export tests' { } } - It 'Filters by name wildcard AND status' -Skip:(!$IsWindows) { + It 'Filters by name wildcard AND status' { $json = @{ name = 'w*'; status = 'Stopped' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -194,7 +197,7 @@ Describe 'Windows Service export tests' { } Context 'Export with dependencies filter' { - It 'Filters by a single dependency' -Skip:(!$IsWindows) { + It 'Filters by a single dependency' { $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -207,7 +210,7 @@ Describe 'Windows Service export tests' { } Context 'Export property validation' { - It 'All exported services have valid startType values' -Skip:(!$IsWindows) { + It 'All exported services have valid startType values' { $json = @{ name = 'w*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $validStartTypes = @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') @@ -216,7 +219,7 @@ Describe 'Windows Service export tests' { } } - It 'All exported services have valid status values' -Skip:(!$IsWindows) { + It 'All exported services have valid status values' { $json = @{ name = 'w*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $validStatuses = @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') @@ -225,7 +228,7 @@ Describe 'Windows Service export tests' { } } - It 'All exported services have valid errorControl values' -Skip:(!$IsWindows) { + It 'All exported services have valid errorControl values' { $json = @{ name = 'w*' } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json $validErrorControls = @('Ignore', 'Normal', 'Severe', 'Critical') @@ -234,7 +237,7 @@ Describe 'Windows Service export tests' { } } - It 'Dependencies is an array when present' -Skip:(!$IsWindows) { + It 'Dependencies is an array when present' { $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress $result = Invoke-DscExport -InputJson $json foreach ($resource in $result.resources | Select-Object -First 3) { diff --git a/resources/windows_service/tests/windows_service_get.tests.ps1 b/resources/windows_service/tests/windows_service_get.tests.ps1 index db8b2ca9e..69fe7a1c2 100644 --- a/resources/windows_service/tests/windows_service_get.tests.ps1 +++ b/resources/windows_service/tests/windows_service_get.tests.ps1 @@ -1,16 +1,17 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Service get tests' { +Describe 'Windows Service get tests' -Skip:(!$IsWindows) { BeforeAll { # Use a well-known Windows service that exists on all Windows machines $resourceType = 'Microsoft.Windows/Service' $knownServiceName = 'wuauserv' - $knownDisplayName = 'Windows Update' + $service = Get-Service -Name $knownServiceName -ErrorAction Stop + $knownDisplayName = $service.DisplayName } Context 'Get by name' { - It 'Returns service info for an existing service' -Skip:(!$IsWindows) { + It 'Returns service info for an existing service' { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -26,7 +27,7 @@ Describe 'Windows Service get tests' { $result.errorControl | Should -Not -BeNullOrEmpty } - It 'Returns _exist false for a non-existent service' -Skip:(!$IsWindows) { + It 'Returns _exist false for a non-existent service' { $json = @{ name = 'nonexistent_service_xyz' } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -39,7 +40,7 @@ Describe 'Windows Service get tests' { } Context 'Get by displayName' { - It 'Returns service info when only displayName is provided' -Skip:(!$IsWindows) { + It 'Returns service info when only displayName is provided' { $json = @{ displayName = $knownDisplayName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -50,7 +51,7 @@ Describe 'Windows Service get tests' { $result._exist | Should -BeTrue } - It 'Returns _exist false for a non-existent display name' -Skip:(!$IsWindows) { + It 'Returns _exist false for a non-existent display name' { $json = @{ displayName = 'Nonexistent Display Name XYZ' } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -61,7 +62,7 @@ Describe 'Windows Service get tests' { } Context 'Get by both name and displayName' { - It 'Returns service info when both name and displayName match' -Skip:(!$IsWindows) { + It 'Returns service info when both name and displayName match' { $json = @{ name = $knownServiceName; displayName = $knownDisplayName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) @@ -72,7 +73,7 @@ Describe 'Windows Service get tests' { $result._exist | Should -BeTrue } - It 'Returns error when name and displayName do not match' -Skip:(!$IsWindows) { + It 'Returns error when name and displayName do not match' { $json = @{ name = $knownServiceName; displayName = 'Wrong Display Name' } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>&1 $LASTEXITCODE | Should -Not -Be 0 @@ -80,7 +81,7 @@ Describe 'Windows Service get tests' { } Context 'Service properties' { - It 'Returns valid startType values' -Skip:(!$IsWindows) { + It 'Returns valid startType values' { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json @@ -88,7 +89,7 @@ Describe 'Windows Service get tests' { $result.startType | Should -BeIn @('Automatic', 'AutomaticDelayedStart', 'Manual', 'Disabled') } - It 'Returns valid status values' -Skip:(!$IsWindows) { + It 'Returns valid status values' { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json @@ -96,7 +97,7 @@ Describe 'Windows Service get tests' { $result.status | Should -BeIn @('Running', 'Stopped', 'Paused', 'StartPending', 'StopPending', 'PausePending', 'ContinuePending') } - It 'Returns valid errorControl values' -Skip:(!$IsWindows) { + It 'Returns valid errorControl values' { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json @@ -104,7 +105,7 @@ Describe 'Windows Service get tests' { $result.errorControl | Should -BeIn @('Ignore', 'Normal', 'Severe', 'Critical') } - It 'Returns dependencies as an array when present' -Skip:(!$IsWindows) { + It 'Returns dependencies as an array when present' { $json = @{ name = $knownServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource get -r $resourceType -f - 2>$testdrive/error.log $output = $out | ConvertFrom-Json diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 index 3527f463e..73669e355 100644 --- a/resources/windows_service/tests/windows_service_set.tests.ps1 +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -Describe 'Windows Service set tests' { +Describe 'Windows Service set tests' -Skip:(!$IsWindows) { BeforeDiscovery { $isAdmin = if ($IsWindows) { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -28,7 +28,7 @@ Describe 'Windows Service set tests' { } } - Context 'Input validation' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Input validation' -Skip:(!$isAdmin) { It 'Fails when name is not provided' { $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 @@ -41,7 +41,7 @@ Describe 'Windows Service set tests' { } } - Context 'Set startType' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Set startType' -Skip:(!$isAdmin) { BeforeAll { $script:originalState = Get-ServiceState -Name $testServiceName } @@ -91,7 +91,7 @@ Describe 'Windows Service set tests' { } } - Context 'Set description' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Set description' -Skip:(!$isAdmin) { BeforeAll { $script:originalState = Get-ServiceState -Name $testServiceName } @@ -117,7 +117,7 @@ Describe 'Windows Service set tests' { } } - Context 'Set service status' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Set service status' -Skip:(!$isAdmin) { BeforeAll { $script:originalState = Get-ServiceState -Name $testServiceName # Ensure the service is startable (not disabled) @@ -178,7 +178,7 @@ Describe 'Windows Service set tests' { } } - Context 'Set multiple properties at once' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Set multiple properties at once' -Skip:(!$isAdmin) { BeforeAll { $script:originalState = Get-ServiceState -Name $testServiceName } @@ -225,7 +225,7 @@ Describe 'Windows Service set tests' { } } - Context 'Set with no changes (idempotent)' -Skip:(!$IsWindows -or !$isAdmin) { + Context 'Set with no changes (idempotent)' -Skip:(!$isAdmin) { It 'Succeeds when only name is provided (no properties to change)' { $json = @{ name = $testServiceName } | ConvertTo-Json -Compress $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log From b61b8bf68646efc257b928ead1873c283c951b87 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 15:27:38 -0700 Subject: [PATCH 07/15] Address copilot feedback --- resources/windows_service/locales/en-us.toml | 1 + resources/windows_service/src/service.rs | 50 ++++++++++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml index 6aa57fc97..fd48f9709 100644 --- a/resources/windows_service/locales/en-us.toml +++ b/resources/windows_service/locales/en-us.toml @@ -13,6 +13,7 @@ nameOrDisplayNameRequired = "At least one of 'name' or 'displayName' must be pro openScmFailed = "Failed to open Service Control Manager: %{error}" queryConfigFailed = "Failed to query service configuration: %{error}" queryStatusFailed = "Failed to query service status: %{error}" +openServiceFailed = "Failed to open service: %{error}" getKeyNameFailed = "Failed to resolve service name from display name: %{error}" displayNameMismatch = "Service display name mismatch: expected '%{expected}', got '%{actual}'" diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index 50ce63dfe..f1432343b 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -4,6 +4,7 @@ use rust_i18n::t; use std::mem; use windows::core::{PCWSTR, PWSTR}; +use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SERVICE_DOES_NOT_EXIST}; use windows::Win32::System::Services::*; const SERVICE_NO_CHANGE_VALUE: u32 = 0xFFFF_FFFF; @@ -49,7 +50,7 @@ unsafe fn parse_multi_string(ptr: PWSTR) -> Vec { let pcwstr = PCWSTR(current); match pcwstr.to_string() { Ok(s) => { - current = current.add(s.len() + 1); + current = current.add(s.encode_utf16().count() + 1); result.push(s); } Err(_) => break, @@ -118,7 +119,7 @@ pub fn get_service(input: &WindowsService) -> Result ScHandle(h), - Err(_) => { + Err(e) if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() => { return Ok(WindowsService { name: input.name.clone(), display_name: input.display_name.clone(), @@ -126,11 +127,22 @@ pub fn get_service(input: &WindowsService) -> Result { + return Err(ServiceError::from(t!("get.openServiceFailed", error = e.to_string()).to_string())); + } }; // Query basic configuration let mut bytes_needed: u32 = 0; - let _ = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); + let sizing_result = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); + if let Err(e) = sizing_result { + if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { + return Err(ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string())); + } + } + if bytes_needed == 0 { + return Err(ServiceError::from(t!("get.queryConfigFailed", error = "buffer size is 0").to_string())); + } let mut config_buffer = vec![0u8; bytes_needed as usize]; let config_ptr = config_buffer.as_mut_ptr().cast::(); QueryServiceConfigW( @@ -447,7 +459,15 @@ unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result(); QueryServiceConfigW( @@ -556,9 +576,12 @@ fn deps_to_multi_string(deps: &[String]) -> Vec { let mut buf = Vec::new(); for dep in deps { buf.extend(dep.encode_utf16()); - buf.push(0); + buf.push(0); // null terminator for each string } - buf.push(0); // double-null terminator + if buf.is_empty() { + buf.push(0); // first null for empty multi-string + } + buf.push(0); // final null terminator (double-null) buf } @@ -574,8 +597,19 @@ pub fn set_service(input: &WindowsService) -> Result { + access |= SERVICE_START | SERVICE_PAUSE_CONTINUE; + } + ServiceStatus::Stopped => { + access |= SERVICE_STOP; + } + ServiceStatus::Paused => { + access |= SERVICE_PAUSE_CONTINUE; + } + _ => {} + } } let service_handle = OpenServiceW(scm.0, PCWSTR(name_wide.as_ptr()), access) From ab2da36d0c0b6aa0aa7cc154be313c3ef8a6b388 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 12 Mar 2026 15:38:02 -0700 Subject: [PATCH 08/15] Update resources/windows_service/windows_service.dsc.resource.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- resources/windows_service/windows_service.dsc.resource.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json index 89fe1025f..f37083a82 100644 --- a/resources/windows_service/windows_service.dsc.resource.json +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -76,7 +76,7 @@ "status": { "type": "string", "title": "Status", - "description": "The current status of the service.", + "description": "The status of the service. When used as desired state, only \"Running\", \"Stopped\", and \"Paused\" are accepted. Additional values (\"StartPending\", \"StopPending\", \"PausePending\", \"ContinuePending\") may be returned to report the current status but must not be used as desired values.", "enum": ["Running", "Stopped", "Paused", "StartPending", "StopPending", "PausePending", "ContinuePending"] }, "startType": { From eb7b9676b0fc24f7f6010c4f20784752d93ad8ce Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 15:39:00 -0700 Subject: [PATCH 09/15] fix cargo.toml formatting --- Cargo.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fb92fa7ac..33423b842 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,12 +19,13 @@ members = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask", "resources/windows_service" + "xtask" ] # This value is modified by the `Set-DefaultWorkspaceMember` helper. @@ -47,12 +48,13 @@ default-members = [ "lib/dsc-lib-security_context", "resources/sshdconfig", "resources/WindowsUpdate", + "resources/windows_service", "tools/dsctest", "tools/test_group_resource", "grammars/tree-sitter-dscexpression", "grammars/tree-sitter-ssh-server-config", "y2j", - "xtask", "resources/windows_service" + "xtask" ] [workspace.metadata.groups] From 25c0c97f02f30395430d36fe362620e272da58ca Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 15:46:55 -0700 Subject: [PATCH 10/15] address error handling feedback --- resources/windows_service/src/service.rs | 28 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index f1432343b..f543db9b0 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -94,7 +94,7 @@ pub fn get_service(input: &WindowsService) -> Result Some(name.clone()), None => { let display_name = input.display_name.as_ref().unwrap(); - resolve_key_name_from_display_name(scm.0, display_name).ok() + resolve_key_name_from_display_name(scm.0, display_name)? } }; @@ -222,19 +222,24 @@ pub fn get_service(input: &WindowsService) -> Result Result { +) -> Result, ServiceError> { let dn_wide = to_wide(display_name); let mut size: u32 = 0; // First call to determine the required buffer size - let _ = unsafe { + let sizing_result = unsafe { GetServiceKeyNameW(scm, PCWSTR(dn_wide.as_ptr()), None, &mut size) }; - if size == 0 { - return Err( - t!("get.getKeyNameFailed", error = "service not found").to_string().into(), - ); + if let Err(e) = sizing_result { + if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() { + return Ok(None); + } + if size == 0 { + return Err( + t!("get.getKeyNameFailed", error = e.to_string()).to_string().into(), + ); + } } size += 1; // null terminator @@ -250,7 +255,7 @@ unsafe fn resolve_key_name_from_display_name( .map_err(|e| t!("get.getKeyNameFailed", error = e.to_string()).to_string())?; } - Ok(String::from_utf16_lossy(&buffer[..size as usize])) + Ok(Some(String::from_utf16_lossy(&buffer[..size as usize]))) } /// Check whether the service is configured for delayed automatic start. @@ -397,7 +402,7 @@ unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, Service let mut resume_handle: u32 = 0; // First call to get required buffer size - let _ = EnumServicesStatusExW( + let sizing_result = EnumServicesStatusExW( scm, SC_ENUM_PROCESS_INFO, SERVICE_WIN32, @@ -408,6 +413,11 @@ unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, Service Some(&mut resume_handle), None, ); + if let Err(e) = sizing_result { + if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { + return Err(t!("export.enumServicesFailed", error = e.to_string()).to_string().into()); + } + } if bytes_needed == 0 { return Ok(Vec::new()); From 9665673d0aaf7981236dac8344c02b031090b3e8 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Thu, 12 Mar 2026 16:53:29 -0700 Subject: [PATCH 11/15] code cleanup --- resources/windows_service/src/main.rs | 67 ++-- resources/windows_service/src/service.rs | 482 +++++++++++------------ 2 files changed, 272 insertions(+), 277 deletions(-) diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index 5a1958554..c3d7ffa3e 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -23,6 +23,35 @@ const EXIT_INVALID_ARGS: i32 = 1; const EXIT_INVALID_INPUT: i32 = 2; const EXIT_SERVICE_ERROR: i32 = 3; +/// Deserialize the required JSON input into a `WindowsService`, or exit with an error. +fn require_input(input_json: Option) -> WindowsService { + let json = match input_json { + Some(j) => j, + None => { + write_error(&t!("main.missingInput")); + exit(EXIT_INVALID_ARGS); + } + }; + match serde_json::from_str(&json) { + Ok(v) => v, + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_INVALID_INPUT); + } + } +} + +/// Serialize a value to JSON and print it to stdout, or exit with an error. +fn print_json(value: &impl serde::Serialize) { + match serde_json::to_string(value) { + Ok(json) => println!("{json}"), + Err(e) => { + write_error(&t!("main.invalidJson", error = e.to_string())); + exit(EXIT_SERVICE_ERROR); + } + } +} + fn main() { let args: Vec = std::env::args().collect(); @@ -36,27 +65,13 @@ fn main() { match operation { "get" => { - let json = match input_json { - Some(j) => j, - None => { - write_error(&t!("main.missingInput")); - exit(EXIT_INVALID_ARGS); - } - }; - - let input: WindowsService = match serde_json::from_str(&json) { - Ok(s) => s, - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_INVALID_INPUT); - } - }; + let input = require_input(input_json); #[cfg(windows)] { match service::get_service(&input) { Ok(result) => { - println!("{}", serde_json::to_string(&result).unwrap()); + print_json(&result); exit(EXIT_SUCCESS); } Err(e) => { @@ -74,27 +89,13 @@ fn main() { } } "set" => { - let json = match input_json { - Some(j) => j, - None => { - write_error(&t!("main.missingInput")); - exit(EXIT_INVALID_ARGS); - } - }; - - let input: WindowsService = match serde_json::from_str(&json) { - Ok(s) => s, - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_INVALID_INPUT); - } - }; + let input = require_input(input_json); #[cfg(windows)] { match service::set_service(&input) { Ok(result) => { - println!("{}", serde_json::to_string(&result).unwrap()); + print_json(&result); exit(EXIT_SUCCESS); } Err(e) => { @@ -128,7 +129,7 @@ fn main() { match service::export_services(filter.as_ref()) { Ok(services) => { for svc in &services { - println!("{}", serde_json::to_string(svc).unwrap()); + print_json(svc); } exit(EXIT_SUCCESS); } diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index f543db9b0..ff4dd687b 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -4,7 +4,7 @@ use rust_i18n::t; use std::mem; use windows::core::{PCWSTR, PWSTR}; -use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SERVICE_DOES_NOT_EXIST}; +use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_MORE_DATA, ERROR_SERVICE_DOES_NOT_EXIST}; use windows::Win32::System::Services::*; const SERVICE_NO_CHANGE_VALUE: u32 = 0xFFFF_FFFF; @@ -71,6 +71,93 @@ impl Drop for ScHandle { } } +impl From for ServiceError { + fn from(e: windows::core::Error) -> Self { + Self { message: e.to_string() } + } +} + +/// Read the full configuration and status of an already-opened service handle. +/// +/// # Safety +/// +/// `service_handle` must be a valid, open service handle with `SERVICE_QUERY_CONFIG` +/// and `SERVICE_QUERY_STATUS` access rights. +unsafe fn read_service_state( + service_handle: SC_HANDLE, + service_name: &str, +) -> Result { + // Query basic configuration + let mut bytes_needed: u32 = 0; + let sizing_result = unsafe { + QueryServiceConfigW(service_handle, None, 0, &mut bytes_needed) + }; + if let Err(e) = sizing_result { + if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { + return Err(t!("get.queryConfigFailed", error = e.to_string()).to_string().into()); + } + } + if bytes_needed == 0 { + return Err(t!("get.queryConfigFailed", error = "buffer size is 0").to_string().into()); + } + let mut config_buffer = vec![0u8; bytes_needed as usize]; + let config_ptr = config_buffer.as_mut_ptr().cast::(); + unsafe { + QueryServiceConfigW( + service_handle, + Some(&mut *config_ptr), + bytes_needed, + &mut bytes_needed, + ) + .map_err(|e| ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string()))?; + } + + let config = unsafe { &*config_ptr }; + let display_name = unsafe { pwstr_to_string(config.lpDisplayName) }; + + let start_type = match config.dwStartType { + SERVICE_AUTO_START => { + if unsafe { is_delayed_auto_start(service_handle) } { + Some(StartType::AutomaticDelayedStart) + } else { + Some(StartType::Automatic) + } + } + SERVICE_DEMAND_START => Some(StartType::Manual), + SERVICE_DISABLED => Some(StartType::Disabled), + _ => None, + }; + + let error_control = match config.dwErrorControl { + SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), + SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), + SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), + SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), + _ => None, + }; + + let executable_path = unsafe { pwstr_to_string(config.lpBinaryPathName) }; + let logon_account = unsafe { pwstr_to_string(config.lpServiceStartName) }; + let deps = unsafe { parse_multi_string(config.lpDependencies) }; + let dependencies = if deps.is_empty() { None } else { Some(deps) }; + + let description = unsafe { query_description(service_handle) }; + let status = unsafe { query_status(service_handle) }?; + + Ok(WindowsService { + name: Some(service_name.to_string()), + display_name, + description, + exist: Some(true), + status: Some(status), + start_type, + executable_path, + logon_account, + error_control, + dependencies, + }) +} + /// Look up a service by `name` and/or `display_name` and return the full service info. /// /// - If only `name` is provided, the service is looked up by name. @@ -83,139 +170,72 @@ pub fn get_service(input: &WindowsService) -> Result Some(name.clone()), - None => { - let display_name = input.display_name.as_ref().unwrap(); - resolve_key_name_from_display_name(scm.0, display_name)? - } - }; + // Resolve the service key name + let service_key_name = match &input.name { + Some(name) => Some(name.clone()), + None => { + let display_name = input.display_name.as_ref().unwrap(); + unsafe { resolve_key_name_from_display_name(scm.0, display_name) }? + } + }; - // If we couldn't resolve a key name, the service doesn't exist - let service_key_name = match service_key_name { - Some(n) => n, - None => { - return Ok(WindowsService { - name: input.name.clone(), - display_name: input.display_name.clone(), - exist: Some(false), - ..Default::default() - }); - } - }; + // If we couldn't resolve a key name, the service doesn't exist + let service_key_name = match service_key_name { + Some(n) => n, + None => { + return Ok(WindowsService { + name: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + }; - // Open the service - let name_wide = to_wide(&service_key_name); - let service_handle = match OpenServiceW( + // Open the service + let name_wide = to_wide(&service_key_name); + let service_handle = match unsafe { + OpenServiceW( scm.0, PCWSTR(name_wide.as_ptr()), SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, - ) { - Ok(h) => ScHandle(h), - Err(e) if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() => { - return Ok(WindowsService { - name: input.name.clone(), - display_name: input.display_name.clone(), - exist: Some(false), - ..Default::default() - }); - } - Err(e) => { - return Err(ServiceError::from(t!("get.openServiceFailed", error = e.to_string()).to_string())); - } - }; - - // Query basic configuration - let mut bytes_needed: u32 = 0; - let sizing_result = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); - if let Err(e) = sizing_result { - if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { - return Err(ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string())); - } - } - if bytes_needed == 0 { - return Err(ServiceError::from(t!("get.queryConfigFailed", error = "buffer size is 0").to_string())); - } - let mut config_buffer = vec![0u8; bytes_needed as usize]; - let config_ptr = config_buffer.as_mut_ptr().cast::(); - QueryServiceConfigW( - service_handle.0, - Some(&mut *config_ptr), - bytes_needed, - &mut bytes_needed, ) - .map_err(|e| ServiceError::from(t!("get.queryConfigFailed", error = e.to_string()).to_string()))?; - - let config = &*config_ptr; - let actual_display_name = pwstr_to_string(config.lpDisplayName); - - // If both name and display_name were provided, verify they match - if input.name.is_some() && input.display_name.is_some() { - let expected_dn = input.display_name.as_ref().unwrap(); - let actual_dn = actual_display_name.as_deref().unwrap_or(""); - if !actual_dn.eq_ignore_ascii_case(expected_dn) { - return Err( - t!("get.displayNameMismatch", expected = expected_dn, actual = actual_dn) - .to_string() - .into(), - ); - } + } { + Ok(h) => ScHandle(h), + Err(e) if e.code() == ERROR_SERVICE_DOES_NOT_EXIST.to_hresult() => { + return Ok(WindowsService { + name: input.name.clone(), + display_name: input.display_name.clone(), + exist: Some(false), + ..Default::default() + }); + } + Err(e) => { + return Err(ServiceError::from(t!("get.openServiceFailed", error = e.to_string()).to_string())); } + }; - // Determine start type (with delayed auto-start check) - let start_type = match config.dwStartType { - SERVICE_AUTO_START => { - if is_delayed_auto_start(service_handle.0) { - Some(StartType::AutomaticDelayedStart) - } else { - Some(StartType::Automatic) - } - } - SERVICE_DEMAND_START => Some(StartType::Manual), - SERVICE_DISABLED => Some(StartType::Disabled), - _ => None, - }; - - // Determine error control - let error_control = match config.dwErrorControl { - SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), - SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), - SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), - SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), - _ => None, - }; + let svc = unsafe { read_service_state(service_handle.0, &service_key_name) }?; - let executable_path = pwstr_to_string(config.lpBinaryPathName); - let logon_account = pwstr_to_string(config.lpServiceStartName); - let deps = parse_multi_string(config.lpDependencies); - let dependencies = if deps.is_empty() { None } else { Some(deps) }; - - // Query description - let description = query_description(service_handle.0); - - // Query current status - let status = query_status(service_handle.0)?; - - Ok(WindowsService { - name: Some(service_key_name), - display_name: actual_display_name, - description, - exist: Some(true), - status: Some(status), - start_type, - executable_path, - logon_account, - error_control, - dependencies, - }) + // If both name and display_name were provided, verify they match + if input.name.is_some() && input.display_name.is_some() { + let expected_dn = input.display_name.as_ref().unwrap(); + let actual_dn = svc.display_name.as_deref().unwrap_or(""); + if !actual_dn.eq_ignore_ascii_case(expected_dn) { + return Err( + t!("get.displayNameMismatch", expected = expected_dn, actual = actual_dn) + .to_string() + .into(), + ); + } } + + Ok(svc) } /// Resolve a service key name from its display name via SCM. @@ -364,45 +384,68 @@ unsafe fn query_status(service_handle: SC_HANDLE) -> Result SERVICE_STATUS_CURRENT_STATE { + match status { + ServiceStatus::Running => SERVICE_RUNNING, + ServiceStatus::Stopped => SERVICE_STOPPED, + ServiceStatus::Paused => SERVICE_PAUSED, + ServiceStatus::StartPending => SERVICE_START_PENDING, + ServiceStatus::StopPending => SERVICE_STOP_PENDING, + ServiceStatus::PausePending => SERVICE_PAUSE_PENDING, + ServiceStatus::ContinuePending => SERVICE_CONTINUE_PENDING, + } +} + /// Export (enumerate) all services, optionally filtering by the provided criteria. /// Returns a list of matching services. pub fn export_services(filter: Option<&WindowsService>) -> Result, ServiceError> { - unsafe { - let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE) - .map_err(|e| t!("get.openScmFailed", error = e.to_string()).to_string())?; - let scm = ScHandle(scm); + let scm = unsafe { OpenSCManagerW(None, None, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE) } + .map_err(|e| ServiceError::from(t!("get.openScmFailed", error = e.to_string()).to_string()))?; + let scm = ScHandle(scm); - let service_names = enumerate_service_names(scm.0)?; - let mut results = Vec::new(); + let services = unsafe { enumerate_services(scm.0) }?; + let mut results = Vec::new(); - for service_name in &service_names { - let svc = match get_service_details(scm.0, service_name) { - Ok(s) => s, - Err(_) => continue, // skip services we can't query - }; + // Pre-compute the status filter value for early rejection before expensive per-service queries + let status_filter_dw = filter.and_then(|f| f.status.as_ref()).map(status_to_current_state); - if let Some(f) = filter { - if !matches_filter(&svc, f) { - continue; - } + for (service_name, current_state) in &services { + // Quick reject based on status before opening the service handle + if let Some(expected_state) = status_filter_dw { + if *current_state != expected_state { + continue; } + } + + let svc = match unsafe { get_service_details(scm.0, service_name) } { + Ok(s) => s, + Err(_) => continue, // skip services we can't query + }; - results.push(svc); + if let Some(f) = filter { + if !matches_filter(&svc, f) { + continue; + } } - Ok(results) + results.push(svc); } + + Ok(results) } -/// Enumerate all Win32 service names using `EnumServicesStatusExW`. -unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, ServiceError> { +/// Enumerate all Win32 services using `EnumServicesStatusExW`, handling pagination. +/// Returns a list of `(service_name, current_state)` tuples. +unsafe fn enumerate_services(scm: SC_HANDLE) -> Result, ServiceError> { unsafe { + let mut all_services = Vec::new(); let mut bytes_needed: u32 = 0; let mut services_returned: u32 = 0; let mut resume_handle: u32 = 0; - // First call to get required buffer size - let sizing_result = EnumServicesStatusExW( + // Sizing call to determine required buffer size + let _ = EnumServicesStatusExW( scm, SC_ENUM_PROCESS_INFO, SERVICE_WIN32, @@ -413,131 +456,81 @@ unsafe fn enumerate_service_names(scm: SC_HANDLE) -> Result, Service Some(&mut resume_handle), None, ); - if let Err(e) = sizing_result { - if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { - return Err(t!("export.enumServicesFailed", error = e.to_string()).to_string().into()); - } - } if bytes_needed == 0 { return Ok(Vec::new()); } - let mut buffer = vec![0u8; bytes_needed as usize]; resume_handle = 0; - EnumServicesStatusExW( - scm, - SC_ENUM_PROCESS_INFO, - SERVICE_WIN32, - SERVICE_STATE_ALL, - Some(&mut buffer), - &mut bytes_needed, - &mut services_returned, - Some(&mut resume_handle), - None, - ) - .map_err(|e| t!("export.enumServicesFailed", error = e.to_string()).to_string())?; + loop { + let mut buffer = vec![0u8; bytes_needed as usize]; + services_returned = 0; + + let result = EnumServicesStatusExW( + scm, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + Some(&mut buffer), + &mut bytes_needed, + &mut services_returned, + Some(&mut resume_handle), + None, + ); - let entries = std::slice::from_raw_parts( - buffer.as_ptr().cast::(), - services_returned as usize, - ); + if services_returned > 0 { + let entries = std::slice::from_raw_parts( + buffer.as_ptr().cast::(), + services_returned as usize, + ); - let mut names = Vec::with_capacity(services_returned as usize); - for entry in entries { - if let Ok(name) = entry.lpServiceName.to_string() { - names.push(name); + for entry in entries { + if let Ok(name) = entry.lpServiceName.to_string() { + all_services.push((name, entry.ServiceStatusProcess.dwCurrentState)); + } + } + } + + match result { + Ok(()) => break, + Err(e) => { + if e.code() == ERROR_MORE_DATA.to_hresult() { + // More services to enumerate; bytes_needed has the required buffer size + continue; + } + return Err(t!("export.enumServicesFailed", error = e.to_string()).to_string().into()); + } } } - Ok(names) + Ok(all_services) } } /// Get full service details given an SCM handle and a service key name. unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result { - unsafe { - let name_wide = to_wide(service_name); - let service_handle = OpenServiceW( + let name_wide = to_wide(service_name); + let service_handle = unsafe { + OpenServiceW( scm, PCWSTR(name_wide.as_ptr()), SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, ) - .map_err(|e| t!("export.openServiceFailed", name = service_name, error = e.to_string()).to_string())?; - let service_handle = ScHandle(service_handle); - - // Query basic configuration - let mut bytes_needed: u32 = 0; - let sizing_result = QueryServiceConfigW(service_handle.0, None, 0, &mut bytes_needed); - if let Err(e) = sizing_result { - if e.code() != ERROR_INSUFFICIENT_BUFFER.to_hresult() { - return Err(t!("get.queryConfigFailed", error = e.to_string()).to_string().into()); - } - } - if bytes_needed == 0 { - return Err(t!("get.queryConfigFailed", error = "buffer size is 0").to_string().into()); - } - let mut config_buffer = vec![0u8; bytes_needed as usize]; - let config_ptr = config_buffer.as_mut_ptr().cast::(); - QueryServiceConfigW( - service_handle.0, - Some(&mut *config_ptr), - bytes_needed, - &mut bytes_needed, - ) - .map_err(|e| t!("get.queryConfigFailed", error = e.to_string()).to_string())?; - - let config = &*config_ptr; - let display_name = pwstr_to_string(config.lpDisplayName); - - let start_type = match config.dwStartType { - SERVICE_AUTO_START => { - if is_delayed_auto_start(service_handle.0) { - Some(StartType::AutomaticDelayedStart) - } else { - Some(StartType::Automatic) - } - } - SERVICE_DEMAND_START => Some(StartType::Manual), - SERVICE_DISABLED => Some(StartType::Disabled), - _ => None, - }; - - let error_control = match config.dwErrorControl { - SERVICE_ERROR_IGNORE => Some(ErrorControl::Ignore), - SERVICE_ERROR_NORMAL => Some(ErrorControl::Normal), - SERVICE_ERROR_SEVERE => Some(ErrorControl::Severe), - SERVICE_ERROR_CRITICAL => Some(ErrorControl::Critical), - _ => None, - }; - - let executable_path = pwstr_to_string(config.lpBinaryPathName); - let logon_account = pwstr_to_string(config.lpServiceStartName); - let deps = parse_multi_string(config.lpDependencies); - let dependencies = if deps.is_empty() { None } else { Some(deps) }; - - let description = query_description(service_handle.0); - let status = query_status(service_handle.0).ok(); - - Ok(WindowsService { - name: Some(service_name.to_string()), - display_name, - description, - exist: Some(true), - status, - start_type, - executable_path, - logon_account, - error_control, - dependencies, - }) } + .map_err(|e| ServiceError::from(t!("export.openServiceFailed", name = service_name, error = e.to_string()).to_string()))?; + let service_handle = ScHandle(service_handle); + + unsafe { read_service_state(service_handle.0, service_name) } } /// Match a string against a pattern supporting `*` wildcards. /// If no wildcard is present, performs an exact case-insensitive comparison. fn matches_wildcard(text: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + let text_lower = text.to_lowercase(); let pattern_lower = pattern.to_lowercase(); @@ -788,8 +781,7 @@ pub fn set_service(input: &WindowsService) -> Result bool { } } + // Note: executable_path and error_control are intentionally not filtered. + // dependencies — service must have at least all specified dependencies if let Some(ref expected_deps) = filter.dependencies { let actual_deps = service.dependencies.as_deref().unwrap_or(&[]); From 8427c7f4f0961cc7747e81e96cc85dffbd5cae7e Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 15:03:04 -0700 Subject: [PATCH 12/15] cleanup how to handle non-windows --- resources/windows_service/src/main.rs | 87 ++++++++++----------------- 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index c3d7ffa3e..5571389f0 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -52,6 +52,13 @@ fn print_json(value: &impl serde::Serialize) { } } +#[cfg(not(windows))] +fn main() { + eprintln!("Error: {}", t!("main.windowsOnly")); + std::process::exit(1); +} + +#[cfg(windows)] fn main() { let args: Vec = std::env::args().collect(); @@ -67,49 +74,29 @@ fn main() { "get" => { let input = require_input(input_json); - #[cfg(windows)] - { - match service::get_service(&input) { - Ok(result) => { - print_json(&result); - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e.to_string()); - exit(EXIT_SERVICE_ERROR); - } + match service::get_service(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); } - } - - #[cfg(not(windows))] - { - let _ = input; - write_error(&t!("main.windowsOnly")); - exit(EXIT_SERVICE_ERROR); } } "set" => { let input = require_input(input_json); - #[cfg(windows)] - { - match service::set_service(&input) { - Ok(result) => { - print_json(&result); - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e.to_string()); - exit(EXIT_SERVICE_ERROR); - } + match service::set_service(&input) { + Ok(result) => { + print_json(&result); + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); } - } - - #[cfg(not(windows))] - { - let _ = input; - write_error(&t!("main.windowsOnly")); - exit(EXIT_SERVICE_ERROR); } } "export" => { @@ -124,27 +111,17 @@ fn main() { None => None, }; - #[cfg(windows)] - { - match service::export_services(filter.as_ref()) { - Ok(services) => { - for svc in &services { - print_json(svc); - } - exit(EXIT_SUCCESS); - } - Err(e) => { - write_error(&e.to_string()); - exit(EXIT_SERVICE_ERROR); + match service::export_services(filter.as_ref()) { + Ok(services) => { + for svc in &services { + print_json(svc); } + exit(EXIT_SUCCESS); + } + Err(e) => { + write_error(&e.to_string()); + exit(EXIT_SERVICE_ERROR); } - } - - #[cfg(not(windows))] - { - let _ = filter; - write_error(&t!("main.windowsOnly")); - exit(EXIT_SERVICE_ERROR); } } _ => { From 352af947cd34987984c6fbf99e8af61790f1efe3 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 15:38:21 -0700 Subject: [PATCH 13/15] address copilot feedback --- resources/windows_service/locales/en-us.toml | 3 +- resources/windows_service/src/main.rs | 4 +-- resources/windows_service/src/service.rs | 33 ++++++++++++++++++- .../tests/windows_service_set.tests.ps1 | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/resources/windows_service/locales/en-us.toml b/resources/windows_service/locales/en-us.toml index fd48f9709..01484a926 100644 --- a/resources/windows_service/locales/en-us.toml +++ b/resources/windows_service/locales/en-us.toml @@ -1,7 +1,7 @@ _version = 1 [main] -missingOperation = "Missing operation. Usage: windows_service --input " +missingOperation = "Missing operation. Usage: windows_service get --input | set --input | export [--input ]" unknownOperation = "Unknown operation: '%{operation}'. Expected: get, set, or export" missingInput = "Missing --input argument" missingInputValue = "Missing value for --input argument" @@ -32,5 +32,6 @@ stopFailed = "Failed to stop service: %{error}" pauseFailed = "Failed to pause service: %{error}" continueFailed = "Failed to continue service: %{error}" unsupportedTransition = "Unsupported status transition from '%{current}' to '%{desired}'" +unsupportedLogonAccount = "Unsupported logon account '%{account}'; only built-in service accounts are supported (LocalSystem, NT AUTHORITY\\LocalService, NT AUTHORITY\\NetworkService)" unsupportedStatus = "Cannot set service to status '%{status}'; only Running, Stopped, and Paused are supported" statusTimeout = "Timed out waiting for service to reach status '%{expected}'; current status is '%{actual}'" diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index 5571389f0..0273ac11d 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -54,8 +54,8 @@ fn print_json(value: &impl serde::Serialize) { #[cfg(not(windows))] fn main() { - eprintln!("Error: {}", t!("main.windowsOnly")); - std::process::exit(1); + write_error(&t!("main.windowsOnly")); + exit(EXIT_SERVICE_ERROR); } #[cfg(windows)] diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index ff4dd687b..7a059386b 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -445,7 +445,7 @@ unsafe fn enumerate_services(scm: SC_HANDLE) -> Result Result Vec { } /// Apply the desired service configuration and status changes, then return the final state. +/// Check whether the given account name is a built-in service account. +fn is_builtin_service_account(account: &str) -> bool { + let normalized = account.to_ascii_uppercase(); + matches!( + normalized.as_str(), + "LOCALSYSTEM" + | "LOCAL SYSTEM" + | "NT AUTHORITY\\LOCALSERVICE" + | "NT AUTHORITY\\NETWORKSERVICE" + | "NT AUTHORITY\\LOCAL SERVICE" + | "NT AUTHORITY\\NETWORK SERVICE" + | ".\\LOCALSYSTEM" + ) +} + pub fn set_service(input: &WindowsService) -> Result { let name = input.name.as_deref() .ok_or_else(|| t!("set.nameRequired").to_string())?; + if let Some(ref account) = input.logon_account { + if !is_builtin_service_account(account) { + return Err(ServiceError::from( + t!("set.unsupportedLogonAccount", account = account).to_string(), + )); + } + } + unsafe { let scm = OpenSCManagerW(None, None, SC_MANAGER_CONNECT) .map_err(|e| t!("set.openScmFailed", error = e.to_string()).to_string())?; diff --git a/resources/windows_service/tests/windows_service_set.tests.ps1 b/resources/windows_service/tests/windows_service_set.tests.ps1 index 73669e355..5ed285119 100644 --- a/resources/windows_service/tests/windows_service_set.tests.ps1 +++ b/resources/windows_service/tests/windows_service_set.tests.ps1 @@ -29,6 +29,21 @@ Describe 'Windows Service set tests' -Skip:(!$IsWindows) { } Context 'Input validation' -Skip:(!$isAdmin) { + BeforeAll { + $script:originalState = Get-ServiceState -Name $testServiceName + } + + AfterAll { + # Restore original logon account + if ($script:originalState -and $script:originalState.logonAccount) { + $restoreJson = @{ + name = $testServiceName + logonAccount = $script:originalState.logonAccount + } | ConvertTo-Json -Compress + $restoreJson | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + } + } + It 'Fails when name is not provided' { $json = @{ startType = 'Manual' } | ConvertTo-Json -Compress $out = $json | dsc resource set -r $resourceType -f - 2>&1 @@ -39,6 +54,24 @@ Describe 'Windows Service set tests' -Skip:(!$IsWindows) { $out = 'not-json' | dsc resource set -r $resourceType -f - 2>&1 $LASTEXITCODE | Should -Not -Be 0 } + + It 'Fails when logonAccount is not a built-in service account' { + $json = @{ name = 'Spooler'; logonAccount = 'DOMAIN\SomeUser' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + } + + It 'Accepts LocalSystem as logonAccount' { + $json = @{ name = 'Spooler'; logonAccount = 'LocalSystem' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + } + + It 'Accepts NT AUTHORITY\NetworkService as logonAccount' { + $json = @{ name = 'Spooler'; logonAccount = 'NT AUTHORITY\NetworkService' } | ConvertTo-Json -Compress + $out = $json | dsc resource set -r $resourceType -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) + } } Context 'Set startType' -Skip:(!$isAdmin) { From 15c267736c78ff94422c40b466b03d1317d47e85 Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 15:40:36 -0700 Subject: [PATCH 14/15] fix unnecessary permissions --- resources/windows_service/src/service.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index 7a059386b..b84f06760 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -630,7 +630,18 @@ pub fn set_service(input: &WindowsService) -> Result { From 3e5ff9af5d0854d6fbd0c37022c625f0493c930e Mon Sep 17 00:00:00 2001 From: "Steve Lee (POWERSHELL HE/HIM) (from Dev Box)" Date: Fri, 13 Mar 2026 16:20:11 -0700 Subject: [PATCH 15/15] fix changing type with interactive flag --- resources/windows_service/src/service.rs | 48 +++++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index b84f06760..dbf425b50 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -685,6 +685,45 @@ pub fn set_service(input: &WindowsService) -> Result SERVICE_ERROR(SERVICE_NO_CHANGE_VALUE), }; + // When changing the logon account to a non-LocalSystem account, the + // deprecated SERVICE_INTERACTIVE_PROCESS flag (0x100) must be stripped + // from the service type, because Windows only allows interactive + // services under LocalSystem. Query the current type and clear the + // flag when necessary; otherwise pass SERVICE_NO_CHANGE. + const SERVICE_INTERACTIVE_PROCESS_FLAG: u32 = 0x100; + let dw_service_type = if let Some(ref account) = input.logon_account { + let upper = account.to_ascii_uppercase(); + let is_local_system = matches!( + upper.as_str(), + "LOCALSYSTEM" | "LOCAL SYSTEM" | ".\\LOCALSYSTEM" + ); + if !is_local_system { + let mut qc_bytes: u32 = 0; + let _ = QueryServiceConfigW(service_handle.0, None, 0, &mut qc_bytes); + if qc_bytes > 0 { + let mut qc_buf = vec![0u8; qc_bytes as usize]; + let qc_ptr = qc_buf.as_mut_ptr().cast::(); + if QueryServiceConfigW( + service_handle.0, + Some(&mut *qc_ptr), + qc_bytes, + &mut qc_bytes, + ).is_ok() { + let cur_type = (*qc_ptr).dwServiceType.0; + ENUM_SERVICE_TYPE(cur_type & !SERVICE_INTERACTIVE_PROCESS_FLAG) + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + } + } else { + ENUM_SERVICE_TYPE(SERVICE_NO_CHANGE_VALUE) + }; + // When setting AutomaticDelayedStart, the service must not belong to a load // order group — Windows rejects ChangeServiceConfig2W for the delayed auto-start // flag with ERROR_INVALID_PARAMETER if one is set. Clear the group by passing an @@ -708,9 +747,14 @@ pub fn set_service(input: &WindowsService) -> Result Result