Improve testability, packaging, and docs
This commit is contained in:
125
README.md
125
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.
|
||||
|
||||
26
src/LibNftables/INftContextHandle.cs
Normal file
26
src/LibNftables/INftContextHandle.cs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -10,6 +10,30 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);CS1591</WarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<PackageId>LibNftables</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Authors>LibNftables Contributors</Authors>
|
||||
<Description>Command-centric .NET wrapper for system-installed libnftables on Linux x64.</Description>
|
||||
<PackageTags>nftables;netfilter;linux;interop</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageReleaseNotes>Initial usable release of the managed command-centric wrapper over libnftables.</PackageReleaseNotes>
|
||||
<PublishRepositoryUrl>false</PublishRepositoryUrl>
|
||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);IncludeProjectReferenceBuildOutput</TargetsForTfmSpecificBuildOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="" Link="README.md" />
|
||||
<None Include="..\LibNftables.Bindings\runtimes\linux-x64\native\libLibNftablesBindings.so"
|
||||
Pack="true"
|
||||
PackagePath="runtimes/linux-x64/native/" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="IncludeProjectReferenceBuildOutput">
|
||||
<ItemGroup>
|
||||
<BuildOutputInPackage Include="$(OutputPath)LibNftables.Bindings.dll" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace LibNftables;
|
||||
/// Use <see cref="NftablesClient"/> for high-level workflows. This type is intended for advanced scenarios
|
||||
/// requiring direct control over context flags and command execution.
|
||||
/// </remarks>
|
||||
public sealed class NftContext : IDisposable
|
||||
public sealed class NftContext : INftContextHandle
|
||||
{
|
||||
private SWIGTYPE_p_nft_ctx? _ctx;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace LibNftables;
|
||||
public sealed class NftablesClient : INftablesClient
|
||||
{
|
||||
private readonly NftablesClientOptions _options;
|
||||
private readonly System.Func<NftContext> _contextFactory;
|
||||
private readonly System.Func<INftContextHandle> _contextFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NftablesClient"/> class.
|
||||
@@ -26,11 +26,17 @@ public sealed class NftablesClient : INftablesClient
|
||||
{
|
||||
}
|
||||
|
||||
internal NftablesClient(NftablesClientOptions? options, System.Func<NftContext> contextFactory)
|
||||
internal NftablesClient(
|
||||
NftablesClientOptions? options,
|
||||
System.Func<INftContextHandle> contextFactory,
|
||||
bool skipRuntimeGuard = false)
|
||||
{
|
||||
_options = options ?? new NftablesClientOptions();
|
||||
_contextFactory = contextFactory;
|
||||
NftRuntimeGuard.EnsureInitializedForLinuxX64();
|
||||
if (!skipRuntimeGuard)
|
||||
{
|
||||
NftRuntimeGuard.EnsureInitializedForLinuxX64();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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;
|
||||
|
||||
3
src/LibNftables/Properties/AssemblyInfo.cs
Normal file
3
src/LibNftables/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LibNftables.Tests")]
|
||||
64
tests/LibNftables.Tests/NativeTestSupport.cs
Normal file
64
tests/LibNftables.Tests/NativeTestSupport.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
tests/LibNftables.Tests/NftErrorTranslatorTests.cs
Normal file
41
tests/LibNftables.Tests/NftErrorTranslatorTests.cs
Normal file
@@ -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<NftPermissionException>(ex);
|
||||
Assert.Equal(1, ex.NativeErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidationErrors_AreClassified()
|
||||
{
|
||||
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 2, "syntax error, unexpected token");
|
||||
|
||||
Assert.IsType<NftValidationException>(ex);
|
||||
Assert.Equal(2, ex.NativeErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedErrors_AreClassified()
|
||||
{
|
||||
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 3, "operation not supported");
|
||||
|
||||
Assert.IsType<NftUnsupportedException>(ex);
|
||||
Assert.Equal(3, ex.NativeErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownErrors_FallBackToBaseException()
|
||||
{
|
||||
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 4, "unexpected native failure");
|
||||
|
||||
Assert.IsType<NftException>(ex);
|
||||
Assert.Equal(4, ex.NativeErrorCode);
|
||||
Assert.DoesNotContain("syntax error", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -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<NftValidationException>(() => 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
252
tests/LibNftables.Tests/NftablesClientUnitTests.cs
Normal file
252
tests/LibNftables.Tests/NftablesClientUnitTests.cs
Normal file
@@ -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<NftValidationException>(() => client.Apply(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithMissingFile_ThrowsValidationException()
|
||||
{
|
||||
var client = CreateClient();
|
||||
var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft");
|
||||
|
||||
Assert.Throws<NftValidationException>(() => client.Apply(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_NullRequest_ThrowsValidationException()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
Assert.Throws<NftValidationException>(() => 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<NftPermissionException>(() => 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<NftValidationException>(() => client.Restore(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restore_WithEmptyRuleset_ThrowsValidationException()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
Assert.Throws<NftValidationException>(() => 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<INftContextHandle>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user