diff --git a/README.md b/README.md
index 4cf1c48..2bad134 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,34 @@
# libnftables-dotnet
-.NET bindings for system-installed `libnftables`, generated with SWIG.
+`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.
+
+## Current Scope
+
+This library is intentionally narrow.
+
+- High-level managed API:
+ - `Validate`
+ - `Apply`
+ - `Snapshot`
+ - `Restore`
+- Low-level managed wrapper:
+ - `NftContext` for direct control over flags, buffering, include paths, variables, and command execution
+
+Non-goals for the current release:
+
+- Typed .NET models for tables, chains, rules, sets, or maps
+- Event monitoring or subscriptions
+- Cross-platform support beyond Linux x64
+
+## Runtime Support
+
+Native operations currently support:
+
+- Linux only
+- x64 only
+- System-installed `libnftables`
+
+The package includes the generated Linux x64 native wrapper, but it still depends on the host system providing `libnftables`.
## Requirements
@@ -8,7 +36,9 @@
- `libnftables` headers and shared library installed
- `gcc`
- .NET SDK 10+
-- `swig` (only needed to regenerate bindings)
+- `swig` only if you regenerate bindings
+
+On Debian/Ubuntu-like systems, the runtime dependency is typically installed from the system package repository. Exact package names can vary by distro.
## Build
@@ -16,35 +46,21 @@
dotnet build
```
-`dotnet build` compiles the native SWIG wrapper (`libLibNftablesBindings.so`) from checked-in generated C wrapper code.
+`dotnet build` compiles the native SWIG wrapper (`libLibNftablesBindings.so`) from the checked-in generated C wrapper code.
-## Regenerate SWIG bindings
+## Test
```bash
-./eng/regen-bindings.sh
+dotnet test LibNftables.slnx
```
-## Native wrapper build only
+The test suite contains:
-```bash
-./eng/build-native.sh
-```
+- Managed/unit tests that do not require a native runtime
+- Native integration tests that self-gate when `libnftables` is unavailable
+- Capability-dependent tests that only run when `CAP_NET_ADMIN` is available
-## Notes
-
-- Native dependency remains system-level `libnftables` (`-lnftables`).
-- Managed APIs:
- - `NftContext`: advanced low-level context wrapper over native calls.
- - `INftablesClient` / `NftablesClient`: high-level command-centric API with `Validate`, `Apply`, `Snapshot`, and `Restore` (sync + async).
-- Fail-fast runtime policy: Linux x64 only for native operations.
-
-## Documentation
-
-- High-level managed API XML docs are provided on all public `LibNftables` types/members.
-- Low-level generated binding reference: `docs/low-level-bindings-reference.md`.
-- Generated SWIG files under `src/LibNftables.Bindings/Generated/` are auto-generated and not hand-edited.
-
-## High-level example
+## High-Level Example
```csharp
using LibNftables;
@@ -57,3 +73,64 @@ if (validation.IsValid)
client.Apply(NftApplyRequest.FromText("add table inet my_table"));
}
```
+
+## Low-Level Example
+
+```csharp
+using LibNftables;
+
+using var context = new NftContext();
+context.DryRun = true;
+context.BufferOutput();
+context.BufferError();
+
+context.RunCommand("add table inet demo");
+
+string? output = context.GetOutputBuffer();
+string? error = context.GetErrorBuffer();
+```
+
+## Troubleshooting
+
+### `NftNativeLoadException`
+
+This usually means one of these is missing or incompatible:
+
+- the bundled wrapper `libLibNftablesBindings.so`
+- the host `libnftables` shared library
+- Linux x64 runtime compatibility
+
+### `NftUnsupportedException`
+
+This is expected on:
+
+- non-Linux hosts
+- non-x64 processes
+
+### `NftPermissionException`
+
+Some operations require elevated privileges or `CAP_NET_ADMIN`, especially when interacting with the live ruleset.
+
+### Validation failures
+
+`Validate` returns `IsValid = false` for invalid nft syntax. `Apply` and `Restore` throw when the request shape is invalid or native parsing fails.
+
+## Bindings and Regeneration
+
+- Native wrapper build only:
+
+```bash
+./eng/build-native.sh
+```
+
+- Regenerate SWIG bindings:
+
+```bash
+./eng/regen-bindings.sh
+```
+
+Low-level generated binding reference:
+
+- `docs/low-level-bindings-reference.md`
+
+Generated SWIG files under `src/LibNftables.Bindings/Generated/` are generated artifacts and should not be edited by hand.
diff --git a/src/LibNftables/INftContextHandle.cs b/src/LibNftables/INftContextHandle.cs
new file mode 100644
index 0000000..dd92fe3
--- /dev/null
+++ b/src/LibNftables/INftContextHandle.cs
@@ -0,0 +1,26 @@
+namespace LibNftables;
+
+internal interface INftContextHandle : System.IDisposable
+{
+ bool DryRun { get; set; }
+
+ NftOptimizeFlags OptimizeFlags { get; set; }
+
+ NftInputFlags InputFlags { get; set; }
+
+ NftOutputFlags OutputFlags { get; set; }
+
+ NftDebugLevel DebugFlags { get; set; }
+
+ void BufferOutput();
+
+ void BufferError();
+
+ string? GetOutputBuffer();
+
+ string? GetErrorBuffer();
+
+ void RunCommand(string commandText);
+
+ void RunCommandFromFile(string path);
+}
diff --git a/src/LibNftables/LibNftables.csproj b/src/LibNftables/LibNftables.csproj
index f110e65..3f60bf1 100644
--- a/src/LibNftables/LibNftables.csproj
+++ b/src/LibNftables/LibNftables.csproj
@@ -10,6 +10,30 @@
enable
true
$(WarningsAsErrors);CS1591
+ $(NoWarn);NU5128
+ LibNftables
+ 0.1.0
+ LibNftables Contributors
+ Command-centric .NET wrapper for system-installed libnftables on Linux x64.
+ nftables;netfilter;linux;interop
+ README.md
+ Initial usable release of the managed command-centric wrapper over libnftables.
+ false
+ true
+ $(TargetsForTfmSpecificBuildOutput);IncludeProjectReferenceBuildOutput
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LibNftables/NftContext.cs b/src/LibNftables/NftContext.cs
index 69a4442..006a74a 100644
--- a/src/LibNftables/NftContext.cs
+++ b/src/LibNftables/NftContext.cs
@@ -10,7 +10,7 @@ namespace LibNftables;
/// Use for high-level workflows. This type is intended for advanced scenarios
/// requiring direct control over context flags and command execution.
///
-public sealed class NftContext : IDisposable
+public sealed class NftContext : INftContextHandle
{
private SWIGTYPE_p_nft_ctx? _ctx;
diff --git a/src/LibNftables/NftRuntimeGuard.cs b/src/LibNftables/NftRuntimeGuard.cs
index 42d4a8e..d584ffc 100644
--- a/src/LibNftables/NftRuntimeGuard.cs
+++ b/src/LibNftables/NftRuntimeGuard.cs
@@ -26,7 +26,8 @@ internal static class NftRuntimeGuard
{
if (!OperatingSystem.IsLinux())
{
- throw new NftUnsupportedException("libnftables managed API supports Linux only.");
+ throw new NftUnsupportedException(
+ "libnftables managed API supports Linux only because it depends on the system libnftables runtime.");
}
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
@@ -50,7 +51,7 @@ internal static class NftRuntimeGuard
TypeInitializationException)
{
var wrapped = new NftNativeLoadException(
- "Failed to load native libnftables runtime. Ensure system package/lib is installed and loadable.",
+ "Failed to load the native libnftables runtime. Ensure libnftables is installed on the system and the bundled Linux x64 wrapper can be loaded.",
ex);
failure = wrapped;
initialized = true;
diff --git a/src/LibNftables/NftablesClient.cs b/src/LibNftables/NftablesClient.cs
index 42e6d19..c589ec1 100644
--- a/src/LibNftables/NftablesClient.cs
+++ b/src/LibNftables/NftablesClient.cs
@@ -13,7 +13,7 @@ namespace LibNftables;
public sealed class NftablesClient : INftablesClient
{
private readonly NftablesClientOptions _options;
- private readonly System.Func _contextFactory;
+ private readonly System.Func _contextFactory;
///
/// Initializes a new instance of the class.
@@ -26,11 +26,17 @@ public sealed class NftablesClient : INftablesClient
{
}
- internal NftablesClient(NftablesClientOptions? options, System.Func contextFactory)
+ internal NftablesClient(
+ NftablesClientOptions? options,
+ System.Func contextFactory,
+ bool skipRuntimeGuard = false)
{
_options = options ?? new NftablesClientOptions();
_contextFactory = contextFactory;
- NftRuntimeGuard.EnsureInitializedForLinuxX64();
+ if (!skipRuntimeGuard)
+ {
+ NftRuntimeGuard.EnsureInitializedForLinuxX64();
+ }
}
///
@@ -131,7 +137,7 @@ public sealed class NftablesClient : INftablesClient
{
ct.ThrowIfCancellationRequested();
- using NftContext ctx = _contextFactory();
+ using INftContextHandle ctx = _contextFactory();
ConfigureContext(ctx, request, forceDryRun);
if (!string.IsNullOrWhiteSpace(request.RulesetText))
@@ -146,7 +152,7 @@ public sealed class NftablesClient : INftablesClient
return new CommandExecutionResult(ctx.GetOutputBuffer(), ctx.GetErrorBuffer());
}
- private void ConfigureContext(NftContext ctx, NftApplyRequest request, bool? forceDryRun)
+ private void ConfigureContext(INftContextHandle ctx, NftApplyRequest request, bool? forceDryRun)
{
ctx.DryRun = forceDryRun ?? request.DryRun;
ctx.InputFlags = _options.DefaultInputFlags | request.InputFlags;
diff --git a/src/LibNftables/Properties/AssemblyInfo.cs b/src/LibNftables/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..343bc31
--- /dev/null
+++ b/src/LibNftables/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("LibNftables.Tests")]
diff --git a/tests/LibNftables.Tests/NativeTestSupport.cs b/tests/LibNftables.Tests/NativeTestSupport.cs
new file mode 100644
index 0000000..c2aedc5
--- /dev/null
+++ b/tests/LibNftables.Tests/NativeTestSupport.cs
@@ -0,0 +1,64 @@
+using System.Globalization;
+
+namespace LibNftables.Tests;
+
+internal static class NativeTestSupport
+{
+ internal static bool HasCapNetAdmin()
+ {
+ const int capNetAdminBit = 12;
+ const ulong mask = 1UL << capNetAdminBit;
+
+ try
+ {
+ foreach (var line in File.ReadLines("/proc/self/status"))
+ {
+ if (!line.StartsWith("CapEff:", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var hex = line["CapEff:".Length..].Trim();
+ if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value))
+ {
+ return (value & mask) != 0;
+ }
+ }
+ }
+ catch
+ {
+ // If capability probing fails, keep tests conservative.
+ }
+
+ return false;
+ }
+
+ internal static bool TryCreateContext(out NftContext context)
+ {
+ try
+ {
+ context = new NftContext();
+ return true;
+ }
+ catch (DllNotFoundException)
+ {
+ context = null!;
+ return false;
+ }
+ catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException)
+ {
+ context = null!;
+ return false;
+ }
+ catch (BadImageFormatException)
+ {
+ context = null!;
+ return false;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ context = null!;
+ return false;
+ }
+ }
+}
diff --git a/tests/LibNftables.Tests/NftContextTests.cs b/tests/LibNftables.Tests/NftContextTests.cs
index 79fce66..259b24d 100644
--- a/tests/LibNftables.Tests/NftContextTests.cs
+++ b/tests/LibNftables.Tests/NftContextTests.cs
@@ -1,5 +1,3 @@
-using System.Globalization;
-
namespace LibNftables.Tests;
public sealed class NftContextTests
@@ -7,7 +5,7 @@ public sealed class NftContextTests
[Fact]
public void ContextFlagsRoundTrip_Works()
{
- if (!TryCreateContext(out var ctx))
+ if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
@@ -38,7 +36,7 @@ public sealed class NftContextTests
[Fact]
public void InvalidCommand_ThrowsNftExceptionWithErrorBuffer()
{
- if (!TryCreateContext(out var ctx))
+ if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
@@ -57,12 +55,12 @@ public sealed class NftContextTests
[Fact]
public void ValidDryRunCommand_CanExecuteAndBufferOutput()
{
- if (!TryCreateContext(out var ctx))
+ if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
- if (!HasCapNetAdmin())
+ if (!NativeTestSupport.HasCapNetAdmin())
{
return;
}
@@ -82,61 +80,4 @@ public sealed class NftContextTests
}
}
- private static bool HasCapNetAdmin()
- {
- const int capNetAdminBit = 12;
- const ulong mask = 1UL << capNetAdminBit;
-
- try
- {
- foreach (var line in File.ReadLines("/proc/self/status"))
- {
- if (!line.StartsWith("CapEff:", StringComparison.Ordinal))
- {
- continue;
- }
-
- var hex = line["CapEff:".Length..].Trim();
- if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value))
- {
- return (value & mask) != 0;
- }
- }
- }
- catch
- {
- // If capability probing fails, keep test conservative.
- }
-
- return false;
- }
-
- private static bool TryCreateContext(out NftContext context)
- {
- try
- {
- context = new NftContext();
- return true;
- }
- catch (DllNotFoundException)
- {
- context = null!;
- return false;
- }
- catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException)
- {
- context = null!;
- return false;
- }
- catch (BadImageFormatException)
- {
- context = null!;
- return false;
- }
- catch (EntryPointNotFoundException)
- {
- context = null!;
- return false;
- }
- }
}
diff --git a/tests/LibNftables.Tests/NftErrorTranslatorTests.cs b/tests/LibNftables.Tests/NftErrorTranslatorTests.cs
new file mode 100644
index 0000000..0a0a7f6
--- /dev/null
+++ b/tests/LibNftables.Tests/NftErrorTranslatorTests.cs
@@ -0,0 +1,41 @@
+namespace LibNftables.Tests;
+
+public sealed class NftErrorTranslatorTests
+{
+ [Fact]
+ public void PermissionErrors_AreClassified()
+ {
+ NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 1, "Operation not permitted");
+
+ Assert.IsType(ex);
+ Assert.Equal(1, ex.NativeErrorCode);
+ }
+
+ [Fact]
+ public void ValidationErrors_AreClassified()
+ {
+ NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 2, "syntax error, unexpected token");
+
+ Assert.IsType(ex);
+ Assert.Equal(2, ex.NativeErrorCode);
+ }
+
+ [Fact]
+ public void UnsupportedErrors_AreClassified()
+ {
+ NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 3, "operation not supported");
+
+ Assert.IsType(ex);
+ Assert.Equal(3, ex.NativeErrorCode);
+ }
+
+ [Fact]
+ public void UnknownErrors_FallBackToBaseException()
+ {
+ NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 4, "unexpected native failure");
+
+ Assert.IsType(ex);
+ Assert.Equal(4, ex.NativeErrorCode);
+ Assert.DoesNotContain("syntax error", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/LibNftables.Tests/NftablesClientTests.cs b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
similarity index 70%
rename from tests/LibNftables.Tests/NftablesClientTests.cs
rename to tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
index 2173a11..87c3752 100644
--- a/tests/LibNftables.Tests/NftablesClientTests.cs
+++ b/tests/LibNftables.Tests/NftablesClientIntegrationTests.cs
@@ -1,23 +1,15 @@
namespace LibNftables.Tests;
-public sealed class NftablesClientTests
+public sealed class NftablesClientIntegrationTests
{
- [Fact]
- public void Apply_WithBothTextAndFile_ThrowsValidationException()
- {
- var client = new NftablesClient();
- var request = new NftApplyRequest
- {
- RulesetText = "flush ruleset",
- RulesetFilePath = "/tmp/does-not-matter.nft",
- };
-
- Assert.Throws(() => client.Apply(request));
- }
-
[Fact]
public void Validate_InvalidRuleset_ReturnsInvalidResult()
{
+ if (!CanCreateClient())
+ {
+ return;
+ }
+
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -28,8 +20,13 @@ public sealed class NftablesClientTests
}
[Fact]
- public async System.Threading.Tasks.Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult()
+ public async Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult()
{
+ if (!CanCreateClient())
+ {
+ return;
+ }
+
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -42,6 +39,11 @@ public sealed class NftablesClientTests
[Fact]
public void Apply_InvalidRuleset_ThrowsValidationException()
{
+ if (!CanCreateClient())
+ {
+ return;
+ }
+
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -51,6 +53,11 @@ public sealed class NftablesClientTests
[Fact]
public void Snapshot_WithInsufficientPrivileges_ThrowsPermissionOrReturnsRuleset()
{
+ if (!CanCreateClient())
+ {
+ return;
+ }
+
var client = new NftablesClient();
try
@@ -63,4 +70,17 @@ public sealed class NftablesClientTests
// Expected in unprivileged environments.
}
}
+
+ private static bool CanCreateClient()
+ {
+ try
+ {
+ _ = new NftablesClient();
+ return true;
+ }
+ catch (NftException)
+ {
+ return false;
+ }
+ }
}
diff --git a/tests/LibNftables.Tests/NftablesClientUnitTests.cs b/tests/LibNftables.Tests/NftablesClientUnitTests.cs
new file mode 100644
index 0000000..848fd13
--- /dev/null
+++ b/tests/LibNftables.Tests/NftablesClientUnitTests.cs
@@ -0,0 +1,252 @@
+namespace LibNftables.Tests;
+
+public sealed class NftablesClientUnitTests
+{
+ [Fact]
+ public void Apply_WithBothTextAndFile_ThrowsValidationException()
+ {
+ var client = CreateClient();
+ var request = new NftApplyRequest
+ {
+ RulesetText = "flush ruleset",
+ RulesetFilePath = "/tmp/does-not-matter.nft",
+ };
+
+ Assert.Throws(() => client.Apply(request));
+ }
+
+ [Fact]
+ public void Apply_WithMissingFile_ThrowsValidationException()
+ {
+ var client = CreateClient();
+ var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft");
+
+ Assert.Throws(() => client.Apply(request));
+ }
+
+ [Fact]
+ public void Apply_NullRequest_ThrowsValidationException()
+ {
+ var client = CreateClient();
+
+ Assert.Throws(() => client.Apply(null!));
+ }
+
+ [Fact]
+ public void Validate_WhenCommandThrowsValidation_ReturnsInvalidResult()
+ {
+ var context = new FakeContext
+ {
+ CommandException = new NftValidationException("invalid ruleset", 7, "syntax error"),
+ };
+ var client = CreateClient(() => context);
+
+ NftValidationResult result = client.Validate(NftApplyRequest.FromText("invalid"));
+
+ Assert.False(result.IsValid);
+ Assert.Equal("syntax error", result.Diagnostics);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_UsesSynchronousValidationFlow()
+ {
+ var context = new FakeContext
+ {
+ OutputBuffer = "ok",
+ ErrorBuffer = string.Empty,
+ };
+ var client = CreateClient(() => context);
+
+ NftValidationResult result = await client.ValidateAsync(NftApplyRequest.FromText("flush ruleset", dryRun: false));
+
+ Assert.True(result.IsValid);
+ Assert.True(context.DryRun);
+ Assert.Equal("ok", result.Output);
+ }
+
+ [Fact]
+ public void Apply_WithFileRequest_UsesFileExecutionPath()
+ {
+ string path = Path.GetTempFileName();
+
+ try
+ {
+ var context = new FakeContext();
+ var client = CreateClient(() => context);
+
+ client.Apply(NftApplyRequest.FromFile(path));
+
+ Assert.Equal(path, context.LastFilePath);
+ Assert.Null(context.LastCommandText);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Apply_PropagatesOptionsAndRequestFlags()
+ {
+ var context = new FakeContext();
+ var options = new NftablesClientOptions
+ {
+ DefaultInputFlags = NftInputFlags.NoDns,
+ DefaultOutputFlags = NftOutputFlags.Json,
+ DefaultDebugFlags = NftDebugLevel.Parser,
+ DefaultOptimizeFlags = NftOptimizeFlags.Enabled,
+ };
+ var request = NftApplyRequest.FromText("flush ruleset", dryRun: true);
+ request.InputFlags = NftInputFlags.Json;
+ request.OutputFlags = NftOutputFlags.Echo;
+ request.DebugFlags = NftDebugLevel.Scanner;
+
+ var client = CreateClient(() => context, options);
+
+ client.Apply(request);
+
+ Assert.True(context.DryRun);
+ Assert.Equal(NftInputFlags.NoDns | NftInputFlags.Json, context.InputFlags);
+ Assert.Equal(NftOutputFlags.Json | NftOutputFlags.Echo, context.OutputFlags);
+ Assert.Equal(NftDebugLevel.Parser | NftDebugLevel.Scanner, context.DebugFlags);
+ Assert.Equal(NftOptimizeFlags.Enabled, context.OptimizeFlags);
+ Assert.True(context.BufferOutputCalled);
+ Assert.True(context.BufferErrorCalled);
+ }
+
+ [Fact]
+ public void Snapshot_WithOutput_ReturnsSnapshot()
+ {
+ var context = new FakeContext
+ {
+ OutputBuffer = "table inet filter { }",
+ ErrorBuffer = string.Empty,
+ };
+ var client = CreateClient(() => context);
+
+ NftSnapshot snapshot = client.Snapshot();
+
+ Assert.Equal("table inet filter { }", snapshot.RulesetText);
+ Assert.Equal("list ruleset", context.LastCommandText);
+ }
+
+ [Fact]
+ public void Snapshot_WithPermissionError_ThrowsPermissionException()
+ {
+ var context = new FakeContext
+ {
+ OutputBuffer = string.Empty,
+ ErrorBuffer = "Operation not permitted",
+ };
+ var client = CreateClient(() => context);
+
+ Assert.Throws(() => client.Snapshot());
+ }
+
+ [Fact]
+ public void Snapshot_WithEmptyOutputAndNoError_ReturnsFlushRulesetFallback()
+ {
+ var context = new FakeContext
+ {
+ OutputBuffer = string.Empty,
+ ErrorBuffer = string.Empty,
+ };
+ var client = CreateClient(() => context);
+
+ NftSnapshot snapshot = client.Snapshot();
+
+ Assert.Equal("flush ruleset", snapshot.RulesetText);
+ }
+
+ [Fact]
+ public void Restore_WithNullSnapshot_ThrowsValidationException()
+ {
+ var client = CreateClient();
+
+ Assert.Throws(() => client.Restore(null!));
+ }
+
+ [Fact]
+ public void Restore_WithEmptyRuleset_ThrowsValidationException()
+ {
+ var client = CreateClient();
+
+ Assert.Throws(() => client.Restore(new NftSnapshot(" ", DateTimeOffset.UtcNow)));
+ }
+
+ [Fact]
+ public async Task RestoreAsync_UsesSnapshotRulesetText()
+ {
+ var context = new FakeContext();
+ var client = CreateClient(() => context);
+ var snapshot = new NftSnapshot("flush ruleset", DateTimeOffset.UtcNow);
+
+ await client.RestoreAsync(snapshot);
+
+ Assert.Equal("flush ruleset", context.LastCommandText);
+ }
+
+ private static NftablesClient CreateClient(
+ Func? contextFactory = null,
+ NftablesClientOptions? options = null)
+ => new(options, contextFactory ?? (() => new FakeContext()), skipRuntimeGuard: true);
+
+ private sealed class FakeContext : INftContextHandle
+ {
+ public bool DryRun { get; set; }
+
+ public NftOptimizeFlags OptimizeFlags { get; set; }
+
+ public NftInputFlags InputFlags { get; set; }
+
+ public NftOutputFlags OutputFlags { get; set; }
+
+ public NftDebugLevel DebugFlags { get; set; }
+
+ public bool BufferOutputCalled { get; private set; }
+
+ public bool BufferErrorCalled { get; private set; }
+
+ public string? OutputBuffer { get; set; }
+
+ public string? ErrorBuffer { get; set; }
+
+ public string? LastCommandText { get; private set; }
+
+ public string? LastFilePath { get; private set; }
+
+ public Exception? CommandException { get; set; }
+
+ public void BufferOutput() => BufferOutputCalled = true;
+
+ public void BufferError() => BufferErrorCalled = true;
+
+ public string? GetOutputBuffer() => OutputBuffer;
+
+ public string? GetErrorBuffer() => ErrorBuffer;
+
+ public void RunCommand(string commandText)
+ {
+ LastCommandText = commandText;
+ ThrowIfNeeded();
+ }
+
+ public void RunCommandFromFile(string path)
+ {
+ LastFilePath = path;
+ ThrowIfNeeded();
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private void ThrowIfNeeded()
+ {
+ if (CommandException is not null)
+ {
+ throw CommandException;
+ }
+ }
+ }
+}