From aeb3c44467b88e158c9b8f5d1da837bad32b8a02 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:16:34 +0200 Subject: [PATCH 1/7] feat: implement export filter functionality for resource exports --- dsc/src/resource_command.rs | 2 +- dsc/tests/dsc_export.tests.ps1 | 131 +++++++++++ lib/dsc-lib/locales/en-us.toml | 3 + lib/dsc-lib/src/configure/config_doc.rs | 4 + lib/dsc-lib/src/configure/export_filter.rs | 221 ++++++++++++++++++ lib/dsc-lib/src/configure/mod.rs | 13 +- .../src/dscresources/command_resource.rs | 8 +- tools/dsctest/src/export.rs | 3 + tools/dsctest/src/main.rs | 1 + 9 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 lib/dsc-lib/src/configure/export_filter.rs diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 2d678434f..5a9919d30 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -339,7 +339,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers } let mut conf = Configuration::new(); - if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input) { + if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input, None) { error!("{err}"); exit(EXIT_DSC_ERROR); } 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/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 968c2c674..735b65194 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..6551a08ee --- /dev/null +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Engine-side post-filtering of exported resource instances. +//! +//! When a resource in a configuration document declares an `exportFilter` directive, +//! the engine retrieves the full export results and applies the filter itself. +//! This provides a consistent filtering experience even for resources that do not +//! implement filtering natively. +//! +//! Filter semantics: +//! * The directive is an array of filter objects; an instance is kept if it matches +//! **any** filter object (logical OR). +//! * Properties within a filter object must **all** match (logical AND). +//! * String values are compared case-insensitively and support the `*` wildcard. +//! * Non-string values are compared for equality; nested objects match recursively +//! (partial match, so only the properties named in the filter are compared). + +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 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] +pub 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..7a09a1930 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -33,6 +33,7 @@ pub mod config_doc; pub mod config_result; pub mod constraints; pub mod depends_on; +pub mod export_filter; pub mod parameters; pub struct Configurator { @@ -103,6 +104,7 @@ 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. +/// * `filter` - Optional `exportFilter` directive applied by the engine to the exported instances. /// /// # Panics /// @@ -111,12 +113,16 @@ 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, 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 +918,8 @@ 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_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/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()), }; From ba1baec1a0c4fcaf4ceb22fab76ef445fc46386f Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:17:19 +0200 Subject: [PATCH 2/7] Remove comment --- lib/dsc-lib/src/configure/export_filter.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/dsc-lib/src/configure/export_filter.rs b/lib/dsc-lib/src/configure/export_filter.rs index 6551a08ee..454b7f1a2 100644 --- a/lib/dsc-lib/src/configure/export_filter.rs +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -1,21 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Engine-side post-filtering of exported resource instances. -//! -//! When a resource in a configuration document declares an `exportFilter` directive, -//! the engine retrieves the full export results and applies the filter itself. -//! This provides a consistent filtering experience even for resources that do not -//! implement filtering natively. -//! -//! Filter semantics: -//! * The directive is an array of filter objects; an instance is kept if it matches -//! **any** filter object (logical OR). -//! * Properties within a filter object must **all** match (logical AND). -//! * String values are compared case-insensitively and support the `*` wildcard. -//! * Non-string values are compared for equality; nested objects match recursively -//! (partial match, so only the properties named in the filter are compared). - use rust_i18n::t; use serde_json::{Map, Value}; use tracing::debug; From 00ec8fbc04d895851caac82b9e76798f1b4e9dd8 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:16:34 +0200 Subject: [PATCH 3/7] feat: implement export filter functionality for resource exports --- dsc/src/resource_command.rs | 2 +- dsc/tests/dsc_export.tests.ps1 | 131 +++++++++++ lib/dsc-lib/locales/en-us.toml | 3 + lib/dsc-lib/src/configure/config_doc.rs | 4 + lib/dsc-lib/src/configure/export_filter.rs | 221 ++++++++++++++++++ lib/dsc-lib/src/configure/mod.rs | 13 +- .../src/dscresources/command_resource.rs | 8 +- tools/dsctest/src/export.rs | 3 + tools/dsctest/src/main.rs | 1 + 9 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 lib/dsc-lib/src/configure/export_filter.rs diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 2d678434f..5a9919d30 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -339,7 +339,7 @@ pub fn export(dsc: &mut DscManager, resource_type: &FullyQualifiedTypeName, vers } let mut conf = Configuration::new(); - if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input) { + if let Err(err) = add_resource_export_results_to_configuration(dsc_resource, &mut conf, input, None) { error!("{err}"); exit(EXIT_DSC_ERROR); } 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/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 968c2c674..735b65194 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..6551a08ee --- /dev/null +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Engine-side post-filtering of exported resource instances. +//! +//! When a resource in a configuration document declares an `exportFilter` directive, +//! the engine retrieves the full export results and applies the filter itself. +//! This provides a consistent filtering experience even for resources that do not +//! implement filtering natively. +//! +//! Filter semantics: +//! * The directive is an array of filter objects; an instance is kept if it matches +//! **any** filter object (logical OR). +//! * Properties within a filter object must **all** match (logical AND). +//! * String values are compared case-insensitively and support the `*` wildcard. +//! * Non-string values are compared for equality; nested objects match recursively +//! (partial match, so only the properties named in the filter are compared). + +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 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] +pub 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..7a09a1930 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -33,6 +33,7 @@ pub mod config_doc; pub mod config_result; pub mod constraints; pub mod depends_on; +pub mod export_filter; pub mod parameters; pub struct Configurator { @@ -103,6 +104,7 @@ 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. +/// * `filter` - Optional `exportFilter` directive applied by the engine to the exported instances. /// /// # Panics /// @@ -111,12 +113,16 @@ 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, 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 +918,8 @@ 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_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/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()), }; From 664abad1ee8e417de8905ac11b7656c3062b267c Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:17:19 +0200 Subject: [PATCH 4/7] Remove comment --- lib/dsc-lib/src/configure/export_filter.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/lib/dsc-lib/src/configure/export_filter.rs b/lib/dsc-lib/src/configure/export_filter.rs index 6551a08ee..454b7f1a2 100644 --- a/lib/dsc-lib/src/configure/export_filter.rs +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -1,21 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Engine-side post-filtering of exported resource instances. -//! -//! When a resource in a configuration document declares an `exportFilter` directive, -//! the engine retrieves the full export results and applies the filter itself. -//! This provides a consistent filtering experience even for resources that do not -//! implement filtering natively. -//! -//! Filter semantics: -//! * The directive is an array of filter objects; an instance is kept if it matches -//! **any** filter object (logical OR). -//! * Properties within a filter object must **all** match (logical AND). -//! * String values are compared case-insensitively and support the `*` wildcard. -//! * Non-string values are compared for equality; nested objects match recursively -//! (partial match, so only the properties named in the filter are compared). - use rust_i18n::t; use serde_json::{Map, Value}; use tracing::debug; From 87a8f3a937fba12a1450813401e78ab4a351602b Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:02:18 +0200 Subject: [PATCH 5/7] Fix Copilot remarks --- lib/dsc-lib/src/configure/export_filter.rs | 4 ++-- lib/dsc-lib/src/configure/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/dsc-lib/src/configure/export_filter.rs b/lib/dsc-lib/src/configure/export_filter.rs index 454b7f1a2..f1fc4e9dc 100644 --- a/lib/dsc-lib/src/configure/export_filter.rs +++ b/lib/dsc-lib/src/configure/export_filter.rs @@ -11,7 +11,7 @@ use tracing::debug; /// /// * `instances` - The exported instances to filter. /// * `filters` - The filter objects from the `exportFilter` directive. -pub fn apply_export_filter(instances: &mut Vec, filters: &[Map]) { +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; @@ -24,7 +24,7 @@ pub fn apply_export_filter(instances: &mut Vec, filters: &[Map]) -> bool { +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; diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 7a09a1930..40df6e27f 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -33,8 +33,8 @@ pub mod config_doc; pub mod config_result; pub mod constraints; pub mod depends_on; -pub mod export_filter; pub mod parameters; +mod export_filter; pub struct Configurator { json: String, From ca500528bb8b7a60602b5b4a90642f38e5e80f8c Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:14:27 +0200 Subject: [PATCH 6/7] Wrong commit --- lib/dsc-lib/src/configure/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index c3f31f164..40df6e27f 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -33,7 +33,6 @@ pub mod config_doc; pub mod config_result; pub mod constraints; pub mod depends_on; -pub mod export_filter; pub mod parameters; mod export_filter; From 6c176a1d0d518cabcd3ecb78798d851b367f9775 Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:00:00 +0200 Subject: [PATCH 7/7] Remove the wildcard support for resources --- .../featureondemand.dsc.resource.json | 4 +- .../optionalfeature.dsc.resource.json | 4 +- .../dism_dsc/src/feature_on_demand/export.rs | 4 +- .../dism_dsc/src/feature_on_demand/types.rs | 10 +- .../dism_dsc/src/optional_feature/export.rs | 4 +- .../dism_dsc/src/optional_feature/types.rs | 10 +- resources/dism_dsc/src/util.rs | 116 ++---------------- .../dism_dsc/src/windows_feature/export.rs | 4 +- .../dism_dsc/src/windows_feature/types.rs | 12 +- .../tests/featureOnDemand_export.tests.ps1 | 52 ++------ .../tests/optionalFeature_export.tests.ps1 | 42 ++----- .../tests/windowsFeature_export.tests.ps1 | 21 ++-- .../windows_feature.dsc.resource.json | 4 +- resources/windows_firewall/src/util.rs | 70 ++--------- .../tests/windows_firewall_export.tests.ps1 | 11 +- resources/windows_service/src/service.rs | 67 ++-------- .../tests/windows_service_export.tests.ps1 | 39 +----- 17 files changed, 91 insertions(+), 383 deletions(-) diff --git a/resources/dism_dsc/featureondemand.dsc.resource.json b/resources/dism_dsc/featureondemand.dsc.resource.json index 0ab06bf78..db56c6f69 100644 --- a/resources/dism_dsc/featureondemand.dsc.resource.json +++ b/resources/dism_dsc/featureondemand.dsc.resource.json @@ -9,7 +9,7 @@ "fod" ], "type": "Microsoft.Windows/FeatureOnDemandList", - "version": "0.1.0", + "version": "0.1.1", "get": { "executable": "dism_dsc", "args": [ @@ -72,7 +72,7 @@ "identity": { "type": "string", "title": "Capability identity", - "description": "The identity of the Windows capability (Feature on Demand). Required for get and set operations. For export operation, this is optional and wildcards (*) are supported for case-insensitive filtering." + "description": "The identity of the Windows capability (Feature on Demand). Required for get and set operations. For export operation, this is optional and supports case-insensitive exact filtering." }, "_exist": { "type": "boolean", diff --git a/resources/dism_dsc/optionalfeature.dsc.resource.json b/resources/dism_dsc/optionalfeature.dsc.resource.json index 77c4b82c9..a3d5fa900 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": [ @@ -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. For export operation, this is optional and supports case-insensitive exact filtering." }, "_exist": { "type": "boolean", diff --git a/resources/dism_dsc/src/feature_on_demand/export.rs b/resources/dism_dsc/src/feature_on_demand/export.rs index 6709a6a7e..3d5a1083c 100644 --- a/resources/dism_dsc/src/feature_on_demand/export.rs +++ b/resources/dism_dsc/src/feature_on_demand/export.rs @@ -5,7 +5,7 @@ use rust_i18n::t; use crate::dism::DismSessionHandle; use crate::feature_on_demand::types::{CapabilityState, FeatureOnDemandInfo, FeatureOnDemandList}; -use crate::util::{matches_wildcard, WildcardFilterable}; +use crate::util::Filterable; pub fn handle_export(input: &str) -> Result { let filters: Vec = if input.trim().is_empty() { @@ -42,7 +42,7 @@ pub fn handle_export(input: &str) -> Result { if !should_get_full { for f in &filters_with_identity { if let Some(ref filter_identity) = f.identity - && matches_wildcard(name, filter_identity) { + && name.eq_ignore_ascii_case(filter_identity) { should_get_full = true; break; } diff --git a/resources/dism_dsc/src/feature_on_demand/types.rs b/resources/dism_dsc/src/feature_on_demand/types.rs index 7b91ee57e..92d8d2646 100644 --- a/resources/dism_dsc/src/feature_on_demand/types.rs +++ b/resources/dism_dsc/src/feature_on_demand/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, Filterable, matches_optional_exact, matches_optional_string}; pub type CapabilityState = DismState; @@ -35,12 +35,12 @@ pub struct FeatureOnDemandInfo { pub install_size: Option, } -impl WildcardFilterable for FeatureOnDemandInfo { +impl Filterable for FeatureOnDemandInfo { fn matches_filter(&self, filter: &Self) -> bool { - matches_optional_wildcard(&self.identity, &filter.identity) + matches_optional_string(&self.identity, &filter.identity) && matches_optional_exact(&self.state, &filter.state) - && matches_optional_wildcard(&self.display_name, &filter.display_name) - && matches_optional_wildcard(&self.description, &filter.description) + && matches_optional_string(&self.display_name, &filter.display_name) + && matches_optional_string(&self.description, &filter.description) && matches_optional_exact(&self.download_size, &filter.download_size) && matches_optional_exact(&self.install_size, &filter.install_size) } diff --git a/resources/dism_dsc/src/optional_feature/export.rs b/resources/dism_dsc/src/optional_feature/export.rs index 5b19f95e3..9073fdc7d 100644 --- a/resources/dism_dsc/src/optional_feature/export.rs +++ b/resources/dism_dsc/src/optional_feature/export.rs @@ -5,7 +5,7 @@ use rust_i18n::t; use crate::dism::DismSessionHandle; use crate::optional_feature::types::{FeatureState, OptionalFeatureInfo, OptionalFeatureList}; -use crate::util::{matches_wildcard, WildcardFilterable}; +use crate::util::Filterable; pub fn handle_export(input: &str) -> Result { let filters: Vec = if input.trim().is_empty() { @@ -46,7 +46,7 @@ pub fn handle_export(input: &str) -> Result { if !should_get_full { for f in &filters_with_name { if let Some(ref filter_name) = f.feature_name - && matches_wildcard(name, filter_name) { + && name.eq_ignore_ascii_case(filter_name) { should_get_full = true; break; } diff --git a/resources/dism_dsc/src/optional_feature/types.rs b/resources/dism_dsc/src/optional_feature/types.rs index 6415d6a48..b54867be5 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, Filterable, matches_optional_exact, matches_optional_string}; pub type FeatureState = DismState; @@ -51,11 +51,11 @@ impl RestartType { } } -impl WildcardFilterable for OptionalFeatureInfo { +impl Filterable for OptionalFeatureInfo { fn matches_filter(&self, filter: &Self) -> bool { - matches_optional_wildcard(&self.feature_name, &filter.feature_name) + matches_optional_string(&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) + && matches_optional_string(&self.display_name, &filter.display_name) + && matches_optional_string(&self.description, &filter.description) } } diff --git a/resources/dism_dsc/src/util.rs b/resources/dism_dsc/src/util.rs index d665e0582..4898da870 100644 --- a/resources/dism_dsc/src/util.rs +++ b/resources/dism_dsc/src/util.rs @@ -48,52 +48,12 @@ impl DismState { } } -/// Match a string against a pattern that supports `*` wildcards (case-insensitive). -pub fn matches_wildcard(text: &str, pattern: &str) -> bool { - let text_lower = text.to_lowercase(); - let pattern_lower = pattern.to_lowercase(); - - if !pattern_lower.contains('*') { - return text_lower == pattern_lower; - } - - let parts: Vec<&str> = pattern_lower.split('*').collect(); - - if !parts[0].is_empty() && !text_lower.starts_with(parts[0]) { - return false; - } - - let mut pos = parts[0].len(); - - let suffix = *parts.last().unwrap_or(&""); - let end = if suffix.is_empty() { - text_lower.len() - } else { - if !text_lower.ends_with(suffix) { - return false; - } - text_lower.len() - suffix.len() - }; - - for part in &parts[1..parts.len().saturating_sub(1)] { - if part.is_empty() { - continue; - } - match text_lower.get(pos..end).and_then(|s| s.find(part)) { - Some(idx) => pos += idx + part.len(), - None => return false, - } - } - - pos <= end -} - -/// Check that an optional string field matches a wildcard filter pattern. +/// Check that an optional string field matches a case-insensitive exact filter value. /// Returns true if the filter has no value (no constraint). -pub fn matches_optional_wildcard(info_value: &Option, filter_value: &Option) -> bool { +pub fn matches_optional_string(info_value: &Option, filter_value: &Option) -> bool { match filter_value { Some(pattern) => match info_value { - Some(value) => matches_wildcard(value, pattern), + Some(value) => value.eq_ignore_ascii_case(pattern), None => false, }, None => true, @@ -112,8 +72,8 @@ pub fn matches_optional_exact(info_value: &Option, filter_value } } -/// Trait for types that support wildcard-based filter matching in export operations. -pub trait WildcardFilterable { +/// Trait for types that support export filtering. +pub trait Filterable { /// Returns true if this instance matches the given filter (AND logic within a single filter). fn matches_filter(&self, filter: &Self) -> bool; @@ -136,64 +96,14 @@ mod tests { use super::*; #[test] - fn test_exact_match() { - assert!(matches_wildcard("Hello", "Hello")); - assert!(matches_wildcard("Hello", "hello")); - assert!(!matches_wildcard("Hello", "World")); - } - - #[test] - fn test_star_only() { - assert!(matches_wildcard("anything", "*")); - assert!(matches_wildcard("", "*")); - } - - #[test] - fn test_prefix_wildcard() { - assert!(matches_wildcard("HelloWorld", "Hello*")); - assert!(matches_wildcard("Hello", "Hello*")); - assert!(!matches_wildcard("World", "Hello*")); - } - - #[test] - fn test_suffix_wildcard() { - assert!(matches_wildcard("HelloWorld", "*World")); - assert!(matches_wildcard("World", "*World")); - assert!(!matches_wildcard("Hello", "*World")); - } - - #[test] - fn test_middle_wildcard() { - assert!(matches_wildcard("HelloWorld", "Hello*World")); - assert!(matches_wildcard("HelloBeautifulWorld", "Hello*World")); - assert!(!matches_wildcard("HelloBeautiful", "Hello*World")); - } - - #[test] - fn test_multiple_wildcards() { - assert!(matches_wildcard("abcdef", "*b*d*")); - assert!(matches_wildcard("abcdef", "a*c*f")); - assert!(!matches_wildcard("abcdef", "a*z*f")); - } - - #[test] - fn test_double_star() { - assert!(matches_wildcard("abc", "**")); - assert!(matches_wildcard("abc", "a**c")); - assert!(matches_wildcard("", "**")); - } - - #[test] - fn test_empty_pattern() { - assert!(matches_wildcard("", "")); - assert!(!matches_wildcard("abc", "")); - } - - #[test] - fn test_case_insensitive() { - assert!(matches_wildcard("HELLO", "hello")); - assert!(matches_wildcard("HelloWorld", "hello*world")); - assert!(matches_wildcard("Microsoft.Windows.Feature", "*windows*")); + fn optional_string_matching_is_case_insensitive_and_exact() { + let value = Some("Hello".to_string()); + + assert!(matches_optional_string(&value, &Some("hello".to_string()))); + assert!(!matches_optional_string(&value, &Some("Hello*".to_string()))); + assert!(!matches_optional_string(&value, &Some("World".to_string()))); + assert!(matches_optional_string(&value, &None)); + assert!(!matches_optional_string(&None, &Some("Hello".to_string()))); } #[test] diff --git a/resources/dism_dsc/src/windows_feature/export.rs b/resources/dism_dsc/src/windows_feature/export.rs index f7ee2205f..0557baed9 100644 --- a/resources/dism_dsc/src/windows_feature/export.rs +++ b/resources/dism_dsc/src/windows_feature/export.rs @@ -4,7 +4,7 @@ use rust_i18n::t; use crate::dism::DismSessionHandle; -use crate::util::{WildcardFilterable, matches_wildcard}; +use crate::util::Filterable; use crate::windows_feature::types::{FeatureState, WindowsFeatureInfo, WindowsFeatureList}; pub fn handle_export(input: &str) -> Result { @@ -48,7 +48,7 @@ pub fn handle_export(input: &str) -> Result { if !should_get_full { for filter in &filters_with_name { if let Some(ref filter_name) = filter.feature_name - && matches_wildcard(name, filter_name) + && name.eq_ignore_ascii_case(filter_name) { should_get_full = true; break; diff --git a/resources/dism_dsc/src/windows_feature/types.rs b/resources/dism_dsc/src/windows_feature/types.rs index 774b3c409..5ccefd3db 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, Filterable, matches_optional_exact, matches_optional_string}; pub type FeatureState = DismState; @@ -68,11 +66,11 @@ impl RestartType { } } -impl WildcardFilterable for WindowsFeatureInfo { +impl Filterable for WindowsFeatureInfo { fn matches_filter(&self, filter: &Self) -> bool { - matches_optional_wildcard(&self.feature_name, &filter.feature_name) + matches_optional_string(&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) + && matches_optional_string(&self.display_name, &filter.display_name) + && matches_optional_string(&self.description, &filter.description) } } diff --git a/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 index b1150e7b5..1530bb38e 100644 --- a/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 +++ b/resources/dism_dsc/tests/featureOnDemand_export.tests.ps1 @@ -25,16 +25,6 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW $knownCapabilityNameOne = $installedMatches[0].Matches[0].Groups[1].Value $knownCapabilityNameTwo = $notPresentMatches[0].Matches[0].Groups[1].Value - # Get the displayName for the known installed capability to use in wildcard displayName tests - $fullInfoJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '","displayName":"*"}]}' - $fullInfoOutput = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $fullInfoJson | ConvertFrom-Json - $knownDisplayName = $fullInfoOutput.resources[0].properties.capabilities[0].displayName - if (-not $knownDisplayName) { - throw "Failed to get displayName for $knownCapabilityNameOne" - } - # Extract a substring from the displayName for wildcard matching (use first word if multi-word) - $displayNameWords = $knownDisplayName -split '\s+' - $knownDisplayNameWord = $displayNameWords[0] } It 'exports all capabilities with no input' -Skip:(!$isElevated) { @@ -65,15 +55,11 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW ) } - It 'exports capabilities filtered by wildcard identity' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"identity":"Language.Basic*"}]}' + It 'treats wildcard characters as literal identity characters' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '*"}]}' $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - $capabilities = $output.resources[0].properties.capabilities - $capabilities | Should -Not -BeNullOrEmpty - foreach ($cap in $capabilities) { - $cap.identity | Should -BeLike 'Language.Basic*' - } + $output.resources[0].properties.capabilities.Count | Should -Be 0 } It 'exports capabilities filtered by state' -Skip:(!$isElevated) { @@ -87,28 +73,6 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW } } - It 'exports capabilities with combined identity and state filter' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"identity":"*","state":"Installed"}]}' - $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $capabilities = $output.resources[0].properties.capabilities - $capabilities | Should -Not -BeNullOrEmpty - foreach ($cap in $capabilities) { - $cap.state | Should -BeExactly 'Installed' - } - } - - It 'exports capabilities filtered by wildcard displayName' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"displayName":"*' + $knownDisplayNameWord + '*"}]}' - $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json - $LASTEXITCODE | Should -Be 0 - $capabilities = $output.resources[0].properties.capabilities - $capabilities | Should -Not -BeNullOrEmpty - foreach ($cap in $capabilities) { - $cap.displayName | Should -BeLike "*$knownDisplayNameWord*" - } - } - It 'exports capabilities with multiple filters using OR logic' -Skip:(!$isElevated) { $inputJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '"},{"identity":"' + $knownCapabilityNameTwo + '"}]}' $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json @@ -120,8 +84,8 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW $identities | Should -Contain $knownCapabilityNameTwo } - It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"identity":"ZZZNonExistent*"}]}' + It 'returns empty results for a non-matching identity filter' -Skip:(!$isElevated) { + $inputJson = '{"capabilities":[{"identity":"ZZZNonExistent"}]}' $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $capabilities = $output.resources[0].properties.capabilities @@ -136,8 +100,10 @@ Describe 'Microsoft.Windows/FeatureOnDemandList - export operation' -Skip:(!$IsW $capabilities.Count | Should -Be 0 } - It 'returns complete capability properties when full-info filter is used' -Skip:(!$isElevated) { - $inputJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '","displayName":"*"}]}' + It 'returns complete capability properties when an exact displayName filter is used' -Skip:(!$isElevated) { + $getInputJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '"}]}' + $knownDisplayName = (dsc resource get -r Microsoft.Windows/FeatureOnDemandList -i $getInputJson | ConvertFrom-Json).actualState.capabilities[0].displayName + $inputJson = '{"capabilities":[{"identity":"' + $knownCapabilityNameOne + '","displayName":"' + $knownDisplayName + '"}]}' $output = dsc resource export -r Microsoft.Windows/FeatureOnDemandList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $capabilities = $output.resources[0].properties.capabilities diff --git a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 index 85d13b66a..115054053 100644 --- a/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/optionalFeature_export.tests.ps1 @@ -54,15 +54,11 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW ) } - It 'exports features filtered by wildcard featureName' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"Printing-*"}]}' + It 'treats wildcard characters as literal featureName characters' -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 - foreach ($feature in $features) { - $feature.featureName | Should -BeLike 'Printing-*' - } + $output.resources[0].properties.features.Count | Should -Be 0 } It 'exports features filtered by state' -Skip:(!$isElevated) { @@ -76,28 +72,6 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW } } - 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 @@ -109,16 +83,18 @@ Describe 'Microsoft.Windows/OptionalFeatureList - export operation' -Skip:(!$IsW $names | Should -Contain $knownFeatureNameTwo } - It 'returns empty results for non-matching wildcard filter' -Skip:(!$isElevated) { - $inputJson = '{"features":[{"featureName":"ZZZNonExistent*"}]}' + It 'returns empty results for a non-matching featureName 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":"*"}]}' + It 'returns complete feature properties when an exact displayName filter is used' -Skip:(!$isElevated) { + $getInputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '"}]}' + $knownDisplayName = (dsc resource get -r Microsoft.Windows/OptionalFeatureList -i $getInputJson | ConvertFrom-Json).actualState.features[0].displayName + $inputJson = '{"features":[{"featureName":"' + $knownFeatureNameOne + '","displayName":"' + $knownDisplayName + '"}]}' $output = dsc resource export -r Microsoft.Windows/OptionalFeatureList -i $inputJson | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $features = $output.resources[0].properties.features diff --git a/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 index ccee09718..aea6dd925 100644 --- a/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 +++ b/resources/dism_dsc/tests/windowsFeature_export.tests.ps1 @@ -40,6 +40,13 @@ Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWi $features[0].featureName | Should -BeExactly $knownEnabledFeature } + It 'treats wildcard characters as literal featureName characters' { + $inputJson = '{"features":[{"featureName":"' + $knownEnabledFeature + '*"}]}' + $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $output.resources[0].properties.features.Count | Should -Be 0 + } + It 'exports features filtered by state Installed' { $inputJson = '{"features":[{"state":"Installed"}]}' $output = dsc resource export -r Microsoft.Windows/WindowsFeatureList -i $inputJson | ConvertFrom-Json @@ -57,20 +64,6 @@ Describe 'Microsoft.Windows/WindowsFeatureList - export operation' -Skip:(!$IsWi $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 diff --git a/resources/dism_dsc/windows_feature.dsc.resource.json b/resources/dism_dsc/windows_feature.dsc.resource.json index 6be6db92d..aa98089da 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": [ @@ -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. For export, this is optional and supports case-insensitive exact filtering." }, "_exist": { "type": "boolean", diff --git a/resources/windows_firewall/src/util.rs b/resources/windows_firewall/src/util.rs index 213073166..a7881e7f5 100644 --- a/resources/windows_firewall/src/util.rs +++ b/resources/windows_firewall/src/util.rs @@ -3,47 +3,10 @@ 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 { +fn matches_optional_string(actual: &Option, filter: &Option) -> bool { match filter { Some(pattern) => match actual { - Some(value) => matches_wildcard(value, pattern), + Some(value) => value.eq_ignore_ascii_case(pattern), None => false, }, None => true, @@ -77,20 +40,20 @@ fn matches_optional_vec(actual: &Option>, filter: &Option 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_string(&rule.name, &filter.name) + && matches_optional_string(&rule.description, &filter.description) + && matches_optional_string(&rule.application_name, &filter.application_name) + && matches_optional_string(&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_string(&rule.local_ports, &filter.local_ports) + && matches_optional_string(&rule.remote_ports, &filter.remote_ports) + && matches_optional_string(&rule.local_addresses, &filter.local_addresses) + && matches_optional_string(&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_string(&rule.grouping, &filter.grouping) && matches_optional_vec(&rule.interface_types, &filter.interface_types) && matches_optional_exact(&rule.edge_traversal, &filter.edge_traversal) } @@ -99,14 +62,3 @@ 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..2492e1ae3 100644 --- a/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 +++ b/resources/windows_firewall/tests/windows_firewall_export.tests.ps1 @@ -64,18 +64,13 @@ Describe 'Microsoft.Windows/FirewallRuleList - export operation' -Skip:(!$IsWind $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 + It 'treats wildcard characters as literal rule name characters' { + $json = @{ rules = @(@{ name = "$($firstRule.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 - $rules | Should -Not -BeNullOrEmpty - $rules | ForEach-Object { $_.name | Should -BeLike $wildcardPattern } + $rules.Count | Should -Be 0 } It 'returns no rules when filter matches nothing' { diff --git a/resources/windows_service/src/service.rs b/resources/windows_service/src/service.rs index 75cbeaaad..c336c1a9e 100644 --- a/resources/windows_service/src/service.rs +++ b/resources/windows_service/src/service.rs @@ -513,55 +513,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(); @@ -877,26 +828,26 @@ 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 { + // name — case-insensitive exact match + if let Some(ref expected) = filter.name { let name = service.name.as_deref().unwrap_or(""); - if !matches_wildcard(name, pattern) { + if !name.eq_ignore_ascii_case(expected) { return false; } } - // display_name — wildcard match - if let Some(ref pattern) = filter.display_name { + // display_name — case-insensitive exact match + if let Some(ref expected) = filter.display_name { let dn = service.display_name.as_deref().unwrap_or(""); - if !matches_wildcard(dn, pattern) { + if !dn.eq_ignore_ascii_case(expected) { return false; } } - // description — wildcard match - if let Some(ref pattern) = filter.description { + // description — case-insensitive exact match + if let Some(ref expected) = filter.description { let desc = service.description.as_deref().unwrap_or(""); - if !matches_wildcard(desc, pattern) { + if !desc.eq_ignore_ascii_case(expected) { return false; } } diff --git a/resources/windows_service/tests/windows_service_export.tests.ps1 b/resources/windows_service/tests/windows_service_export.tests.ps1 index c9d4c3922..10db3a5c3 100644 --- a/resources/windows_service/tests/windows_service_export.tests.ps1 +++ b/resources/windows_service/tests/windows_service_export.tests.ps1 @@ -58,34 +58,11 @@ Describe 'Windows Service export tests' -Skip:(!$IsWindows) { $result.resources[0].properties.name | Should -BeExactly 'wuauserv' } - It 'Filters by name with leading wildcard' { - $json = @{ name = '*serv' } | ConvertTo-Json -Compress + It 'Treats wildcard characters as literal service name characters' { + $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 -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*' - } + $result.resources.Count | Should -Be 0 } It 'Returns empty when name filter matches nothing' { @@ -97,16 +74,6 @@ Describe 'Windows Service export tests' -Skip:(!$IsWindows) { } 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