From b4d1f397621adde5ba4116a1ed0011e88242fd7d Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 10 Mar 2026 18:12:28 +1000 Subject: [PATCH] EffectiveConfig: add semantics wiring, validation, and tests - Add DelegateMultiValueOptionValidator for multi-value option validation - Extend EffectiveConfigOptionKindMap, SpecialOptionSemantics, RenderFragmentRegistry - Add EffectiveConfigSemanticsWiringTests --- .../EffectiveConfigSemanticsWiringTests.cs | 54 +++++++++++++++++++ .../Config/EffectiveConfigOptionKindMap.cs | 5 +- .../EffectiveConfigSpecialOptionSemantics.cs | 47 +++++++++++----- .../EffectiveConfigRenderFragmentRegistry.cs | 31 ++++++++--- .../DelegateMultiValueOptionValidator.cs | 13 +++++ 5 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs diff --git a/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs b/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs new file mode 100644 index 0000000..1a6c3d2 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/EffectiveConfigSemanticsWiringTests.cs @@ -0,0 +1,54 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +namespace DnsmasqWebUI.Tests; + +/// +/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior. +/// +public class EffectiveConfigSemanticsWiringTests +{ + [Fact] + public void ParserBehaviorMap_UsesSpecialSemantics_ForSpecialOptions() + { + var specialOptions = new[] + { + DnsmasqConfKeys.UseStaleCache, + DnsmasqConfKeys.AddMac, + DnsmasqConfKeys.AddSubnet, + DnsmasqConfKeys.Umbrella, + DnsmasqConfKeys.Do0x20Encode, + DnsmasqConfKeys.ConnmarkAllowlistEnable, + DnsmasqConfKeys.DnssecCheckUnsigned, + DnsmasqConfKeys.Leasequery, + DnsmasqConfKeys.DhcpGenerateNames, + DnsmasqConfKeys.DhcpBroadcast, + DnsmasqConfKeys.BootpDynamic, + }; + + foreach (var option in specialOptions) + { + var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option); + Assert.NotNull(semantics); + Assert.Equal(semantics!.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option)); + } + } + + [Fact] + public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics() + { + var registry = new EffectiveConfigRenderFragmentRegistry(); + var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); + Assert.NotNull(factory); + + var descriptor = factory!( + EffectiveConfigFieldBuilder.SectionDhcp, + DnsmasqConfKeys.Leasequery, + status: null, + getItems: _ => null); + + Assert.NotNull(descriptor.Validator); + Assert.NotNull(descriptor.Validator!.ValidateItem("not-an-ip", Array.Empty())); + Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty())); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs index a460d68..a9e1d95 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs @@ -216,6 +216,8 @@ public static class EffectiveConfigOptionKindMap /// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it /// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value, /// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache. +/// For special options, behavior is sourced from +/// to keep parse/write/validation semantics centralized. /// public static class EffectiveConfigParserBehaviorMap { @@ -400,6 +402,7 @@ public static class EffectiveConfigParserBehaviorMap /// Returns LastWins, Flag, or Multi for the given option name; defaults to LastWins if unknown. public static EffectiveConfigParserBehavior GetBehavior(string optionName) => - BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins; + EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName)?.ParserBehavior + ?? (BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins); } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs index f9c6db2..85bfb3e 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSpecialOptionSemantics.cs @@ -13,9 +13,15 @@ public sealed record OptionSemantics( string OptionName, EffectiveConfigParserBehavior ParserBehavior, EffectiveConfigWriteBehavior WriteBehavior, - EffectiveConfigSingleValueValidator? Validator + EffectiveConfigSingleValueValidator? SingleValueValidator, + EffectiveConfigMultiItemValidator? MultiItemValidator ); +/// +/// Delegate for validating one item in a multi-value option editor. +/// +public delegate string? EffectiveConfigMultiItemValidator(string? value); + /// /// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators. /// Inverse-pair options (e.g. Do0x20Encode) have their (enabled key, disabled key) in a separate table. @@ -29,57 +35,68 @@ public static class EffectiveConfigSpecialOptionSemantics DnsmasqConfKeys.UseStaleCache, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateUseStaleCache), + SpecialOptionValidators.ValidateUseStaleCache, + MultiItemValidator: null), [DnsmasqConfKeys.AddMac] = new OptionSemantics( DnsmasqConfKeys.AddMac, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateAddMac), + SpecialOptionValidators.ValidateAddMac, + MultiItemValidator: null), [DnsmasqConfKeys.AddSubnet] = new OptionSemantics( DnsmasqConfKeys.AddSubnet, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateAddSubnet), + SpecialOptionValidators.ValidateAddSubnet, + MultiItemValidator: null), [DnsmasqConfKeys.Umbrella] = new OptionSemantics( DnsmasqConfKeys.Umbrella, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateUmbrella), + SpecialOptionValidators.ValidateUmbrella, + MultiItemValidator: null), [DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics( DnsmasqConfKeys.Do0x20Encode, EffectiveConfigParserBehavior.Flag, EffectiveConfigWriteBehavior.InversePair, - Validator: null), + SingleValueValidator: null, + MultiItemValidator: null), [DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics( DnsmasqConfKeys.ConnmarkAllowlistEnable, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateConnmarkAllowlistEnable), + SpecialOptionValidators.ValidateConnmarkAllowlistEnable, + MultiItemValidator: null), [DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics( DnsmasqConfKeys.DnssecCheckUnsigned, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - SpecialOptionValidators.ValidateDnssecCheckUnsigned), + SpecialOptionValidators.ValidateDnssecCheckUnsigned, + MultiItemValidator: null), [DnsmasqConfKeys.Leasequery] = new OptionSemantics( DnsmasqConfKeys.Leasequery, EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue, - null), + SingleValueValidator: null, + SpecialOptionValidators.ValidateLeasequeryValue), [DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics( DnsmasqConfKeys.DhcpGenerateNames, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - null), + SingleValueValidator: null, + MultiItemValidator: null), [DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics( DnsmasqConfKeys.DhcpBroadcast, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - null), + SingleValueValidator: null, + MultiItemValidator: null), [DnsmasqConfKeys.BootpDynamic] = new OptionSemantics( DnsmasqConfKeys.BootpDynamic, EffectiveConfigParserBehavior.LastWins, EffectiveConfigWriteBehavior.KeyOnlyOrValue, - null), + SingleValueValidator: null, + MultiItemValidator: null), }; /// Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints. @@ -103,5 +120,9 @@ public static class EffectiveConfigSpecialOptionSemantics /// Returns validator from semantics for special options; otherwise null. public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) => - TryGetSemantics(optionName)?.Validator; + TryGetSemantics(optionName)?.SingleValueValidator; + + /// Returns per-item multi validator from semantics for special options; otherwise null. + public static EffectiveConfigMultiItemValidator? GetMultiItemValidator(string optionName) => + TryGetSemantics(optionName)?.MultiItemValidator; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs index 54c35f6..81662bd 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistry.cs @@ -4,8 +4,8 @@ using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; @@ -103,19 +103,19 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm // Key-only or key=value options: checkbox (On) + optional value input; with semantic validators. RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, SpecialOptionValidators.ValidateUseStaleCache); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac, SpecialOptionValidators.ValidateAddMac); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddMac); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet, SpecialOptionValidators.ValidateAddSubnet); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.AddSubnet); RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella, SpecialOptionValidators.ValidateUmbrella); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.Umbrella); // connmark-allowlist-enable, dnssec-check-unsigned: key-only or key=value with validation. RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, SpecialOptionValidators.ValidateConnmarkAllowlistEnable); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable); RegisterComponent(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, typeof(KeyOnlyOrValueDisplay)); - RegisterValidatedSingle(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, SpecialOptionValidators.ValidateDnssecCheckUnsigned); + RegisterSemanticSingleValidator(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned); // DHCP key-only-or-value options (optional value; no custom validator). RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames, typeof(KeyOnlyOrValueDisplay)); @@ -182,7 +182,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir); - RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); + RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid); @@ -273,6 +273,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm validator); } + private void RegisterSemanticSingleValidator(string sectionId, string optionName) + { + var validator = EffectiveConfigSpecialOptionSemantics.GetValidator(optionName); + if (validator is not null) + RegisterValidatedSingle(sectionId, optionName, validator); + } + + private void RegisterSemanticMultiDescriptor(string sectionId, string optionName, IMultiValueEditBehavior? behavior = null) + { + var validator = EffectiveConfigSpecialOptionSemantics.GetMultiItemValidator(optionName) is { } validate + ? new DelegateMultiValueOptionValidator(validate) + : null; + RegisterMultiDescriptor(sectionId, optionName, behavior, validator); + } + /// public EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName) { diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs new file mode 100644 index 0000000..96cfe9b --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/DelegateMultiValueOptionValidator.cs @@ -0,0 +1,13 @@ +using DnsmasqWebUI.Infrastructure.Helpers.Config; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; + +/// +/// Adapts a semantics multi-item validator delegate to . +/// +public sealed class DelegateMultiValueOptionValidator(EffectiveConfigMultiItemValidator validate) + : IMultiValueOptionValidator +{ + public string? ValidateItem(string normalized, IReadOnlyList current, int? editIndex = null) => + validate(normalized); +}