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);
+}