diff --git a/README.md b/README.md
index 01430a4..1edc361 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ This library is intentionally narrow.
- High-level managed API:
- typed `NftRuleset` / `NftTable` / `NftSet` / `NftMap` / `NftChain` / `NftRule` authoring
- `RenderRuleset`
+ - `ValidateAndRenderRuleset`
- `ValidateRuleset`
- `ApplyRuleset`
- `Snapshot`
@@ -114,6 +115,55 @@ if (validation.IsValid)
Raw command text remains available through `NftApplyRequest` as a fallback for nft syntax not yet modeled by the typed API.
+## Supported Typed Subset
+
+The typed API currently supports this subset directly:
+
+- Sets with typed values for IPv4/IPv6 addresses and CIDRs, ports/services, interface names, marks, and raw literals.
+- Maps declared and populated inline, authored through dictionary-style helpers on `NftMap`.
+- Chains with typed base-chain metadata: type, hook, priority, and policy.
+- Common firewall rules:
+ - source/destination address matches
+ - source/destination port matches
+ - input/output interface matches
+ - set membership matches
+ - verdicts: accept, drop, reject, jump
+
+Use `NftApplyRequest` as the fallback when you need:
+
+- nft features outside the current typed subset
+- live map mutation commands
+- custom map key/value type expressions with non-raw typed values
+- snapshot parsing back into typed object models
+- full nft expression parity
+
+## Map Authoring Example
+
+```csharp
+var map = new NftMap
+{
+ Name = "service_policy",
+ KeyType = NftMapType.InetService,
+ ValueType = NftMapType.Verdict,
+};
+
+map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
+```
+
+Entries are rendered in insertion order. Duplicate keys are rejected by `NftMap.Add`. Use `NftMap.Set` to replace an existing key without changing its order.
+
+## Validate-And-Render Example
+
+```csharp
+NftRenderedValidationResult preview = client.ValidateAndRenderRuleset(ruleset);
+
+if (preview.ValidationResult.IsValid)
+{
+ client.ApplyRuleset(ruleset);
+}
+```
+
## Low-Level Example
```csharp
@@ -153,7 +203,7 @@ Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when
### Validation failures
-`ValidateRuleset` returns `IsValid = false` for invalid nft syntax after rendering the typed model. `ApplyRuleset` and `Restore` throw when the typed/request shape is invalid or native parsing fails.
+`ValidateRuleset` and `ValidateAndRenderRuleset` return `IsValid = false` for invalid nft syntax after rendering the typed model. `ApplyRuleset` and `Restore` throw when the typed/request shape is invalid or native parsing fails.
## Bindings and Regeneration
diff --git a/src/LibNftables/INftablesClient.cs b/src/LibNftables/INftablesClient.cs
index df01c68..4ba2314 100644
--- a/src/LibNftables/INftablesClient.cs
+++ b/src/LibNftables/INftablesClient.cs
@@ -39,6 +39,17 @@ public interface INftablesClient
/// Thrown when the typed model itself is invalid.
string RenderRuleset(NftRuleset ruleset);
+ ///
+ /// Renders and validates a typed nftables ruleset in one flow.
+ ///
+ /// The typed ruleset to render and validate.
+ /// A cancellation token.
+ /// The rendered text and validation outcome.
+ /// Thrown when the typed model itself is invalid before native validation begins.
+ /// Thrown when runtime/platform is unsupported.
+ /// Thrown when required native runtime components cannot be loaded.
+ NftRenderedValidationResult ValidateAndRenderRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
+
///
/// Asynchronously validates a ruleset request using dry-run mode.
///
@@ -61,6 +72,17 @@ public interface INftablesClient
/// Thrown when required native runtime components cannot be loaded.
System.Threading.Tasks.Task ValidateRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
+ ///
+ /// Asynchronously renders and validates a typed nftables ruleset in one flow.
+ ///
+ /// The typed ruleset to render and validate.
+ /// A cancellation token.
+ /// A task that resolves to the rendered text and validation outcome.
+ /// Thrown when the typed model itself is invalid before native validation begins.
+ /// Thrown when runtime/platform is unsupported.
+ /// Thrown when required native runtime components cannot be loaded.
+ System.Threading.Tasks.Task ValidateAndRenderRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
+
///
/// Applies a ruleset request.
///
diff --git a/src/LibNftables/NftMap.cs b/src/LibNftables/NftMap.cs
index f5209f6..6628464 100644
--- a/src/LibNftables/NftMap.cs
+++ b/src/LibNftables/NftMap.cs
@@ -5,6 +5,8 @@ namespace LibNftables;
///
public sealed class NftMap
{
+ private readonly List _entries = new();
+
///
/// Gets or sets the map name.
///
@@ -43,7 +45,133 @@ public sealed class NftMap
public string? CustomValueTypeExpression { get; set; }
///
- /// Gets the entries declared inline with the map definition.
+ /// Gets the number of entries currently stored in the map.
///
- public IList Entries { get; } = new List();
+ public int Count => _entries.Count;
+
+ ///
+ /// Gets the entries declared inline with the map definition in insertion order.
+ ///
+ public IReadOnlyList Entries => _entries;
+
+ ///
+ /// Adds a new entry to the map.
+ ///
+ /// The entry key.
+ /// The entry value.
+ /// Thrown when or is null.
+ /// Thrown when an entry with the same rendered key already exists.
+ public void Add(NftValue key, NftValue value)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+
+ if (ContainsKey(key))
+ {
+ throw new ArgumentException($"Map already contains an entry for key '{key.RenderedText}'.", nameof(key));
+ }
+
+ _entries.Add(new NftMapEntry
+ {
+ Key = key,
+ Value = value,
+ });
+ }
+
+ ///
+ /// Adds or replaces an entry in the map while preserving insertion order for existing keys.
+ ///
+ /// The entry key.
+ /// The entry value.
+ /// Thrown when or is null.
+ public void Set(NftValue key, NftValue value)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+
+ int existingIndex = FindIndex(key);
+ if (existingIndex >= 0)
+ {
+ _entries[existingIndex].Value = value;
+ return;
+ }
+
+ Add(key, value);
+ }
+
+ ///
+ /// Determines whether the map contains an entry for the specified key.
+ ///
+ /// The entry key.
+ /// when the key exists; otherwise .
+ public bool ContainsKey(NftValue key)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ return FindIndex(key) >= 0;
+ }
+
+ ///
+ /// Attempts to retrieve the value for the specified key.
+ ///
+ /// The entry key.
+ /// The matching value when found.
+ /// when the key exists; otherwise .
+ public bool TryGetValue(NftValue key, out NftValue value)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+
+ int index = FindIndex(key);
+ if (index >= 0)
+ {
+ value = _entries[index].Value!;
+ return true;
+ }
+
+ value = null!;
+ return false;
+ }
+
+ ///
+ /// Removes the entry for the specified key when present.
+ ///
+ /// The entry key.
+ /// when an entry was removed; otherwise .
+ public bool Remove(NftValue key)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+
+ int index = FindIndex(key);
+ if (index < 0)
+ {
+ return false;
+ }
+
+ _entries.RemoveAt(index);
+ return true;
+ }
+
+ ///
+ /// Removes all entries from the map.
+ ///
+ public void Clear() => _entries.Clear();
+
+ private int FindIndex(NftValue key)
+ {
+ for (int i = 0; i < _entries.Count; i++)
+ {
+ NftMapEntry entry = _entries[i];
+ if (entry.Key is null)
+ {
+ continue;
+ }
+
+ if (entry.Key.Kind == key.Kind &&
+ string.Equals(entry.Key.RenderedText, key.RenderedText, StringComparison.Ordinal))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
}
diff --git a/src/LibNftables/NftRenderedValidationResult.cs b/src/LibNftables/NftRenderedValidationResult.cs
new file mode 100644
index 0000000..ce6d82d
--- /dev/null
+++ b/src/LibNftables/NftRenderedValidationResult.cs
@@ -0,0 +1,8 @@
+namespace LibNftables;
+
+///
+/// Represents the rendered nft command text and validation outcome for a typed ruleset preview.
+///
+/// The rendered nft command text.
+/// The validation outcome for the rendered ruleset.
+public sealed record NftRenderedValidationResult(string RenderedRulesetText, NftValidationResult ValidationResult);
diff --git a/src/LibNftables/NftRulesetRenderer.cs b/src/LibNftables/NftRulesetRenderer.cs
index 61c4632..2e31bcd 100644
--- a/src/LibNftables/NftRulesetRenderer.cs
+++ b/src/LibNftables/NftRulesetRenderer.cs
@@ -404,8 +404,14 @@ internal static class NftRulesetRenderer
throw new NftValidationException($"Map '{map.Name}' contains a null entry at index {i}.", 0, null);
}
- string renderedKey = RenderValue(entry.Key!, $"Map '{map.Name}' key");
- _ = RenderValue(entry.Value!, $"Map '{map.Name}' value");
+ NftValue key = entry.Key ?? throw new NftValidationException($"Map '{map.Name}' contains a null key at index {i}.", 0, null);
+ NftValue value = entry.Value ?? throw new NftValidationException($"Map '{map.Name}' contains a null value at index {i}.", 0, null);
+
+ string renderedKey = RenderValue(key, $"Map '{map.Name}' key");
+ _ = RenderValue(value, $"Map '{map.Name}' value");
+
+ ValidateMapEntryValueCompatibility(map, key, isKey: true);
+ ValidateMapEntryValueCompatibility(map, value, isKey: false);
if (!keys.Add(renderedKey))
{
@@ -414,6 +420,84 @@ internal static class NftRulesetRenderer
}
}
+ private static void ValidateMapEntryValueCompatibility(NftMap map, NftValue value, bool isKey)
+ {
+ string role = isKey ? "key" : "value";
+ NftMapType? typedType = isKey ? map.KeyType : map.ValueType;
+ string? customType = isKey ? map.CustomKeyTypeExpression : map.CustomValueTypeExpression;
+
+ if (!typedType.HasValue)
+ {
+ if (value.Kind != NftValueKind.Raw)
+ {
+ throw new NftValidationException(
+ $"Map '{map.Name}' uses custom {role} type '{customType}', so only raw values are supported for that {role}.",
+ 0,
+ null);
+ }
+
+ return;
+ }
+
+ switch (typedType.Value)
+ {
+ case NftMapType.Ipv4Address:
+ ValidateTypedMapAddressValue(map, value, role, AddressFamily.InterNetwork);
+ return;
+ case NftMapType.Ipv6Address:
+ ValidateTypedMapAddressValue(map, value, role, AddressFamily.InterNetworkV6);
+ return;
+ case NftMapType.InetService:
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Port, NftValueKind.Raw);
+ return;
+ case NftMapType.InterfaceName:
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Interface, NftValueKind.Raw);
+ return;
+ case NftMapType.Mark:
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Mark, NftValueKind.Raw);
+ return;
+ case NftMapType.Verdict:
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Verdict, NftValueKind.Raw);
+ return;
+ case NftMapType.EtherAddress:
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Raw);
+ return;
+ default:
+ throw new NftValidationException($"Map '{map.Name}' uses unsupported typed {role} type '{typedType}'.", 0, null);
+ }
+ }
+
+ private static void ValidateTypedMapAddressValue(NftMap map, NftValue value, string role, AddressFamily family)
+ {
+ if (value.Kind == NftValueKind.Raw)
+ {
+ return;
+ }
+
+ ValidateTypedMapValueKind(map, value, role, NftValueKind.Address, NftValueKind.Network);
+ if (value.AddressFamily != family)
+ {
+ string familyName = family == AddressFamily.InterNetwork ? "IPv4" : "IPv6";
+ throw new NftValidationException(
+ $"Map '{map.Name}' {role} '{value.RenderedText}' is not compatible with the declared {familyName} map type.",
+ 0,
+ null);
+ }
+ }
+
+ private static void ValidateTypedMapValueKind(NftMap map, NftValue value, string role, params NftValueKind[] allowedKinds)
+ {
+ if (Array.IndexOf(allowedKinds, value.Kind) >= 0)
+ {
+ return;
+ }
+
+ throw new NftValidationException(
+ $"Map '{map.Name}' {role} '{value.RenderedText}' is not compatible with the declared map type.",
+ 0,
+ null);
+ }
+
private static void ValidateChain(NftChain chain, NftTable table)
{
if (chain is null)
diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs
index 32983c9..b1618b2 100644
--- a/src/LibNftables/NftablesClient.cs
+++ b/src/LibNftables/NftablesClient.cs
@@ -60,13 +60,23 @@ public sealed class NftablesClient : INftablesClient
public NftValidationResult ValidateRuleset(NftRuleset ruleset, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
- return Validate(CreateTypedRequest(ruleset), ct);
+ return ValidateAndRenderRuleset(ruleset, ct).ValidationResult;
}
///
public string RenderRuleset(NftRuleset ruleset)
=> NftRulesetRenderer.Render(ruleset);
+ ///
+ public NftRenderedValidationResult ValidateAndRenderRuleset(NftRuleset ruleset, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ string rendered = RenderRuleset(ruleset);
+ NftValidationResult validation = Validate(NftApplyRequest.FromText(rendered), ct);
+ return new NftRenderedValidationResult(rendered, validation);
+ }
+
///
public Task ValidateAsync(NftApplyRequest request, CancellationToken ct = default)
=> Task.FromResult(Validate(request, ct));
@@ -75,6 +85,10 @@ public sealed class NftablesClient : INftablesClient
public Task ValidateRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
=> Task.FromResult(ValidateRuleset(ruleset, ct));
+ ///
+ public Task ValidateAndRenderRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
+ => Task.FromResult(ValidateAndRenderRuleset(ruleset, ct));
+
///
public void Apply(NftApplyRequest request, CancellationToken ct = default)
{
diff --git a/tests/LibNftables.Tests/NftRulesetRendererTests.cs b/tests/LibNftables.Tests/NftRulesetRendererTests.cs
index a3de865..a4454e7 100644
--- a/tests/LibNftables.Tests/NftRulesetRendererTests.cs
+++ b/tests/LibNftables.Tests/NftRulesetRendererTests.cs
@@ -29,6 +29,27 @@ public sealed class NftRulesetRendererTests
Assert.Contains("443 : drop", rendered, StringComparison.Ordinal);
}
+ [Fact]
+ public void Render_WithIncompatibleTypedMapKey_ThrowsValidationException()
+ {
+ var ruleset = new NftRuleset();
+ var table = new NftTable
+ {
+ Name = "filter",
+ };
+ var map = new NftMap
+ {
+ Name = "bad_map",
+ KeyType = NftMapType.InetService,
+ ValueType = NftMapType.Verdict,
+ };
+ map.Add(NftValue.Interface("eth0"), NftValue.Verdict(NftVerdict.Accept));
+ table.Maps.Add(map);
+ ruleset.Tables.Add(table);
+
+ Assert.Throws(() => NftRulesetRenderer.Render(ruleset));
+ }
+
[Fact]
public void Render_WithNoTables_ThrowsValidationException()
{
@@ -130,16 +151,8 @@ public sealed class NftRulesetRendererTests
KeyType = NftMapType.InetService,
ValueType = NftMapType.Verdict,
};
- map.Entries.Add(new NftMapEntry
- {
- Key = NftValue.Port(80),
- Value = NftValue.Verdict(NftVerdict.Accept),
- });
- map.Entries.Add(new NftMapEntry
- {
- Key = NftValue.Port(443),
- Value = NftValue.Verdict(NftVerdict.Drop),
- });
+ map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+ map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
table.Maps.Add(map);
var chain = new NftChain
diff --git a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
index 1d88987..5b8827c 100644
--- a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
+++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
@@ -119,6 +119,61 @@ public sealed class NftablesClientIntegrationTests
Assert.True(result.IsValid);
}
+ [Fact]
+ public void ValidateAndRenderRuleset_WithTypedMapAndRule_ReturnsValidResult()
+ {
+ if (!CanCreateClient())
+ {
+ return;
+ }
+
+ var client = new NftablesClient();
+ var ruleset = new NftRuleset();
+ var table = new NftTable
+ {
+ Family = NftFamily.Inet,
+ Name = "typed_preview",
+ };
+ var set = new NftSet
+ {
+ Name = "blocked_ipv4",
+ Type = NftSetType.Ipv4Address,
+ };
+ set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.1")));
+ table.Sets.Add(set);
+
+ var map = new NftMap
+ {
+ Name = "service_policy",
+ KeyType = NftMapType.InetService,
+ ValueType = NftMapType.Verdict,
+ };
+ map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+ table.Maps.Add(map);
+
+ var chain = new NftChain
+ {
+ Name = "input",
+ Type = NftChainType.Filter,
+ Hook = NftHook.Input,
+ Priority = 0,
+ };
+ chain.Rules.Add(new NftRule
+ {
+ SourceAddressSetName = "blocked_ipv4",
+ TransportProtocol = NftTransportProtocol.Tcp,
+ DestinationPort = NftValue.Port(22),
+ Verdict = NftVerdict.Accept,
+ });
+ table.Chains.Add(chain);
+ ruleset.Tables.Add(table);
+
+ NftRenderedValidationResult result = client.ValidateAndRenderRuleset(ruleset);
+
+ Assert.True(result.ValidationResult.IsValid);
+ Assert.Contains("add map inet typed_preview service_policy", result.RenderedRulesetText, StringComparison.Ordinal);
+ }
+
private static bool CanCreateClient()
{
try
diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs
index 630d62d..1e20c86 100644
--- a/tests/LibNftables.Tests/NftablesClientUnitTests.cs
+++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs
@@ -87,6 +87,29 @@ public sealed class NftablesClientUnitTests
context.LastCommandText);
}
+ [Fact]
+ public void ValidateAndRenderRuleset_ReturnsRenderedTextAndValidation()
+ {
+ var context = new FakeContext
+ {
+ OutputBuffer = "ok",
+ ErrorBuffer = string.Empty,
+ };
+ var client = CreateClient(() => context);
+
+ NftRenderedValidationResult result = client.ValidateAndRenderRuleset(CreateTypedRuleset());
+
+ Assert.True(result.ValidationResult.IsValid);
+ Assert.Equal(result.RenderedRulesetText, context.LastCommandText);
+ Assert.Equal(
+ "add table inet filter" + Environment.NewLine +
+ "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine +
+ "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine +
+ "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine +
+ "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine,
+ result.RenderedRulesetText);
+ }
+
[Fact]
public void ApplyRuleset_RendersTypedRulesetAndExecutesCommand()
{
@@ -122,6 +145,41 @@ public sealed class NftablesClientUnitTests
context.LastCommandText);
}
+ [Fact]
+ public void Map_Add_DuplicateKey_ThrowsArgumentException()
+ {
+ var map = new NftMap
+ {
+ Name = "service_policy",
+ KeyType = NftMapType.InetService,
+ ValueType = NftMapType.Verdict,
+ };
+
+ map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+
+ Assert.Throws(() => map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Drop)));
+ }
+
+ [Fact]
+ public void Map_Set_ReplacesValueWithoutChangingInsertionOrder()
+ {
+ var map = new NftMap
+ {
+ Name = "service_policy",
+ KeyType = NftMapType.InetService,
+ ValueType = NftMapType.Verdict,
+ };
+
+ map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+ map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
+ map.Set(NftValue.Port(80), NftValue.Verdict(NftVerdict.Reject));
+
+ Assert.Equal(2, map.Count);
+ Assert.Equal("80", map.Entries[0].Key!.RenderedText);
+ Assert.Equal("reject", map.Entries[0].Value!.RenderedText);
+ Assert.Equal("443", map.Entries[1].Key!.RenderedText);
+ }
+
[Fact]
public void RenderRuleset_ReturnsTypedRulesetTextWithoutExecuting()
{
@@ -291,16 +349,8 @@ public sealed class NftablesClientUnitTests
KeyType = NftMapType.InetService,
ValueType = NftMapType.Verdict,
};
- map.Entries.Add(new NftMapEntry
- {
- Key = NftValue.Port(80),
- Value = NftValue.Verdict(NftVerdict.Accept),
- });
- map.Entries.Add(new NftMapEntry
- {
- Key = NftValue.Port(443),
- Value = NftValue.Verdict(NftVerdict.Drop),
- });
+ map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept));
+ map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop));
table.Maps.Add(map);
var chain = new NftChain