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/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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;