From 1dfc6aebfde9aea25bc322a376cc54a46488e50f Mon Sep 17 00:00:00 2001 From: Vibe Myass Date: Mon, 16 Mar 2026 03:51:49 +0000 Subject: [PATCH] Add typed high-level ruleset and set API --- README.md | 33 ++- src/LibNftables/INftablesClient.cs | 49 +++++ src/LibNftables/NftFamily.cs | 37 ++++ src/LibNftables/NftRuleset.cs | 12 ++ src/LibNftables/NftRulesetRenderer.cs | 198 ++++++++++++++++++ src/LibNftables/NftSet.cs | 37 ++++ src/LibNftables/NftSetType.cs | 37 ++++ src/LibNftables/NftTable.cs | 22 ++ src/LibNftables/NftablesClient.cs | 28 +++ .../NftRulesetRendererTests.cs | 99 +++++++++ .../NftablesClientIntegrationTests.cs | 30 +++ .../NftablesClientUnitTests.cs | 69 ++++++ 12 files changed, 644 insertions(+), 7 deletions(-) create mode 100644 src/LibNftables/NftFamily.cs create mode 100644 src/LibNftables/NftRuleset.cs create mode 100644 src/LibNftables/NftRulesetRenderer.cs create mode 100644 src/LibNftables/NftSet.cs create mode 100644 src/LibNftables/NftSetType.cs create mode 100644 src/LibNftables/NftTable.cs create mode 100644 tests/LibNftables.Tests/NftRulesetRendererTests.cs diff --git a/README.md b/README.md index 2bad134..8f9d8e9 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # libnftables-dotnet -`libnftables-dotnet` is a command-centric .NET wrapper over system-installed `libnftables`, with low-level SWIG-generated bindings and a small managed API for common workflows. +`libnftables-dotnet` is a typed-first .NET wrapper over system-installed `libnftables`, with a high-level object model for common workflows and low-level SWIG-generated bindings for advanced control. ## Current Scope This library is intentionally narrow. - High-level managed API: - - `Validate` - - `Apply` + - typed `NftRuleset` / `NftTable` / `NftSet` authoring + - `ValidateRuleset` + - `ApplyRuleset` - `Snapshot` - `Restore` - Low-level managed wrapper: @@ -16,7 +17,7 @@ This library is intentionally narrow. Non-goals for the current release: -- Typed .NET models for tables, chains, rules, sets, or maps +- Typed rule expressions, maps, and snapshot parsing back into object models - Event monitoring or subscriptions - Cross-platform support beyond Linux x64 @@ -67,13 +68,31 @@ using LibNftables; INftablesClient client = new NftablesClient(); -var validation = client.Validate(NftApplyRequest.FromText("add table inet my_table")); +var ruleset = new NftRuleset(); +var table = new NftTable +{ + Family = NftFamily.Inet, + Name = "filter", +}; +var blocked = new NftSet +{ + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, +}; +blocked.Elements.Add("10.0.0.1"); +blocked.Elements.Add("10.0.0.2"); +table.Sets.Add(blocked); +ruleset.Tables.Add(table); + +var validation = client.ValidateRuleset(ruleset); if (validation.IsValid) { - client.Apply(NftApplyRequest.FromText("add table inet my_table")); + client.ApplyRuleset(ruleset); } ``` +Raw command text remains available through `NftApplyRequest` as a fallback for nft syntax not yet modeled by the typed API. + ## Low-Level Example ```csharp @@ -113,7 +132,7 @@ Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when ### Validation failures -`Validate` returns `IsValid = false` for invalid nft syntax. `Apply` and `Restore` throw when the request shape is invalid or native parsing fails. +`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. ## Bindings and Regeneration diff --git a/src/LibNftables/INftablesClient.cs b/src/LibNftables/INftablesClient.cs index 22504d0..c143859 100644 --- a/src/LibNftables/INftablesClient.cs +++ b/src/LibNftables/INftablesClient.cs @@ -18,6 +18,19 @@ public interface INftablesClient /// Thrown when required native runtime components cannot be loaded. NftValidationResult Validate(NftApplyRequest request, System.Threading.CancellationToken ct = default); + /// + /// Validates a typed nftables ruleset by rendering it to nft command text in dry-run mode. + /// + /// The typed ruleset to validate. + /// A cancellation token. + /// + /// A validation result. Invalid nft syntax or schema errors return = . + /// + /// 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. + NftValidationResult ValidateRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Asynchronously validates a ruleset request using dry-run mode. /// @@ -29,6 +42,17 @@ public interface INftablesClient /// Thrown when required native runtime components cannot be loaded. System.Threading.Tasks.Task ValidateAsync(NftApplyRequest request, System.Threading.CancellationToken ct = default); + /// + /// Asynchronously validates a typed nftables ruleset by rendering it to nft command text in dry-run mode. + /// + /// The typed ruleset to validate. + /// A cancellation token. + /// A task that resolves to a validation result. + /// 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 ValidateRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Applies a ruleset request. /// @@ -41,6 +65,18 @@ public interface INftablesClient /// Thrown for other native execution failures. void Apply(NftApplyRequest request, System.Threading.CancellationToken ct = default); + /// + /// Applies a typed nftables ruleset by rendering it to nft command text. + /// + /// The typed ruleset to apply. + /// A cancellation token. + /// Thrown when the typed model itself is invalid or the rendered ruleset cannot be parsed/validated. + /// Thrown when insufficient privileges are available for runtime operation. + /// Thrown when runtime/platform is unsupported. + /// Thrown when required native runtime components cannot be loaded. + /// Thrown for other native execution failures. + void ApplyRuleset(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Asynchronously applies a ruleset request. /// @@ -54,6 +90,19 @@ public interface INftablesClient /// Thrown for other native execution failures. System.Threading.Tasks.Task ApplyAsync(NftApplyRequest request, System.Threading.CancellationToken ct = default); + /// + /// Asynchronously applies a typed nftables ruleset by rendering it to nft command text. + /// + /// The typed ruleset to apply. + /// A cancellation token. + /// A completed task when apply succeeds. + /// Thrown when the typed model itself is invalid or the rendered ruleset cannot be parsed/validated. + /// Thrown when insufficient privileges are available for runtime operation. + /// Thrown when runtime/platform is unsupported. + /// Thrown when required native runtime components cannot be loaded. + /// Thrown for other native execution failures. + System.Threading.Tasks.Task ApplyRulesetAsync(NftRuleset ruleset, System.Threading.CancellationToken ct = default); + /// /// Captures the current nftables ruleset from the system. /// diff --git a/src/LibNftables/NftFamily.cs b/src/LibNftables/NftFamily.cs new file mode 100644 index 0000000..850e5ff --- /dev/null +++ b/src/LibNftables/NftFamily.cs @@ -0,0 +1,37 @@ +namespace LibNftables; + +/// +/// Supported nftables address families for typed ruleset authoring. +/// +public enum NftFamily +{ + /// + /// The inet family. + /// + Inet, + + /// + /// The ip family. + /// + Ip, + + /// + /// The ip6 family. + /// + Ip6, + + /// + /// The arp family. + /// + Arp, + + /// + /// The bridge family. + /// + Bridge, + + /// + /// The netdev family. + /// + Netdev, +} diff --git a/src/LibNftables/NftRuleset.cs b/src/LibNftables/NftRuleset.cs new file mode 100644 index 0000000..0fb933b --- /dev/null +++ b/src/LibNftables/NftRuleset.cs @@ -0,0 +1,12 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables ruleset authored through the high-level API. +/// +public sealed class NftRuleset +{ + /// + /// Gets the tables included in this ruleset. + /// + public IList Tables { get; } = new List(); +} diff --git a/src/LibNftables/NftRulesetRenderer.cs b/src/LibNftables/NftRulesetRenderer.cs new file mode 100644 index 0000000..643b5a2 --- /dev/null +++ b/src/LibNftables/NftRulesetRenderer.cs @@ -0,0 +1,198 @@ +using System.Text; + +namespace LibNftables; + +internal static class NftRulesetRenderer +{ + internal static string Render(NftRuleset ruleset) + { + if (ruleset is null) + { + throw new NftValidationException("Ruleset must not be null.", 0, null); + } + + if (ruleset.Tables.Count == 0) + { + throw new NftValidationException("Ruleset must contain at least one table.", 0, null); + } + + var tableNames = new HashSet(StringComparer.Ordinal); + var builder = new StringBuilder(); + + foreach (NftTable table in ruleset.Tables) + { + ValidateTable(table); + + string tableKey = $"{table.Family}:{table.Name}"; + if (!tableNames.Add(tableKey)) + { + throw new NftValidationException( + $"Ruleset contains duplicate table '{table.Name}' in family '{ToKeyword(table.Family)}'.", + 0, + null); + } + + builder.Append("add table ") + .Append(ToKeyword(table.Family)) + .Append(' ') + .Append(table.Name) + .AppendLine(); + + var setNames = new HashSet(StringComparer.Ordinal); + foreach (NftSet set in table.Sets) + { + ValidateSet(set, table); + + if (!setNames.Add(set.Name!)) + { + throw new NftValidationException( + $"Table '{table.Name}' contains duplicate set '{set.Name}'.", + 0, + null); + } + + builder.Append("add set ") + .Append(ToKeyword(table.Family)) + .Append(' ') + .Append(table.Name) + .Append(' ') + .Append(set.Name) + .Append(" { type ") + .Append(GetTypeExpression(set)) + .Append(';'); + + if (set.Elements.Count > 0) + { + builder.Append(" elements = { "); + + for (int i = 0; i < set.Elements.Count; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(set.Elements[i]); + } + + builder.Append(" };"); + } + + builder.Append(" }") + .AppendLine(); + } + } + + return builder.ToString(); + } + + private static void ValidateTable(NftTable table) + { + if (table is null) + { + throw new NftValidationException("Ruleset tables must not contain null entries.", 0, null); + } + + ValidateIdentifier(table.Name, "Table name"); + } + + private static void ValidateSet(NftSet set, NftTable table) + { + if (set is null) + { + throw new NftValidationException( + $"Table '{table.Name}' contains a null set definition.", + 0, + null); + } + + ValidateIdentifier(set.Name, "Set name"); + + bool hasTypedType = set.Type.HasValue; + bool hasCustomType = !string.IsNullOrWhiteSpace(set.CustomTypeExpression); + + if (hasTypedType == hasCustomType) + { + throw new NftValidationException( + $"Set '{set.Name}' must specify exactly one type source: Type or CustomTypeExpression.", + 0, + null); + } + + if (hasCustomType) + { + ValidateToken(set.CustomTypeExpression!, $"Set '{set.Name}' custom type"); + } + + for (int i = 0; i < set.Elements.Count; i++) + { + string? element = set.Elements[i]; + if (string.IsNullOrWhiteSpace(element)) + { + throw new NftValidationException( + $"Set '{set.Name}' contains an empty element at index {i}.", + 0, + null); + } + + ValidateToken(element, $"Set '{set.Name}' element"); + } + } + + private static void ValidateIdentifier(string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new NftValidationException($"{fieldName} must not be null, empty, or whitespace.", 0, null); + } + + foreach (char ch in value) + { + if (char.IsWhiteSpace(ch) || ch is '{' or '}' or ';' or '"' or '\'') + { + throw new NftValidationException( + $"{fieldName} contains unsupported character '{ch}'.", + 0, + null); + } + } + } + + private static void ValidateToken(string value, string fieldName) + { + foreach (char ch in value) + { + if (ch is '\r' or '\n' or ';') + { + throw new NftValidationException( + $"{fieldName} contains unsupported character '{ch}'.", + 0, + null); + } + } + } + + private static string GetTypeExpression(NftSet set) + => set.Type switch + { + NftSetType.Ipv4Address => "ipv4_addr", + NftSetType.Ipv6Address => "ipv6_addr", + NftSetType.InetService => "inet_service", + NftSetType.EtherAddress => "ether_addr", + NftSetType.InterfaceName => "ifname", + NftSetType.Mark => "mark", + _ => set.CustomTypeExpression!, + }; + + private static string ToKeyword(NftFamily family) + => family switch + { + NftFamily.Inet => "inet", + NftFamily.Ip => "ip", + NftFamily.Ip6 => "ip6", + NftFamily.Arp => "arp", + NftFamily.Bridge => "bridge", + NftFamily.Netdev => "netdev", + _ => throw new NftValidationException($"Unsupported nft family '{family}'.", 0, null), + }; +} diff --git a/src/LibNftables/NftSet.cs b/src/LibNftables/NftSet.cs new file mode 100644 index 0000000..6cc1f6f --- /dev/null +++ b/src/LibNftables/NftSet.cs @@ -0,0 +1,37 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables set definition backed by native C# collections. +/// +public sealed class NftSet +{ + /// + /// Gets or sets the set name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a common typed set element type. + /// + /// + /// Set either or . + /// + public NftSetType? Type { get; set; } + + /// + /// Gets or sets a custom nftables type expression for the set. + /// + /// + /// This exists as a fallback for types not yet modeled by . + /// Set either or . + /// + public string? CustomTypeExpression { get; set; } + + /// + /// Gets the set elements to declare inline with the set definition. + /// + /// + /// Values are rendered as nftables literals and should already be valid for the selected type. + /// + public IList Elements { get; } = new List(); +} diff --git a/src/LibNftables/NftSetType.cs b/src/LibNftables/NftSetType.cs new file mode 100644 index 0000000..bccb39a --- /dev/null +++ b/src/LibNftables/NftSetType.cs @@ -0,0 +1,37 @@ +namespace LibNftables; + +/// +/// Common typed nftables set element types supported by the high-level API. +/// +public enum NftSetType +{ + /// + /// IPv4 addresses (ipv4_addr). + /// + Ipv4Address, + + /// + /// IPv6 addresses (ipv6_addr). + /// + Ipv6Address, + + /// + /// Internet service/port values (inet_service). + /// + InetService, + + /// + /// Ethernet/MAC addresses (ether_addr). + /// + EtherAddress, + + /// + /// Interface names (ifname). + /// + InterfaceName, + + /// + /// Packet marks (mark). + /// + Mark, +} diff --git a/src/LibNftables/NftTable.cs b/src/LibNftables/NftTable.cs new file mode 100644 index 0000000..8928e93 --- /dev/null +++ b/src/LibNftables/NftTable.cs @@ -0,0 +1,22 @@ +namespace LibNftables; + +/// +/// Represents a typed nftables table definition. +/// +public sealed class NftTable +{ + /// + /// Gets or sets the nftables family for the table. + /// + public NftFamily Family { get; set; } = NftFamily.Inet; + + /// + /// Gets or sets the table name. + /// + public string? Name { get; set; } + + /// + /// Gets the sets declared in this table. + /// + public IList Sets { get; } = new List(); +} diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs index c589ec1..a7027bf 100644 --- a/src/LibNftables/NftablesClient.cs +++ b/src/LibNftables/NftablesClient.cs @@ -56,10 +56,21 @@ public sealed class NftablesClient : INftablesClient } } + /// + public NftValidationResult ValidateRuleset(NftRuleset ruleset, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Validate(CreateTypedRequest(ruleset), ct); + } + /// public Task ValidateAsync(NftApplyRequest request, CancellationToken ct = default) => Task.FromResult(Validate(request, ct)); + /// + public Task ValidateRulesetAsync(NftRuleset ruleset, CancellationToken ct = default) + => Task.FromResult(ValidateRuleset(ruleset, ct)); + /// public void Apply(NftApplyRequest request, CancellationToken ct = default) { @@ -68,6 +79,13 @@ public sealed class NftablesClient : INftablesClient _ = Execute(request, forceDryRun: null, ct); } + /// + public void ApplyRuleset(NftRuleset ruleset, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Apply(CreateTypedRequest(ruleset), ct); + } + /// public Task ApplyAsync(NftApplyRequest request, CancellationToken ct = default) { @@ -75,6 +93,13 @@ public sealed class NftablesClient : INftablesClient return Task.CompletedTask; } + /// + public Task ApplyRulesetAsync(NftRuleset ruleset, CancellationToken ct = default) + { + ApplyRuleset(ruleset, ct); + return Task.CompletedTask; + } + /// public NftSnapshot Snapshot(CancellationToken ct = default) { @@ -210,5 +235,8 @@ public sealed class NftablesClient : INftablesClient return false; } + private static NftApplyRequest CreateTypedRequest(NftRuleset ruleset) + => NftApplyRequest.FromText(NftRulesetRenderer.Render(ruleset), dryRun: false); + private sealed record CommandExecutionResult(string? Output, string? Error); } diff --git a/tests/LibNftables.Tests/NftRulesetRendererTests.cs b/tests/LibNftables.Tests/NftRulesetRendererTests.cs new file mode 100644 index 0000000..3a0d889 --- /dev/null +++ b/tests/LibNftables.Tests/NftRulesetRendererTests.cs @@ -0,0 +1,99 @@ +namespace LibNftables.Tests; + +public sealed class NftRulesetRendererTests +{ + [Fact] + public void Render_WithTypedSet_RendersExpectedCommands() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Family = NftFamily.Inet, + Name = "filter", + }; + var set = new NftSet + { + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, + }; + set.Elements.Add("10.0.0.1"); + set.Elements.Add("10.0.0.2"); + table.Sets.Add(set); + ruleset.Tables.Add(table); + + string rendered = NftRulesetRenderer.Render(ruleset); + + 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, + rendered); + } + + [Fact] + public void Render_WithCustomTypeExpression_UsesCustomKeyword() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Family = NftFamily.Ip, + Name = "custom", + }; + var set = new NftSet + { + Name = "ports", + CustomTypeExpression = "inet_service", + }; + set.Elements.Add("80"); + table.Sets.Add(set); + ruleset.Tables.Add(table); + + string rendered = NftRulesetRenderer.Render(ruleset); + + Assert.Contains("type inet_service;", rendered, StringComparison.Ordinal); + } + + [Fact] + public void Render_WithNoTables_ThrowsValidationException() + { + Assert.Throws(() => NftRulesetRenderer.Render(new NftRuleset())); + } + + [Fact] + public void Render_WithConflictingSetTypeSources_ThrowsValidationException() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Name = "filter", + }; + table.Sets.Add(new NftSet + { + Name = "conflict", + Type = NftSetType.Ipv4Address, + CustomTypeExpression = "ipv4_addr", + }); + ruleset.Tables.Add(table); + + Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + } + + [Fact] + public void Render_WithInvalidElement_ThrowsValidationException() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Name = "filter", + }; + var set = new NftSet + { + Name = "bad", + Type = NftSetType.Mark, + }; + set.Elements.Add("1; drop"); + table.Sets.Add(set); + ruleset.Tables.Add(table); + + Assert.Throws(() => NftRulesetRenderer.Render(ruleset)); + } +} diff --git a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs index 87c3752..4b1196b 100644 --- a/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs +++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs @@ -71,6 +71,36 @@ public sealed class NftablesClientIntegrationTests } } + [Fact] + public void ValidateRuleset_WithTypedSetDefinition_ReturnsValidResult() + { + if (!CanCreateClient()) + { + return; + } + + var client = new NftablesClient(); + var ruleset = new NftRuleset(); + var table = new NftTable + { + Family = NftFamily.Inet, + Name = "typed_validation", + }; + var set = new NftSet + { + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, + }; + set.Elements.Add("10.0.0.1"); + set.Elements.Add("10.0.0.2"); + table.Sets.Add(set); + ruleset.Tables.Add(table); + + NftValidationResult result = client.ValidateRuleset(ruleset); + + Assert.True(result.IsValid); + } + private static bool CanCreateClient() { try diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs index 848fd13..8ded37e 100644 --- a/tests/LibNftables.Tests/NftablesClientUnitTests.cs +++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs @@ -64,6 +64,55 @@ public sealed class NftablesClientUnitTests Assert.Equal("ok", result.Output); } + [Fact] + public void ValidateRuleset_RendersTypedRulesetAndUsesDryRun() + { + var context = new FakeContext + { + OutputBuffer = "ok", + ErrorBuffer = string.Empty, + }; + var client = CreateClient(() => context); + + NftValidationResult result = client.ValidateRuleset(CreateTypedRuleset()); + + Assert.True(result.IsValid); + Assert.True(context.DryRun); + 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, + context.LastCommandText); + } + + [Fact] + public void ApplyRuleset_RendersTypedRulesetAndExecutesCommand() + { + var context = new FakeContext(); + var client = CreateClient(() => context); + + client.ApplyRuleset(CreateTypedRuleset()); + + Assert.False(context.DryRun); + 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, + context.LastCommandText); + } + + [Fact] + public async Task ApplyRulesetAsync_UsesTypedRulesetText() + { + var context = new FakeContext(); + var client = CreateClient(() => context); + + await client.ApplyRulesetAsync(CreateTypedRuleset()); + + 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, + context.LastCommandText); + } + [Fact] public void Apply_WithFileRequest_UsesFileExecutionPath() { @@ -191,6 +240,26 @@ public sealed class NftablesClientUnitTests NftablesClientOptions? options = null) => new(options, contextFactory ?? (() => new FakeContext()), skipRuntimeGuard: true); + private static NftRuleset CreateTypedRuleset() + { + var ruleset = new NftRuleset(); + var table = new NftTable + { + Family = NftFamily.Inet, + Name = "filter", + }; + var set = new NftSet + { + Name = "blocked_ipv4", + Type = NftSetType.Ipv4Address, + }; + set.Elements.Add("10.0.0.1"); + set.Elements.Add("10.0.0.2"); + table.Sets.Add(set); + ruleset.Tables.Add(table); + return ruleset; + } + private sealed class FakeContext : INftContextHandle { public bool DryRun { get; set; }