EffectiveConfig: add semantics wiring, validation, and tests

- Add DelegateMultiValueOptionValidator for multi-value option validation
- Extend EffectiveConfigOptionKindMap, SpecialOptionSemantics, RenderFragmentRegistry
- Add EffectiveConfigSemanticsWiringTests
This commit is contained in:
2026-03-10 18:12:28 +10:00
parent 214d6c686e
commit b4d1f39762
5 changed files with 128 additions and 22 deletions
@@ -0,0 +1,54 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
/// </summary>
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<string>()));
Assert.Null(descriptor.Validator.ValidateItem("10.0.0.0/24", Array.Empty<string>()));
}
}
@@ -216,6 +216,8 @@ public static class EffectiveConfigOptionKindMap
/// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it /// 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, /// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value,
/// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache. /// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache.
/// For special options, behavior is sourced from <see cref="EffectiveConfigSpecialOptionSemantics"/>
/// to keep parse/write/validation semantics centralized.
/// </summary> /// </summary>
public static class EffectiveConfigParserBehaviorMap public static class EffectiveConfigParserBehaviorMap
{ {
@@ -400,6 +402,7 @@ public static class EffectiveConfigParserBehaviorMap
/// <summary>Returns LastWins, Flag, or Multi for the given option name; defaults to LastWins if unknown.</summary> /// <summary>Returns LastWins, Flag, or Multi for the given option name; defaults to LastWins if unknown.</summary>
public static EffectiveConfigParserBehavior GetBehavior(string optionName) => 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);
} }
@@ -13,9 +13,15 @@ public sealed record OptionSemantics(
string OptionName, string OptionName,
EffectiveConfigParserBehavior ParserBehavior, EffectiveConfigParserBehavior ParserBehavior,
EffectiveConfigWriteBehavior WriteBehavior, EffectiveConfigWriteBehavior WriteBehavior,
EffectiveConfigSingleValueValidator? Validator EffectiveConfigSingleValueValidator? SingleValueValidator,
EffectiveConfigMultiItemValidator? MultiItemValidator
); );
/// <summary>
/// Delegate for validating one item in a multi-value option editor.
/// </summary>
public delegate string? EffectiveConfigMultiItemValidator(string? value);
/// <summary> /// <summary>
/// Lookup for special-option semantics. Used by EffectiveConfigWriteSemantics and registry for validators. /// 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. /// 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, DnsmasqConfKeys.UseStaleCache,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateUseStaleCache), SpecialOptionValidators.ValidateUseStaleCache,
MultiItemValidator: null),
[DnsmasqConfKeys.AddMac] = new OptionSemantics( [DnsmasqConfKeys.AddMac] = new OptionSemantics(
DnsmasqConfKeys.AddMac, DnsmasqConfKeys.AddMac,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateAddMac), SpecialOptionValidators.ValidateAddMac,
MultiItemValidator: null),
[DnsmasqConfKeys.AddSubnet] = new OptionSemantics( [DnsmasqConfKeys.AddSubnet] = new OptionSemantics(
DnsmasqConfKeys.AddSubnet, DnsmasqConfKeys.AddSubnet,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateAddSubnet), SpecialOptionValidators.ValidateAddSubnet,
MultiItemValidator: null),
[DnsmasqConfKeys.Umbrella] = new OptionSemantics( [DnsmasqConfKeys.Umbrella] = new OptionSemantics(
DnsmasqConfKeys.Umbrella, DnsmasqConfKeys.Umbrella,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateUmbrella), SpecialOptionValidators.ValidateUmbrella,
MultiItemValidator: null),
[DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics( [DnsmasqConfKeys.Do0x20Encode] = new OptionSemantics(
DnsmasqConfKeys.Do0x20Encode, DnsmasqConfKeys.Do0x20Encode,
EffectiveConfigParserBehavior.Flag, EffectiveConfigParserBehavior.Flag,
EffectiveConfigWriteBehavior.InversePair, EffectiveConfigWriteBehavior.InversePair,
Validator: null), SingleValueValidator: null,
MultiItemValidator: null),
[DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics( [DnsmasqConfKeys.ConnmarkAllowlistEnable] = new OptionSemantics(
DnsmasqConfKeys.ConnmarkAllowlistEnable, DnsmasqConfKeys.ConnmarkAllowlistEnable,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateConnmarkAllowlistEnable), SpecialOptionValidators.ValidateConnmarkAllowlistEnable,
MultiItemValidator: null),
[DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics( [DnsmasqConfKeys.DnssecCheckUnsigned] = new OptionSemantics(
DnsmasqConfKeys.DnssecCheckUnsigned, DnsmasqConfKeys.DnssecCheckUnsigned,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
SpecialOptionValidators.ValidateDnssecCheckUnsigned), SpecialOptionValidators.ValidateDnssecCheckUnsigned,
MultiItemValidator: null),
[DnsmasqConfKeys.Leasequery] = new OptionSemantics( [DnsmasqConfKeys.Leasequery] = new OptionSemantics(
DnsmasqConfKeys.Leasequery, DnsmasqConfKeys.Leasequery,
EffectiveConfigParserBehavior.Multi, EffectiveConfigParserBehavior.Multi,
EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue, EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue,
null), SingleValueValidator: null,
SpecialOptionValidators.ValidateLeasequeryValue),
[DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics( [DnsmasqConfKeys.DhcpGenerateNames] = new OptionSemantics(
DnsmasqConfKeys.DhcpGenerateNames, DnsmasqConfKeys.DhcpGenerateNames,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null), SingleValueValidator: null,
MultiItemValidator: null),
[DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics( [DnsmasqConfKeys.DhcpBroadcast] = new OptionSemantics(
DnsmasqConfKeys.DhcpBroadcast, DnsmasqConfKeys.DhcpBroadcast,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null), SingleValueValidator: null,
MultiItemValidator: null),
[DnsmasqConfKeys.BootpDynamic] = new OptionSemantics( [DnsmasqConfKeys.BootpDynamic] = new OptionSemantics(
DnsmasqConfKeys.BootpDynamic, DnsmasqConfKeys.BootpDynamic,
EffectiveConfigParserBehavior.LastWins, EffectiveConfigParserBehavior.LastWins,
EffectiveConfigWriteBehavior.KeyOnlyOrValue, EffectiveConfigWriteBehavior.KeyOnlyOrValue,
null), SingleValueValidator: null,
MultiItemValidator: null),
}; };
/// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary> /// <summary>Keys (enabled, disabled) for InversePair options only. Used by write path and readonly hints.</summary>
@@ -103,5 +120,9 @@ public static class EffectiveConfigSpecialOptionSemantics
/// <summary>Returns validator from semantics for special options; otherwise null.</summary> /// <summary>Returns validator from semantics for special options; otherwise null.</summary>
public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) => public static EffectiveConfigSingleValueValidator? GetValidator(string optionName) =>
TryGetSemantics(optionName)?.Validator; TryGetSemantics(optionName)?.SingleValueValidator;
/// <summary>Returns per-item multi validator from semantics for special options; otherwise null.</summary>
public static EffectiveConfigMultiItemValidator? GetMultiItemValidator(string optionName) =>
TryGetSemantics(optionName)?.MultiItemValidator;
} }
@@ -4,8 +4,8 @@ using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays; using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; 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. // Key-only or key=value options: checkbox (On) + optional value input; with semantic validators.
RegisterComponent(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.UseStaleCache, typeof(KeyOnlyOrValueDisplay)); 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)); 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)); 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)); 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. // connmark-allowlist-enable, dnssec-check-unsigned: key-only or key=value with validation.
RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ConnmarkAllowlistEnable, typeof(KeyOnlyOrValueDisplay)); 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)); 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). // DHCP key-only-or-value options (optional value; no custom validator).
RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames, typeof(KeyOnlyOrValueDisplay)); RegisterComponent(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpGenerateNames, typeof(KeyOnlyOrValueDisplay));
@@ -182,7 +182,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsfile);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpHostsdir);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpOptsdir);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRelay);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid);
RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid); RegisterMultiDescriptor(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpRemoteid);
@@ -273,6 +273,21 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
validator); 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);
}
/// <inheritdoc /> /// <inheritdoc />
public EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName) public EffectiveConfigDescriptorFactory? GetDescriptorFactory(string sectionId, string optionName)
{ {
@@ -0,0 +1,13 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
/// <summary>
/// Adapts a semantics multi-item validator delegate to <see cref="IMultiValueOptionValidator"/>.
/// </summary>
public sealed class DelegateMultiValueOptionValidator(EffectiveConfigMultiItemValidator validate)
: IMultiValueOptionValidator
{
public string? ValidateItem(string normalized, IReadOnlyList<string> current, int? editIndex = null) =>
validate(normalized);
}