Complete typed map ergonomics and preview API

This commit is contained in:
Vibe Myass
2026-03-16 04:16:40 +00:00
parent e89739a64f
commit 6ae9ccf5e5
9 changed files with 450 additions and 26 deletions

View File

@@ -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

View File

@@ -39,6 +39,17 @@ public interface INftablesClient
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid.</exception>
string RenderRuleset(NftRuleset ruleset);
/// <summary>
/// Renders and validates a typed nftables ruleset in one flow.
/// </summary>
/// <param name="ruleset">The typed ruleset to render and validate.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>The rendered text and validation outcome.</returns>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid before native validation begins.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
NftRenderedValidationResult ValidateAndRenderRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously validates a ruleset request using dry-run mode.
/// </summary>
@@ -61,6 +72,17 @@ public interface INftablesClient
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
System.Threading.Tasks.Task<NftValidationResult> ValidateRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Asynchronously renders and validates a typed nftables ruleset in one flow.
/// </summary>
/// <param name="ruleset">The typed ruleset to render and validate.</param>
/// <param name="ct">A cancellation token.</param>
/// <returns>A task that resolves to the rendered text and validation outcome.</returns>
/// <exception cref="NftValidationException">Thrown when the typed model itself is invalid before native validation begins.</exception>
/// <exception cref="NftUnsupportedException">Thrown when runtime/platform is unsupported.</exception>
/// <exception cref="NftNativeLoadException">Thrown when required native runtime components cannot be loaded.</exception>
System.Threading.Tasks.Task<NftRenderedValidationResult> ValidateAndRenderRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default);
/// <summary>
/// Applies a ruleset request.
/// </summary>

View File

@@ -5,6 +5,8 @@ namespace LibNftables;
/// </summary>
public sealed class NftMap
{
private readonly List<NftMapEntry> _entries = new();
/// <summary>
/// Gets or sets the map name.
/// </summary>
@@ -43,7 +45,133 @@ public sealed class NftMap
public string? CustomValueTypeExpression { get; set; }
/// <summary>
/// Gets the entries declared inline with the map definition.
/// Gets the number of entries currently stored in the map.
/// </summary>
public IList<NftMapEntry> Entries { get; } = new List<NftMapEntry>();
public int Count => _entries.Count;
/// <summary>
/// Gets the entries declared inline with the map definition in insertion order.
/// </summary>
public IReadOnlyList<NftMapEntry> Entries => _entries;
/// <summary>
/// Adds a new entry to the map.
/// </summary>
/// <param name="key">The entry key.</param>
/// <param name="value">The entry value.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when an entry with the same rendered key already exists.</exception>
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,
});
}
/// <summary>
/// Adds or replaces an entry in the map while preserving insertion order for existing keys.
/// </summary>
/// <param name="key">The entry key.</param>
/// <param name="value">The entry value.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="value"/> is null.</exception>
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);
}
/// <summary>
/// Determines whether the map contains an entry for the specified key.
/// </summary>
/// <param name="key">The entry key.</param>
/// <returns><see langword="true"/> when the key exists; otherwise <see langword="false"/>.</returns>
public bool ContainsKey(NftValue key)
{
ArgumentNullException.ThrowIfNull(key);
return FindIndex(key) >= 0;
}
/// <summary>
/// Attempts to retrieve the value for the specified key.
/// </summary>
/// <param name="key">The entry key.</param>
/// <param name="value">The matching value when found.</param>
/// <returns><see langword="true"/> when the key exists; otherwise <see langword="false"/>.</returns>
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;
}
/// <summary>
/// Removes the entry for the specified key when present.
/// </summary>
/// <param name="key">The entry key.</param>
/// <returns><see langword="true"/> when an entry was removed; otherwise <see langword="false"/>.</returns>
public bool Remove(NftValue key)
{
ArgumentNullException.ThrowIfNull(key);
int index = FindIndex(key);
if (index < 0)
{
return false;
}
_entries.RemoveAt(index);
return true;
}
/// <summary>
/// Removes all entries from the map.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,8 @@
namespace LibNftables;
/// <summary>
/// Represents the rendered nft command text and validation outcome for a typed ruleset preview.
/// </summary>
/// <param name="RenderedRulesetText">The rendered nft command text.</param>
/// <param name="ValidationResult">The validation outcome for the rendered ruleset.</param>
public sealed record NftRenderedValidationResult(string RenderedRulesetText, NftValidationResult ValidationResult);

View File

@@ -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)

View File

@@ -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;
}
/// <inheritdoc />
public string RenderRuleset(NftRuleset ruleset)
=> NftRulesetRenderer.Render(ruleset);
/// <inheritdoc />
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);
}
/// <inheritdoc />
public Task<NftValidationResult> ValidateAsync(NftApplyRequest request, CancellationToken ct = default)
=> Task.FromResult(Validate(request, ct));
@@ -75,6 +85,10 @@ public sealed class NftablesClient : INftablesClient
public Task<NftValidationResult> ValidateRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
=> Task.FromResult(ValidateRuleset(ruleset, ct));
/// <inheritdoc />
public Task<NftRenderedValidationResult> ValidateAndRenderRulesetAsync(NftRuleset ruleset, CancellationToken ct = default)
=> Task.FromResult(ValidateAndRenderRuleset(ruleset, ct));
/// <inheritdoc />
public void Apply(NftApplyRequest request, CancellationToken ct = default)
{

View File

@@ -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<NftValidationException>(() => 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

View File

@@ -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

View File

@@ -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<ArgumentException>(() => 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