diff --git a/dsc/tests/dsc_export.tests.ps1 b/dsc/tests/dsc_export.tests.ps1 index bf71a6624..393196073 100644 --- a/dsc/tests/dsc_export.tests.ps1 +++ b/dsc/tests/dsc_export.tests.ps1 @@ -213,3 +213,134 @@ resources: $out.resources[0].properties.id | Should -Be 1 } } + +Describe 'export filter directive tests' { + It 'exportFilter applies equality filtering for non-string properties' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Test Export + type: Test/Export + directives: + exportFilter: + - count: 2 + properties: + count: 5 +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 1 + $out.resources[0].properties.count | Should -Be 2 + } + + It 'exportFilter supports wildcards and is case-insensitive' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Test Export + type: Test/Export + directives: + exportFilter: + - name: '*STANCE3' + properties: + count: 5 +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 1 + $out.resources[0].properties.name | Should -BeExactly 'Instance3' + } + + It 'exportFilter objects are a logical OR' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Test Export + type: Test/Export + directives: + exportFilter: + - count: 0 + - name: '*stance2' + properties: + count: 5 +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 2 + $out.resources[0].properties.count | Should -Be 0 + $out.resources[1].properties.name | Should -BeExactly 'Instance2' + } + + It 'properties within an exportFilter object are a logical AND' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Test Export + type: Test/Export + directives: + exportFilter: + - count: 2 + name: 'instance2' + properties: + count: 5 +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 1 + $out.resources[0].properties.count | Should -Be 2 + } + + It 'exportFilter with an AND mismatch returns no instances' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: Test Export + type: Test/Export + directives: + exportFilter: + - count: 2 + name: 'instance1' + properties: + count: 5 +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 0 + } + + It 'exportFilter works for a resource that does not support filtering natively' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: No Native Filtering + type: Test/ExportSchemaNoFiltering + directives: + exportFilter: + - name: '*e*' +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 2 + $out.resources.properties.name | Should -Be @('Steve', 'Tess') + } + + It 'exportFilter works with an exporter resource' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: export this + type: Test/Exporter + directives: + exportFilter: + - type: '*Foo' + properties: + typeNames: + - Test/Foo + - Test/Bar +'@ + $out = dsc config export -i $yaml 2>$TESTDRIVE/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content "$TESTDRIVE/error.log" -Raw) + @($out.resources).Count | Should -Be 1 + $out.resources[0].type | Should -BeExactly 'Test/Foo' + } +} diff --git a/helpers.build.psm1 b/helpers.build.psm1 index f931f0e4d..c129322ce 100644 --- a/helpers.build.psm1 +++ b/helpers.build.psm1 @@ -2448,6 +2448,8 @@ function Get-CodeCoverageReport { $currentLineNum++ } elseif ($diffLine.StartsWith('-') -and -not $diffLine.StartsWith('---')) { # Deleted lines don't advance the new file line counter + } elseif ($diffLine.StartsWith('\')) { + # "\ No newline at end of file" marker — not a real line } else { $currentLineNum++ } @@ -2469,10 +2471,7 @@ function Get-CodeCoverageReport { } if (-not $fileCoverage) { - # File not in coverage report means LLVM found no instrumentable - # executable code in it (e.g., struct/enum definitions with derive - # macros). Skip it rather than penalizing as uncovered. - Write-Verbose -Verbose "No LCOV data for '$file' - file has no instrumentable code, skipping" + Write-Verbose -Verbose "Skipping '$file': not in LCOV data (possibly platform-specific or not instrumented)" continue } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 257e7b2d5..b000d572d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -35,6 +35,9 @@ dependencyNotInOrder = "Dependency not found in order" circularDependency = "Circular dependency detected for resource named '%{resource}'" invocationOrder = "Resource invocation order" +[configure.export_filter] +filteredInstances = "Export filter reduced %{original} instances to %{retained}" + [configure.mod] nestedArraysNotSupported = "Nested arrays not supported" arrayElementCouldNotTransformAsString = "Array element could not be transformed as string" diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index 4a01d0294..01c8717b4 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -236,6 +236,10 @@ pub struct ConfigDirective { #[serde(rename_all = "camelCase")] #[dsc_repo_schema(base_name = "directive", folder_path = "resource")] pub struct ResourceDirective { + /// Filters applied by the engine to exported instances. Filters in the array are logically + /// OR'd while properties within a filter are logically AND'd. String values support the `*` wildcard. + #[serde(skip_serializing_if = "Option::is_none")] + pub export_filter: Option>>, /// Specify specific adapter type used for implicit operations #[serde(skip_serializing_if = "Option::is_none")] pub require_adapter: Option, diff --git a/lib/dsc-lib/src/configure/export_filter.rs b/lib/dsc-lib/src/configure/export_filter.rs new file mode 100644 index 000000000..f1fc4e9dc --- /dev/null +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use rust_i18n::t; +use serde_json::{Map, Value}; +use tracing::debug; + +/// Apply an export filter to a list of exported instances, retaining only matching instances. +/// +/// # Arguments +/// +/// * `instances` - The exported instances to filter. +/// * `filters` - The filter objects from the `exportFilter` directive. +pub(super) fn apply_export_filter(instances: &mut Vec, filters: &[Map]) { + if filters.is_empty() { + // an empty filter list means no filtering is applied + return; + } + + let original_count = instances.len(); + instances.retain(|instance| instance_matches_filters(instance, filters)); + debug!("{}", t!("configure.export_filter.filteredInstances", original = original_count, retained = instances.len())); +} + +/// Check if an instance matches any of the filter objects (logical OR). +#[must_use] +fn instance_matches_filters(instance: &Value, filters: &[Map]) -> bool { + let Some(instance) = instance.as_object() else { + // non-object instances can't be matched by property filters + return false; + }; + + filters.iter().any(|filter| instance_matches_filter(instance, filter)) +} + +/// Check if an instance matches all properties of a single filter object (logical AND). +fn instance_matches_filter(instance: &Map, filter: &Map) -> bool { + filter.iter().all(|(name, expected)| { + instance.get(name).is_some_and(|actual| value_matches(actual, expected)) + }) +} + +/// Check if an actual value matches an expected filter value. +fn value_matches(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + // strings are compared case-insensitively with `*` wildcard support + (Value::String(actual_str), Value::String(pattern)) => wildcard_match(pattern, actual_str), + // nested objects match recursively as a partial match + (Value::Object(actual_obj), Value::Object(expected_obj)) => instance_matches_filter(actual_obj, expected_obj), + // everything else requires equality + _ => actual == expected, + } +} + +/// Match `text` against `pattern` where `*` matches zero or more characters. +/// The comparison is case-insensitive. +fn wildcard_match(pattern: &str, text: &str) -> bool { + let pattern: Vec = pattern.to_lowercase().chars().collect(); + let text: Vec = text.to_lowercase().chars().collect(); + + // iterative greedy matching with backtracking on the last `*` + let (mut p, mut t) = (0usize, 0usize); + let mut star: Option = None; + let mut star_text = 0usize; + + while t < text.len() { + if p < pattern.len() && pattern[p] == '*' { + star = Some(p); + star_text = t; + p += 1; + } else if p < pattern.len() && pattern[p] == text[t] { + p += 1; + t += 1; + } else if let Some(star_pos) = star { + // backtrack: let the last `*` consume one more character + p = star_pos + 1; + star_text += 1; + t = star_text; + } else { + return false; + } + } + + // remaining pattern must be all `*` + pattern[p..].iter().all(|c| *c == '*') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn to_filters(value: Value) -> Vec> { + serde_json::from_value(value).unwrap() + } + + #[test] + fn wildcard_match_exact() { + assert!(wildcard_match("sshd", "sshd")); + assert!(!wildcard_match("sshd", "sshd2")); + assert!(!wildcard_match("sshd2", "sshd")); + } + + #[test] + fn wildcard_match_case_insensitive() { + assert!(wildcard_match("SSHD", "sshd")); + assert!(wildcard_match("*Ssh*", "OpenSSH Server")); + } + + #[test] + fn wildcard_match_star() { + assert!(wildcard_match("*ssh*", "ssh")); + assert!(wildcard_match("*ssh*", "openssh-server")); + assert!(wildcard_match("ssh*", "sshd")); + assert!(wildcard_match("*shd", "sshd")); + assert!(wildcard_match("*", "")); + assert!(wildcard_match("*", "anything")); + assert!(wildcard_match("s*h*d", "sshd")); + assert!(!wildcard_match("*ssh*", "no match")); + assert!(!wildcard_match("ssh*", "openssh")); + } + + #[test] + fn empty_filter_list_matches_nothing_but_apply_is_noop() { + let mut instances = vec![json!({"name": "one"}), json!({"name": "two"})]; + apply_export_filter(&mut instances, &[]); + assert_eq!(instances.len(), 2); + } + + #[test] + fn filters_are_logical_or() { + let filters = to_filters(json!([ + { "name": "*ssh*" }, + { "startType": "automatic" } + ])); + // matches first filter + assert!(instance_matches_filters(&json!({"name": "sshd", "startType": "manual"}), &filters)); + // matches second filter + assert!(instance_matches_filters(&json!({"name": "spooler", "startType": "automatic"}), &filters)); + // matches neither + assert!(!instance_matches_filters(&json!({"name": "spooler", "startType": "manual"}), &filters)); + } + + #[test] + fn properties_within_filter_are_logical_and() { + let filters = to_filters(json!([ + { "name": "*ssh*", "startType": "automatic" } + ])); + assert!(instance_matches_filters(&json!({"name": "sshd", "startType": "automatic"}), &filters)); + assert!(!instance_matches_filters(&json!({"name": "sshd", "startType": "manual"}), &filters)); + assert!(!instance_matches_filters(&json!({"name": "spooler", "startType": "automatic"}), &filters)); + } + + #[test] + fn missing_property_does_not_match() { + let filters = to_filters(json!([{ "name": "*ssh*" }])); + assert!(!instance_matches_filters(&json!({"startType": "automatic"}), &filters)); + } + + #[test] + fn non_string_values_use_equality() { + let filters = to_filters(json!([{ "count": 2, "enabled": true }])); + assert!(instance_matches_filters(&json!({"count": 2, "enabled": true}), &filters)); + assert!(!instance_matches_filters(&json!({"count": 3, "enabled": true}), &filters)); + assert!(!instance_matches_filters(&json!({"count": 2, "enabled": false}), &filters)); + // a string pattern does not match a non-string value + let filters = to_filters(json!([{ "count": "*" }])); + assert!(!instance_matches_filters(&json!({"count": 2}), &filters)); + } + + #[test] + fn nested_objects_match_recursively() { + let filters = to_filters(json!([ + { "properties": { "name": "b*r" } } + ])); + assert!(instance_matches_filters(&json!({"properties": {"name": "bar", "other": 1}}), &filters)); + assert!(!instance_matches_filters(&json!({"properties": {"name": "baz"}}), &filters)); + } + + #[test] + fn empty_filter_object_matches_everything() { + let filters = to_filters(json!([{}])); + assert!(instance_matches_filters(&json!({"name": "anything"}), &filters)); + } + + #[test] + fn apply_export_filter_retains_matching() { + let mut instances = vec![ + json!({"name": "sshd", "startType": "automatic"}), + json!({"name": "spooler", "startType": "automatic"}), + json!({"name": "ssh-agent", "startType": "manual"}), + ]; + let filters = to_filters(json!([{ "name": "*ssh*" }])); + apply_export_filter(&mut instances, &filters); + assert_eq!(instances.len(), 2); + assert_eq!(instances[0]["name"], "sshd"); + assert_eq!(instances[1]["name"], "ssh-agent"); + } + + #[test] + fn non_object_instances_do_not_match() { + let filters = to_filters(json!([{ "name": "*" }])); + assert!(!instance_matches_filters(&json!("just a string"), &filters)); + assert!(!instance_matches_filters(&json!(42), &filters)); + } +} diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 80d80513f..ee9924fb6 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -34,6 +34,7 @@ pub mod config_result; pub mod constraints; pub mod depends_on; pub mod parameters; +mod export_filter; pub struct Configurator { json: String, @@ -103,7 +104,6 @@ macro_rules! find_resource_or_error { /// * `resource` - The resource to export. /// * `conf` - The configuration to add the results to. /// * `input` - The input to the export operation. -/// /// # Panics /// /// Doesn't panic because there is a match/Some check before `unwrap()`; false positive. @@ -111,12 +111,41 @@ macro_rules! find_resource_or_error { /// # Errors /// /// This function will return an error if the underlying resource fails. -pub fn add_resource_export_results_to_configuration(resource: &DscResource, conf: &mut Configuration, input: &str) -> Result { +pub fn add_resource_export_results_to_configuration( + resource: &DscResource, + conf: &mut Configuration, + input: &str, +) -> Result { + add_filtered_resource_export_results_to_configuration(resource, conf, input, None) +} + +/// Add filtered results of an export operation to a configuration. +/// +/// # Arguments +/// +/// * `resource` - The resource to export. +/// * `conf` - The configuration to add the results to. +/// * `input` - The input to the export operation. +/// * `filter` - Optional `exportFilter` directive applied by the engine to the exported instances. +/// +/// # Errors +/// +/// This function will return an error if the underlying resource fails. +pub fn add_filtered_resource_export_results_to_configuration( + resource: &DscResource, + conf: &mut Configuration, + input: &str, + filter: Option<&[Map]>, +) -> Result { let start_datetime = chrono::Local::now(); - let export_result = resource.export(input)?; + let mut export_result = resource.export(input)?; let end_datetime = chrono::Local::now(); + if let Some(filter) = filter { + export_filter::apply_export_filter(&mut export_result.actual_state, filter); + } + if resource.kind == Kind::Exporter { for instance in &export_result.actual_state { let mut resource = serde_json::from_value::(instance.clone())?; @@ -912,7 +941,13 @@ impl Configurator { debug!("resource_type {}", &resource.resource_type); let input = add_metadata(dsc_resource, properties, resource.metadata.clone())?; trace!("{}", t!("configure.mod.exportInput", input = input)); - let export_result = match add_resource_export_results_to_configuration(dsc_resource, &mut conf, input.as_str()) { + let export_filter = resource.directives.as_ref().and_then(|d| d.export_filter.as_deref()); + let export_result = match add_filtered_resource_export_results_to_configuration( + dsc_resource, + &mut conf, + input.as_str(), + export_filter, + ) { Ok(result) => result, Err(e) => { progress.set_failure(get_failure_from_error(&e)); diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 206069cfe..93b3e4357 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -696,11 +696,11 @@ pub fn invoke_export(resource: &DscResource, input: Option<&str>, target_resourc validate_security_context(&export.require_security_context, &command_resource.type_name, "export")?; if let Some(input) = input { - if matches!(export.schema_or_filtering, Some(ExportSchemaOrFiltering::SupportsFiltering(false))) { - return Err(DscError::Operation(t!("dscresources.commandResource.exportFilteringNotSupported", resource = &resource.type_name).to_string())); - } - if !input.is_empty() { + if matches!(export.schema_or_filtering, Some(ExportSchemaOrFiltering::SupportsFiltering(false))) { + return Err(DscError::Operation(t!("dscresources.commandResource.exportFilteringNotSupported", resource = &resource.type_name).to_string())); + } + verify_with_export_schema(input, resource, target_resource)?; command_input = get_command_input(export.input.as_ref(), input)?; diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index d6331a6e8..52d88d1c4 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -471,3 +471,31 @@ impl std::str::FromStr for FunctionCategory { } } } + +#[cfg(test)] +mod tests { + use super::FunctionCategory; + use std::str::FromStr; + + #[test] + fn function_categories_round_trip_case_insensitively() { + for category in FunctionCategory::ALL { + let display_name = category.to_string(); + assert_eq!(FunctionCategory::from_str(&display_name), Ok(category.clone())); + assert_eq!( + FunctionCategory::from_str(&display_name.to_uppercase()), + Ok(category), + ); + } + } + + #[test] + fn invalid_function_category_lists_valid_values() { + let error = FunctionCategory::from_str("invalid").expect_err("category should be invalid"); + + assert!(error.contains("invalid")); + for category in FunctionCategory::ALL { + assert!(error.contains(&category.to_string())); + } + } +} diff --git a/resources/dism_dsc/locales/en-us.toml b/resources/dism_dsc/locales/en-us.toml index 3127bfa8d..ee9b47599 100644 --- a/resources/dism_dsc/locales/en-us.toml +++ b/resources/dism_dsc/locales/en-us.toml @@ -15,7 +15,6 @@ featureNameRequired = "featureName is required for get operation" failedSerializeOutput = "Failed to serialize output: %{err}" [export] -failedParseInput = "Failed to parse input: %{err}" failedSerializeOutput = "Failed to serialize output: %{err}" [fod_get] diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index 77c4b82c9..431e0298e 100644 --- a/resources/dism_dsc/optionalfeature.dsc.resource.json +++ b/resources/dism_dsc/optionalfeature.dsc.resource.json @@ -8,7 +8,7 @@ "feature" ], "type": "Microsoft.Windows/OptionalFeatureList", - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "dism_dsc", "args": [ @@ -35,7 +35,7 @@ "export", "optional-feature" ], - "input": "stdin", + "supportsFiltering": false, "requireSecurityContext": "elevated" }, "schema": { @@ -63,7 +63,7 @@ "features": { "type": "array", "title": "Features", - "description": "An array of optional feature filters or feature information objects.", + "description": "An array of optional feature information objects.", "items": { "type": "object", "additionalProperties": false, @@ -71,7 +71,7 @@ "featureName": { "type": "string", "title": "Feature name", - "description": "The name of the Windows optional feature. Required for get operation. For export operation, this is optional and wildcards (*) are supported for case-insensitive filtering." + "description": "The name of the Windows optional feature. Required for get operation. Returned by export operation." }, "_exist": { "type": "boolean", @@ -95,12 +95,12 @@ "displayName": { "type": "string", "title": "Display name", - "description": "The display name of the optional feature. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + "description": "The display name of the optional feature. Returned by the get operation." }, "description": { "type": "string", "title": "Description", - "description": "The description of the optional feature. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + "description": "The description of the optional feature. Returned by the get operation." }, "restartRequired": { "type": "string", diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index 5b19f95e3..04eae221c 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -5,90 +5,21 @@ use rust_i18n::t; use crate::dism::DismSessionHandle; use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, OptionalFeatureList}; -use crate::util::{matches_wildcard, WildcardFilterable}; - -pub fn handle_export(input: &str) -> Result { - let filters: Vec = if input.trim().is_empty() { - vec![OptionalFeatureInfo::default()] - } else { - let list: OptionalFeatureList = serde_json::from_str(input) - .map_err(|e| t!("export.failedParseInput", err = e.to_string()).to_string())?; - list.features - }; +pub fn handle_export(_input: &str) -> Result { let session = DismSessionHandle::open()?; let all_basics = session.get_all_feature_basics()?; - // Check if any filter requires full info (displayName or description) - let needs_full_info = filters + let features = all_basics .iter() - .any(|f| f.display_name.is_some() || f.description.is_some()); - - let mut results = Vec::new(); - - // When full info is needed, pre-partition filters by whether they specify a feature_name. - // This lets us skip get_feature_info() for features that cannot match any name-constrained filter. - let (filters_with_name, filters_without_name): (Vec<&OptionalFeatureInfo>, Vec<&OptionalFeatureInfo>) = - if needs_full_info { - filters.iter().partition(|f| f.feature_name.is_some()) - } else { - (Vec::new(), Vec::new()) - }; - - for (name, state_val) in &all_basics { - let state = FeatureState::from_dism(*state_val); - - if needs_full_info { - // Decide whether this feature could possibly match any filter based on its name. - // If any filter does not constrain feature_name, we must consider every feature, - // since such filters may match on displayName/description alone. - let mut should_get_full = !filters_without_name.is_empty(); - if !should_get_full { - for f in &filters_with_name { - if let Some(ref filter_name) = f.feature_name - && matches_wildcard(name, filter_name) { - should_get_full = true; - break; - } - } - } - if !should_get_full { - // This feature cannot satisfy any name-constrained filter, and there are - // no filters without a feature_name, so skip the expensive get_feature_info(). - continue; - } - // Get full info so we can filter on displayName/description and other fields. - let info = match session.get_feature_info(name) { - Ok(info) => info, - Err(_) => OptionalFeatureInfo { - feature_name: Some(name.clone()), - exist: None, - state, - display_name: None, - description: None, - restart_required: None, - }, - }; - - if info.matches_any_filter(&filters) { - results.push(info); - } - } else { - // Fast path: only need name and state for filtering, skip expensive - // per-feature DismGetFeatureInfo calls to match dism /online /get-features speed. - let basic = OptionalFeatureInfo { - feature_name: Some(name.clone()), - state: state.clone(), - ..OptionalFeatureInfo::default() - }; - - if basic.matches_any_filter(&filters) { - results.push(basic); - } - } - } - - let output = OptionalFeatureList { restart_required_meta: None, features: results }; + .map(|(name, state_val)| OptionalFeatureInfo { + feature_name: Some(name.clone()), + state: FeatureState::from_dism(*state_val), + ..OptionalFeatureInfo::default() + }) + .collect(); + + let output = OptionalFeatureList { restart_required_meta: None, features }; serde_json::to_string(&output) .map_err(|e| t!("export.failedSerializeOutput", err = e.to_string()).to_string()) } diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index 6415d6a48..f1db14f7a 100644 --- a/resources/dism_dsc/src/optional_feature/types.rs +++ b/resources/dism_dsc/src/optional_feature/types.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use crate::util::{DismState, WildcardFilterable, matches_optional_wildcard, matches_optional_exact}; +use crate::util::DismState; pub type FeatureState = DismState; @@ -51,11 +51,3 @@ impl RestartType { } } -impl WildcardFilterable for OptionalFeatureInfo { - fn matches_filter(&self, filter: &Self) -> bool { - matches_optional_wildcard(&self.feature_name, &filter.feature_name) - && matches_optional_exact(&self.state, &filter.state) - && matches_optional_wildcard(&self.display_name, &filter.display_name) - && matches_optional_wildcard(&self.description, &filter.description) - } -} diff --git a/resources/dism_dsc/src/windows_feature/export.rs b/resources/dism_dsc/src/windows_feature/export.rs index f7ee2205f..b8df67521 100644 --- a/resources/dism_dsc/src/windows_feature/export.rs +++ b/resources/dism_dsc/src/windows_feature/export.rs @@ -4,96 +4,24 @@ use rust_i18n::t; use crate::dism::DismSessionHandle; -use crate::util::{WildcardFilterable, matches_wildcard}; use crate::windows_feature::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; -pub fn handle_export(input: &str) -> Result { - let filters: Vec = if input.trim().is_empty() { - vec![WindowsFeatureInfo::default()] - } else { - let list: WindowsFeatureList = serde_json::from_str(input) - .map_err(|e| t!("export.failedParseInput", err = e.to_string()).to_string())?; - if list.features.is_empty() { - vec![WindowsFeatureInfo::default()] - } else { - list.features - } - }; - +pub fn handle_export(_input: &str) -> Result { let session = DismSessionHandle::open()?; let all_basics = session.get_all_feature_basics()?; - let needs_full_info = filters + let features = all_basics .iter() - .any(|filter| filter.display_name.is_some() || filter.description.is_some()); - - let mut results = Vec::new(); - - let (filters_with_name, filters_without_name): ( - Vec<&WindowsFeatureInfo>, - Vec<&WindowsFeatureInfo>, - ) = if needs_full_info { - filters - .iter() - .partition(|filter| filter.feature_name.is_some()) - } else { - (Vec::new(), Vec::new()) - }; - - for (name, state_val) in &all_basics { - let state = FeatureState::from_dism(*state_val); - - if needs_full_info { - let mut should_get_full = !filters_without_name.is_empty(); - if !should_get_full { - for filter in &filters_with_name { - if let Some(ref filter_name) = filter.feature_name - && matches_wildcard(name, filter_name) - { - should_get_full = true; - break; - } - } - } - if !should_get_full { - continue; - } - - let info = match session.get_windows_feature_info(name) { - Ok(info) => info, - Err(_) => WindowsFeatureInfo { - feature_name: Some(name.clone()), - exist: None, - state, - display_name: None, - description: None, - restart_required: None, - enable_all: None, - source_paths: None, - limit_access: None, - ..Default::default() - }, - }; - - if info.matches_any_filter(&filters) { - results.push(info); - } - } else { - let basic = WindowsFeatureInfo { - feature_name: Some(name.clone()), - state: state.clone(), - ..WindowsFeatureInfo::default() - }; - - if basic.matches_any_filter(&filters) { - results.push(basic); - } - } - } + .map(|(name, state_val)| WindowsFeatureInfo { + feature_name: Some(name.clone()), + state: FeatureState::from_dism(*state_val), + ..WindowsFeatureInfo::default() + }) + .collect(); let output = WindowsFeatureList { restart_required_meta: None, - features: results, + features, }; serde_json::to_string(&output) .map_err(|e| t!("export.failedSerializeOutput", err = e.to_string()).to_string()) diff --git a/resources/dism_dsc/src/windows_feature/types.rs b/resources/dism_dsc/src/windows_feature/types.rs index 774b3c409..a5cc1df04 100644 --- a/resources/dism_dsc/src/windows_feature/types.rs +++ b/resources/dism_dsc/src/windows_feature/types.rs @@ -4,9 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use crate::util::{ - DismState, WildcardFilterable, matches_optional_exact, matches_optional_wildcard, -}; +use crate::util::DismState; pub type FeatureState = DismState; @@ -68,11 +66,3 @@ impl RestartType { } } -impl WildcardFilterable for WindowsFeatureInfo { - fn matches_filter(&self, filter: &Self) -> bool { - matches_optional_wildcard(&self.feature_name, &filter.feature_name) - && matches_optional_exact(&self.state, &filter.state) - && matches_optional_wildcard(&self.display_name, &filter.display_name) - && matches_optional_wildcard(&self.description, &filter.description) - } -} diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 index 85d13b66a..1af193ace 100644 --- a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -11,21 +11,6 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } } - BeforeAll { - # Use dism command to get a known feature name - $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Failed to get features using dism: $dismOutput" - } - $enabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$' - $disabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$' - if (-not $enabledMatches -or -not $disabledMatches) { - throw "Failed to find both enabled and disabled features in DISM output.`nOutput:`n$dismOutput" - } - $knownFeatureNameOne = $enabledMatches[0].Matches[0].Groups[1].Value - $knownFeatureNameTwo = $disabledMatches[0].Matches[0].Groups[1].Value - } - It 'exports all features with no input' -Skip:(!$isElevated) { $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 @@ -39,98 +24,10 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW ) } - It 'exports features filtered by exact featureName' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - $features.Count | Should -Be 1 - $feature = $features[0] - $feature.featureName | Should -BeExactly $knownFeatureNameOne - $feature.state | Should -BeIn @( - 'NotPresent', 'UninstallPending', 'Staged', 'Removed', - 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' - ) - } - - It 'exports features filtered by wildcard featureName' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Printing-*"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - foreach ($feature in $features) { - $feature.featureName | Should -BeLike 'Printing-*' - } - } - - It 'exports features filtered by state' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"state":"Installed"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - foreach ($feature in $features) { - $feature.state | Should -BeExactly 'Installed' - } - } - - It 'exports features with combined featureName and state filter' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"*","state":"Installed"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - foreach ($feature in $features) { - $feature.state | Should -BeExactly 'Installed' - } - } - - It 'exports features filtered by wildcard displayName' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"displayName":"*Print*"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - foreach ($feature in $features) { - $feature.displayName | Should -BeLike '*Print*' - } - } - - It 'exports features with multiple filters using OR logic' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"},{"featureName":"' + $knownFeatureNameTwo + '"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - $names = $features | ForEach-Object { $_.featureName } - $names | Should -Contain $knownFeatureNameOne - $names | Should -Contain $knownFeatureNameTwo - } - - It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"ZZZNonExistent*"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features.Count | Should -Be 0 - } - - It 'returns complete feature properties when full-info filter is used' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '","displayName":"*"}]}' - $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features.Count | Should -Be 1 - $feature = $features[0] - $feature.featureName | Should -BeExactly $knownFeatureNameOne - $feature.state | Should -BeIn @( - 'NotPresent', 'UninstallPending', 'Staged', 'Removed', - 'Installed', 'InstallPending', 'Superseded', 'PartiallyInstalled' - ) - $feature.displayName | Should -Not -BeNullOrEmpty - $feature.description | Should -Not -BeNullOrEmpty - $feature.restartRequired | Should -BeIn @('No', 'Possible', 'Required') + It 'returns an error when export input is provided' -Skip:(!$isElevated) { + $inputJson = '{"features":[{"featureName":"TelnetClient"}]}' + dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson 2>$TESTDRIVE/error.log | Out-Null + $LASTEXITCODE | Should -Be 2 + (Get-Content -Raw $TESTDRIVE/error.log) | Should -Match 'does not support export filtering' } } diff --git a/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 index ccee09718..389ae31b6 100644 --- a/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 @@ -2,21 +2,6 @@ # Licensed under the MIT License. Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWindows) { - BeforeAll { - # Discover at least one enabled and one disabled feature using DISM - $dismOutput = & dism /Online /Get-Features /Format:Table /English 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "Failed to enumerate features using dism: $dismOutput" - } - $enabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Enabled\s*$' - $disabledMatches = $dismOutput | Select-String -Pattern '^\s*(\S+)\s+\|\s+Disabled\s*$' - if (-not $enabledMatches -or -not $disabledMatches) { - throw "Failed to find both enabled and disabled features in DISM output.`nOutput:`n$dismOutput" - } - $knownEnabledFeature = $enabledMatches[0].Matches[0].Groups[1].Value - $knownDisabledFeature = $disabledMatches[0].Matches[0].Groups[1].Value - } - It 'exports all features with no input filter' { $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 @@ -30,53 +15,10 @@ Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWi ) } - It 'exports features filtered by exact featureName' { - $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"}]}' - $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - $features.Count | Should -Be 1 - $features[0].featureName | Should -BeExactly $knownEnabledFeature - } - - It 'exports features filtered by state Installed' { - $inputJson = '{"features":[{"state":"Installed"}]}' - $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -Not -BeNullOrEmpty - $features | ForEach-Object { $_.state | Should -Be 'Installed' } - } - - It 'returns empty features list for a non-matching filter' { - $inputJson = '{"features":[{"featureName":"NonExistent-Feature-1234567890"}]}' - $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - $features | Should -BeNullOrEmpty - } - - It 'exports with wildcard featureName filter' { - # Use the first 3 characters of a known feature name as a wildcard prefix - $prefix = $knownEnabledFeature.Substring(0, [Math]::Min(3, $knownEnabledFeature.Length)) - $inputJson = '{"features":[{"featureName":"' + $prefix + '*"}]}' - $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $features = $output.resources[0].properties.features - # At minimum the known feature should be present if its name starts with $prefix - $features | Should -Not -BeNullOrEmpty - $features | ForEach-Object { - $_.featureName.ToLower() | Should -BeLike "$($prefix.ToLower())*" - } - } - - It 'exports multiple feature filters (OR logic)' { - $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '"},{"featureName":"' + $knownDisabledFeature + '"}]}' - $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $featureNames = $output.resources[0].properties.features | Select-Object -ExpandProperty featureName - $featureNames | Should -Contain $knownEnabledFeature - $featureNames | Should -Contain $knownDisabledFeature + It 'returns an error when export input is provided' { + $inputJson = '{"features":[{"featureName":"Web-Server"}]}' + dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson 2>$TESTDRIVE/error.log | Out-Null + $LASTEXITCODE | Should -Be 2 + (Get-Content -Raw $TESTDRIVE/error.log) | Should -Match 'does not support export filtering' } } diff --git a/resources/dism_dsc/windows_feature.dsc.resource.json b/resources/dism_dsc/windows_feature.dsc.resource.json index 6be6db92d..971187bed 100644 --- a/resources/dism_dsc/windows_feature.dsc.resource.json +++ b/resources/dism_dsc/windows_feature.dsc.resource.json @@ -7,7 +7,7 @@ "feature" ], "type": "Microsoft.Windows/WindowsFeatureList", - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "dism_dsc", "args": [ @@ -37,7 +37,7 @@ "export", "windows-feature" ], - "input": "stdin" + "supportsFiltering": false }, "exitCodes": { "0": "Success", @@ -66,7 +66,7 @@ "features": { "type": "array", "title": "Features", - "description": "An array of feature filters or feature information objects.", + "description": "An array of feature information objects.", "items": { "type": "object", "additionalProperties": false, @@ -74,7 +74,7 @@ "featureName": { "type": "string", "title": "Feature name", - "description": "The name of the Windows feature as reported by DISM. Required for get and set operations. For export, this is optional and wildcards (*) are supported for case-insensitive filtering." + "description": "The name of the Windows feature as reported by DISM. Required for get and set operations. Returned by export operation." }, "_exist": { "type": "boolean", @@ -99,12 +99,12 @@ "displayName": { "type": "string", "title": "Display name", - "description": "The display name of the feature. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + "description": "The display name of the feature. Returned by the get operation." }, "description": { "type": "string", "title": "Description", - "description": "The description of the feature. Returned by get and export operations. For export, wildcards (*) are supported for case-insensitive filtering." + "description": "The description of the feature. Returned by the get operation." }, "restartRequired": { "type": "string", diff --git a/resources/windows_firewall/src/firewall.rs b/resources/windows_firewall/src/firewall.rs index 8a4b67491..f5c6714a6 100644 --- a/resources/windows_firewall/src/firewall.rs +++ b/resources/windows_firewall/src/firewall.rs @@ -11,7 +11,6 @@ use windows::Win32::System::Ole::IEnumVARIANT; use windows::Win32::System::Variant::{VARIANT, VariantClear}; use crate::types::{FirewallError, FirewallRule, FirewallRuleList, Metadata, RuleAction, RuleDirection, UnspecifiedRulesAction}; -use crate::util::matches_any_filter; /// RAII wrapper for VARIANT that automatically calls VariantClear on drop struct SafeVariant(VARIANT); @@ -558,21 +557,13 @@ pub fn set_rules(input: &FirewallRuleList, what_if: bool) -> Result) -> Result { +pub fn export_rules() -> Result { let store = FirewallStore::open()?; let all_rules = store.enumerate_rules()?; - let default_filter; - let filter_rules: &[FirewallRule] = match filters { - Some(input) if !input.rules.is_empty() => &input.rules, - _ => { default_filter = [FirewallRule::default()]; &default_filter } - }; let mut results = Vec::new(); for rule in all_rules { - let model = rule_to_model(&rule)?; - if matches_any_filter(&model, filter_rules) { - results.push(model); - } + results.push(rule_to_model(&rule)?); } Ok(FirewallRuleList { rules: results, unspecified_rules_action: None }) diff --git a/resources/windows_firewall/src/main.rs b/resources/windows_firewall/src/main.rs index 97ee20ec0..60d229143 100644 --- a/resources/windows_firewall/src/main.rs +++ b/resources/windows_firewall/src/main.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. mod types; -mod util; #[cfg(windows)] mod firewall; @@ -97,18 +96,7 @@ fn main() { } } "export" => { - let filters: Option = match input_json { - Some(json) => match serde_json::from_str(&json) { - Ok(value) => Some(value), - Err(error) => { - write_error(&t!("main.invalidJson", error = error.to_string())); - exit(EXIT_INVALID_INPUT); - } - }, - None => None, - }; - - match firewall::export_rules(filters.as_ref()) { + match firewall::export_rules() { Ok(result) => { print_json(&result); exit(EXIT_SUCCESS); diff --git a/resources/windows_firewall/src/util.rs b/resources/windows_firewall/src/util.rs deleted file mode 100644 index 213073166..000000000 --- a/resources/windows_firewall/src/util.rs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::types::FirewallRule; - -pub fn matches_wildcard(text: &str, pattern: &str) -> bool { - let text_lower = text.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - - if !pattern_lower.contains('*') { - return text_lower == pattern_lower; - } - - let parts: Vec<&str> = pattern_lower.split('*').collect(); - if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { - return false; - } - - let mut position = parts[0].len(); - let suffix = *parts.last().unwrap_or(&""); - let end = if suffix.is_empty() { - text_lower.len() - } else { - if !text_lower.ends_with(suffix) { - return false; - } - text_lower.len() - suffix.len() - }; - - for part in &parts[1..parts.len().saturating_sub(1)] { - if part.is_empty() { - continue; - } - match text_lower.get(position..end).and_then(|s| s.find(part)) { - Some(index) => position += index + part.len(), - None => return false, - } - } - - position <= end -} - -fn matches_optional_wildcard(actual: &Option, filter: &Option) -> bool { - match filter { - Some(pattern) => match actual { - Some(value) => matches_wildcard(value, pattern), - None => false, - }, - None => true, - } -} - -fn matches_optional_exact(actual: &Option, filter: &Option) -> bool { - match filter { - Some(expected) => match actual { - Some(value) => value == expected, - None => false, - }, - None => true, - } -} - -fn normalize_string_vec(values: &[String]) -> Vec { - let mut normalized: Vec = values.iter().map(|value| value.to_lowercase()).collect(); - normalized.sort_unstable(); - normalized -} - -fn matches_optional_vec(actual: &Option>, filter: &Option>) -> bool { - match filter { - Some(expected) => match actual { - Some(value) => normalize_string_vec(value) == normalize_string_vec(expected), - None => false, - }, - None => true, - } -} - -pub fn rule_matches_filter(rule: &FirewallRule, filter: &FirewallRule) -> bool { - matches_optional_wildcard(&rule.name, &filter.name) - && matches_optional_wildcard(&rule.description, &filter.description) - && matches_optional_wildcard(&rule.application_name, &filter.application_name) - && matches_optional_wildcard(&rule.service_name, &filter.service_name) - && matches_optional_exact(&rule.protocol, &filter.protocol) - && matches_optional_wildcard(&rule.local_ports, &filter.local_ports) - && matches_optional_wildcard(&rule.remote_ports, &filter.remote_ports) - && matches_optional_wildcard(&rule.local_addresses, &filter.local_addresses) - && matches_optional_wildcard(&rule.remote_addresses, &filter.remote_addresses) - && matches_optional_exact(&rule.direction, &filter.direction) - && matches_optional_exact(&rule.action, &filter.action) - && matches_optional_exact(&rule.enabled, &filter.enabled) - && matches_optional_vec(&rule.profiles, &filter.profiles) - && matches_optional_wildcard(&rule.grouping, &filter.grouping) - && matches_optional_vec(&rule.interface_types, &filter.interface_types) - && matches_optional_exact(&rule.edge_traversal, &filter.edge_traversal) -} - -pub fn matches_any_filter(rule: &FirewallRule, filters: &[FirewallRule]) -> bool { - filters.iter().any(|filter| rule_matches_filter(rule, filter)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn wildcard_matching_is_case_insensitive() { - assert!(matches_wildcard("Firewall-Rule", "firewall-*")); - assert!(matches_wildcard("AllowTCP", "*tcp")); - assert!(!matches_wildcard("AllowUDP", "*tcp")); - } -} diff --git a/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 b/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 index f0575c9a7..08cbb4a75 100644 --- a/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 +++ b/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 @@ -19,18 +19,6 @@ Describe 'Microsoft.Windows/FirewallRuleList - export operation' -Skip:(!$IsWind return $raw | ConvertFrom-Json } - - $initialExport = Invoke-DscExport - if ($LASTEXITCODE -ne 0) { - throw "Failed to export firewall rules: $(Get-Content -Raw $testdrive/error.log)" - } - - $sampleRules = $initialExport.resources[0].properties.rules | Select-Object -First 2 name, direction - if ($sampleRules.Count -lt 2) { - throw 'At least two exported firewall rules are required for export tests.' - } - $firstRule = $sampleRules[0] - $secondRule = $sampleRules[1] } It 'exports all rules with no input' { @@ -43,47 +31,10 @@ Describe 'Microsoft.Windows/FirewallRuleList - export operation' -Skip:(!$IsWind $rules[0].name | Should -Not -BeNullOrEmpty } - It 'applies AND logic within a single filter object' { - $json = @{ rules = @(@{ name = $firstRule.name; direction = $firstRule.direction }) } | ConvertTo-Json -Compress -Depth 5 - $output = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) - - $rules = $output.resources[0].properties.rules - $rules.Count | Should -Be 1 - $rules[0].name | Should -BeExactly $firstRule.name - } - - It 'applies OR logic across filter objects' { - $json = @{ rules = @(@{ name = $firstRule.name }, @{ name = $secondRule.name }) } | ConvertTo-Json -Compress -Depth 5 - $output = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) - - $rules = $output.resources[0].properties.rules - $names = $rules | ForEach-Object { $_.name } - $names | Should -Contain $firstRule.name - $names | Should -Contain $secondRule.name - } - - It 'supports wildcard name filtering' { - # Build a wildcard pattern from the first rule name: take the first word and append '*' - $prefix = ($firstRule.name -split '[-_ ]')[0] - $wildcardPattern = "${prefix}*" - - $json = @{ rules = @(@{ name = $wildcardPattern }) } | ConvertTo-Json -Compress -Depth 5 - $output = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) - - $rules = $output.resources[0].properties.rules - $rules | Should -Not -BeNullOrEmpty - $rules | ForEach-Object { $_.name | Should -BeLike $wildcardPattern } - } - - It 'returns no rules when filter matches nothing' { - $json = @{ rules = @(@{ name = 'DSC-NonExistent-Rule-Filter-12345' }) } | ConvertTo-Json -Compress -Depth 5 - $output = Invoke-DscExport -InputJson $json - $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Raw $testdrive/error.log) - - $rules = $output.resources[0].properties.rules - $rules.Count | Should -Be 0 + It 'returns an error when export input is provided' { + $json = @{ rules = @(@{ name = 'DSC-Test-Rule' }) } | ConvertTo-Json -Compress -Depth 5 + Invoke-DscExport -InputJson $json | Out-Null + $LASTEXITCODE | Should -Be 2 + (Get-Content -Raw $testdrive/error.log) | Should -Match 'does not support export filtering' } } diff --git a/resources/windows_firewall/windows_firewall.dsc.resource.json b/resources/windows_firewall/windows_firewall.dsc.resource.json index 61da68d8b..6a587fcd9 100644 --- a/resources/windows_firewall/windows_firewall.dsc.resource.json +++ b/resources/windows_firewall/windows_firewall.dsc.resource.json @@ -6,7 +6,7 @@ "Windows", "Firewall" ], - "version": "0.2.0", + "version": "0.2.1", "get": { "executable": "windows_firewall", "args": [ @@ -38,12 +38,9 @@ "export": { "executable": "windows_firewall", "args": [ - "export", - { - "jsonInputArg": "--input", - "mandatory": false - } - ] + "export" + ], + "supportsFiltering": false }, "exitCodes": { "0": "Success", @@ -55,7 +52,7 @@ "embedded": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Windows Firewall Rule List", - "description": "Manage Windows Firewall rules using the netfw.h APIs. The input is a single object that contains a rules array. For export, each object in the array is a filter where properties are ANDed together and the array entries are ORed together.", + "description": "Manage Windows Firewall rules using the netfw.h APIs. The input is a single object that contains a rules array.", "type": "object", "additionalProperties": false, "required": [ diff --git a/resources/windows_service/src/main.rs b/resources/windows_service/src/main.rs index c5f3c9968..776d4307b 100644 --- a/resources/windows_service/src/main.rs +++ b/resources/windows_service/src/main.rs @@ -116,18 +116,7 @@ fn main() { } } "export" => { - let filter: Option = match input_json { - Some(json) => match serde_json::from_str(&json) { - Ok(s) => Some(s), - Err(e) => { - write_error(&t!("main.invalidJson", error = e.to_string())); - exit(EXIT_INVALID_INPUT); - } - }, - None => None, - }; - - match service::export_services(filter.as_ref()) { + match service::export_services() { Ok(services) => { for svc in &services { print_json(svc); diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index 75cbeaaad..23e6828cb 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -371,22 +371,8 @@ 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> { +/// Export (enumerate) all services. Returns a list of all services. +pub fn export_services() -> Result, ServiceError> { 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); @@ -394,24 +380,12 @@ pub fn export_services(filter: Option<&WindowsService>) -> Result s, Err(_) => continue, // skip services we can't query }; - if let Some(f) = filter && !matches_filter(&svc, f) { - continue; - } - results.push(svc); } @@ -513,55 +487,6 @@ unsafe fn get_service_details(scm: SC_HANDLE, service_name: &str) -> Result bool { - if pattern == "*" { - return true; - } - - 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 - && let Some(last) = parts.last() - && !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(); @@ -875,80 +800,6 @@ unsafe fn wait_for_status( } } -/// 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; - } - } - - // 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(&[]); - 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 -} - /// Compute the projected state of a service after applying `input`, without /// making any changes. Populates `_metadata.whatIf` with one entry per change /// that would be applied. If the service does not exist, returns the desired diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 index c9d4c3922..e1dfa1e5d 100644 --- a/resources/windows_service/tests/windows_service_export.tests.ps1 +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -6,14 +6,7 @@ Describe 'Windows Service export tests' -Skip:(!$IsWindows) { $resourceType = 'Microsoft.Windows/Service' function Invoke-DscExport { - param( - [string]$InputJson - ) - if ($InputJson) { - $raw = $InputJson | dsc resource export -r $resourceType -f - 2>$testdrive/error.log - } else { - $raw = dsc resource export -r $resourceType 2>$testdrive/error.log - } + $raw = dsc resource export -r $resourceType 2>$testdrive/error.log $parsed = $raw | ConvertFrom-Json return $parsed } @@ -49,201 +42,12 @@ Describe 'Windows Service export tests' -Skip:(!$IsWindows) { } } - Context 'Export with name filter' { - It 'Filters by exact service name' { + Context 'Export with input' { + It 'Returns an error when export input is provided' { $json = @{ name = 'wuauserv' } | 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.name | Should -BeExactly 'wuauserv' - } - - 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) - $result.resources.Count | Should -BeGreaterThan 0 - foreach ($resource in $result.resources) { - $resource.properties.name | Should -BeLike '*serv' - } - } - - 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) - $result.resources.Count | Should -BeGreaterThan 0 - foreach ($resource in $result.resources) { - $resource.properties.name | Should -BeLike 'w*' - } - } - - 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) - $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' { - $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) - $result.resources.Count | Should -Be 0 - } - } - - Context 'Export with displayName filter' { - 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) - $result.resources.Count | Should -BeGreaterThan 0 - foreach ($resource in $result.resources) { - $resource.properties.displayName | Should -BeLike '*Update*' - } - } - - 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 $knownDisplayName - $result.resources[0].properties.name | Should -BeExactly 'wuauserv' - } - } - - Context 'Export with status filter' { - 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) - $result.resources.Count | Should -BeGreaterThan 0 - foreach ($resource in $result.resources) { - $resource.properties.status | Should -BeExactly 'Running' - } - } - - 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) - $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' { - $json = @{ startType = 'Automatic' } | ConvertTo-Json -Compress - $result = Invoke-DscExport -InputJson $json - $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' - } - } - - 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) - $result.resources.Count | Should -BeGreaterThan 0 - foreach ($resource in $result.resources) { - $resource.properties.startType | Should -BeExactly 'Manual' - } - } - - 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) - $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' { - $json = @{ status = 'Running'; startType = 'Automatic' } | ConvertTo-Json -Compress - $result = Invoke-DscExport -InputJson $json - $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' - $resource.properties.startType | Should -BeExactly 'Automatic' - } - } - - 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) - 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' { - $json = @{ dependencies = @('rpcss') } | ConvertTo-Json -Compress - $result = Invoke-DscExport -InputJson $json - $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 - ($resource.properties.dependencies | ForEach-Object { $_.ToLower() }) | Should -Contain 'rpcss' - } - } - } - - Context 'Export property validation' { - It 'All exported services have valid startType values' { - $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' { - $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' { - $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' { - $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 - } + dsc resource export -r $resourceType -i $json 2>$testdrive/error.log | Out-Null + $LASTEXITCODE | Should -Be 2 + (Get-Content -Raw $testdrive/error.log) | Should -Match 'does not support export filtering' } } } diff --git a/resources/windows_service/windows_service.dsc.resource.json b/resources/windows_service/windows_service.dsc.resource.json index 86495312c..f7fe32988 100644 --- a/resources/windows_service/windows_service.dsc.resource.json +++ b/resources/windows_service/windows_service.dsc.resource.json @@ -5,7 +5,7 @@ "tags": [ "Windows" ], - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "windows_service", "args": [ @@ -36,12 +36,9 @@ "export": { "executable": "windows_service", "args": [ - "export", - { - "jsonInputArg": "--input", - "mandatory": false - } - ] + "export" + ], + "supportsFiltering": false }, "exitCodes": { "0": "Success", diff --git a/tools/dsctest/src/export.rs b/tools/dsctest/src/export.rs index 20c9349e5..89152a90a 100644 --- a/tools/dsctest/src/export.rs +++ b/tools/dsctest/src/export.rs @@ -9,6 +9,9 @@ use serde::{Deserialize, Serialize}; pub struct Export { /// Number of instances to return pub count: u64, + /// Name of the instance + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub _name: Option, #[serde(rename = "_securityContext", skip_serializing_if = "Option::is_none")] diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index 4a4156f7c..ebf48c816 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -129,6 +129,7 @@ fn main() { for i in 0..export.count { let instance = Export { count: i, + name: Some(format!("Instance{i}")), _name: Some("TestName".to_string()), _security_context: Some("elevated".to_string()), };