From 8f2f2d714c53c45f88d3e37eec207bec36f086e6 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:44:47 +0000 Subject: [PATCH 01/28] test: implement testing gap 01 - binary-process-failures Added configurable process responses and disposal assertions; covered mutation/listing failures and flush ordering. Preserve stderr and reject failed partial dumps. HasChain now suppresses only recognizable missing-chain errors. Validation: Fast suite: 395 passed, 15 skipped; compiler required DOTNET_PROCESSOR_COUNT=2 and a 1 GiB GC limit on this host. --- .../MockIptablesSystemProcess.cs | 9 ++- .../BinaryAdapterFailureTests.cs | 55 +++++++++++++++++++ IPTables.Net.Tests/ScriptedSystem.cs | 30 ++++++++++ .../Client/IPTablesBinaryAdapterClient.cs | 19 +++---- .../Iptables/Helpers/ExecutionHelper.cs | 8 +-- iptables-plans/01-binary-process-failures.md | 24 ++++++++ iptables-plans/README.md | 36 ++++++++++++ 7 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 IPTables.Net.Tests/BinaryAdapterFailureTests.cs create mode 100644 IPTables.Net.Tests/ScriptedSystem.cs create mode 100644 iptables-plans/01-binary-process-failures.md create mode 100644 iptables-plans/README.md diff --git a/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs b/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs index 33b0e94..f35d834 100644 --- a/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs +++ b/IPTables.Net.TestFramework/MockIptablesSystemProcess.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.Globalization; using System.IO; @@ -14,8 +14,10 @@ public class MockIptablesSystemProcess : ISystemProcess private Thread _outputReader; private Thread _errorReader; - public MockIptablesSystemProcess(StreamReader output = null, StreamReader error = null) + public MockIptablesSystemProcess(StreamReader output = null, StreamReader error = null, int exitCode = 0, StreamWriter input = null) { + ExitCode = exitCode; + StandardInput = input; StandardOutput = output; if (output != null) { @@ -153,9 +155,10 @@ public ProcessStartInfo StartInfo public event DataReceivedEventHandler OutputDataReceived; public event DataReceivedEventHandler ErrorDataReceived; public event EventHandler Exited; + public bool IsDisposed { get; private set; } public void Dispose() { - + IsDisposed = true; } } } diff --git a/IPTables.Net.Tests/BinaryAdapterFailureTests.cs b/IPTables.Net.Tests/BinaryAdapterFailureTests.cs new file mode 100644 index 0000000..2c286d7 --- /dev/null +++ b/IPTables.Net.Tests/BinaryAdapterFailureTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; + +namespace IPTables.Net.Tests; +public class BinaryAdapterFailureTests +{ + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(17)] + public void MutationsReportFailuresAndDispose(int exit) + { + foreach (var operation in new[] { "add", "replace", "delete", "flush" }) + { + var os = new ScriptedSystem { Respond = (_, _) => new("", "detail", exit) }; + var system = new IpTablesSystem(os, new IPTablesBinaryAdapter()); + using var client = system.GetTableAdapter(4); + var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(4), 4); + rule.Chain.AddRule(rule); + Action action = operation switch { + "add" => () => client.AddRule(rule), "replace" => () => client.ReplaceRule(rule), + "delete" => () => client.DeleteRule(rule), _ => () => client.DeleteChain("filter", "custom", true) + }; + if (exit == 0) action(); + else Assert.Contains("detail", Assert.Throws(action).Message); + Assert.All(os.Calls, call => Assert.True(call.Process.IsDisposed)); + if (operation == "flush") + Assert.Equal(exit == 0 ? new[] { "-t filter -F custom", "-t filter -X custom" } : new[] { "-t filter -F custom" }, os.Calls.Select(c => c.Arguments)); + } + } + [Theory] + [InlineData("", "denied", 1)] [InlineData("*filter\nCOMMIT", "denied", 1)] + [InlineData("", "denied", 0)] [InlineData("", "", 2)] + public void FailedListingsAreNeverAccepted(string output, string error, int exit) + { + var os = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; + using var client = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(4); + Assert.Throws(() => client.ListRules("filter")); + Assert.True(Assert.Single(os.Calls).Process.IsDisposed); + } + [Fact] + public void HasChainOnlySuppressesMissingChain() + { + var os = new ScriptedSystem(); + using var client = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(4); + Assert.True(client.HasChain("filter", "custom")); + os.Respond = (_, _) => new("", "No chain/target/match by that name.", 1); + Assert.False(client.HasChain("filter", "custom")); + os.Respond = (_, _) => new("", "Permission denied", 1); + Assert.Throws(() => client.HasChain("filter", "custom")); + os.Respond = (_, _) => throw new InvalidOperationException("start failed"); + Assert.Throws(() => client.AddChain("filter", "custom")); + } +} diff --git a/IPTables.Net.Tests/ScriptedSystem.cs b/IPTables.Net.Tests/ScriptedSystem.cs new file mode 100644 index 0000000..7101e72 --- /dev/null +++ b/IPTables.Net.Tests/ScriptedSystem.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using SystemInteract; +using IPTables.Net.TestFramework; + +namespace IPTables.Net.Tests; + +internal sealed class ScriptedSystem : ISystemFactory +{ + internal record Reply(string Output = "", string Error = "", int Exit = 0); + internal record Call(string Binary, string Arguments, MemoryStream Input, MockIptablesSystemProcess Process) + { + public string Text => Encoding.UTF8.GetString(Input.ToArray()); + } + public readonly List Calls = new(); + public Func Respond = (_, _) => new(); + public ISystemProcess StartProcess(string command, string arguments) + { + var reply = Respond(command, arguments); + var input = new MemoryStream(); + var process = new MockIptablesSystemProcess(Reader(reply.Output), Reader(reply.Error), reply.Exit, + new StreamWriter(input, new UTF8Encoding(false))); + Calls.Add(new(command, arguments, input, process)); + return process; + } + private static StreamReader Reader(string text) => new(new MemoryStream(Encoding.UTF8.GetBytes(text))); + public Stream Open(string path, FileMode mode, FileAccess access) => throw new NotSupportedException(); +} diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs index 3a1deba..ff72e04 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; using SystemInteract; @@ -72,15 +72,12 @@ public override Version GetIptablesVersion() public override bool HasChain(string table, string chainName) { var command = string.Format("-L {0} -t {1}", chainName, table); - try - { - ExecutionHelper.ExecuteIptables(_iptables, command, _iptablesBinary); - return true; - } - catch (IpTablesNetException) - { - return false; - } + using var process = System.StartProcess(_iptablesBinary, command); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode == 0) return true; + if (process.ExitCode == 1 && (error.Contains("No chain/target/match by that name") || + error.Contains("does not exist"))) return false; + throw new IpTablesNetException("Unable to query chain " + chainName + ": " + error); } public override void AddChain(string table, string chainName) @@ -111,7 +108,7 @@ public override IpTablesChainSet ListRules(string table) string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - if (string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error)) + if (process.ExitCode != 0 || (string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))) { throw new IpTablesNetException(error); } diff --git a/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs b/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs index a763dd6..d90e80a 100644 --- a/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs +++ b/IPTables.Net/Iptables/Helpers/ExecutionHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using SystemInteract; using IPTables.Net.Exceptions; @@ -28,14 +28,14 @@ public static void ExecuteIptables(IpTablesSystem system, string command, string //ERR: INVALID COMMAND LINE if (process.ExitCode == 2) - throw new IpTablesNetException("IPTables execution failed: Invalid Command Line - " + command); + throw new IpTablesNetException("IPTables execution failed: Invalid Command Line - " + command + ": " + error); //ERR: GENERAL ERROR if (process.ExitCode == 1) - throw new IpTablesNetException("IPTables execution failed: Error - " + command); + throw new IpTablesNetException("IPTables execution failed: Error - " + command + ": " + error); //ERR: UNKNOWN - throw new IpTablesNetException("IPTables execution failed: Unknown Error - " + command); + throw new IpTablesNetException("IPTables execution failed: Unknown Error - " + command + ": " + error); } } } diff --git a/iptables-plans/01-binary-process-failures.md b/iptables-plans/01-binary-process-failures.md new file mode 100644 index 0000000..17eb128 --- /dev/null +++ b/iptables-plans/01-binary-process-failures.md @@ -0,0 +1,24 @@ +# Gap 01: Binary adapter process failures + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[ExecutionHelper](../IPTables.Net/Iptables/Helpers/ExecutionHelper.cs) maps exit codes 1, 2, and other failures to exceptions. [IPTablesBinaryAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs) also handles listing errors and catches exceptions in `HasChain`. [MockIptablesSystemProcess](../IPTables.Net.TestFramework/MockIptablesSystemProcess.cs) leaves `ExitCode` at zero with a private setter. Existing [binary synchronization tests](../IPTables.Net.Tests/IpTablesBinarySyncChainTests.cs) therefore do not exercise these branches. The system suite's invalid-table test provides limited real failure coverage, not this matrix. + +## Tests to add + +- Extend a process fake to configure exit codes, stdout/stderr, start failures, and disposal observation. +- Exercise add, replace, delete, and flush-before-delete with exit codes 0, 1, 2, and an unexpected value; assert exception context and command ordering. +- Test listing with empty output, stderr, nonzero exit, and partial output. Define whether unsuccessful partial dumps must be rejected. +- Distinguish missing-chain behavior from permission/tool failures in `HasChain`; decide the intended contract before encoding current broad exception suppression. + +## Completion + +Add `BinaryAdapterFailureTests.cs` under `IPTables.Net.Tests/`. Every configured failure must produce the intended caller-visible result, and processes must be disposed. Run the fast suite without invoking real iptables. + +## Implementation record + +Added configurable process responses and disposal assertions; covered mutation/listing failures and flush ordering. Preserve stderr and reject failed partial dumps. HasChain now suppresses only recognizable missing-chain errors. + +Validation: Fast suite: 395 passed, 15 skipped; compiler required DOTNET_PROCESSOR_COUNT=2 and a 1 GiB GC limit on this host. diff --git a/iptables-plans/README.md b/iptables-plans/README.md new file mode 100644 index 0000000..040f427 --- /dev/null +++ b/iptables-plans/README.md @@ -0,0 +1,36 @@ +# Testing gap plans + +Reviewed the managed implementation, xUnit suite, test doubles, native integration boundaries, and test runner on 2026-10-11. Each numbered document describes one actionable coverage gap. These are source-review findings, not a measured line/branch coverage report or a claim that every missing case has been enumerated. Suspected defects are identified as review observations until reproduced. + +Baseline: `bash ./test.sh --fast` from the repository root passed with **386 passed, 15 skipped, 0 failed (401 total)**. Native dependencies were absent, so the helper build was skipped. Full/system tests were not run. Direct `./test.sh --fast` failed with permission denied; the tracked script mode is `100644` (see gap 24). + +All plans are open. P1 means prioritize because the gap can conceal incorrect firewall changes, state loss, or native resource problems; P2 means important correctness or compatibility coverage. Existing happy-path tests should remain. Add assertions against independently specified results rather than simply replaying output through the same parser. + +| Gap | Priority | Scope | +| --- | --- | --- | +| [01](01-binary-process-failures.md) | P1 | Binary adapter process failures | +| [02](02-restore-commit-process.md) | P1 | Real restore commit execution | +| [03](03-restore-transaction-lifecycle.md) | P1 | Restore transaction reuse and fallback | +| [04](04-restore-output-integrity.md) | P1 | Restore stream failures and quoted payloads | +| [05](05-ruleset-sync-retries.md) | P1 | Whole-ruleset synchronization and retries | +| [06](06-rule-sync-protection.md) | P1 | Deletion predicates and custom equality | +| [07](07-ipset-process-transactions.md) | P1 | Real ipset restore and transaction execution | +| [08](08-ipset-sync-modes.md) | P1 | Set modes, deletion filters, and replacement | +| [09](09-ipset-entry-identity.md) | P1 | Entry equality, hashing, and collection membership | +| [10](10-ipset-parser-fidelity.md) | P2 | Set/entry parser fidelity and invalid input | +| [11](11-save-parser-errors.md) | P1 | Save parser framing and recovery | +| [12](12-rule-parser-invalid-input.md) | P2 | Invalid commands and argument tokenization | +| [13](13-managed-ipv6.md) | P1 | Managed IPv6 parsing and adapter selection | +| [14](14-native-ipv6.md) | P1 | IPv6 native helper integration | +| [15](15-native-handle-lifecycle.md) | P1 | Native handle and transaction cleanup | +| [16](16-conntrack-behavior.md) | P1 | Conntrack data integrity and failure cleanup | +| [17](17-nfacct-client.md) | P1 | Accounting XML and commands | +| [18](18-route-rule-controllers.md) | P1 | Route/rule assertions and command errors | +| [19](19-ip-object-sync.md) | P1 | Route/rule reconciliation and object identity | +| [20](20-address-port-boundaries.md) | P2 | CIDR and address/port boundaries | +| [21](21-rule-generator-boundaries.md) | P2 | Generator limits and semantic preservation | +| [22](22-ruleset-clone-isolation.md) | P2 | Deep clone fidelity and independence | +| [23](23-capability-detection.md) | P2 | Version parsing and SYNPROXY detection | +| [24](24-test-runner-contracts.md) | P2 | Runner modes, cleanup, and CI parity | + +For managed plans, validate with `bash ./test.sh --fast`; once executable permissions are corrected, use the documented `./test.sh --fast`. Native plans specify separate Linux prerequisites. Use disposable hosts or isolated network namespaces for tests that mutate networking. Do not assume libiptc supports the nft backend. Shared process-fake improvements in gaps 01, 02, and 07 can be implemented once and reused. From b9990006f99942749da0a875287785bed5bf3c69 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:45:38 +0000 Subject: [PATCH 02/28] test: implement testing gap 02 - restore-commit-process Exercise the production restore commit with configurable process responses, exact input and disposal assertions. Preserve stderr for every exit code, attach failed-rule context, reject failed save output, and accept patched help on stdout or stderr. Validation: Fast suite: 407 passed, 15 skipped. --- .../RestoreAdapterCommitTests.cs | 58 ++++++++++++ .../Client/IPTablesRestoreAdapterClient.cs | 91 +++++-------------- iptables-plans/02-restore-commit-process.md | 25 +++++ 3 files changed, 104 insertions(+), 70 deletions(-) create mode 100644 IPTables.Net.Tests/RestoreAdapterCommitTests.cs create mode 100644 iptables-plans/02-restore-commit-process.md diff --git a/IPTables.Net.Tests/RestoreAdapterCommitTests.cs b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs new file mode 100644 index 0000000..79c7bd0 --- /dev/null +++ b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs @@ -0,0 +1,58 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter; + +namespace IPTables.Net.Tests; +public class RestoreAdapterCommitTests +{ + [Theory] + [InlineData(0, "")] [InlineData(1, "line 2 failed")] [InlineData(1, "line 1 failed")] + [InlineData(1, "line 999 failed")] [InlineData(1, "general failure")] + [InlineData(2, "invalid option")] [InlineData(9, "unknown failure")] + public void ProductionCommitReportsExitAndInput(int exit, string error) + { + var os = new ScriptedSystem { Respond = (_, _) => new("", error, exit) }; + var system = new IpTablesSystem(os, new IPTablesRestoreAdapter()); + using var client = system.GetTableAdapter(4); + client.StartTransaction(); + client.AddRule("-A INPUT -j ACCEPT"); + try + { + if (exit == 0) client.EndTransactionCommit(); + else + { + var ex = Assert.Throws(() => client.EndTransactionCommit()); + Assert.Contains(error, ex.Message); + if (error == "line 2 failed") Assert.Contains("-A INPUT -j ACCEPT", ex.Message); + } + var call = Assert.Single(os.Calls); + Assert.Equal("iptables-restore", call.Binary); + Assert.Equal("--noflush --noclear", call.Arguments); + Assert.Equal("*filter\n-A INPUT -j ACCEPT\nCOMMIT\n\n", call.Text.Replace("\r", "")); + Assert.False(call.Input.CanWrite); + Assert.True(call.Process.IsDisposed); + } + finally { client.EndTransactionRollback(); } + } + [Theory] + [InlineData("--noclear", "", 0, true)] [InlineData("", "--noclear", 0, true)] + [InlineData("", "", 0, false)] [InlineData("--noclear", "denied", 1, false)] + public void PatchedBinaryCheck(string output, string error, int exit, bool success) + { + var os = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; + var adapter = new IPTablesRestoreAdapter(); + var system = new IpTablesSystem(os, adapter); + if (success) adapter.CheckBinary(system, 4); + else Assert.Throws(() => adapter.CheckBinary(system, 4)); + Assert.True(Assert.Single(os.Calls).Process.IsDisposed); + } + [Fact] + public void StartFailureCanBeRolledBack() + { + var os = new ScriptedSystem { Respond = (_, _) => throw new InvalidOperationException("start") }; + using var client = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.StartTransaction(); client.AddRule("-A INPUT -j ACCEPT"); + Assert.Throws(() => client.EndTransactionCommit()); + client.EndTransactionRollback(); + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs index 4f2ae5e..35ffbcb 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -53,7 +53,7 @@ public void CheckBinary() { string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - if (!error.Contains(NoClearOption)) + if (process.ExitCode != 0 || !(output + error).Contains(NoClearOption)) throw new IpTablesNetException( "iptables-restore client is not compiled from patched source (patch-iptables-restore.diff)"); } @@ -183,6 +183,7 @@ public override IpTablesChainSet ListRules(string table) { string toEnd, error; ProcessHelper.ReadToEnd(process, out toEnd, out error); + if (process.ExitCode != 0) throw new IpTablesNetException(error); return IPTablesSaveParser.GetRulesFromOutput(_system, toEnd, table, _ipVersion); } } @@ -196,78 +197,28 @@ public override void StartTransaction() public override void EndTransactionCommit() { if (!_inTransaction) return; - - using (var process = StartProcess(_iptablesRestoreBinary, NoFlushOption + " " + NoClearOption)) + using var buffer = new MemoryStream(); + using (var writer = new StreamWriter(buffer, new System.Text.UTF8Encoding(false), 1024, true)) { - if (_builder.WriteOutput(process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - - //OK - if (process.ExitCode != 0) - { - //ERR: INVALID COMMAND LINE - if (process.ExitCode == 2) - { - var ms = new MemoryStream(); - var sw = new StreamWriter(ms); - _builder.WriteOutput(sw); - sw.Flush(); - ms.Seek(0, SeekOrigin.Begin); - var sr = new StreamReader(ms); - Log.Error("Error invalid command line: {error}", sr.ReadToEnd()); - throw new IpTablesNetException( - "IpTables-Restore execution failed: Invalid Command Line - " + - process.StandardError.ReadToEnd()); - } - - //ERR: GENERAL ERROR - if (process.ExitCode == 1) - { - Log.Error("An General Error Occured: {error}", error); - - var ms = new MemoryStream(); - var sw = new StreamWriter(ms); - _builder.WriteOutput(sw); - sw.Flush(); - ms.Seek(0, SeekOrigin.Begin); - var sr = new StreamReader(ms); - var rules = sr.ReadToEnd(); - - var r = new Regex("line ([0-9]+) failed"); - if (r.IsMatch(error)) - { - var m = r.Match(error); - var g = m.Groups[1]; - var i = int.Parse(g.Value); - - throw new IpTablesNetException("IpTables-Restore failed to parse rule: " + - rules.Split(new char[] {'\n'}) - .Skip(i - 1) - .FirstOrDefault()); - } - - throw new IpTablesNetException("IpTables-Restore execution failed: Error"); - } - - //ERR: UNKNOWN - throw new IpTablesNetException("IpTables-Restore execution failed: Unknown Error"); - } - } - - try - { - process.Close(); - } - catch + _builder.WriteOutput(writer); + writer.Flush(); + } + var rules = System.Text.Encoding.UTF8.GetString(buffer.ToArray()); + if (rules.Length != 0) + { + using var process = StartProcess(_iptablesRestoreBinary, NoFlushOption + " " + NoClearOption); + process.StandardInput.Write(rules); + process.StandardInput.Close(); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode != 0) { + var line = Regex.Match(error, @"line ([0-9]+) failed"); + string context = ""; + if (line.Success && int.TryParse(line.Groups[1].Value, out var number)) + context = rules.Split('\n').ElementAtOrDefault(number - 1) ?? ""; + throw new IpTablesNetException($"IpTables-Restore execution failed (exit {process.ExitCode}): {error} {context}".Trim()); } } - - _inTransaction = false; } diff --git a/iptables-plans/02-restore-commit-process.md b/iptables-plans/02-restore-commit-process.md new file mode 100644 index 0000000..d482ee9 --- /dev/null +++ b/iptables-plans/02-restore-commit-process.md @@ -0,0 +1,25 @@ +# Gap 02: Real restore commit execution + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[MockIpTablesRestoreAdapterClient.EndTransactionCommit](../IPTables.Net.TestFramework/IpTablesRestore/MockIpTablesRestoreAdapterClient.cs) replaces production commit with serialization to memory. Consequently, [restore synchronization tests](../IPTables.Net.Tests/IpTablesRestoreSyncTests.cs) miss process startup, stdin completion, exit-code handling, and diagnostics in [IPTablesRestoreAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs). + +## Tests to add + +- Use the production client through `IPTablesRestoreAdapter` with an injectable `ISystemFactory` fake; do not override commit. +- Assert selected executable, `--noflush --noclear`, exact stdin content, closing stdin before waiting, and process disposal. +- Cover exits 0, 1 with and without `line N failed`, 2, and unexpected codes. Verify the reported rule for first/last/out-of-range line numbers. +- Preserve meaningful stderr in command-line failures; the implementation currently reads stderr again after `ReadToEnd` has consumed it. +- Exercise `CheckBinary` with and without the required patched option, and process start/read failures. + +## Completion + +Add `RestoreAdapterCommitTests.cs`. Fast tests execute the production commit body and prove both successful delivery and useful failures. Real patched-restore compatibility remains a separate optional integration check. + +## Implementation record + +Exercise the production restore commit with configurable process responses, exact input and disposal assertions. Preserve stderr for every exit code, attach failed-rule context, reject failed save output, and accept patched help on stdout or stderr. + +Validation: Fast suite: 407 passed, 15 skipped. From 231923b60bc295b099ed0439ac9f815027d366b8 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:46:30 +0000 Subject: [PATCH 03/28] test: implement testing gap 03 - restore-transaction-lifecycle Clear successful restore transactions, prevent immediate replace/add-chain/raw-add commands from being queued, preserve flush-before-delete, and remove the throwing managed finalizer. Add reuse, rollback, failure recovery, nested-start, disposal, and IPv6 fallback tests. Validation: Fast suite: 410 passed, 15 skipped. --- .../RestoreAdapterLifecycleTests.cs | 55 +++++++++++++++++++ .../Client/IPTablesRestoreAdapterClient.cs | 20 +++++-- .../03-restore-transaction-lifecycle.md | 25 +++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs create mode 100644 iptables-plans/03-restore-transaction-lifecycle.md diff --git a/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs b/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs new file mode 100644 index 0000000..45a1350 --- /dev/null +++ b/IPTables.Net.Tests/RestoreAdapterLifecycleTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +namespace IPTables.Net.Tests; +public class RestoreAdapterLifecycleTests +{ + [Fact] + public void CommitRollbackAndFailureDoNotReplayOldCommands() + { + var os = new ScriptedSystem(); + using var client = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.EndTransactionCommit(); client.EndTransactionRollback(); + client.StartTransaction(); client.EndTransactionCommit(); + Assert.Empty(os.Calls); + client.StartTransaction(); client.AddRule("-A INPUT -j ACCEPT"); client.EndTransactionCommit(); + client.StartTransaction(); client.AddRule("-A INPUT -j DROP"); client.EndTransactionRollback(); + client.StartTransaction(); client.AddRule("-A OUTPUT -j RETURN"); client.EndTransactionCommit(); + Assert.DoesNotContain("INPUT", os.Calls[1].Text); + os.Respond = (_, _) => new("", "denied", 1); + client.StartTransaction(); client.AddRule("-A INPUT -j DROP"); + Assert.Throws(() => client.EndTransactionCommit()); + client.EndTransactionRollback(); + os.Respond = (_, _) => new(); + client.StartTransaction(); client.AddRule("-A OUTPUT -j ACCEPT"); client.EndTransactionCommit(); + Assert.DoesNotContain("DROP", os.Calls.Last().Text); + } + [Fact] + public void NestedStartAndActiveDisposeRequireExplicitResolution() + { + using var client = new IpTablesSystem(new ScriptedSystem(), new IPTablesRestoreAdapter()).GetTableAdapter(4); + client.StartTransaction(); + Assert.Throws(() => client.StartTransaction()); + Assert.Throws(() => client.Dispose()); + client.EndTransactionRollback(); + } + [Fact] + public void ImmediateOperationsNeverLeakIntoNextTransaction() + { + var os = new ScriptedSystem(); + var system = new IpTablesSystem(os, new IPTablesRestoreAdapter()); + using var client = system.GetTableAdapter(6); + var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(6), 6); + rule.Chain.AddRule(rule); + client.AddRule(rule); client.AddRule("-A INPUT -j DROP"); client.InsertRule(rule); + client.ReplaceRule(rule); client.DeleteRule(rule); client.DeleteRule("filter", "INPUT", 1); + client.AddChain("filter", "custom"); client.DeleteChain("filter", "custom", true); + Assert.All(os.Calls, c => Assert.Equal("ip6tables", c.Binary)); + Assert.Equal(new[] { "-t filter -F custom", "-t filter -X custom" }, os.Calls.TakeLast(2).Select(c => c.Arguments)); + client.StartTransaction(); client.AddRule("-A OUTPUT -j RETURN"); client.EndTransactionCommit(); + Assert.Equal("*filter\n-A OUTPUT -j RETURN\nCOMMIT\n\n", os.Calls.Last().Text.Replace("\r", "")); + Assert.Equal("ip6tables-restore", os.Calls.Last().Binary); + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs index 35ffbcb..0a95f7d 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs @@ -109,6 +109,7 @@ public override void ReplaceRule(IpTablesRule rule) //Revert to using IPTables Binary if non transactional var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); binaryClient.ReplaceRule(rule); + return; } var command = rule.GetActionCommand("-R", false, true); @@ -131,6 +132,11 @@ public override void AddRule(IpTablesRule rule) public override void AddRule(string command) { + if (!_inTransaction) + { + new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary).AddRule(command); + return; + } var table = ExtractTable(command); _builder.AddCommand(table, command); } @@ -160,6 +166,7 @@ public override void AddChain(string table, string chainName) //Revert to using IPTables Binary if non transactional var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); binaryClient.AddChain(table, chainName); + return; } _builder.AddChain(table, chainName); @@ -169,12 +176,16 @@ public override void DeleteChain(string table, string chainName, bool flush = fa { if (_inTransaction) { - _builder.DeleteChain(table, chainName); + if (!_builder.DeleteChain(table, chainName)) + { + if (flush) _builder.AddCommand(table, "-F " + chainName); + _builder.AddCommand(table, "-X " + chainName); + } return; } var binaryClient = new IPTablesBinaryAdapterClient(_ipVersion, _system, _iptablesBinary); - binaryClient.DeleteChain(table, chainName); + binaryClient.DeleteChain(table, chainName, flush); } public override IpTablesChainSet ListRules(string table) @@ -219,6 +230,7 @@ public override void EndTransactionCommit() throw new IpTablesNetException($"IpTables-Restore execution failed (exit {process.ExitCode}): {error} {context}".Trim()); } } + _builder.Clear(); _inTransaction = false; } @@ -228,10 +240,6 @@ public override void EndTransactionRollback() _inTransaction = false; } - ~IPTablesRestoreAdapterClient() - { - Dispose(); - } public override void Dispose() diff --git a/iptables-plans/03-restore-transaction-lifecycle.md b/iptables-plans/03-restore-transaction-lifecycle.md new file mode 100644 index 0000000..fb22142 --- /dev/null +++ b/iptables-plans/03-restore-transaction-lifecycle.md @@ -0,0 +1,25 @@ +# Gap 03: Restore transaction reuse and binary fallback + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IPTablesRestoreAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs) maintains a persistent builder and transaction flag. Commit does not explicitly clear the builder. Nontransactional `ReplaceRule` and `AddChain` fall through after binary execution, and the raw-string `AddRule` always queues. Existing restore tests use a fresh client for one successful synchronization. + +## Tests to add + +- Commit transaction A, then B on the same client; independently assert that B contains only its own commands. +- Roll back queued work, then commit new work; assert no rolled-back commands execute. +- Cover nested start, empty transaction, commit/rollback without start, and explicit disposal while active. +- After a failed commit, roll back and reuse the client; establish its recovery contract without assuming kernel changes were undone. +- Compare each rule/chain operation inside and outside a transaction, including raw-string add and `DeleteChain(flush: true)`. Follow an immediate operation with a transaction to detect unintended queued work. + +## Completion + +Add `RestoreAdapterLifecycleTests.cs` using production methods and process fakes. Assert external command logs and transaction outcomes in the fast suite. Treat the observations above as regression candidates, not verified failures. + +## Implementation record + +Clear successful restore transactions, prevent immediate replace/add-chain/raw-add commands from being queued, preserve flush-before-delete, and remove the throwing managed finalizer. Add reuse, rollback, failure recovery, nested-start, disposal, and IPv6 fallback tests. + +Validation: Fast suite: 410 passed, 15 skipped. From d495feb8e65885ca015aae8051aeca85db7fb78e Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:47:19 +0000 Subject: [PATCH 04/28] test: implement testing gap 04 - restore-output-integrity Normalize syntactic single quotes without corrupting apostrophes inside double-quoted payloads. Reject unterminated quotes and propagate restore stream failures. Cover Unicode, escaped quotes, backslashes, empty arguments, per-table framing, duplicate chains, and writes failing at each boundary. Validation: Fast suite passed; native restore syntax compatibility remains covered by the separate integration work. --- .../RestoreTableBuilderTests.cs | 53 +++++++++++++++++++ .../Helper/IPTablesRestoreTableBuilder.cs | 44 +++++++++++---- iptables-plans/04-restore-output-integrity.md | 24 +++++++++ 3 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 IPTables.Net.Tests/RestoreTableBuilderTests.cs create mode 100644 iptables-plans/04-restore-output-integrity.md diff --git a/IPTables.Net.Tests/RestoreTableBuilderTests.cs b/IPTables.Net.Tests/RestoreTableBuilderTests.cs new file mode 100644 index 0000000..6e4abb6 --- /dev/null +++ b/IPTables.Net.Tests/RestoreTableBuilderTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Text; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter.Client.Helper; +namespace IPTables.Net.Tests; +public class RestoreTableBuilderTests +{ + [Theory] + [InlineData("'two words'", "\"two words\"")] + [InlineData("\"O'Brien\"", "\"O'Brien\"")] + [InlineData("'say \"hi\"'", "\"say \\\"hi\\\"\"")] + [InlineData("''", "\"\"")] + [InlineData("'λ\\path'", "\"λ\\\\path\"")] + public void QuotesPreserveLiteralPayload(string input, string expected) + { + var builder = new IPTablesRestoreTableBuilder(); + builder.AddCommand("filter", "-A INPUT -m comment --comment " + input); + Assert.Contains("--comment " + expected + "\n", Render(builder)); + } + [Fact] + public void TablesChainsAndClearHaveExactFraming() + { + var builder = new IPTablesRestoreTableBuilder(); + builder.AddChain("filter", "INPUT"); builder.AddChain("nat", "custom"); + Assert.Throws(() => builder.AddChain("filter", "INPUT")); + Assert.Equal("*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n\n*nat\n:custom - [0:0]\nCOMMIT\n\n", Render(builder)); + builder.Clear(); Assert.False(builder.HasChain("filter", "INPUT")); Assert.Equal("", Render(builder)); + Assert.Throws(() => builder.AddCommand("filter", "-A INPUT --comment 'broken")); + } + [Theory] + [InlineData(0)] [InlineData(8)] [InlineData(30)] [InlineData(50)] + public void FailureAtAnyOutputBoundaryThrows(int limit) + { + var builder = new IPTablesRestoreTableBuilder(); builder.AddChain("filter", "INPUT"); + builder.AddCommand("filter", "-A INPUT -j ACCEPT"); + var stream = new FailingStream(limit); + var writer = new StreamWriter(stream, new UTF8Encoding(false)); + Assert.Throws(() => builder.WriteOutput(writer)); + } + private static string Render(IPTablesRestoreTableBuilder builder) + { + using var stream = new MemoryStream(); using var writer = new StreamWriter(stream, new UTF8Encoding(false)); + builder.WriteOutput(writer); writer.Flush(); return Encoding.UTF8.GetString(stream.ToArray()).Replace("\r", ""); + } + private sealed class FailingStream(int limit) : MemoryStream + { + public override void Write(byte[] buffer, int offset, int count) + { if (Length + count > limit) throw new IOException("broken pipe"); base.Write(buffer, offset, count); } + public override void Write(ReadOnlySpan buffer) + { if (Length + buffer.Length > limit) throw new IOException("broken pipe"); base.Write(buffer); } + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs index 946bff4..3f23783 100644 --- a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs +++ b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -35,25 +35,47 @@ public void AddCommand(string table, string ruleCommand) var commandTable = _tables[table]; //iptables-restore doesnt support ' quotes - ruleCommand = ruleCommand.Replace('\'', '"'); + ruleCommand = NormalizeQuotes(ruleCommand); commandTable.Commands.Add(ruleCommand); } - private bool WriteOutputLine(StreamWriter output, string line) + private static string NormalizeQuotes(string value) { - if (!output.BaseStream.CanWrite) return false; - try - { - output.WriteLine(line); - output.Flush(); - } - catch (IOException) + var output = new StringBuilder(); + char quote = '\0'; + for (int i = 0; i < value.Length; i++) { - return false; + char c = value[i]; + if (quote == '\0') + { + if (c == '\'' || c == '"') quote = c; + output.Append(c == '\'' ? '"' : c); + } + else if (quote == '"') + { + output.Append(c); + if (c == '\\' && i + 1 < value.Length) output.Append(value[++i]); + else if (c == '"') quote = '\0'; + } + else if (c == '\'') { output.Append('"'); quote = '\0'; } + else + { + if (c == '\\' && i + 1 < value.Length && (value[i + 1] == '\'' || value[i + 1] == '\\')) c = value[++i]; + if (c == '"' || c == '\\') output.Append('\\'); + output.Append(c); + } } + if (quote != '\0') throw new IpTablesNetException("Unterminated quoted restore argument"); + return output.ToString(); + } + private bool WriteOutputLine(StreamWriter output, string line) + { + if (!output.BaseStream.CanWrite) throw new IOException("Restore stream is not writable"); + output.WriteLine(line); + output.Flush(); return true; } diff --git a/iptables-plans/04-restore-output-integrity.md b/iptables-plans/04-restore-output-integrity.md new file mode 100644 index 0000000..7336fdd --- /dev/null +++ b/iptables-plans/04-restore-output-integrity.md @@ -0,0 +1,24 @@ +# Gap 04: Restore stream failures and quoted payloads + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IPTablesRestoreTableBuilder](../IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs) replaces every apostrophe with a double quote and returns `true` from several failed-write paths. The [existing quote test](../IPTables.Net.Tests/IpTablesRestoreSyncTests.cs) only covers a space-containing comment. It cannot detect payload corruption or truncated restore input reported as successful output. + +## Tests to add + +- Preserve comment payloads containing apostrophes, literal double quotes, backslashes, empty text, and Unicode. Assert literal expected output and recovered payload separately. +- Inject nonwritable streams and write/flush exceptions at the table header, chain declaration, rule, and `COMMIT` boundaries. +- Assert that incomplete serialization is surfaced to the caller and cannot be reported as a successful commit; resolve the ambiguous boolean contract before adding assertions. +- Cover multiple tables, per-table `COMMIT`, built-in versus custom-chain declarations, duplicate chain rejection, and clear/rebuild behavior. + +## Completion + +Add `RestoreTableBuilderTests.cs` and connect stream failure coverage to gap 02. Run the fast suite. A deliberately truncated stream or changed comment must fail a meaningful assertion. + +## Implementation record + +Normalize syntactic single quotes without corrupting apostrophes inside double-quoted payloads. Reject unterminated quotes and propagate restore stream failures. Cover Unicode, escaped quotes, backslashes, empty arguments, per-table framing, duplicate chains, and writes failing at each boundary. + +Validation: Fast suite passed; native restore syntax compatibility remains covered by the separate integration work. From c6c8f0b144c3b94a1c203ec13c14a77d2b5d0a50 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:48:23 +0000 Subject: [PATCH 05/28] test: implement testing gap 05 - ruleset-sync-retries Add direct whole-ruleset orchestration tests for multi-table creation, references, selective deletion, no-op updates, bounded retries, and original error preservation. Rebuild pending chain state on every attempt, reject negative retry budgets, and roll back commit failures without masking errors. Validation: Fast suite passed. Native ordered-commit ownership is validated in plan 15. --- IPTables.Net.Tests/RecordingAdapter.cs | 31 +++++ IPTables.Net.Tests/RuleSetSyncTests.cs | 49 ++++++++ IPTables.Net/Iptables/IpTablesRuleSet.cs | 147 +++++----------------- iptables-plans/05-ruleset-sync-retries.md | 25 ++++ 4 files changed, 136 insertions(+), 116 deletions(-) create mode 100644 IPTables.Net.Tests/RecordingAdapter.cs create mode 100644 IPTables.Net.Tests/RuleSetSyncTests.cs create mode 100644 iptables-plans/05-ruleset-sync-retries.md diff --git a/IPTables.Net.Tests/RecordingAdapter.cs b/IPTables.Net.Tests/RecordingAdapter.cs new file mode 100644 index 0000000..23b2fb9 --- /dev/null +++ b/IPTables.Net.Tests/RecordingAdapter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client; +namespace IPTables.Net.Tests; +internal sealed class RecordingAdapter : IpTablesAdapterClientBase, IIPTablesAdapter +{ + public readonly List Log = new(); + public Action OnCall = _ => { }; + public Func Read; + public IpTablesSystem System; + public int Version; + public IIPTablesAdapterClient GetClient(IpTablesSystem system, int ipVersion = 4) { System = system; Version = ipVersion; return this; } + private void Call(string text) { Log.Add(text); OnCall(text); } + public override void StartTransaction() => Call("start"); + public override void EndTransactionCommit() => Call("commit"); + public override void EndTransactionRollback() => Call("rollback"); + public override void Dispose() => Call("dispose"); + public override bool HasChain(string table, string chainName) => ListRules(table).HasChain(chainName, table); + public override void AddChain(string table, string chainName) => Call($"create {table}/{chainName}"); + public override void DeleteChain(string table, string chainName, bool flush = false) => Call($"delete {table}/{chainName}"); + public override IpTablesChainSet ListRules(string table) { Call("read " + table); return Read?.Invoke(table) ?? new(Version); } + public override void DeleteRule(string table, string chainName, int position) => Call($"remove {table}/{chainName}/{position}"); + public override void DeleteRule(IpTablesRule rule) => Call("remove " + rule.GetActionCommand()); + public override void InsertRule(IpTablesRule rule) => Call("insert " + rule.GetActionCommand()); + public override void ReplaceRule(IpTablesRule rule) => Call("replace " + rule.GetActionCommand()); + public override void AddRule(IpTablesRule rule) => Call("add " + rule.GetActionCommand()); + public override void AddRule(string rule) => Call("add " + rule); + public override System.Version GetIptablesVersion() => new(1, 8, 0); +} diff --git a/IPTables.Net.Tests/RuleSetSyncTests.cs b/IPTables.Net.Tests/RuleSetSyncTests.cs new file mode 100644 index 0000000..cc93da2 --- /dev/null +++ b/IPTables.Net.Tests/RuleSetSyncTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.TableSync; +namespace IPTables.Net.Tests; +public class RuleSetSyncTests +{ + [Theory] + [InlineData("create filter/B")] [InlineData("add -A A -j B")] [InlineData("commit")] + public void RetryRediscoversWithoutRetainingPendingChains(string failAt) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var desired = new IpTablesRuleSet(4, new[] { "-A A -j B", "-A B -j ACCEPT", "-A X -t nat -j RETURN" }, system); + bool failed = false; + adapter.OnCall = call => { if (!failed && call == failAt) { failed = true; throw new IpTablesNetExceptionErrno("retry", 11); } }; + desired.Sync(new DefaultRuleSync(), maxRetries: 1); + Assert.True(failed); Assert.Equal(2, adapter.Log.Count(x => x == "read filter")); + Assert.Single(adapter.Log.Where(x => x == "rollback")); + var retry = adapter.Log.Skip(adapter.Log.IndexOf("rollback") + 1).ToList(); + Assert.Single(retry.Where(x => x == "create filter/A")); + Assert.True(retry.IndexOf("create filter/B") < retry.IndexOf("add -A A -j B")); + Assert.Contains("create nat/X", retry); + } + [Theory] + [InlineData(0, 11)] [InlineData(1, 11)] [InlineData(2, 5)] + public void RetriesAreBoundedAndOriginalFailureSurvivesRollback(int retries, int errno) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var failure = new IpTablesNetExceptionErrno("original", errno); + adapter.OnCall = c => { if (c == "commit") throw failure; if (c == "rollback") throw new Exception("rollback"); }; + Assert.Same(failure, Assert.Throws(() => new IpTablesRuleSet(4, system).Sync(new DefaultRuleSync(), maxRetries: retries))); + Assert.Equal(errno == 11 ? retries + 1 : 1, adapter.Log.Count(x => x == "commit")); + Assert.Equal("dispose", adapter.Log.Last()); + Assert.Throws(() => new IpTablesRuleSet(4, system).Sync(new DefaultRuleSync(), maxRetries: -1)); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] + public void ExistingRulesAndDeletionPolicy(int mode) + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var desired = new IpTablesRuleSet(4, new[] { "-A A -j ACCEPT" }, system); + adapter.Read = _ => new IpTablesRuleSet(4, new[] { "-A A -j ACCEPT", "-A old -j RETURN", "-A foreign -j RETURN" }, system).Chains; + Func delete = mode == 0 ? null : c => mode == 2 && c.Name == "old"; + desired.Sync(new DefaultRuleSync(), delete); desired.Sync(new DefaultRuleSync(), delete); + Assert.DoesNotContain(adapter.Log, x => x.StartsWith("add ") || x.StartsWith("replace ") || x.Contains("delete filter/foreign")); + Assert.Equal(mode == 2 ? 2 : 0, adapter.Log.Count(x => x == "delete filter/old")); + } +} diff --git a/IPTables.Net/Iptables/IpTablesRuleSet.cs b/IPTables.Net/Iptables/IpTablesRuleSet.cs index ac55d33..580a781 100644 --- a/IPTables.Net/Iptables/IpTablesRuleSet.cs +++ b/IPTables.Net/Iptables/IpTablesRuleSet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Sockets; @@ -148,128 +148,43 @@ public IpTablesChain AddChain(string name, string table) public void Sync(IRuleSync sync, Func canDeleteChain = null, int maxRetries = 10) { - using (var client = _system.GetTableAdapter(_ipVersion)) + if (maxRetries < 0) throw new ArgumentOutOfRangeException(nameof(maxRetries)); + using var client = _system.GetTableAdapter(_ipVersion); + for (int attempt = 0; ; attempt++) { - var chainsToAdd = new List(); - bool needed; - var retries = maxRetries; - - do + try { - try + client.StartTransaction(); + var tables = Chains.Select(c => c.Table).Distinct().ToList(); + var current = tables.ToDictionary(t => t, t => _system.GetChains(client, t).ToList()); + foreach (var chain in Chains) + if (!current[chain.Table].Any(c => c.Name == chain.Name)) + current[chain.Table].Add(_system.AddChain(client, chain)); + if (client is IPTablesLibAdapterClient) { - //Start transaction - client.StartTransaction(); - try - { - //Load all chains, figure out what to add - var tableChains = new Dictionary>(); - foreach (var chain in Chains) - { - if (!tableChains.ContainsKey(chain.Table)) - { - var chains = _system.GetChains(client, chain.Table).ToList(); - tableChains.Add(chain.Table, chains); - } - - if ( - tableChains[chain.Table].FirstOrDefault( - a => a.Name == chain.Name && a.Table == chain.Table) == - null) - //Chain doesnt exist, to create - chainsToAdd.Add(chain); - } - - //Add the new chains / rules - foreach (var chain in chainsToAdd) - tableChains[chain.Table].Add(_system.AddChain(client, chain)); - - chainsToAdd.Clear(); - - //Special case - if (client is IPTablesLibAdapterClient) - { - //Sync chain adds before starting rule adds - client.EndTransactionCommit(); - client.StartTransaction(); - } - - //Update chains with differing rules - foreach (var chain in Chains) - { - var realChain = - tableChains[chain.Table].First(a => a.Name == chain.Name && a.Table == chain.Table); - if (realChain != null) - //Update chain - realChain.SyncInternal(client, chain.Rules, sync); - } - } - catch - { - try - { - client.EndTransactionRollback(); - } - catch - { - } - - throw; - } - - //End Transaction: COMMIT client.EndTransactionCommit(); - - if (canDeleteChain != null) - { - //Start transaction - //Needs new transaction, bug in libiptc? - client.StartTransaction(); - - try - { - foreach (var table in Chains.Select(a => a.Table).Distinct()) - foreach (var chain in _system.GetChains(table, _ipVersion)) - if (!_chains.HasChain(chain.Name, chain.Table) && canDeleteChain(chain)) - chain.Delete(client); - } - catch - { - try - { - client.EndTransactionRollback(); - } - catch - { - } - - throw; - } - - //End Transaction: COMMIT - if (client is IPTablesLibAdapterClient) - (client as IPTablesLibAdapterClient).EndTransactionCommit(sync.TableOrder); - else - client.EndTransactionCommit(); - } - - needed = false; + client.StartTransaction(); } - catch (IpTablesNetExceptionErrno ex) + foreach (var chain in Chains) + current[chain.Table].First(c => c.Name == chain.Name).SyncInternal(client, chain.Rules, sync); + client.EndTransactionCommit(); + if (canDeleteChain != null) { - client.EndTransactionRollback(); - if (ex.Errno == 11 && retries != 0) //Resource Temporarily unavailable - { - Thread.Sleep(100 * (maxRetries - retries)); - retries--; - needed = true; - } - else - { - throw; - } + client.StartTransaction(); + foreach (var table in tables) + foreach (var chain in _system.GetChains(client, table)) + if (!_chains.HasChain(chain.Name, table) && canDeleteChain(chain)) chain.Delete(client); + if (client is IPTablesLibAdapterClient native) native.EndTransactionCommit(sync.TableOrder); + else client.EndTransactionCommit(); } - } while (needed); + return; + } + catch (Exception ex) + { + try { client.EndTransactionRollback(); } catch { /* Preserve the original failure. */ } + if (ex is not IpTablesNetExceptionErrno error || error.Errno != 11 || attempt >= maxRetries) throw; + Thread.Sleep(100 * attempt); + } } } diff --git a/iptables-plans/05-ruleset-sync-retries.md b/iptables-plans/05-ruleset-sync-retries.md new file mode 100644 index 0000000..e866cdd --- /dev/null +++ b/iptables-plans/05-ruleset-sync-retries.md @@ -0,0 +1,25 @@ +# Gap 05: Whole-ruleset synchronization and retry recovery + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IpTablesRuleSet.Sync](../IPTables.Net/Iptables/IpTablesRuleSet.cs) coordinates chain discovery/creation, transactions, cleanup, errno-11 retries, and a native-adapter special case. [MockIptablesSystemFactory.TestSync](../IPTables.Net.TestFramework/MockIptablesSystemFactory.cs) synchronizes only the first chain. [IpTablesRuleSetTests](../IPTables.Net.Tests/IpTablesRuleSetTests.cs) tests construction rather than this orchestration. + +## Tests to add + +- Exercise `IpTablesRuleSet.Sync` directly with recording adapters across multiple chains and tables, including references to newly created chains. +- Inject errno 11 once and repeatedly; assert fresh discovery, bounded attempts, rollback, eventual success, and exhaustion. Cover `maxRetries = 0` and define rejection of negative values. +- Inject nonretryable errors and errors during chain creation, rule updates, commit, and deletion. Assert no accidental duplicate creation or masked original exception. +- Cover absent/false/selective `canDeleteChain`, preservation of unrelated chains, empty desired rulesets, and a second no-op synchronization. +- Verify the native adapter's separate chain-creation commit and configured deletion-table order using a suitable seam or isolated integration fixture. + +## Completion + +Add `RuleSetSyncTests.cs`; deterministic orchestration cases run fast. Native-specific commit behavior runs in the native suite. Assertions cover the complete operation sequence and resulting state, not just the first chain. + +## Implementation record + +Add direct whole-ruleset orchestration tests for multi-table creation, references, selective deletion, no-op updates, bounded retries, and original error preservation. Rebuild pending chain state on every attempt, reject negative retry budgets, and roll back commit failures without masking errors. + +Validation: Fast suite passed. Native ordered-commit ownership is validated in plan 15. From 3dc511d607979cffcacf1a677a3e37248eeb53c2 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:49:17 +0000 Subject: [PATCH 06/28] test: implement testing gap 06 - rule-sync-protection Protect rules against replacement as well as deletion, materialize desired sequences once, and replace at the actual current-chain position with model rollback on command failure. Test protected rules at every position through both binary and restore clients, convergence, empty desired rules, duplicate rules, and custom equality. Validation: Fast suite passed. --- IPTables.Net.Tests/RuleSyncProtectionTests.cs | 45 +++++++++++++++++++ IPTables.Net/Iptables/IpTablesRule.cs | 9 ++-- .../Iptables/TableSync/DefaultRuleSync.cs | 5 ++- iptables-plans/06-rule-sync-protection.md | 24 ++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 IPTables.Net.Tests/RuleSyncProtectionTests.cs create mode 100644 iptables-plans/06-rule-sync-protection.md diff --git a/IPTables.Net.Tests/RuleSyncProtectionTests.cs b/IPTables.Net.Tests/RuleSyncProtectionTests.cs new file mode 100644 index 0000000..a69dd08 --- /dev/null +++ b/IPTables.Net.Tests/RuleSyncProtectionTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Modules.Comment; +using IPTables.Net.Iptables.TableSync; +namespace IPTables.Net.Tests; +public class RuleSyncProtectionTests +{ + [Theory] + [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)] + [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)] + public void ProtectedRulesSurviveChangesAndConverge(int position, bool restore) + { + var os = new ScriptedSystem(); + var system = new IpTablesSystem(os, restore ? new IPTablesRestoreAdapter() : new IPTablesBinaryAdapter()); + var commands = new List { "-A INPUT -j ACCEPT", "-A INPUT -j RETURN" }; + commands.Insert(position, "-A INPUT -j DROP -m comment --comment protected"); + var current = new IpTablesRuleSet(4, commands, system).Chains.First(); + var desired = new IpTablesRuleSet(4, new[] { "-A INPUT -p tcp -j ACCEPT", "-A INPUT -p udp -j ACCEPT", "-A INPUT -j RETURN" }, system); + var sync = new DefaultRuleSync(shouldDelete: r => r.GetModule("comment")?.CommentText != "protected"); + using var client = system.GetTableAdapter(4); + current.Sync(client, desired.Rules, sync); + Assert.Single(current.Rules.Where(r => r.GetModule("comment")?.CommentText == "protected")); + Assert.Equal(desired.Rules.Select(r => r.GetActionCommand()), current.Rules.Where(sync.ShouldDelete).Select(r => r.GetActionCommand())); + int before = os.Calls.Count; current.Sync(client, desired.Rules, sync); Assert.Equal(before, os.Calls.Count); + current.Sync(client, Array.Empty(), sync); + Assert.Single(current.Rules); + } + [Fact] + public void CustomEqualityCanIgnoreCommentsAndDuplicatesRemainOrdered() + { + var adapter = new RecordingAdapter(); var system = new IpTablesSystem(null, adapter); + var current = new IpTablesRuleSet(4, new[] { "-A INPUT -j ACCEPT -m comment --comment one", "-A INPUT -j ACCEPT -m comment --comment two" }, system).Chains.First(); + var desired = new IpTablesRuleSet(4, new[] { "-A INPUT -j ACCEPT", "-A INPUT -j ACCEPT" }, system); + new DefaultRuleSync(comparer: new IgnoreComment()).SyncChainRules(adapter, desired.Rules, current); + Assert.Empty(adapter.Log); Assert.Equal(2, current.Rules.Count); + } + private sealed class IgnoreComment : IEqualityComparer + { + public bool Equals(IpTablesRule x, IpTablesRule y) => x.GetActionCommand().Split(" -m comment")[0] == y.GetActionCommand().Split(" -m comment")[0]; + public int GetHashCode(IpTablesRule obj) => 0; + } +} diff --git a/IPTables.Net/Iptables/IpTablesRule.cs b/IPTables.Net/Iptables/IpTablesRule.cs index d83ed65..5c0c7e7 100644 --- a/IPTables.Net/Iptables/IpTablesRule.cs +++ b/IPTables.Net/Iptables/IpTablesRule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -509,8 +509,11 @@ public void ReplaceRule(IIPTablesAdapterClient client, IpTablesRule withRule) if (Chain == null) throw new IpTablesNetException("Unknown Chain"); var idx = Chain.Rules.IndexOf(this); if (idx == -1) throw new IpTablesNetException("Could not find rule to replace"); - client.ReplaceRule(withRule); - Chain.Rules[idx] = withRule; + var replacement = withRule.ShallowClone(); + replacement.Chain = Chain; + Chain.Rules[idx] = replacement; + try { client.ReplaceRule(replacement); } + catch { Chain.Rules[idx] = this; throw; } } #endregion diff --git a/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs b/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs index 8bd3fa4..996dfc9 100644 --- a/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs +++ b/IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using IPTables.Net.Iptables.Adapter.Client; @@ -51,6 +51,7 @@ public void SyncChainRules(IIPTablesAdapterClient client, IEnumerable(chain.Rules); + with = with.ToList(); int i = 0, len = with.Count(); @@ -77,6 +78,8 @@ public void SyncChainRules(IIPTablesAdapterClient client, IEnumerable Date: Sun, 11 Oct 2026 00:50:16 +0000 Subject: [PATCH 07/28] test: implement testing gap 07 - ipset-process-transactions Execute real ipset restore and transaction methods with process fakes. Correct inverted restore success and missing create/timeout serialization, reject failed save output, add explicit rollback and nested-start protection, and clear transaction buffers even after failure. Cover immediate operation errors and recovery. Validation: Fast suite passed. --- IPTables.Net.Tests/IpSetAdapterTests.cs | 51 +++++++++++++ .../IpSet/Adapter/IpSetBinaryAdapter.cs | 72 +++++++------------ .../07-ipset-process-transactions.md | 24 +++++++ 3 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 IPTables.Net.Tests/IpSetAdapterTests.cs create mode 100644 iptables-plans/07-ipset-process-transactions.md diff --git a/IPTables.Net.Tests/IpSetAdapterTests.cs b/IPTables.Net.Tests/IpSetAdapterTests.cs new file mode 100644 index 0000000..0a36a81 --- /dev/null +++ b/IPTables.Net.Tests/IpSetAdapterTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.IpSet.Adapter; +namespace IPTables.Net.Tests; +public class IpSetAdapterTests +{ + [Theory] + [InlineData(0, "", true)] [InlineData(1, "", false)] [InlineData(2, "denied", false)] + public void RestoreSuccessMeansZeroExitAndIncludesFullCommands(int exit, string error, bool success) + { + var os = new ScriptedSystem { Respond = (_, _) => new("", error, exit) }; + var adapter = new IpSetBinaryAdapter(os); + var sets = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1 timeout 12" }, null); + if (error.Length == 0) Assert.Equal(success, adapter.RestoreSets(sets.Sets)); + else Assert.Throws(() => adapter.RestoreSets(sets.Sets)); + var call = Assert.Single(os.Calls); + Assert.StartsWith("create demo hash:ip", call.Text); + Assert.Contains("add demo 192.0.2.1 timeout 12", call.Text); + Assert.False(call.Input.CanWrite); Assert.True(call.Process.IsDisposed); + } + [Fact] + public void TransactionLifecycleAndFailureRecovery() + { + var os = new ScriptedSystem(); var adapter = new IpSetBinaryAdapter(os); + adapter.StartTransaction(); Assert.Throws(() => adapter.StartTransaction()); + Assert.True(adapter.EndTransactionCommit()); Assert.Empty(os.Calls); + adapter.StartTransaction(); adapter.DestroySet("old"); adapter.EndTransactionRollback(); + adapter.StartTransaction(); adapter.DestroySet("new"); adapter.EndTransactionCommit(); + Assert.Equal("destroy new\n", os.Calls.Last().Text.Replace("\r", "")); + os.Respond = (_, _) => new("", "failure", 1); + adapter.StartTransaction(); adapter.DestroySet("bad"); + Assert.Throws(() => adapter.EndTransactionCommit()); Assert.False(adapter.InTransaction); + os.Respond = (_, _) => new(); + adapter.StartTransaction(); adapter.DestroySet("last"); adapter.EndTransactionCommit(); + Assert.DoesNotContain("bad", os.Calls.Last().Text); + } + [Fact] + public void ImmediateFailuresAndFailedSavesAreReported() + { + var os = new ScriptedSystem { Respond = (_, _) => new("create partial hash:ip", "failure", 1) }; + var adapter = new IpSetBinaryAdapter(os); + var set = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, null).Sets.Single(); + foreach (Action call in new Action[] { () => adapter.CreateSet(set), () => adapter.DestroySet("demo"), + () => adapter.AddEntry(set.Entries.Single()), () => adapter.DeleteEntry(set.Entries.Single()), () => adapter.SwapSet("a", "b") }) + Assert.Throws(call); + var target = new IpSetSets(null); Assert.Throws(() => adapter.SaveSets(target)); Assert.Empty(target.Sets); + Assert.All(os.Calls, c => Assert.True(c.Process.IsDisposed)); + } +} diff --git a/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs b/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs index e988ff6..e9029e0 100644 --- a/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs +++ b/IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using SystemInteract; using IPTables.Net.Exceptions; @@ -24,51 +24,28 @@ public IpSetBinaryAdapter(ISystemFactory system) _system = system; } - private bool ExecuteTransaction() - { - string output, error; - - using (var process = _system.StartProcess(BinaryName, "restore")) - { - if (WriteStrings(_transactionCommands, process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - ProcessHelper.ReadToEnd(process, out output, out error); - - //OK - if (process.ExitCode == 0) return true; - } - else - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - error = error.Trim(); - if (error.Length != 0) - throw new IpTablesNetException(string.Format("Failed to execute transaction: {0}", error)); + private bool ExecuteTransaction() => ExecuteRestore(_transactionCommands); + private bool ExecuteRestore(IEnumerable commands) + { + using var process = _system.StartProcess(BinaryName, "restore"); + if (!WriteStrings(commands, process.StandardInput)) throw new IOException("Unable to write ipset restore input"); + process.StandardInput.Close(); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode == 0) return true; + if (!string.IsNullOrWhiteSpace(error)) throw new IpTablesNetException("Failed to execute ipset restore: " + error.Trim()); return false; } public bool RestoreSets(IEnumerable sets) { - //ipset restore - using (var process = _system.StartProcess(BinaryName, "restore")) + var commands = new List(); + foreach (var set in sets) { - if (WriteSets(sets, process.StandardInput)) - { - process.StandardInput.Flush(); - process.StandardInput.Close(); - ProcessHelper.WaitForExit(process); - - //OK - if (process.ExitCode != 0) return true; - } - - return false; + commands.Add(set.GetFullCommand()); + foreach (var entry in set.Entries) commands.Add(entry.GetFullCommand()); } + return ExecuteRestore(commands); } private bool WriteStrings(IEnumerable strings, StreamWriter standardInput) @@ -111,12 +88,10 @@ public virtual void SaveSets(IpSetSets sets, string setName = null) if (!string.IsNullOrEmpty(setName)) args += " " + ShellHelper.EscapeArguments(setName); using (var process = _system.StartProcess(BinaryName, args)) { - ProcessHelper.ReadToEnd(process, line => - { - if (line == null) return; - var trimmed = line.Trim(); - if (trimmed.Length != 0) sets.Accept(trimmed, iptables); - }, err => { }); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode != 0) throw new IpTablesNetException("Failed to save sets: " + error); + foreach (var line in output.Split('\n')) + if (!string.IsNullOrWhiteSpace(line)) sets.Accept(line.Trim(), iptables); } } @@ -152,14 +127,15 @@ public void DestroySet(string name) public bool EndTransactionCommit() { - var ret = true; - if (_transactionCommands != null && _transactionCommands.Count != 0) ret = ExecuteTransaction(); - _transactionCommands = null; - return ret; + try { return _transactionCommands == null || _transactionCommands.Count == 0 || ExecuteTransaction(); } + finally { _transactionCommands = null; } } + public void EndTransactionRollback() => _transactionCommands = null; + public void StartTransaction() { + if (InTransaction) throw new IpTablesNetException("IPSet transaction already started"); _transactionCommands = new List(); } diff --git a/iptables-plans/07-ipset-process-transactions.md b/iptables-plans/07-ipset-process-transactions.md new file mode 100644 index 0000000..a4b1e05 --- /dev/null +++ b/iptables-plans/07-ipset-process-transactions.md @@ -0,0 +1,24 @@ +# Gap 07: Real ipset restore and transaction execution + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[MockIpsetSystemFactory.TestSync](../IPTables.Net.TestFramework/MockIpsetSystemFactory.cs) explicitly calls `Sync(..., false)`. Existing synchronization coverage therefore bypasses the default transactional path in [IpSetBinaryAdapter](../IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs). `RestoreSets` currently returns true on a nonzero exit, making its success contract a specific regression candidate. + +## Tests to add + +- Drive the production adapter with configurable stdin/stdout/stderr and exit codes. Verify successful and failed `RestoreSets` results, complete create/add command syntax, and stream closure. +- Cover nonempty and empty transactions, successful commit, nonzero exit with and without stderr, and a broken stdin stream. +- Specify nested-start and post-failure reuse behavior; assert queued commands cannot disappear or leak into a later commit unnoticed. +- Cover immediate create, destroy, add, delete, and swap failures, plus failed/partial `SaveSets` output. + +## Completion + +Add `IpSetAdapterTests.cs`; production restore/commit methods execute in fast tests. Confirm the intended boolean meaning before changing behavior. Reuse the process fake from gap 01 and assert disposal on all paths. + +## Implementation record + +Execute real ipset restore and transaction methods with process fakes. Correct inverted restore success and missing create/timeout serialization, reject failed save output, add explicit rollback and nested-start protection, and clear transaction buffers even after failure. Cover immediate operation errors and recovery. + +Validation: Fast suite passed. From 90dc24587197701de722f3d44443420dc03c340d Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:51:17 +0000 Subject: [PATCH 08/28] test: implement testing gap 08 - ipset-sync-modes Cover all ipset sync modes with existing/missing sets and explicit deletion policies. Preserve entries during SetOnly replacement, carry bucket/init metadata, detect temporary-name collisions, reject incompatible family/type swaps before mutation, and discard queued transactions on failure. Validation: Fast suite passed. Kernel swap compatibility is exercised only on a disposable Linux host. --- IPTables.Net.Tests/IpSetModeTests.cs | 49 +++++++++++++++++++++++++++ IPTables.Net/IpSet/IpSetSet.cs | 4 +-- IPTables.Net/IpSet/IpSetSets.cs | 26 +++++++++++++- iptables-plans/08-ipset-sync-modes.md | 25 ++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 IPTables.Net.Tests/IpSetModeTests.cs create mode 100644 iptables-plans/08-ipset-sync-modes.md diff --git a/IPTables.Net.Tests/IpSetModeTests.cs b/IPTables.Net.Tests/IpSetModeTests.cs new file mode 100644 index 0000000..2ecbf5b --- /dev/null +++ b/IPTables.Net.Tests/IpSetModeTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +namespace IPTables.Net.Tests; +public class IpSetModeTests +{ + [Theory] + [InlineData(IpSetSyncMode.SetOnly, false, false)] [InlineData(IpSetSyncMode.SetOnly, true, false)] + [InlineData(IpSetSyncMode.SetAndEntries, false, true)] [InlineData(IpSetSyncMode.SetAndEntries, true, true)] + [InlineData(IpSetSyncMode.SetAndEntriesOnCreate, false, true)] [InlineData(IpSetSyncMode.SetAndEntriesOnCreate, true, false)] + public void ModesControlEntryPopulation(IpSetSyncMode mode, bool exists, bool populate) + { + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" && exists ? "create demo hash:ip\n" : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, system); + desired.Sets.Single().SyncMode = mode; desired.Sync(); + var text = string.Join("", os.Calls.Select(c => c.Text)); + Assert.Equal(populate, text.Contains("add demo 192.0.2.1")); + Assert.Equal(!exists, text.Contains("create demo")); + Assert.False(system.SetAdapter.InTransaction); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] + public void DeletionRequiresExplicitPredicate(int policy) + { + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" ? "create old hash:ip\ncreate foreign hash:ip\n" : "") }; + var system = new IpTablesSystem(os, null); + new IpSetSets(system).Sync(policy == 0 ? null : s => policy == 2 && s.Name == "old"); + var text = string.Join("", os.Calls.Select(c => c.Text)); + Assert.Equal(policy == 2, text.Contains("destroy old")); Assert.DoesNotContain("destroy foreign", text); + } + [Fact] + public void ReplacementPreservesSetOnlyEntriesAndRejectsCollisionsAndFamilyChanges() + { + var saved = "create demo hash:ip\nadd demo 192.0.2.1 timeout 10\n"; + var os = new ScriptedSystem { Respond = (_, a) => new(a == "save" ? saved : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { "create demo hash:ip hashsize 2048" }, system); + desired.Sets.Single().SyncMode = IpSetSyncMode.SetOnly; + desired.Sync(); + var text = os.Calls.Last().Text; + Assert.True(text.IndexOf("add demo_S 192.0.2.1 timeout 10", StringComparison.Ordinal) < text.IndexOf("swap demo_S demo", StringComparison.Ordinal)); + saved += "create demo_S hash:ip\n"; + Assert.Throws(() => desired.Sync()); Assert.False(system.SetAdapter.InTransaction); + saved = "create demo hash:ip\n"; desired.Sets.Single().Family = "inet6"; + Assert.Throws(() => desired.Sync()); Assert.False(system.SetAdapter.InTransaction); + } +} diff --git a/IPTables.Net/IpSet/IpSetSet.cs b/IPTables.Net/IpSet/IpSetSet.cs index c10f9cd..3a920ad 100644 --- a/IPTables.Net/IpSet/IpSetSet.cs +++ b/IPTables.Net/IpSet/IpSetSet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -217,7 +217,7 @@ public static IpSetSet Parse(string rule, IpTablesSystem system, int startOffset public bool SetEquals(IpSetSet set, bool size = true) { - if (!(set.MaxElem == MaxElem && set.Name == Name && set.Timeout == Timeout && _bucketSize == set._bucketSize && + if (!(set.Family == Family && set.MaxElem == MaxElem && set.Name == Name && set.Timeout == Timeout && _bucketSize == set._bucketSize && set.Type == Type && set.BitmapRange.Equals(BitmapRange) && set.CreateOptions.OrderBy(a => a) .SequenceEqual(CreateOptions.OrderBy(a => a)))) return false; diff --git a/IPTables.Net/IpSet/IpSetSets.cs b/IPTables.Net/IpSet/IpSetSets.cs index 1c67e75..fbdb06b 100644 --- a/IPTables.Net/IpSet/IpSetSets.cs +++ b/IPTables.Net/IpSet/IpSetSets.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.Exceptions; using IPTables.Net.Supporting; @@ -38,6 +38,8 @@ public void Sync( //Start transaction System.SetAdapter.StartTransaction(); + try + { // Dump sets in system var systemSets = System.SetAdapter.SaveSets(System); @@ -59,13 +61,29 @@ public void Sync( //Update if applicable if (!systemSet.SetEquals(set)) { + if (systemSet.Type != set.Type || systemSet.Family != set.Family) + throw new IpTablesNetException("Cannot swap sets with different types or address families: " + set.Name); + if (systemSets.HasSet(set.Name + "_S")) + throw new IpTablesNetException("Temporary set already exists: " + set.Name + "_S"); + var previousEntries = systemSet.Entries; + // Create a new set as _S of the target systemSet = new IpSetSet(set.Type, set.Name + "_S", set.Timeout, set.Family, System, set.SyncMode, set.BitmapRange, set.CreateOptions); systemSet.HashSize = set.HashSize; systemSet.MaxElem = set.MaxElem; + systemSet.BucketSize = set.BucketSize; + systemSet.InitVal = set.InitVal; System.SetAdapter.CreateSet(systemSet); + if (set.SyncMode == IpSetSyncMode.SetOnly) + foreach (var previous in previousEntries) + { + var copy = IpSetEntry.ParseFromParts(systemSet, previous.GetKeyCommand()); + copy.Timeout = previous.Timeout; + System.SetAdapter.AddEntry(copy); + } + // Swap (setname becomes setname+"_S" but keeps it's items) System.SetAdapter.SwapSet(systemSet.Name, set.Name); System.SetAdapter.DestroySet(systemSet.Name); @@ -92,6 +110,12 @@ public void Sync( //End Transaction: COMMIT if (!System.SetAdapter.EndTransactionCommit()) throw new IpTablesNetException("Failed to commit IPSets"); + } + catch + { + if (transactional) System.SetAdapter.EndTransactionRollback(); + throw; + } } diff --git a/iptables-plans/08-ipset-sync-modes.md b/iptables-plans/08-ipset-sync-modes.md new file mode 100644 index 0000000..798496e --- /dev/null +++ b/iptables-plans/08-ipset-sync-modes.md @@ -0,0 +1,25 @@ +# Gap 08: Set synchronization modes, deletion filters, and replacement + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IpSetSets.Sync](../IPTables.Net/IpSet/IpSetSets.cs) supports three synchronization modes, selective deletion, and replacement through a temporary `_S` set. [IpSetSyncTests](../IPTables.Net.Tests/IpSetSyncTests.cs) covers ordinary entries, some `SetOnly` cases, and a hash-size change. It does not cover `SetAndEntriesOnCreate` or selective/default deletion policy. [IpSetSet.SetEquals](../IPTables.Net/IpSet/IpSetSet.cs) does not compare `Family`. + +## Tests to add + +- Cross all synchronization modes with absent, unchanged, and replaced sets; assert when entries are preserved or populated. +- Test no deletion predicate, always-false, and selective predicates with unrelated existing sets. +- Change family, type, timeout, bucket size, bitmap range, and create options individually; specify which require replacement and assert the decision. +- Inject failures at create/swap/destroy/populate stages and cover an existing temporary-name collision. Assert the reported failure and observed state without promising transactional rollback from ipset. +- Reconcile an already-converged state and assert no commands. + +## Completion + +Extend `IpSetSyncTests.cs` using stateful fakes. Run fast tests for policy and command order; validate actual swap restrictions separately in a disposable Linux namespace. + +## Implementation record + +Cover all ipset sync modes with existing/missing sets and explicit deletion policies. Preserve entries during SetOnly replacement, carry bucket/init metadata, detect temporary-name collisions, reject incompatible family/type swaps before mutation, and discard queued transactions on failure. + +Validation: Fast suite passed. Kernel swap compatibility is exercised only on a disposable Linux host. From 511322ee30b777bf02144d81b1e963d7c119edb0 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:52:04 +0000 Subject: [PATCH 09/28] test: implement testing gap 09 - ipset-entry-identity Make entry-key hashing agree with equality by excluding timeout and including the second address. Use the key comparer for parsed sets, add entries only after parsing completes, and implement full entry hashing. Test hash-set/dictionary lookup, deduplication, timeout updates, tuple distinctions, and constructor/parser parity. Validation: Fast suite passed. --- IPTables.Net.Tests/IpSetEntryIdentityTests.cs | 45 +++++++++++++++++++ IPTables.Net/IpSet/IpSetEntry.cs | 5 ++- IPTables.Net/IpSet/IpSetEntryKeyComparer.cs | 8 ++-- IPTables.Net/IpSet/IpSetSet.cs | 2 +- IPTables.Net/IpSet/Parser/IpSetEntryParser.cs | 3 +- iptables-plans/09-ipset-entry-identity.md | 25 +++++++++++ 6 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 IPTables.Net.Tests/IpSetEntryIdentityTests.cs create mode 100644 iptables-plans/09-ipset-entry-identity.md diff --git a/IPTables.Net.Tests/IpSetEntryIdentityTests.cs b/IPTables.Net.Tests/IpSetEntryIdentityTests.cs new file mode 100644 index 0000000..c8d1b17 --- /dev/null +++ b/IPTables.Net.Tests/IpSetEntryIdentityTests.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables.DataTypes; +namespace IPTables.Net.Tests; +public class IpSetEntryIdentityTests +{ + [Fact] + public void TimeoutDoesNotChangeKeyMembership() + { + var set = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1 timeout 10", "add demo 192.0.2.1 timeout 20" }, null).Sets.Single(); + var entry = Assert.Single(set.Entries); + var equivalent = IpSetEntry.ParseFromParts(set, "192.0.2.1"); equivalent.Timeout = 99; + var comparer = IpSetEntryKeyComparer.Instance; + Assert.True(comparer.Equals(entry, equivalent)); Assert.Equal(comparer.GetHashCode(entry), comparer.GetHashCode(equivalent)); + Assert.True(set.Entries.Contains(equivalent)); + var map = new Dictionary(comparer) { [entry] = 7 }; Assert.Equal(7, map[equivalent]); + entry.Timeout = 30; Assert.True(set.Entries.Remove(equivalent)); Assert.Empty(set.Entries); + } + [Theory] + [InlineData("192.0.2.1,tcp:80,198.51.100.2")] + [InlineData("192.0.2.1,udp:80,198.51.100.1")] + [InlineData("192.0.2.1,tcp:81,198.51.100.1")] + [InlineData("192.0.2.2,tcp:80,198.51.100.1")] + public void EveryTupleComponentParticipatesInIdentity(string other) + { + var set = new IpSetSets(new[] { "create demo hash:ip,port,ip" }, null).Sets.Single(); + var first = IpSetEntry.ParseFromParts(set, "192.0.2.1,tcp:80,198.51.100.1"); + var second = IpSetEntry.ParseFromParts(set, other); + Assert.False(IpSetEntryKeyComparer.Instance.Equals(first, second)); + var entries = new HashSet(IpSetEntryKeyComparer.Instance) { first, second }; Assert.Equal(2, entries.Count); + } + [Fact] + public void ParsedAndConstructedCollectionsUseTheSameKeyRules() + { + var parsed = new IpSetSets(new[] { "create demo hash:ip", "add demo 192.0.2.1" }, null).Sets.Single(); + var constructed = new IpSetSet(parsed.Type, parsed.Name, 0, "inet", null, IpSetSyncMode.SetAndEntries); + var one = new IpSetEntry(constructed, IpCidr.Parse("192.0.2.1")); + constructed.Entries.Add(one); + Assert.True(parsed.Entries.Contains(one)); + Assert.False(constructed.Entries.Add(IpSetEntry.ParseFromParts(constructed, "192.0.2.1"))); + var duplicate = IpSetEntry.ParseFromParts(constructed, "192.0.2.1"); + Assert.Equal(one, duplicate); Assert.Equal(one.GetHashCode(), duplicate.GetHashCode()); + } +} diff --git a/IPTables.Net/IpSet/IpSetEntry.cs b/IPTables.Net/IpSet/IpSetEntry.cs index 3b8940d..0a0a418 100644 --- a/IPTables.Net/IpSet/IpSetEntry.cs +++ b/IPTables.Net/IpSet/IpSetEntry.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using IPTables.Net.Exceptions; @@ -131,6 +131,7 @@ public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffs for (var i = startOffset; i < arguments.Length; i++) i += parser.FeedToSkip(i, i == startOffset); + entry.Set.Entries.Add(entry); return entry; } catch (Exception ex) @@ -140,6 +141,8 @@ public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffs } } + public override int GetHashCode() => HashCode.Combine(_set, IpSetEntryKeyComparer.Instance.GetHashCode(this), _timeout); + public bool KeyEquals(IpSetEntry other, bool cidr = true) { var r = _port == other._port && (!cidr || Cidr.Equals(other.Cidr)) && Cidr2.Equals(other.Cidr2) && diff --git a/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs b/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs index 25d2371..95ee063 100644 --- a/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs +++ b/IPTables.Net/IpSet/IpSetEntryKeyComparer.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; namespace IPTables.Net.IpSet @@ -7,9 +7,7 @@ internal class IpSetEntryKeyComparer : IEqualityComparer { public bool Equals(IpSetEntry x, IpSetEntry y) { - Debug.Assert(x != null, nameof(x) + " != null"); - var ret = x.KeyEquals(y); - return ret; + return ReferenceEquals(x, y) || (x != null && y != null && x.KeyEquals(y)); } public int GetHashCode(IpSetEntry obj) @@ -20,7 +18,7 @@ public int GetHashCode(IpSetEntry obj) hashCode = (hashCode * 397) ^ (obj.Protocol != null ? obj.Protocol.GetHashCode() : 0); hashCode = (hashCode * 397) ^ obj.Port.GetHashCode(); hashCode = (hashCode * 397) ^ (obj.Mac != null ? obj.Mac.GetHashCode() : 0); - hashCode = (hashCode * 397) ^ obj.Timeout.GetHashCode(); + hashCode = (hashCode * 397) ^ obj.Cidr2.GetHashCode(); return hashCode; } } diff --git a/IPTables.Net/IpSet/IpSetSet.cs b/IPTables.Net/IpSet/IpSetSet.cs index 3a920ad..a719ef9 100644 --- a/IPTables.Net/IpSet/IpSetSet.cs +++ b/IPTables.Net/IpSet/IpSetSet.cs @@ -155,7 +155,7 @@ public IpSetSet(IpSetType type, string name, int timeout, string family, IpTable internal IpSetSet(IpTablesSystem system) { _system = system; - _entries = new HashSet(); + _entries = new HashSet(IpSetEntryKeyComparer.Instance); _createOptions = new List(); } diff --git a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs index 5edaca8..da5b0a2 100644 --- a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs +++ b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs @@ -1,4 +1,4 @@ -using IPTables.Net.Exceptions; +using IPTables.Net.Exceptions; using IPTables.Net.Iptables.DataTypes; namespace IPTables.Net.IpSet.Parser @@ -83,7 +83,6 @@ public int FeedToSkip(int position, bool first) var set = _sets.GetSetByName(option); if (set == null) throw new IpTablesNetException($"The set {option} does not exist"); _entry.Set = set; - set.Entries.Add(_entry); } else if (option == "timeout") { diff --git a/iptables-plans/09-ipset-entry-identity.md b/iptables-plans/09-ipset-entry-identity.md new file mode 100644 index 0000000..584652c --- /dev/null +++ b/iptables-plans/09-ipset-entry-identity.md @@ -0,0 +1,25 @@ +# Gap 09: IpSet entry equality and collection membership + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IpSetEntryKeyComparer](../IPTables.Net/IpSet/IpSetEntryKeyComparer.cs) hashes timeout, while [IpSetEntry.KeyEquals](../IPTables.Net/IpSet/IpSetEntry.cs) ignores it. The entry parser [inserts entries before populating their fields](../IPTables.Net/IpSet/Parser/IpSetEntryParser.cs), and parsed/programmatically constructed sets use different collection initialization paths in [IpSetSet](../IPTables.Net/IpSet/IpSetSet.cs). Existing tests inspect parsed fields or command lists, not hash-set invariants. + +## Tests to add + +- For key-equal entries with different timeouts, assert equal comparer hashes and working `Contains`, `Remove`, deduplication, and dictionary lookup. +- Compare entries that differ only in the second address, protocol, port, or MAC; each must remain distinct when it changes the key. +- Parse duplicate entries and query/remove a fully populated equivalent entry after parsing. +- Compare parsed and programmatically constructed sets under the same operations; include entries whose metadata changes. +- Feed these cases into synchronization and assert no unnecessary additions/deletions and no silently merged distinct entries. + +## Completion + +Add `IpSetEntryIdentityTests.cs`. Fast tests cover collection behavior, not a prescribed hash formula. Reproduce the equality/hash inconsistency before fixing it. + +## Implementation record + +Make entry-key hashing agree with equality by excluding timeout and including the second address. Use the key comparer for parsed sets, add entries only after parsing completes, and implement full entry hashing. Test hash-set/dictionary lookup, deduplication, timeout updates, tuple distinctions, and constructor/parser parity. + +Validation: Fast suite passed. From e81c6de87d7dc07eb068668c3bc69fc24dc4d357 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:53:08 +0000 Subject: [PATCH 10/28] test: implement testing gap 10 - ipset-parser-fidelity Add set and entry round-trip metadata tests and invalid-input atomicity tests. Validate tuple arity, required option values and counter text; reject extra entry tokens, preserve initval and timeout serialization, and explicitly test rejection of unsupported MAC set types rather than adding unsupported type flags. Validation: Fast suite passed. Packet/byte counters remain intentionally observational and are not serialized as key data. --- .../IpSetParserFidelityTests.cs | 35 +++++++++++++++++++ IPTables.Net/IpSet/IpSetEntry.cs | 2 +- IPTables.Net/IpSet/IpSetSet.cs | 4 ++- IPTables.Net/IpSet/IpSetTypeHelper.cs | 3 +- IPTables.Net/IpSet/Parser/IpSetEntryParser.cs | 9 ++++- IPTables.Net/IpSet/Parser/IpSetSetParser.cs | 3 +- iptables-plans/10-ipset-parser-fidelity.md | 24 +++++++++++++ 7 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 IPTables.Net.Tests/IpSetParserFidelityTests.cs create mode 100644 iptables-plans/10-ipset-parser-fidelity.md diff --git a/IPTables.Net.Tests/IpSetParserFidelityTests.cs b/IPTables.Net.Tests/IpSetParserFidelityTests.cs new file mode 100644 index 0000000..b5893eb --- /dev/null +++ b/IPTables.Net.Tests/IpSetParserFidelityTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +namespace IPTables.Net.Tests; +public class IpSetParserFidelityTests +{ + [Fact] + public void SetAndEntryMetadataSurviveRoundTrip() + { + var set = IpSetSet.Parse("demo hash:ip,port,ip bucketsize 8 initval 0x10 timeout 60 counters", null); + var copy = IpSetSet.Parse(set.GetCommand(), null); + Assert.Equal(8, copy.BucketSize); Assert.Equal(16u, copy.InitVal); Assert.Equal(60, copy.Timeout); Assert.Contains("counters", copy.CreateOptions); + var sets = new IpSetSets(null); sets.AddSet(copy); + var entry = IpSetEntry.Parse("demo 192.0.2.1,TCP:80,198.51.100.1 timeout 10 packets 7 bytes 900", sets); + Assert.Equal("192.0.2.1,tcp:80,198.51.100.1", entry.GetKeyCommand()); + Assert.Equal("add demo 192.0.2.1,tcp:80,198.51.100.1 timeout 10", Assert.Single(copy.GetEntryCommands())); + } + [Theory] + [InlineData("demo")] [InlineData("unknown 192.0.2.1,tcp:80")] + [InlineData("demo 192.0.2.1")] [InlineData("demo 192.0.2.1,tcp:80,extra")] + [InlineData("demo 192.0.2.1,tcp:99999")] [InlineData("demo 192.0.2.1,tcp:80 timeout")] + [InlineData("demo 192.0.2.1,tcp:80 packets bad")] [InlineData("demo 192.0.2.1,tcp:80 unexpected")] + public void InvalidEntriesNeverLeavePartialMembers(string command) + { + var sets = new IpSetSets(new[] { "create demo hash:ip,port" }, null); + Assert.Throws(() => IpSetEntry.Parse(command, sets)); + Assert.Empty(sets.Sets.Single().Entries); + } + [Theory] + [InlineData("demo")] [InlineData("demo hash")] + [InlineData("demo hash:ip timeout")] [InlineData("demo hash:ip,mac")] + public void IncompleteAndUnsupportedSetTypesAreRejected(string command) + { Assert.Throws(() => IpSetSet.Parse(command, null)); } +} diff --git a/IPTables.Net/IpSet/IpSetEntry.cs b/IPTables.Net/IpSet/IpSetEntry.cs index 0a0a418..2a03b81 100644 --- a/IPTables.Net/IpSet/IpSetEntry.cs +++ b/IPTables.Net/IpSet/IpSetEntry.cs @@ -122,7 +122,7 @@ public static IpSetEntry Parse(string command, IpSetSets sets, int startOffset = public static IpSetEntry Parse(string[] arguments, IpSetSets sets, int startOffset = 0) { - if (arguments.Length < 2 + startOffset) return null; + if (arguments.Length < 2 + startOffset) throw new IpTablesNetException("Missing set name or entry key"); try { diff --git a/IPTables.Net/IpSet/IpSetSet.cs b/IPTables.Net/IpSet/IpSetSet.cs index a719ef9..d40bd5a 100644 --- a/IPTables.Net/IpSet/IpSetSet.cs +++ b/IPTables.Net/IpSet/IpSetSet.cs @@ -179,6 +179,8 @@ public string GetCommand() if (_bucketSize > 0 && _bucketSize != 12) command += " bucketsize " + _bucketSize; + if (_initVal != 0) command += " initval " + _initVal; + foreach (var co in _createOptions) command += " " + co; return command; @@ -192,7 +194,7 @@ public string GetFullCommand() public IEnumerable GetEntryCommands() { var ret = new List(); - foreach (var entry in Entries) ret.Add("add " + _name + " " + entry.GetKeyCommand()); + foreach (var entry in Entries) ret.Add(entry.GetFullCommand()); return ret; } diff --git a/IPTables.Net/IpSet/IpSetTypeHelper.cs b/IPTables.Net/IpSet/IpSetTypeHelper.cs index 3bdec62..e051e6e 100644 --- a/IPTables.Net/IpSet/IpSetTypeHelper.cs +++ b/IPTables.Net/IpSet/IpSetTypeHelper.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using IPTables.Net.Exceptions; @@ -52,6 +52,7 @@ public static IpSetType StringToType(string str) else throw new IpTablesNetException(string.Format("Unknown set type: {0}", str)); + if (parts.Length != 2) throw new IpTablesNetException("Invalid set type: " + str); var types = parts[1].Split(','); foreach (var t in types) if (t == "ip") diff --git a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs index da5b0a2..063ec40 100644 --- a/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs +++ b/IPTables.Net/IpSet/Parser/IpSetEntryParser.cs @@ -8,6 +8,7 @@ internal class IpSetEntryParser private readonly string[] _arguments; private IpSetEntry _entry; private IpSetSets _sets; + private bool _hasKey; public IpSetEntryParser(string[] arguments, IpSetEntry entry, IpSetSets sets) { @@ -23,6 +24,7 @@ public string GetCurrentArg(int position) public string GetNextArg(int position, int offset = 1) { + if (position + offset >= _arguments.Length) throw new IpTablesNetException("Missing value for " + _arguments[position]); return _arguments[position + offset]; } @@ -35,6 +37,7 @@ public static void ParseEntry(IpSetEntry entry, string value) { var typeComponents = entry.Set.TypeComponents; var optionComponents = value.Split(new char[] {','}); + if (optionComponents.Length != typeComponents.Length) throw new IpTablesNetException("Invalid entry tuple arity"); for (var i = 0; i < optionComponents.Length; i++) switch (typeComponents[i]) @@ -91,12 +94,16 @@ public int FeedToSkip(int position, bool first) } else if (option == "packets" || option == "bytes") { - // ignore + // Counters are observational metadata, not part of an entry key. + ulong.Parse(GetNextArg(position)); return 1; } + else if (_hasKey) + throw new IpTablesNetException("Unknown entry option: " + option); else { ParseEntry(_entry, option); + _hasKey = true; } return 0; diff --git a/IPTables.Net/IpSet/Parser/IpSetSetParser.cs b/IPTables.Net/IpSet/Parser/IpSetSetParser.cs index d7f20f1..d341027 100644 --- a/IPTables.Net/IpSet/Parser/IpSetSetParser.cs +++ b/IPTables.Net/IpSet/Parser/IpSetSetParser.cs @@ -1,4 +1,4 @@ -using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.DataTypes; namespace IPTables.Net.IpSet.Parser { @@ -21,6 +21,7 @@ public string GetCurrentArg() public string GetNextArg(int offset = 1) { + if (Position + offset >= _arguments.Length) throw new IPTables.Net.Exceptions.IpTablesNetException("Missing value for " + GetCurrentArg()); return _arguments[Position + offset]; } diff --git a/iptables-plans/10-ipset-parser-fidelity.md b/iptables-plans/10-ipset-parser-fidelity.md new file mode 100644 index 0000000..6d1b4f4 --- /dev/null +++ b/iptables-plans/10-ipset-parser-fidelity.md @@ -0,0 +1,24 @@ +# Gap 10: IpSet parser fidelity and invalid input + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +[IpSetParseTest](../IPTables.Net.Tests/IpSetParseTest.cs) checks a limited set of positive inputs, often only selected fields. [IpSetSetParser](../IPTables.Net/IpSet/Parser/IpSetSetParser.cs) parses bucket size and initval; [IpSetEntryParser](../IPTables.Net/IpSet/Parser/IpSetEntryParser.cs) handles tuple components, timeout, and ignored counters. Invalid arity, missing values, and complete rendered entry fidelity lack direct assertions. + +## Tests to add + +- Verify parse/render/reparse for nondefault bucket size, initval, create options, entry timeout, MAC tuples, and two-address tuples; define intentionally omitted fields explicitly. +- Assert that ignored packet/byte counters do not alter the entry key, while timeout survives APIs that promise a full entry command. +- Cover unknown set references, missing set type, missing option values, too few/many tuple components, invalid numeric values, and extra tokens. +- After rejected entries, assert that the containing set has no partially initialized member. + +## Completion + +Extend `IpSetParseTest.cs` or add focused parser tests. Each positive case checks all relevant fields and output; negative cases check error context and unchanged collection state. Run the fast suite. IPv6 tuple cases are tracked in gap 13. + +## Implementation record + +Add set and entry round-trip metadata tests and invalid-input atomicity tests. Validate tuple arity, required option values and counter text; reject extra entry tokens, preserve initval and timeout serialization, and explicitly test rejection of unsupported MAC set types rather than adding unsupported type flags. + +Validation: Fast suite passed. Packet/byte counters remain intentionally observational and are not serialized as key data. From 8bcb94251ef506d607c560a426d182ecdf4cded8 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:54:01 +0000 Subject: [PATCH 11/28] test: implement testing gap 11 - save-parser-errors Validate save framing and counter syntax, distinguish incomplete dumps from valid empty tables, select the requested table from multi-table input, and apply ignoreErrors consistently to counter-prefixed and plain rules. Assert CRLF handling, exact rule order, large counters, and recovery of later valid rules. Validation: Fast suite passed. Chain policy/counters remain outside the existing model. --- IPTables.Net.Tests/SaveParserErrorTests.cs | 35 ++++++ .../Client/Helper/IPTablesSaveParser.cs | 115 +++++++----------- iptables-plans/11-save-parser-errors.md | 24 ++++ 3 files changed, 106 insertions(+), 68 deletions(-) create mode 100644 IPTables.Net.Tests/SaveParserErrorTests.cs create mode 100644 iptables-plans/11-save-parser-errors.md diff --git a/IPTables.Net.Tests/SaveParserErrorTests.cs b/IPTables.Net.Tests/SaveParserErrorTests.cs new file mode 100644 index 0000000..ea10a93 --- /dev/null +++ b/IPTables.Net.Tests/SaveParserErrorTests.cs @@ -0,0 +1,35 @@ +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter.Client.Helper; +namespace IPTables.Net.Tests; +public class SaveParserErrorTests +{ + [Theory] + [InlineData("")] [InlineData("COMMIT")] [InlineData("*filter\n:INPUT ACCEPT [0:0]")] + [InlineData("*nat\nCOMMIT")] [InlineData("*nat\n*filter\nCOMMIT")] + [InlineData("*filter\n[12 -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[a:2] -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[1:2:3] -A INPUT -j ACCEPT\nCOMMIT")] + [InlineData("*filter\n[9223372036854775808:2] -A INPUT -j ACCEPT\nCOMMIT")] + public void MalformedDumpsAreNotEmptyTables(string input) + { Assert.Throws(() => IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4)); } + [Theory] + [InlineData("")] [InlineData("[1:2] ")] + public void RecoveryIsConsistentForCounterAndPlainRules(string prefix) + { + var input = "*filter\n:INPUT ACCEPT [0:0]\n" + prefix + "-A INPUT --invalid\n-A INPUT -j ACCEPT\nCOMMIT"; + Assert.ThrowsAny(() => IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4)); + var parsed = IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4, true); + Assert.Equal("-A INPUT -j ACCEPT", Assert.Single(parsed.GetChain("INPUT", "filter").Rules).GetActionCommand()); + } + [Fact] + public void RequestedTableHasIndependentChainsOrderAndLargeCounters() + { + const string input = "# header\r\n*nat\r\n:OTHER - [0:0]\r\nCOMMIT\r\n\r\n*filter\r\n:INPUT ACCEPT [0:0]\r\n[12:9223372036854775807] -A INPUT -j ACCEPT\r\n-A INPUT -j DROP\r\nCOMMIT"; + var result = IPTablesSaveParser.GetRulesFromOutput(null, input, "filter", 4); + var chain = Assert.Single(result.Chains); Assert.Equal("INPUT", chain.Name); + Assert.Equal(new[] { "-A INPUT -j ACCEPT", "-A INPUT -j DROP" }, chain.Rules.Select(r => r.GetActionCommand())); + Assert.Equal(long.MaxValue, chain.Rules[0].Counters.Bytes); Assert.Equal(12, chain.Rules[0].Counters.Packets); + Assert.Empty(IPTablesSaveParser.GetRulesFromOutput(null, "*filter\nCOMMIT", "filter", 4).Chains); + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs index 13d7c1d..8f951df 100644 --- a/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs +++ b/IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System; +using System.Globalization; using IPTables.Net.Exceptions; namespace IPTables.Net.Iptables.Adapter.Client.Helper @@ -11,75 +9,56 @@ internal class IPTablesSaveParser public static IpTablesChainSet GetRulesFromOutput(IpTablesSystem system, string output, string table, int ipVersion, bool ignoreErrors = false) { - var ret = new IpTablesChainSet(ipVersion); - string ttable = null; - - foreach (var lineRaw in output.Split(new[] {'\n'})) + var result = new IpTablesChainSet(ipVersion); + string current = null; + foreach (var raw in output.Split('\n')) { - var line = lineRaw.Trim(); - - if (string.IsNullOrEmpty(line)) + var line = raw.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + if (line[0] == '*') + { + if (current != null) throw new IpTablesNetException("Missing COMMIT before " + line); + current = line.Substring(1); continue; - - var c = line[0]; - IpTablesRule rule; - switch (c) + } + if (line == "COMMIT") { - case '*': - ttable = line.Substring(1); - break; - - case ':': - var split = line.Split(new[] {' '}); - ret.AddChain(new IpTablesChain(ttable, split[0].Substring(1), ipVersion, system)); - break; - - //Byte & packet count - case '[': - var positionEnd = line.IndexOf(']'); - if (positionEnd == -1) - throw new IpTablesNetException("Parsing error, could not find end of counters"); - var counters = line.Substring(1, positionEnd - 1).Split(new[] {':'}); - line = line.Substring(positionEnd + 1); - - try - { - rule = IpTablesRule.Parse(line, system, ret, ipVersion, ttable); - } - catch - { - if (ignoreErrors) continue; - throw; - } - - rule.Counters = new PacketCounters( - bytes: long.Parse(counters[1]), - packets: long.Parse(counters[0])); - ret.AddRule(rule); - break; - - - case '-': - rule = IpTablesRule.Parse(line, system, ret, ipVersion, ttable); - ret.AddRule(rule); - break; - - case '#': - break; - - case 'C': - if (line == "COMMIT" && ttable == table) - { - if (ttable == null) throw new IpTablesNetException("Parsing error"); - return ret; - } - - throw new IpTablesNetException("Unexepected table \"" + table + "\" found \"" + ttable + - "\" instead"); + if (current == null) throw new IpTablesNetException("COMMIT without table"); + if (current == table) return result; + current = null; + continue; + } + if (current == null) throw new IpTablesNetException("Content outside a table: " + line); + if (current != table) continue; + if (line[0] == ':') + { + var fields = line.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 3 || fields[0].Length == 1) throw new IpTablesNetException("Invalid chain declaration: " + line); + result.AddChain(new IpTablesChain(table, fields[0].Substring(1), ipVersion, system)); + continue; } + PacketCounters? counters = null; + if (line[0] == '[') + { + int end = line.IndexOf(']'); + if (end < 0) throw new IpTablesNetException("Unterminated counters: " + line); + var parts = line.Substring(1, end - 1).Split(':'); + if (parts.Length != 2 || !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var packets) || + !long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var bytes)) + throw new IpTablesNetException("Invalid counters: " + line); + counters = new PacketCounters(bytes, packets); + line = line.Substring(end + 1).TrimStart(); + } + try + { + if (!line.StartsWith("-")) throw new IpTablesNetException("Invalid rule: " + line); + var rule = IpTablesRule.Parse(line, system, result, ipVersion, table); + if (counters.HasValue) rule.Counters = counters.Value; + result.AddRule(rule); + } + catch (IpTablesNetException) when (ignoreErrors) { } } - - return null; + throw new IpTablesNetException("Incomplete or missing table dump: " + table); } } } diff --git a/iptables-plans/11-save-parser-errors.md b/iptables-plans/11-save-parser-errors.md new file mode 100644 index 0000000..bafda46 --- /dev/null +++ b/iptables-plans/11-save-parser-errors.md @@ -0,0 +1,24 @@ +# Gap 11: Save parser framing, counters, and recovery + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IpTablesSaveReadingTests](../IPTables.Net.Tests/IpTablesSaveReadingTests.cs) covers valid IPv4 filter/nat dumps and one rule-counter ordering case. [IPTablesSaveParser](../IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs) has untested behavior for incomplete dumps, wrong table commits, malformed counters, and `ignoreErrors`, whose catch currently surrounds only counter-prefixed rule parsing. + +## Tests to add + +- Cover empty output, CRLF, comments, blank lines, missing `COMMIT`, wrong requested table, and multiple table blocks. Define the accepted input framing before asserting selection behavior. +- Test missing counter delimiters, wrong field counts, nonnumeric and overflowing counters, and large valid values. +- Supply the same invalid rule with and without counter prefixes under both `ignoreErrors` settings; specify consistent recovery and preserve subsequent valid rules. +- Verify chain association, complete rule order, and model fields rather than only chain counts. Document whether chain policy/counters are intentionally outside the model. + +## Completion + +Extend `IpTablesSaveReadingTests.cs`; fast tests distinguish malformed/incomplete dumps from valid empty tables and prevent silent loss of later rules. + +## Implementation record + +Validate save framing and counter syntax, distinguish incomplete dumps from valid empty tables, select the requested table from multi-table input, and apply ignoreErrors consistently to counter-prefixed and plain rules. Assert CRLF handling, exact rule order, large counters, and recovery of later valid rules. + +Validation: Fast suite passed. Chain policy/counters remain outside the existing model. From 46301df343b5d9dbacb1940b44ad0f482138f5fd Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:55:54 +0000 Subject: [PATCH 12/28] test: implement testing gap 12 - rule-parser-invalid-input Add deterministic tokenization and invalid-command theories, preserving empty quoted arguments, whitespace and escaped payloads. Report missing option values, reject malformed offsets and dangling/repeated negation, accept optional insert positions, and retain the deliberate unknown-module polyfill contract. Validation: Fast suite passed. --- IPTables.Net.Tests/ArgumentHelperTests.cs | 19 ++++++ IPTables.Net.Tests/CommandParserErrorTests.cs | 30 +++++++++ IPTables.Net/Iptables/IpTablesCommand.cs | 9 ++- .../Iptables/Modules/CommandParser.cs | 8 ++- IPTables.Net/Supporting/ArgumentHelper.cs | 63 ++++++------------- .../12-rule-parser-invalid-input.md | 25 ++++++++ 6 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 IPTables.Net.Tests/ArgumentHelperTests.cs create mode 100644 IPTables.Net.Tests/CommandParserErrorTests.cs create mode 100644 iptables-plans/12-rule-parser-invalid-input.md diff --git a/IPTables.Net.Tests/ArgumentHelperTests.cs b/IPTables.Net.Tests/ArgumentHelperTests.cs new file mode 100644 index 0000000..72e2ab0 --- /dev/null +++ b/IPTables.Net.Tests/ArgumentHelperTests.cs @@ -0,0 +1,19 @@ +using System; +using IPTables.Net.Supporting; +namespace IPTables.Net.Tests; +public class ArgumentHelperTests +{ + [Theory] + [InlineData("a\tb c", "a", "b", "c")] + [InlineData("'' \"\" x", "", "", "x")] + [InlineData("'two words' \"O'Brien\" path\\name", "two words", "O'Brien", "path\\name")] + [InlineData("'it\\'s' \"say \\\"hi\\\"\" a\\ b", "it's", "say \"hi\"", "a b")] + public void TokenizationHasIndependentExpectedValues(string input, string a, string b, string c) + { Assert.Equal(new[] { a, b, c }, ArgumentHelper.SplitArguments(input)); } + [Fact] + public void EmptyAndUnterminatedInput() + { + Assert.Empty(ArgumentHelper.SplitArguments("")); Assert.Empty(ArgumentHelper.SplitArguments(" \t\r\n")); + Assert.Throws(() => ArgumentHelper.SplitArguments("'open")); + } +} diff --git a/IPTables.Net.Tests/CommandParserErrorTests.cs b/IPTables.Net.Tests/CommandParserErrorTests.cs new file mode 100644 index 0000000..5b39007 --- /dev/null +++ b/IPTables.Net.Tests/CommandParserErrorTests.cs @@ -0,0 +1,30 @@ +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +namespace IPTables.Net.Tests; +public class CommandParserErrorTests +{ + [Theory] + [InlineData("")] [InlineData(" \t ")] [InlineData("-A")] [InlineData("-A INPUT -m")] + [InlineData("-A INPUT -j")] [InlineData("-A INPUT -t")] [InlineData("-A INPUT -m tcp --dport")] + [InlineData("-A INPUT --unknown")] + [InlineData("-A INPUT ! ! -p tcp")] [InlineData("-A INPUT !")] + [InlineData("-A INPUT -m tcp --dport nope")] [InlineData("-A INPUT -m comment --comment 'bad")] + [InlineData("-R INPUT")] [InlineData("-D INPUT")] + [InlineData("-R INPUT 0 -j ACCEPT")] [InlineData("-R INPUT -1 -j ACCEPT")] + [InlineData("-R INPUT 4294967295 -j ACCEPT")] + public void InvalidCommandsHaveContextAndDoNotMutateChains(string input) + { + var chains = new IpTablesChainSet(4); + Assert.Contains(input, Assert.Throws(() => IpTablesCommand.Parse(input, null, chains)).Message); + Assert.Empty(chains.Chains); + } + [Fact] + public void UnknownModulesRemainSupportedThroughPolyfill() + { RuleParseAssert.RoundTrips("-A INPUT -m missing"); } + + [Theory] + [InlineData("-D INPUT 1", 0)] [InlineData("-R INPUT 2 -j ACCEPT", 1)] + [InlineData("-I INPUT -j ACCEPT", -1)] [InlineData("-I INPUT", -1)] + public void ValidOffsets(string input, int offset) + { Assert.Equal(offset, IpTablesCommand.Parse(input, null, new IpTablesChainSet(4)).Offset); } +} diff --git a/IPTables.Net/Iptables/IpTablesCommand.cs b/IPTables.Net/Iptables/IpTablesCommand.cs index 78d97eb..bd2e3a6 100644 --- a/IPTables.Net/Iptables/IpTablesCommand.cs +++ b/IPTables.Net/Iptables/IpTablesCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -70,7 +70,9 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl out CommandParser parserOut, int version = 4, string defaultTable = "filter") { Debug.Assert(chains.IpVersion == version); - var arguments = ArgumentHelper.SplitArguments(rule); + string[] arguments; + try { arguments = ArgumentHelper.SplitArguments(rule); } + catch (Exception ex) { throw new IpTablesParserException(rule, ex); } var count = arguments.Length; var ipRule = new IpTablesRule(system, new IpTablesChain(defaultTable, null, version, system)); var ipCmd = new IpTablesCommand(null, defaultTable, IpTablesCommandType.Unknown, -1, ipRule); @@ -84,6 +86,7 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl { if (arguments[i] == "!") { + if (not) throw new IpTablesNetException("Repeated negation"); not = true; continue; } @@ -91,6 +94,8 @@ internal static IpTablesCommand Parse(string rule, IpTablesSystem system, IpTabl i += parser.FeedToSkip(i, not); not = false; } + if (not) throw new IpTablesNetException("Trailing negation"); + if (count == 0 || string.IsNullOrWhiteSpace(ipCmd.ChainName)) throw new IpTablesNetException("Missing command or chain"); } catch (Exception ex) { diff --git a/IPTables.Net/Iptables/Modules/CommandParser.cs b/IPTables.Net/Iptables/Modules/CommandParser.cs index 76cc7bb..bec39f3 100644 --- a/IPTables.Net/Iptables/Modules/CommandParser.cs +++ b/IPTables.Net/Iptables/Modules/CommandParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -57,6 +57,7 @@ public string GetCurrentArg() public string GetNextArg(int offset = 1) { + if (Position + offset >= _arguments.Length) throw new IpTablesNetException("Missing value for " + GetCurrentArg()); return _arguments[Position + offset]; } @@ -82,15 +83,16 @@ public int FeedToSkip(int position, bool not) _ipCommand.Type = IpTablesCommand.GetCommandType(option); if (option == "-D" || option == "-R" || option == "-I") { - var nextArg = GetNextArg(2); + var nextArg = GetRemainingArgs() >= 2 ? GetNextArg(2) : ""; uint offset; if (uint.TryParse(nextArg, out offset)) { - if (offset == 0) throw new Exception("Invalid offset"); + if (offset == 0 || offset > int.MaxValue) throw new IpTablesNetException("Invalid offset"); _ipCommand.Offset = (int) offset - 1; return 2; } + if (option == "-R" || (option == "-D" && nextArg.Length == 0)) throw new IpTablesNetException("Missing rule position or specification"); _ipCommand.Offset = -1; } diff --git a/IPTables.Net/Supporting/ArgumentHelper.cs b/IPTables.Net/Supporting/ArgumentHelper.cs index 28f0cf2..37283f7 100644 --- a/IPTables.Net/Supporting/ArgumentHelper.cs +++ b/IPTables.Net/Supporting/ArgumentHelper.cs @@ -1,57 +1,34 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; using System.Text; - namespace IPTables.Net.Supporting { public class ArgumentHelper { public static string[] SplitArguments(string commandLine) { - var parmChars = commandLine.ToCharArray(); - var inSingleQuote = false; - var inDoubleQuote = false; - var inSpace = true; - var lastChar = 0; - for (var index = 0; index < parmChars.Length; index++) + ArgumentNullException.ThrowIfNull(commandLine); + var result = new List(); var token = new StringBuilder(); + char quote = '\0'; bool started = false; + for (int i = 0; i < commandLine.Length; i++) { - // replace double quote with 0x00 if not in single quote - if (parmChars[index] == '"' && !inSingleQuote) + char c = commandLine[i]; + if (c == '\\' && i + 1 < commandLine.Length && + (commandLine[i + 1] == '\\' || commandLine[i + 1] == quote || + (quote == '\0' && (char.IsWhiteSpace(commandLine[i + 1]) || commandLine[i + 1] == '\'' || commandLine[i + 1] == '"')))) + { token.Append(commandLine[++i]); started = true; } + else if (quote != '\0') + { if (c == quote) quote = '\0'; else token.Append(c); started = true; } + else if (c == '\'' || c == '"') { quote = c; started = true; } + else if (char.IsWhiteSpace(c)) { - inDoubleQuote = !inDoubleQuote; - parmChars[index] = '\x00'; - } - - // replace single quote with 0x00 if not in single quote - if (parmChars[index] == '\'' && !inDoubleQuote) - { - inSingleQuote = !inSingleQuote; - parmChars[index] = '\x00'; - } - - // replace space with 0x01 if not in any quotes - if (parmChars[index] == ' ' && !inSingleQuote && !inDoubleQuote) - { - if (inSpace) - { - parmChars[index] = '\x00'; - } - else - { - parmChars[index] = '\x01'; - inSpace = true; - } - } - else - { - lastChar = index; - inSpace = false; + if (started) { result.Add(token.ToString()); token.Clear(); started = false; } } + else { token.Append(c); started = true; } } - - // remove all ignore chars (0x00), then split by space seperator (0x01) - return new string(parmChars, 0, lastChar + 1).Replace("\x00", "").Split(new[] {'\x01'}); + if (quote != '\0') throw new FormatException("Unterminated quoted argument"); + if (started) result.Add(token.ToString()); + return result.ToArray(); } } -} \ No newline at end of file +} diff --git a/iptables-plans/12-rule-parser-invalid-input.md b/iptables-plans/12-rule-parser-invalid-input.md new file mode 100644 index 0000000..375baf0 --- /dev/null +++ b/iptables-plans/12-rule-parser-invalid-input.md @@ -0,0 +1,25 @@ +# Gap 12: Invalid commands and argument tokenization + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +The numerous `Single*ParseTests` mostly cover successful rendering. [CommandParser](../IPTables.Net/Iptables/Modules/CommandParser.cs) indexes following arguments directly and converts command offsets. [ArgumentHelper.SplitArguments](../IPTables.Net/Supporting/ArgumentHelper.cs) implements quote/space handling without focused edge-case tests. [EscapeHelperTests](../IPTables.Net.Tests/EscapeHelperTests.cs) tests output escaping, not the complete input-tokenization contract. + +## Tests to add + +- Cover empty/whitespace input, trailing `-A`, `-m`, `-j`, `-t`, and module options missing values; assert intentional errors with useful context. +- Exercise `-I`, `-R`, and `-D` at position 1, omitted positions where allowed, zero, negative, overflow, and missing rule bodies. +- Test unknown modules/options, duplicate module loading, standalone/repeated negation, and invalid numeric option values. +- Add tokenizer cases for empty quoted arguments, mixed quotes, literal apostrophes, escaped quotes/backslashes, tabs, and unmatched quotes. Define accepted syntax rather than silently treating accidental behavior as a contract. +- Rejected parsing must not leave a supplied chain collection partly mutated. + +## Completion + +Add `CommandParserErrorTests.cs` and `ArgumentHelperTests.cs`. Run fast tests with independent expected tokens and exception/state assertions. + +## Implementation record + +Add deterministic tokenization and invalid-command theories, preserving empty quoted arguments, whitespace and escaped payloads. Report missing option values, reject malformed offsets and dangling/repeated negation, accept optional insert positions, and retain the deliberate unknown-module polyfill contract. + +Validation: Fast suite passed. From 3727274916f735f5d4aae575091484329d38e0cb Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 00:57:37 +0000 Subject: [PATCH 13/28] test: implement testing gap 13 - managed-ipv6 Add IPv6 core/TCP/UDP and NAT round trips with independent address assertions, IPv6 save parsing, executable selection and inet6 tuple synchronization. Reject mismatched core address families. Fix CIDR lookup losing the second tuple address, which caused identical sets to delete and re-add entries. Validation: Fast suite passed; no native dependency required. --- IPTables.Net.Tests/IPv6ManagedTests.cs | 56 +++++++++++++++++++ .../Iptables/Modules/Core/CoreModule.cs | 15 +++-- .../Supporting/DictionaryExtension.cs | 4 +- iptables-plans/13-managed-ipv6.md | 25 +++++++++ 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 IPTables.Net.Tests/IPv6ManagedTests.cs create mode 100644 iptables-plans/13-managed-ipv6.md diff --git a/IPTables.Net.Tests/IPv6ManagedTests.cs b/IPTables.Net.Tests/IPv6ManagedTests.cs new file mode 100644 index 0000000..83fc318 --- /dev/null +++ b/IPTables.Net.Tests/IPv6ManagedTests.cs @@ -0,0 +1,56 @@ +using System.Linq; +using System.Net; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client.Helper; +using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.Modules.Core; +namespace IPTables.Net.Tests; +public class IPv6ManagedTests +{ + [Theory] + [InlineData("::/0")] [InlineData("::1")] [InlineData("2001:db8::/64")] [InlineData("2001:db8::1")] + public void CoreTcpAndUdpRoundTripWithVersionAndFields(string address) + { + foreach (var protocol in new[] { "tcp", "udp" }) + { + var text = $"-A INPUT -p {protocol} -s {address} -j ACCEPT -m {protocol} --dport 443"; + var rule = RuleParseAssert.RoundTrips(text, version: 6); + Assert.Equal(6, rule.IpVersion); Assert.Equal(IpCidr.Parse(address), rule.GetModule("core").Source.Value); + var saved = IPTablesSaveParser.GetRulesFromOutput(null, "*filter\n:INPUT ACCEPT [0:0]\n" + text + "\nCOMMIT", "filter", 6); + Assert.Equal(text, Assert.Single(saved.GetChain("INPUT", "filter").Rules).GetActionCommand()); + } + } + [Theory] + [InlineData("[2001:db8::1]:80-90")] [InlineData("[2001:db8::1]-[2001:db8::2]:443")] + [InlineData("2001:db8::1")] + public void NatRangesAndAddressFields(string range) + { + RuleParseAssert.RoundTrips("-A PREROUTING -t nat -j DNAT --to-destination " + range, version: 6); + var parsed = IPPortOrRange.Parse(range); Assert.Equal(IPAddress.Parse("2001:db8::1"), parsed.LowerAddress); + Assert.Equal(range, parsed.ToString()); + } + [Fact] + public void FamiliesAreValidatedAndFactoriesSelectIpv6Executables() + { + Assert.Throws(() => IpTablesRule.Parse("-A INPUT -s 192.0.2.1", null, new IpTablesChainSet(6), 6)); + var os = new ScriptedSystem { Respond = (_, _) => new("*filter\nCOMMIT") }; + using var binary = new IpTablesSystem(os, new IPTablesBinaryAdapter()).GetTableAdapter(6); + binary.AddRule("-A INPUT -j ACCEPT"); binary.ListRules("filter"); + using var restore = new IpTablesSystem(os, new IPTablesRestoreAdapter()).GetTableAdapter(6); + restore.ListRules("filter"); restore.StartTransaction(); restore.AddRule("-A INPUT -j ACCEPT"); restore.EndTransactionCommit(); + Assert.Equal(new[] { "ip6tables", "ip6tables-save", "ip6tables-save", "ip6tables-restore" }, os.Calls.Select(c => c.Binary)); + } + [Fact] + public void Ipv6SetTuplesConvergeWithoutCommands() + { + const string saved = "create demo hash:ip,port,ip family inet6\nadd demo 2001:db8::1,tcp:443,2001:db8::2\n"; + var os = new ScriptedSystem { Respond = (_, _) => new(saved) }; + var system = new IpTablesSystem(os, null); var sets = new IpSetSets(saved.Split('\n').Where(x => x.Length != 0), system); + Assert.Equal("inet6", sets.Sets.Single().Family); + Assert.Equal(IPAddress.Parse("2001:db8::2"), sets.Sets.Single().Entries.Single().Cidr2.Address); + sets.Sync(); Assert.Single(os.Calls); Assert.Equal("save", os.Calls[0].Arguments); + } +} diff --git a/IPTables.Net/Iptables/Modules/Core/CoreModule.cs b/IPTables.Net/Iptables/Modules/Core/CoreModule.cs index f3d2180..e65df81 100644 --- a/IPTables.Net/Iptables/Modules/Core/CoreModule.cs +++ b/IPTables.Net/Iptables/Modules/Core/CoreModule.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; @@ -41,8 +41,15 @@ public class CoreModule : ModuleBase, IIpTablesModule, IEquatable public string Target { get; set; } = null; public TargetMode TargetMode { get; set; } = TargetMode.Jump; - public CoreModule(int version) : base(version) + private readonly int _version; + public CoreModule(int version) : base(version) { _version = version; } + + private IpCidr ParseAddress(string text) { + var value = IpCidr.Parse(text); + var family = _version == 4 ? System.Net.Sockets.AddressFamily.InterNetwork : System.Net.Sockets.AddressFamily.InterNetworkV6; + if (value.Address.AddressFamily != family) throw new IPTables.Net.Exceptions.IpTablesNetException("Address family does not match rule version"); + return value; } public string Jump @@ -93,11 +100,11 @@ public int Feed(CommandParser parser, bool not) return 1; case OptionSourceLong: case OptionSourceShort: - Source = new ValueOrNot(IpCidr.Parse(parser.GetNextArg()), IpCidr.Any, not); + Source = new ValueOrNot(ParseAddress(parser.GetNextArg()), IpCidr.Any, not); return 1; case OptionDestinationLong: case OptionDestinationShort: - Destination = new ValueOrNot(IpCidr.Parse(parser.GetNextArg()), IpCidr.Any, not); + Destination = new ValueOrNot(ParseAddress(parser.GetNextArg()), IpCidr.Any, not); return 1; case OptionJumpLong: case OptionJumpShort: diff --git a/IPTables.Net/Supporting/DictionaryExtension.cs b/IPTables.Net/Supporting/DictionaryExtension.cs index 0bb2a56..bd582b1 100644 --- a/IPTables.Net/Supporting/DictionaryExtension.cs +++ b/IPTables.Net/Supporting/DictionaryExtension.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Diagnostics; using IPTables.Net.IpSet; using IPTables.Net.Iptables.DataTypes; @@ -84,7 +84,7 @@ public static bool FindCidr(this IDictionary dict, I out IpSetEntry o, out TValue f) { var find = new IpSetEntry(findOriginal.Set, findOriginal.Cidr, findOriginal.Protocol, findOriginal.Port, - findOriginal.Mac); + findOriginal.Mac) { Cidr2 = findOriginal.Cidr2 }; for (var i = find.Cidr.Prefix; i != 0; i--) { find.Cidr = IpCidr.NewRebase(find.Cidr.Address, i); diff --git a/iptables-plans/13-managed-ipv6.md b/iptables-plans/13-managed-ipv6.md new file mode 100644 index 0000000..c2de0ad --- /dev/null +++ b/iptables-plans/13-managed-ipv6.md @@ -0,0 +1,25 @@ +# Gap 13: Managed IPv6 parsing and adapter selection + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[RuleParseAssert](../IPTables.Net.Tests/RuleParseAssert.cs) defaults to IPv4, and current round-trip callers do not exercise version 6. The suite's IPv6 address equality and route smoke examples do not validate IPv6 iptables models. Production [IpCidr](../IPTables.Net/Iptables/DataTypes/IpCidr.cs), [IPPortOrRange](../IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs), and adapter factories expose IPv6 behavior. + +## Tests to add + +- Parameterize representative core/TCP/UDP rules and save dumps for version 6, including `::`, `::1`, compressed addresses, `/0`, `/64`, and `/128`. +- Check IPv6 NAT address/range serialization with and without bracketed ports where supported; assert parsed fields independently of rendering. +- Test family mismatch handling and ensure chain/rule version is preserved through parsing and generation. +- Record binary/save/restore executable selection for `GetTableAdapter(6)`. +- Parse and synchronize `family inet6` sets with IPv6 and two-address tuple entries, preserving address and protocol identity. + +## Completion + +Add `IPv6ManagedTests.cs` and extend existing parameterized suites. All cases run with the fast suite and no native libraries. Native family coverage is gap 14. + +## Implementation record + +Add IPv6 core/TCP/UDP and NAT round trips with independent address assertions, IPv6 save parsing, executable selection and inet6 tuple synchronization. Reject mismatched core address families. Fix CIDR lookup losing the second tuple address, which caused identical sets to delete and re-add entries. + +Validation: Fast suite passed; no native dependency required. From 22e3c1c34cdf82266c55978da6516edd204891df Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:02:39 +0000 Subject: [PATCH 14/28] test: implement testing gap 14 - native-ipv6 Add separately parameterized IPv4/IPv6 native edit-and-commit tests with independent save/counter readback and an IPv6 ABI-specific incompatible-revision test. Probe family prerequisites and report missing kernel tables explicitly rather than entering unsupported native paths. Validation: Fast suite passed. Isolated full run: IPv4 passed; two IPv6 cases skipped because this host lacks ip6_tables. An initial constructor-failure crash is addressed in plan 15. --- IPTables.Net.Tests/NativeFamilyTests.cs | 91 +++++++++++++++++++++++++ iptables-plans/14-native-ipv6.md | 24 +++++++ 2 files changed, 115 insertions(+) create mode 100644 IPTables.Net.Tests/NativeFamilyTests.cs create mode 100644 iptables-plans/14-native-ipv6.md diff --git a/IPTables.Net.Tests/NativeFamilyTests.cs b/IPTables.Net.Tests/NativeFamilyTests.cs new file mode 100644 index 0000000..c161912 --- /dev/null +++ b/IPTables.Net.Tests/NativeFamilyTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using IPTables.Net.Iptables.NativeLibrary; +namespace IPTables.Net.Tests; +[Collection(SystemIptablesCollectionDefinition.Name)] +public class NativeFamilyTests +{ + [Theory] + [InlineData(4)] [InlineData(6)] + public void EachFamilyCommitsEditsAndCountersWithIndependentReadback(int version) + { + RequireFamily(version); + var binary = IptablesSystemTestSupport.GetBinary(version); + var name = "gap" + Guid.NewGuid().ToString("N").Substring(0, 12); + var address = version == 4 ? "192.0.2.1/32" : "2001:db8::1/128"; + try + { + using (var table = new IptcInterface("filter", version)) + { + Assert.True(table.AddChain(name)); + Assert.Equal(1, table.ExecuteCommand($"{binary} -A {name} -s {address} -p tcp -m tcp --dport 80 -c 7 900 -j ACCEPT")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -I {name} 1 -j DROP")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -R {name} 1 -j RETURN")); + Assert.Equal(1, table.ExecuteCommand($"{binary} -D {name} 1")); + Assert.Single(table.GetRules(name)); Assert.True(table.Commit(), table.GetErrorString()); + } + using (var read = new IptcInterface("filter", version)) + { + Assert.Contains(name, read.GetChains()); + var rule = read.GetRuleString(name, Assert.Single(read.GetRules(name))); + Assert.Contains(address, rule); Assert.Contains("--dport 80", rule); + } + var saved = Save(binary); + Assert.Contains($"[7:900] -A {name}", saved); Assert.Contains(address, saved); + Assert.Equal(0, IptcInterface.RefCount); + } + finally { IptablesSystemTestSupport.Execute(binary, "-F " + name, false); IptablesSystemTestSupport.Execute(binary, "-X " + name, false); } + } + [Fact] + public void Ipv6IncompatibleRevisionReportsRuleAndDoesNotInstallIt() + { + RequireFamily(6); + var binary = IptablesSystemTestSupport.GetBinary(6); + var name = "gap" + Guid.NewGuid().ToString("N").Substring(0, 12); + IptablesSystemTestSupport.RequireSuccess(binary, "-N " + name); + try + { + using var table = new IptcInterface("filter", 6); + table.ExecuteCommand($"{binary} -A {name} -p tcp -m tcp --dport 80 -j ACCEPT"); + var match = IntPtr.Add(Assert.Single(table.GetRules(name)), Marshal.SizeOf()); + Assert.Equal("tcp", Marshal.PtrToStringAnsi(IntPtr.Add(match, 2))); + Marshal.WriteByte(match, 31, byte.MaxValue); + Assert.False(table.Commit()); Assert.NotEqual(0, table.GetLastError()); + Assert.Contains("revision 255", table.GetErrorString()); Assert.Contains(name, table.GetErrorString()); + Assert.DoesNotContain("-A " + name, Save(binary)); + } + finally { IptablesSystemTestSupport.Execute(binary, "-F " + name, false); IptablesSystemTestSupport.Execute(binary, "-X " + name, false); } + } + internal static void RequireFamily(int version) + { + TestEnvironment.RequireLinuxSystemTests(); + if (IptablesSystemTestSupport.Execute(IptablesSystemTestSupport.GetBinary(version), "-t filter -L", false) != 0) + Assert.Skip($"IPv{version} filter table unavailable: requires kernel modules and network administration privileges."); + } + internal static string Save(string binary) + { + using var process = Process.Start(new ProcessStartInfo(binary + "-save", "-c -t filter") { RedirectStandardOutput = true, RedirectStandardError = true }); + var output = process.StandardOutput.ReadToEnd(); var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); Assert.True(process.ExitCode == 0, error); return output; + } + [StructLayout(LayoutKind.Sequential)] + private struct NativeIp6 + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] Source; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] Destination; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] SourceMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public uint[] DestinationMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] Input; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] Output; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] InputMask; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] OutputMask; + public ushort Protocol; public byte Tos, Flags, InverseFlags; + } + [StructLayout(LayoutKind.Sequential)] + private struct NativeEntry6 + { + public NativeIp6 Ip; public uint Cache; public ushort TargetOffset, NextOffset; + public uint ComeFrom; public ulong Packets, Bytes; + } +} diff --git a/iptables-plans/14-native-ipv6.md b/iptables-plans/14-native-ipv6.md new file mode 100644 index 0000000..780e656 --- /dev/null +++ b/iptables-plans/14-native-ipv6.md @@ -0,0 +1,24 @@ +# Gap 14: IPv6 native helper integration + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IptablesSystemTestSupport.IpVersion](../IPTables.Net.Tests/IptablesSystemTestSupport.cs) is constant 4. [IptcInterfaceTest](../IPTables.Net.Tests/IptcInterfaceTest.cs) contains alternate IPv6 strings, but its fixture never selects them. [IptcInterface](../IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs) and [ipthelper](../ipthelper/ipthelper.c) expose separate IPv4/IPv6 entry points, so IPv4 success cannot validate IPv6 layout, serialization, or commit behavior. + +## Tests to add + +- Run independent IPv4 and IPv6 fixtures for chain discovery, rule output/input, add/insert/replace/delete, commit, and readback. +- Assert IPv6 source/destination prefixes, protocol/options, rule ordering, and counters using an independent `ip6tables-save` observation. +- Add IPv6-specific failed-commit diagnostics and verify no unintended installation. Do not reuse the IPv4 native entry layout from the incompatible-match test. +- Verify family switching after all handles are disposed; serialize fixtures around native global initialization. + +## Completion + +Use disposable Linux networking with root/capabilities, libipthelper, and compatible legacy binaries. Run the stable full suite for both families with explicit assertions that IPv6 tests executed; report unavailable prerequisites as skips. Do not merely change the shared constant and assume both families ran. + +## Implementation record + +Add separately parameterized IPv4/IPv6 native edit-and-commit tests with independent save/counter readback and an IPv6 ABI-specific incompatible-revision test. Probe family prerequisites and report missing kernel tables explicitly rather than entering unsupported native paths. + +Validation: Fast suite passed. Isolated full run: IPv4 passed; two IPv6 cases skipped because this host lacks ip6_tables. An initial constructor-failure crash is addressed in plan 15. From 25d47ea87b98b074ab90476c0b1c4f6183e742e3 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:03:59 +0000 Subject: [PATCH 15/28] test: implement testing gap 15 - native-handle-lifecycle Fix native table-init errors long-jumping without an active recovery frame, constructor/refcount cleanup, rejected-family finalization, and duplicate/omitted ordered commits. Bound BPF formatting and release allocations on all failures. Add native construction, disposal, rollback/reuse, commit-order and BPF buffer tests. Validation: Fast suite passed. Isolated native lifecycle/family tests passed where supported; IPv6 kernel-dependent cases skip because ip6_tables is absent. --- IPTables.Net.Tests/NativeLifecycleTests.cs | 51 ++++++++++++++ .../Client/IPTablesLibAdapterClient.cs | 8 +-- .../Iptables/NativeLibrary/IptcInterface.cs | 31 ++++++--- iptables-plans/15-native-handle-lifecycle.md | 25 +++++++ ipthelper/ip6tables.c | 38 ++++------- ipthelper/iptables.c | 38 ++++------- ipthelper/ipthelper.c | 67 +++++++------------ 7 files changed, 146 insertions(+), 112 deletions(-) create mode 100644 IPTables.Net.Tests/NativeLifecycleTests.cs create mode 100644 iptables-plans/15-native-handle-lifecycle.md diff --git a/IPTables.Net.Tests/NativeLifecycleTests.cs b/IPTables.Net.Tests/NativeLifecycleTests.cs new file mode 100644 index 0000000..61c6504 --- /dev/null +++ b/IPTables.Net.Tests/NativeLifecycleTests.cs @@ -0,0 +1,51 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Adapter; +using IPTables.Net.Iptables.Adapter.Client; +using IPTables.Net.Iptables.NativeLibrary; +namespace IPTables.Net.Tests; +[Collection(SystemIptablesCollectionDefinition.Name)] +public class NativeLifecycleTests +{ + [Fact] + public void FailedConstructionAndRejectedFamilyDoNotLeakReferences() + { + NativeFamilyTests.RequireFamily(4); + Assert.Equal(0, IptcInterface.RefCount); + Assert.Throws(() => new IptcInterface("not_a_table", 4)); + Assert.Equal(0, IptcInterface.RefCount); + using (var first = new IptcInterface("filter", 4)) + { + using (var second = new IptcInterface("filter", 4)) Assert.Equal(2, IptcInterface.RefCount); + Assert.Throws(() => new IptcInterface("filter", 6)); + GC.Collect(); GC.WaitForPendingFinalizers(); Assert.Equal(1, IptcInterface.RefCount); + first.Dispose(); first.Dispose(); Assert.Equal(0, IptcInterface.RefCount); + Assert.Throws(() => first.GetChains()); + } + using var valid = new IptcInterface("filter", 4); Assert.NotEmpty(valid.GetChains()); + } + [Fact] + public void RollbackAndOrderedCommitReleaseEveryOpenedTable() + { + NativeFamilyTests.RequireFamily(4); + var system = new IpTablesSystem(null, new IPTablesLibAdapter()); + using var client = (IPTablesLibAdapterClient)system.GetTableAdapter(4); + client.StartTransaction(); client.ListRules("filter"); client.EndTransactionRollback(); Assert.Equal(0, IptcInterface.RefCount); + client.StartTransaction(); client.ListRules("filter"); client.ListRules("mangle"); + client.EndTransactionCommit(new[] { "missing", "filter", "filter" }); Assert.Equal(0, IptcInterface.RefCount); + client.StartTransaction(); client.ListRules("filter"); client.EndTransactionCommit(); Assert.Equal(0, IptcInterface.RefCount); + } + [Theory] + [InlineData("RAW", "tcp", 4096, true)] [InlineData("invalid", "tcp", 4096, false)] + [InlineData("RAW", "not valid (", 4096, false)] [InlineData("RAW", "tcp", 1, false)] + [InlineData("RAW", "tcp", 0, false)] [InlineData("RAW", "tcp", -1, false)] + public void BpfCompilerHandlesErrorsAndSmallBuffers(string link, string code, int size, bool valid) + { + TestEnvironment.RequireLinuxSystemTests(); + var result = IptcInterface.BpfCompile(link, code, size); + if (valid) { Assert.NotNull(result); Assert.Contains(",", result); } + else Assert.Null(result); + Assert.NotNull(IptcInterface.BpfCompile("RAW", "udp", 4096)); + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs index 8100366..952c956 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -231,7 +231,7 @@ public void EndTransactionCommit(IEnumerable tableCommitOrder) { if (!_inTransaction) return; - if (tableCommitOrder == null) tableCommitOrder = _interfaces.Keys; + tableCommitOrder = (tableCommitOrder ?? Enumerable.Empty()).Concat(_interfaces.Keys).Distinct().ToList(); IpTablesNetExceptionErrno ex = null; foreach (var table in tableCommitOrder) @@ -268,10 +268,6 @@ public override void EndTransactionRollback() _inTransaction = false; } - ~IPTablesLibAdapterClient() - { - Dispose(); - } public override void Dispose() { diff --git a/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs b/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs index 7a61d44..fdc3e93 100644 --- a/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs +++ b/IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs @@ -1,4 +1,4 @@ -//#define DEBUG_NATIVE_IPTCP +//#define DEBUG_NATIVE_IPTCP using System; using System.Collections.Generic; @@ -434,6 +434,8 @@ public string LastError() return err; } + private static readonly object HelperLock = new object(); + private bool _ownsHelper; private static int _helperInit = 0; private static int _helperInitCount = 0; public bool Disposed = false; @@ -473,8 +475,11 @@ public static bool DllExists() public IptcInterface(string table, int ipVersion, ILogger log = null) { + if (ipVersion != 4 && ipVersion != 6) throw new ArgumentOutOfRangeException(nameof(ipVersion)); _ipVersion = ipVersion; logger = log; + lock (HelperLock) + { if (_helperInit == ipVersion) { _helperInitCount++; @@ -497,7 +502,9 @@ public IptcInterface(string table, int ipVersion, ILogger log = null) _helperInitCount++; } - OpenTable(table); + _ownsHelper = true; + try { OpenTable(table); } catch { Dispose(); throw; } + } } ~IptcInterface() @@ -507,16 +514,17 @@ public IptcInterface(string table, int ipVersion, ILogger log = null) public void Dispose() { - if (_handle != IntPtr.Zero) - { - Debug.Assert(_helperInit >= 0); - Free(); - } - - if (!Disposed) + lock (HelperLock) { - if (--_helperInitCount == 0) _helperInit = 0; + if (Disposed) return; + if (_handle != IntPtr.Zero) Free(); + if (_ownsHelper) + { + if (--_helperInitCount == 0) _helperInit = 0; + _ownsHelper = false; + } Disposed = true; + GC.SuppressFinalize(this); } } @@ -533,7 +541,7 @@ private void DebugEntry(string message) private void RequireHandle() { - Debug.Assert(!Disposed); + if (Disposed) throw new ObjectDisposedException(nameof(IptcInterface)); if (_handle == IntPtr.Zero) throw new IpTablesNetException("No IP Table currently open"); } @@ -549,6 +557,7 @@ private void Free() public void OpenTable(string table) { + if (Disposed) throw new ObjectDisposedException(nameof(IptcInterface)); if (_handle != IntPtr.Zero) throw new IpTablesNetException("A table is already open, commit or discard first"); _lastCommitError = null; diff --git a/iptables-plans/15-native-handle-lifecycle.md b/iptables-plans/15-native-handle-lifecycle.md new file mode 100644 index 0000000..faf4fd1 --- /dev/null +++ b/iptables-plans/15-native-handle-lifecycle.md @@ -0,0 +1,25 @@ +# Gap 15: Native handle and transaction cleanup + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[IptcInterfaceTest](../IPTables.Net.Tests/IptcInterfaceTest.cs) checks normal refcounts and one incompatible-match commit error. It does not cover failed construction, multiple live handles, family conflicts, or rollback/reuse. [IptcInterface](../IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs) increments global initialization counts before opening a table. [IPTablesLibAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs) owns multiple interfaces and accepts an explicit commit order. + +## Tests to add + +- Fail table opening and verify a subsequent valid construction and disposal restore the initial refcount. +- Test simultaneous same-family handles, rejected mixed-family initialization, repeated dispose, and family switching after cleanup. +- Exercise rollback, commit then reuse, failed commit then recovery, and operations with no open handle. +- Cover multi-table commit orders containing missing, duplicate, or omitted tables; specify ownership/cleanup for every opened interface and preserve errno diagnostics. +- Cover `BpfCompile` success, invalid filter/link type, and insufficient output buffer in a separate native-only fixture; current BPF parsing tests do not compile programs. + +## Completion + +Extend native tests and inspect corresponding [helper allocation/commit code](../ipthelper/ipthelper.c). Use fault injection where kernel failures cannot be produced reliably. Run in disposable Linux environments, asserting resource/state recovery in addition to exceptions. + +## Implementation record + +Fix native table-init errors long-jumping without an active recovery frame, constructor/refcount cleanup, rejected-family finalization, and duplicate/omitted ordered commits. Bound BPF formatting and release allocations on all failures. Add native construction, disposal, rollback/reuse, commit-order and BPF buffer tests. + +Validation: Fast suite passed. Isolated native lifecycle/family tests passed where supported; IPv6 kernel-dependent cases skip because ip6_tables is absent. diff --git a/ipthelper/ip6tables.c b/ipthelper/ip6tables.c index 5952f7e..a4d8049 100644 --- a/ipthelper/ip6tables.c +++ b/ipthelper/ip6tables.c @@ -1368,32 +1368,18 @@ EXPORT void* set_modprobe(const char* program) { } EXPORT void* init_handle6(const char* table){ - const char* err; - void* handle = ip6tc_init(table); - err = ip6tc_strerror(errno); - - /* try to insmod the module if iptc_init failed */ - if (!handle) - { - if (xtables_modprobe_program == NULL) - { - xtables_error(OTHER_PROBLEM, "init error: %s", err); - } - else if (xtables_load_ko(xtables_modprobe_program, false) != -1) - { - handle = ip6tc_init(table); - if (!handle) - { - xtables_error(OTHER_PROBLEM, "unable to init module %s after loading: %s", xtables_modprobe_program, err); - } - } - else - { - xtables_error(OTHER_PROBLEM, "unable to load module %s. init error: %s", xtables_modprobe_program, err); - } - } - - return handle; + extern char *errbuffer; + errbuffer = NULL; + void *handle = ip6tc_init(table); + if (!handle && xtables_modprobe_program != NULL) { + int saved_errno = errno; + if (xtables_load_ko(xtables_modprobe_program, false) != -1) + handle = ip6tc_init(table); + else + errno = saved_errno; + } + /* Managed callers have no active xtables longjmp target. Return errno. */ + return handle; } int do_command6(int argc, char *argv[], char **table, void **handle) diff --git a/ipthelper/iptables.c b/ipthelper/iptables.c index a852412..49b1670 100644 --- a/ipthelper/iptables.c +++ b/ipthelper/iptables.c @@ -921,32 +921,18 @@ static void clear_rule_matches(struct xtables_rule_match **matches) } EXPORT void* init_handle4(const char* table){ - const char* err; - void* handle = iptc_init(table); - err = iptc_strerror(errno); - - /* try to insmod the module if iptc_init failed */ - if (!handle) - { - if (xtables_modprobe_program == NULL) - { - xtables_error(OTHER_PROBLEM, "init error: %s", err); - } - else if (xtables_load_ko(xtables_modprobe_program, false) != -1) - { - handle = iptc_init(table); - if (!handle) - { - xtables_error(OTHER_PROBLEM, "unable to init module %s after loading: %s", xtables_modprobe_program, err); - } - } - else - { - xtables_error(OTHER_PROBLEM, "unable to load module %s. init error: %s", xtables_modprobe_program, err); - } - } - - return handle; + extern char *errbuffer; + errbuffer = NULL; + void *handle = iptc_init(table); + if (!handle && xtables_modprobe_program != NULL) { + int saved_errno = errno; + if (xtables_load_ko(xtables_modprobe_program, false) != -1) + handle = iptc_init(table); + else + errno = saved_errno; + } + /* Managed callers have no active xtables longjmp target. Return errno. */ + return handle; } static void diff --git a/ipthelper/ipthelper.c b/ipthelper/ipthelper.c index 31097d3..8cc2a3e 100644 --- a/ipthelper/ipthelper.c +++ b/ipthelper/ipthelper.c @@ -1178,48 +1178,29 @@ EXPORT void ipth_free(void* ptr) EXPORT char* ipth_bpf_compile(const char* dltname, const char* code, int length) { - struct bpf_program program; - struct bpf_insn *ins; - int i, dlt, n; - char* buffer = (char*)malloc(length + 1); - char* bufferptr = buffer; - - dlt = pcap_datalink_name_to_val(dltname); - if (dlt == -1) { - return NULL; - } - - if (pcap_compile_nopcap(65535, dlt, &program, code, 1, - PCAP_NETMASK_UNKNOWN)) { - fprintf(stderr, "Compilation error\n"); - return NULL; - } - - n = snprintf(bufferptr, length, "%d,", program.bf_len); - bufferptr += n; - length -= n; - ins = program.bf_insns; - for (i = 0; i < program.bf_len-1; ++ins, ++i){ - if(length == 0){ - goto error; - } - n = snprintf(bufferptr, length, "%u %u %u %u,", ins->code, ins->jt, ins->jf, ins->k); - bufferptr += n; - length -= n; - } - - n = snprintf(bufferptr, length, "%u %u %u %u", ins->code, ins->jt, ins->jf, ins->k); - bufferptr += n; - length -= n; - if(length == 0){ - goto error; - } - - goto ok; + struct bpf_program program; + int dlt, n, used = 0; + char *buffer; + if (length <= 0 || dltname == NULL || code == NULL) return NULL; + dlt = pcap_datalink_name_to_val(dltname); + if (dlt == -1) return NULL; + if (pcap_compile_nopcap(65535, dlt, &program, code, 1, PCAP_NETMASK_UNKNOWN)) return NULL; + buffer = malloc((size_t)length); + if (buffer == NULL) { pcap_freecode(&program); return NULL; } + n = snprintf(buffer, length, "%u,", program.bf_len); + if (n < 0 || n >= length) goto error; + used = n; + for (unsigned int i = 0; i < program.bf_len; ++i) { + struct bpf_insn *ins = &program.bf_insns[i]; + n = snprintf(buffer + used, length - used, "%u %u %u %u%s", ins->code, ins->jt, ins->jf, ins->k, + i + 1 < program.bf_len ? "," : ""); + if (n < 0 || n >= length - used) goto error; + used += n; + } + pcap_freecode(&program); + return buffer; error: - free(buffer); - buffer = NULL; -ok: - pcap_freecode(&program); - return buffer; + free(buffer); + pcap_freecode(&program); + return NULL; } From 24dc38d944565d5ef5ce4b52f126a0de12890031 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:48:10 +0000 Subject: [PATCH 16/28] test: implement testing gap 16 - conntrack-behavior Correct conntrack native cleanup signatures, Linux family mapping, independent dump buffers, shared-state locking, and failure cleanup. Reject malformed extraction/restore lengths. Add deterministic native-boundary tests and seeded UDP filter assertions. Validation: Fast suite: 526 passed, 26 skipped. Isolated Linux conntrack selection: 18 passed, including seeded flow and native dump/extraction. --- IPTables.Net.Tests/ConntrackBehaviorTests.cs | 87 ++++++++++++++++++ IPTables.Net.Tests/ConntrackLibraryTests.cs | 51 ++++++++++- IPTables.Net/Conntrack/ConntrackApi.cs | 17 ++++ IPTables.Net/Conntrack/ConntrackHelper.cs | 9 +- IPTables.Net/Conntrack/ConntrackSystem.cs | 94 ++++++++++---------- iptables-plans/16-conntrack-behavior.md | 25 ++++++ ipthelper/ct.cpp | 3 +- 7 files changed, 235 insertions(+), 51 deletions(-) create mode 100644 IPTables.Net.Tests/ConntrackBehaviorTests.cs create mode 100644 IPTables.Net/Conntrack/ConntrackApi.cs create mode 100644 iptables-plans/16-conntrack-behavior.md diff --git a/IPTables.Net.Tests/ConntrackBehaviorTests.cs b/IPTables.Net.Tests/ConntrackBehaviorTests.cs new file mode 100644 index 0000000..ed12307 --- /dev/null +++ b/IPTables.Net.Tests/ConntrackBehaviorTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using IPTables.Net.Conntrack; +using IPTables.Net.Exceptions; +namespace IPTables.Net.Tests; +public class ConntrackBehaviorTests +{ + private sealed class Native : ConntrackApi + { + public int Result, Family, Frees, Clears, Constants, MarkClears; + public bool Expectations, Found = true; + public uint MarkValue, Mask; + private readonly List nodes = new(); + internal override int Constant(string key) { Constants++; return key == "known" ? 123 : -1; } + internal override void Filter(int family, ConntrackQueryFilter[] filters) { Family = family; } + internal override void ClearFilter() { Clears++; } + internal override int Dump(bool expectations, ref ConntrackHelper.CrImg image) + { + Expectations = expectations; + for (int i = 0; i < 2; i++) + { + var ptr = Marshal.AllocHGlobal(IntPtr.Size + 20); + nodes.Add(ptr); + Marshal.WriteIntPtr(ptr, image.CrNode); + var bytes = new byte[20]; bytes[0] = 20; bytes[19] = (byte)i; + Marshal.Copy(bytes, 0, IntPtr.Add(ptr, IntPtr.Size), 20); + image.CrNode = ptr; + } + return Result; + } + internal override int Length(IntPtr node) => IntPtr.Size + 20; + internal override void Free(ref ConntrackHelper.CrImg image) + { foreach (var node in nodes) Marshal.FreeHGlobal(node); nodes.Clear(); image.CrNode = IntPtr.Zero; Frees++; } + internal override void Mark(uint mark, uint mask) { MarkValue = mark; Mask = mask; } + internal override void ClearMark() { MarkClears++; } + internal override int Restore(bool expectations, byte[] data) { Expectations = expectations; return Result; } + internal override bool Extract(ConntrackQueryFilter[] filters, byte[] data, IntPtr output, int size) + { Marshal.WriteInt32(output, 123); return Found; } + } + [Theory] + [InlineData(AddressFamily.Unspecified, 0)] + [InlineData(AddressFamily.InterNetwork, 2)] + [InlineData(AddressFamily.InterNetworkV6, 10)] + public void DumpOwnsEachBufferAndMapsLinuxFamily(AddressFamily family, int expected) + { + var native = new Native(); var system = new ConntrackSystem(native); var records = new List(); + system.Dump(true, records.Add, addressFamily: family); + Assert.Equal(expected, native.Family); Assert.True(native.Expectations); + Assert.Equal(new byte[] { 1, 0 }, records.ConvertAll(x => x[19])); + Assert.NotSame(records[0], records[1]); Assert.Equal(1, native.Frees); Assert.Equal(1, native.Clears); + } + [Fact] + public void CallbackAndNativeFailuresReleaseStateAndAllowRetry() + { + var native = new Native(); var system = new ConntrackSystem(native); + Assert.Throws(() => system.Dump(false, _ => throw new InvalidOperationException())); + native.Result = -22; + Assert.Throws(() => system.Dump(false, _ => Assert.Fail("Unexpected callback"))); + native.Result = 0; system.Dump(false, _ => { }); + Assert.Equal(3, native.Frees); Assert.Equal(3, native.Clears); + } + [Theory] + [InlineData(0)] [InlineData(3)] [InlineData(-22)] + public void RestoreAlwaysClearsOverride(int result) + { + var native = new Native { Result = result }; var system = new ConntrackSystem(native); + if (result < 0) Assert.Throws(() => system.Restore(true, Array.Empty(), 7, 255)); + else Assert.Equal(result, system.Restore(true, Array.Empty(), 7, 255)); + Assert.True(native.Expectations); Assert.Equal(7u, native.MarkValue); Assert.Equal(255u, native.Mask); Assert.Equal(1, native.MarkClears); + } + [Fact] + public void ConstantsAndExtractionHaveExplicitMissingContracts() + { + var native = new Native(); var system = new ConntrackSystem(native); + Assert.Equal(123, system.GetConstant("known")); Assert.Equal(123, system.GetConstant("known")); Assert.Equal(1, native.Constants); + Assert.Throws(() => system.GetConstant("unknown")); + var data = new byte[20]; data[0] = 20; + Assert.True(system.ExtractField(Array.Empty(), data, out var value)); Assert.Equal(123, value); + native.Found = false; + Assert.False(system.ExtractField(Array.Empty(), data, out value)); Assert.Equal(0, value); + Assert.Throws(() => system.ExtractField(Array.Empty(), new byte[4], out _)); + data[0] = 21; + Assert.Throws(() => system.ExtractField(Array.Empty(), data, out _)); + } +} diff --git a/IPTables.Net.Tests/ConntrackLibraryTests.cs b/IPTables.Net.Tests/ConntrackLibraryTests.cs index 8ad5418..d2d93fa 100644 --- a/IPTables.Net.Tests/ConntrackLibraryTests.cs +++ b/IPTables.Net.Tests/ConntrackLibraryTests.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text; using IPTables.Net.Conntrack; @@ -11,6 +12,7 @@ namespace IPTables.Net.Tests { + [Collection(SystemIptablesCollectionDefinition.Name)] [Trait("Category", "NotWorkingOnTravis")] public class ConntrackLibraryTests { @@ -28,6 +30,48 @@ public void TestDump() ConntrackSystem cts = new ConntrackSystem(); List list = new List(); cts.Dump(false, list.Add); + Assert.All(list, record => Assert.Equal(record.Length, (int)BitConverter.ToUInt32(record, 0))); + Assert.Equal(list.Count, list.Distinct().Count()); + } + + [Fact] + public void SeededUdpFlowIsReturnedByMatchingFilter() + { + NativeFamilyTests.RequireFamily(4); + using var receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + using var sender = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var destination = (IPEndPoint)receiver.Client.LocalEndPoint; + var binary = IptablesSystemTestSupport.GetBinary(4); + var rule = $"OUTPUT -p udp --dport {destination.Port} -m conntrack --ctstate NEW -j ACCEPT"; + IptablesSystemTestSupport.RequireSuccess(binary, "-A " + rule); + try + { + sender.Send(new byte[] { 7 }, 1, destination); + var system = new ConntrackSystem(); + var address = GCHandle.Alloc(IPAddress.Loopback.GetAddressBytes(), GCHandleType.Pinned); + try + { + var filters = new[] + { + new ConntrackQueryFilter { Key = system.GetConstant("CTA_TUPLE_ORIG"), Max = system.GetConstant("CTA_TUPLE_MAX") }, + new ConntrackQueryFilter { Key = system.GetConstant("CTA_TUPLE_IP"), Max = system.GetConstant("CTA_IP_MAX") }, + new ConntrackQueryFilter { Key = system.GetConstant("CTA_IP_V4_DST"), CompareLength = 4, Compare = address.AddrOfPinnedObject() } + }; + var records = new List(); + system.Dump(false, records.Add, filters, AddressFamily.InterNetwork); + Assert.NotEmpty(records); + Assert.All(records, record => + { + Assert.True(system.ExtractField(filters, record, out var value)); + Assert.Equal(BitConverter.ToUInt32(IPAddress.Loopback.GetAddressBytes()), value); + }); + Marshal.WriteInt32(address.AddrOfPinnedObject(), BitConverter.ToInt32(IPAddress.Parse("192.0.2.254").GetAddressBytes())); + records.Clear(); system.Dump(false, records.Add, filters, AddressFamily.InterNetwork); + Assert.Empty(records); + } + finally { address.Free(); } + } + finally { IptablesSystemTestSupport.Execute(binary, "-D " + rule, false); } } [Fact] @@ -57,6 +101,11 @@ public void TestDumpFiltered() List list = new List(); cts.Dump(false, list.Add, qf); + Assert.All(list, record => + { + Assert.True(cts.ExtractField(qf, record, out var destination)); + Assert.Equal(addr32, destination); + }); } finally { diff --git a/IPTables.Net/Conntrack/ConntrackApi.cs b/IPTables.Net/Conntrack/ConntrackApi.cs new file mode 100644 index 0000000..7e35454 --- /dev/null +++ b/IPTables.Net/Conntrack/ConntrackApi.cs @@ -0,0 +1,17 @@ +using System; +namespace IPTables.Net.Conntrack +{ + internal class ConntrackApi + { + internal virtual int Constant(string key) => ConntrackHelper.cr_constant(key); + internal virtual void Mark(uint mark, uint mask) => ConntrackHelper.restore_mark_init(mark, mask); + internal virtual void ClearMark() => ConntrackHelper.restore_mark_free(); + internal virtual int Restore(bool expectations, byte[] data) => ConntrackHelper.restore_nf_cts(expectations, data, data.Length); + internal virtual void Filter(int family, ConntrackQueryFilter[] filters) => ConntrackHelper.conditional_init(family, filters, filters?.Length ?? 0); + internal virtual void ClearFilter() => ConntrackHelper.conditional_free(); + internal virtual int Dump(bool expectations, ref ConntrackHelper.CrImg image) => ConntrackHelper.dump_nf_cts(expectations, ref image); + internal virtual int Length(IntPtr node) => ConntrackHelper.cr_length(node); + internal virtual void Free(ref ConntrackHelper.CrImg image) => ConntrackHelper.cr_free(ref image); + internal virtual bool Extract(ConntrackQueryFilter[] filters, byte[] data, IntPtr output, int size) => ConntrackHelper.cr_extract_field(filters, filters.Length, data, output, size); + } +} diff --git a/IPTables.Net/Conntrack/ConntrackHelper.cs b/IPTables.Net/Conntrack/ConntrackHelper.cs index 1c869e9..3e53b47 100644 --- a/IPTables.Net/Conntrack/ConntrackHelper.cs +++ b/IPTables.Net/Conntrack/ConntrackHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; @@ -32,18 +32,19 @@ internal struct CrImg public static extern int cr_constant(string constant); [DllImport(Helper)] - public static extern int cr_free(CrImg img); + public static extern void cr_free(ref CrImg img); [DllImport(Helper)] public static extern int cr_length(IntPtr node); [DllImport(Helper)] - public static extern int conditional_free(); + public static extern void conditional_free(); [DllImport(Helper)] - public static extern int conditional_init(int address_family, [In] ConntrackQueryFilter[] qf, int qfLength); + public static extern void conditional_init(int address_family, [In] ConntrackQueryFilter[] qf, int qfLength); [DllImport(Helper)] + [return: MarshalAs(UnmanagedType.I1)] public static extern bool cr_extract_field([In] ConntrackQueryFilter[] qf, int qfLength, byte[] data, IntPtr output, int outputLen); } diff --git a/IPTables.Net/Conntrack/ConntrackSystem.cs b/IPTables.Net/Conntrack/ConntrackSystem.cs index f622730..4e60751 100644 --- a/IPTables.Net/Conntrack/ConntrackSystem.cs +++ b/IPTables.Net/Conntrack/ConntrackSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -11,7 +11,10 @@ namespace IPTables.Net.Conntrack { public class ConntrackSystem { - private object _queryLock = new object(); + private static readonly object QueryLock = new object(); + private readonly ConntrackApi _native; + public ConntrackSystem() : this(new ConntrackApi()) { } + internal ConntrackSystem(ConntrackApi native) { _native = native; } private Dictionary _constants = new Dictionary(); public ushort GetConstant(string key) @@ -21,8 +24,8 @@ public ushort GetConstant(string key) ushort value; if (!_constants.TryGetValue(key, out value)) { - var v = ConntrackHelper.cr_constant(key); - if (v == -1) throw new KeyNotFoundException(string.Format("Unable to lookup constant {0}", key)); + var v = _native.Constant(key); + if (v < 0 || v > ushort.MaxValue) throw new KeyNotFoundException(string.Format("Unable to lookup constant {0}", key)); Debug.Assert(v <= ushort.MaxValue); value = (ushort) v; _constants.Add(key, value); @@ -34,13 +37,17 @@ public ushort GetConstant(string key) public bool ExtractField(ConntrackQueryFilter[] qf, byte[] conn, out T output) where T : struct { + ArgumentNullException.ThrowIfNull(qf); + ArgumentNullException.ThrowIfNull(conn); + if (conn.Length < 20 || BitConverter.ToUInt32(conn, 0) < 20 || BitConverter.ToUInt32(conn, 0) > conn.Length) + throw new ArgumentException("Invalid conntrack record length", nameof(conn)); var size = Marshal.SizeOf(typeof(T)); var handle = Marshal.AllocHGlobal(size); if (handle == IntPtr.Zero) throw new IpTablesNetException("Unable to allocate memory for Conntrack field"); try { - var ret = ConntrackHelper.cr_extract_field(qf, qf.Length, conn, handle, size); + var ret = _native.Extract(qf, conn, handle, size); if (ret) { var obj = Marshal.PtrToStructure(handle, typeof(T)); @@ -70,60 +77,57 @@ public bool ExtractField(ConntrackQueryFilter[] qf, byte[] conn, out T output /// remaining unprocessed data public int Restore(bool expectationsTable, byte[] data, uint restoreMark = 0, uint restoreMarkMask = 0) { - var useRestoreMark = restoreMark != 0 || restoreMarkMask != 0; - if (useRestoreMark) ConntrackHelper.restore_mark_init(restoreMark, restoreMarkMask); - var errorCode = ConntrackHelper.restore_nf_cts(expectationsTable, data, data.Length); - if (errorCode < 0) - throw new IpTablesNetException(string.Format("An error occured while loading NFCTs with the errno: {0}", - -errorCode)); - if (useRestoreMark) ConntrackHelper.restore_mark_free(); - - return errorCode; + ArgumentNullException.ThrowIfNull(data); + lock (QueryLock) + { + var useRestoreMark = restoreMark != 0 || restoreMarkMask != 0; + try + { + if (useRestoreMark) _native.Mark(restoreMark, restoreMarkMask); + var result = _native.Restore(expectationsTable, data); + if (result < 0) throw new IpTablesNetException($"Unable to restore conntrack records: errno {-result}"); + return result; + } + finally { if (useRestoreMark) _native.ClearMark(); } + } } public void Dump(bool expectationTable, Action cb, ConntrackQueryFilter[] qf = null, AddressFamily addressFamily = AddressFamily.Unspecified) { - lock (_queryLock) + ArgumentNullException.ThrowIfNull(cb); + int family = addressFamily switch { - ConntrackHelper.conditional_init((int) addressFamily, qf, qf == null ? 0 : qf.Length); - + AddressFamily.Unspecified => 0, + AddressFamily.InterNetwork => 2, + AddressFamily.InterNetworkV6 => 10, + _ => throw new ArgumentOutOfRangeException(nameof(addressFamily)) + }; + lock (QueryLock) + { + var img = new ConntrackHelper.CrImg(); try { - var buffer = new byte[1]; - var img = new ConntrackHelper.CrImg(); - Debug.Assert(img.CrNode == IntPtr.Zero); - - ConntrackHelper.dump_nf_cts(expectationTable, ref img); - - try - { - var ptr = img.CrNode; - while (ptr != IntPtr.Zero) - { - var crsize = ConntrackHelper.cr_length(ptr); - var newPtr = Marshal.ReadIntPtr(ptr); - - crsize -= IntPtr.Size; - Debug.Assert(crsize > 0); - if (buffer.Length != crsize) buffer = new byte[crsize]; - - Marshal.Copy(new IntPtr((long) ptr + IntPtr.Size), buffer, 0, crsize); - cb(buffer); - - ptr = newPtr; - } - } - finally + _native.Filter(family, qf); + var result = _native.Dump(expectationTable, ref img); + if (result < 0) throw new IpTablesNetException($"Unable to dump conntrack records: errno {-result}"); + var ptr = img.CrNode; + while (ptr != IntPtr.Zero) { - if (img.CrNode != IntPtr.Zero) ConntrackHelper.cr_free(img); + var size = _native.Length(ptr) - IntPtr.Size; + if (size < 20) throw new IpTablesNetException("Invalid conntrack record length"); + var buffer = new byte[size]; + Marshal.Copy(IntPtr.Add(ptr, IntPtr.Size), buffer, 0, size); + cb(buffer); + ptr = Marshal.ReadIntPtr(ptr); } } finally { - if (qf != null || addressFamily != AddressFamily.Unspecified) ConntrackHelper.conditional_free(); + try { if (img.CrNode != IntPtr.Zero) _native.Free(ref img); } + finally { _native.ClearFilter(); } } } } } -} \ No newline at end of file +} diff --git a/iptables-plans/16-conntrack-behavior.md b/iptables-plans/16-conntrack-behavior.md new file mode 100644 index 0000000..c2a2419 --- /dev/null +++ b/iptables-plans/16-conntrack-behavior.md @@ -0,0 +1,25 @@ +# Gap 16: Conntrack data integrity and failure cleanup + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +[ConntrackLibraryTests](../IPTables.Net.Tests/ConntrackLibraryTests.cs) asserts struct size; its two dump tests collect records without checking them. The class is categorized `NotWorkingOnTravis`. [ConntrackSystem](../IPTables.Net/Conntrack/ConntrackSystem.cs) additionally implements restore, mark overrides, extraction, caching, and cleanup against [ct.cpp](../ipthelper/ct.cpp). Dump reuses an equal-sized buffer, and restore-mark cleanup follows a possible throw. + +## Tests to add + +- Seed known flows and assert dump count/content, matching and nonmatching filters, family selection, and expectation-table behavior. +- Specify callback buffer ownership; retain multiple equal-sized records to detect unintended aliasing. +- Verify valid/unknown constants, successful/missing field extraction, and malformed record handling using controlled native inputs or a test shim. +- Test restore success, remaining-byte results, native errors, mark/mask application, and cleanup after failure. +- Throw from a dump callback, then repeat the operation to prove resources and filter state were released. Cover multiple instances if native state is shared. + +## Completion + +Keep deterministic marshaling/shim cases separate from unstable kernel tests. Run actual conntrack operations only in a disposable capable Linux environment with `RUN_UNSTABLE_SYSTEM_TESTS=1 bash ./test.sh --full`; use timeouts and assert contents, not just absence of exceptions. + +## Implementation record + +Correct conntrack native cleanup signatures, Linux family mapping, independent dump buffers, shared-state locking, and failure cleanup. Reject malformed extraction/restore lengths. Add deterministic native-boundary tests and seeded UDP filter assertions. + +Validation: Fast suite: 526 passed, 26 skipped. Isolated Linux conntrack selection: 18 passed, including seeded flow and native dump/extraction. diff --git a/ipthelper/ct.cpp b/ipthelper/ct.cpp index 1323322..6bb9345 100644 --- a/ipthelper/ct.cpp +++ b/ipthelper/ct.cpp @@ -512,7 +512,8 @@ int restore_nf_cts(bool expectation, char* data, int data_len) nlh = (struct nlmsghdr *)&data[i]; - if (i + nlh->nlmsg_len > data_len) + if (nlh->nlmsg_len < sizeof(struct nlmsghdr)) { exit_code = -EINVAL; goto out; } + if (nlh->nlmsg_len > (unsigned)(data_len - i)) { break; } From 1ebf227da541df59fe9110420f91c32f27067b50 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:48:56 +0000 Subject: [PATCH 17/28] test: implement testing gap 17 - nfacct-client Unify accounting XML parsing and correct swapped byte/packet counters. Preserve unsigned counters, escape object names, and report malformed records and command failures consistently. Add Get/List/reset/existence and process-disposal coverage. Validation: Fast suite: 537 passed, 27 skipped, zero failures. --- IPTables.Net.Tests/NfAcctTests.cs | 44 +++++++++++ IPTables.Net/NfAcct/NfAcct.cs | 116 ++++++++--------------------- iptables-plans/17-nfacct-client.md | 24 ++++++ 3 files changed, 97 insertions(+), 87 deletions(-) create mode 100644 IPTables.Net.Tests/NfAcctTests.cs create mode 100644 iptables-plans/17-nfacct-client.md diff --git a/IPTables.Net.Tests/NfAcctTests.cs b/IPTables.Net.Tests/NfAcctTests.cs new file mode 100644 index 0000000..61f17bf --- /dev/null +++ b/IPTables.Net.Tests/NfAcctTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using IPTables.Net.Exceptions; +namespace IPTables.Net.Tests; +public class NfAcctTests +{ + private const string Xml = "alpha7900beta1844674407370955161510"; + [Fact] + public void GetAndListAgreeOnCountersAndMissingNames() + { + var fake = new ScriptedSystem { Respond = (_, _) => new(Xml) }; var client = new NfAcct.NfAcct(fake); + var first = client.Get("alpha"); var all = client.List(); + Assert.Equal(900ul, first.Bytes); Assert.Equal(7ul, first.Packets); + Assert.Equal(first.Bytes, all[0].Bytes); Assert.Equal(first.Packets, all[0].Packets); + Assert.Equal(ulong.MaxValue, all[1].Packets); Assert.Null(client.Get("missing")); + Assert.True(client.Exist("alpha")); Assert.False(client.Exist("missing")); + } + [Fact] + public void CommandsResetAndEscapeNames() + { + var fake = new ScriptedSystem(); var client = new NfAcct.NfAcct(fake); + Assert.Null(client.Get("a b", true)); Assert.Empty(client.List(true)); client.Add("a b"); client.Delete("a\"b"); + Assert.Equal(new[] { "get \"a b\" xml reset", "list xml reset", "add \"a b\"", "del \"a\\\"b\"" }, fake.Calls.Select(x => x.Arguments)); + Assert.All(fake.Calls, call => { Assert.Equal("/usr/sbin/nfacct", call.Binary); Assert.True(call.Process.IsDisposed); }); + } + [Theory] + [InlineData("")] [InlineData("")] + [InlineData("a-10")] + [InlineData("a118446744073709551616")] + public void MalformedObjectsFailConsistently(string output) + { + var client = new NfAcct.NfAcct(new ScriptedSystem { Respond = (_, _) => new(output) }); + Assert.Throws(() => client.Get("a")); Assert.Throws(() => client.List()); + } + [Theory] + [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] + public void EveryCommandReportsNonzeroExit(int operation) + { + var fake = new ScriptedSystem { Respond = (_, _) => new("partial", "denied", 2) }; var client = new NfAcct.NfAcct(fake); + Action[] actions = { () => client.Get("a"), () => client.List(), () => client.Add("a"), () => client.Delete("a"), () => client.Exist("a") }; + Assert.Contains("denied", Assert.Throws(actions[operation]).Message); + Assert.True(Assert.Single(fake.Calls).Process.IsDisposed); + } +} diff --git a/IPTables.Net/NfAcct/NfAcct.cs b/IPTables.Net/NfAcct/NfAcct.cs index 2bad1bc..bc94157 100644 --- a/IPTables.Net/NfAcct/NfAcct.cs +++ b/IPTables.Net/NfAcct/NfAcct.cs @@ -1,103 +1,45 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Xml; using System.Xml.Linq; using SystemInteract; - +using IPTables.Net.Exceptions; +using IPTables.Net.Supporting; namespace IPTables.Net.NfAcct { public class NfAcct { - private ISystemFactory _system; - - public NfAcct(ISystemFactory system) + private readonly ISystemFactory _system; + public NfAcct(ISystemFactory system) { _system = system; } + private string Execute(params string[] arguments) { - _system = system; + using var process = _system.StartProcess("/usr/sbin/nfacct", ShellHelper.BuildArgumentString(arguments)); + ProcessHelper.ReadToEnd(process, out var output, out var error); + if (process.ExitCode != 0) throw new IpTablesNetException($"nfacct exited with {process.ExitCode}: {error} {output}"); + return output; } - - private NfAcctUsage FromXml(string output, string name) + private static List Parse(string output) { - XDocument doc; + if (string.IsNullOrWhiteSpace(output)) return new List(); try { - doc = XDocument.Parse(output); + return XDocument.Parse(output).Descendants("obj").Select(node => + { + var name = (string)node.Element("name"); + if (string.IsNullOrEmpty(name) || !ulong.TryParse((string)node.Element("bytes"), out var bytes) || + !ulong.TryParse((string)node.Element("pkts"), out var packets)) + throw new FormatException("Invalid nfacct object: name, bytes and pkts are required."); + return new NfAcctUsage(name, bytes, packets); + }).ToList(); } - catch (XmlException) - { - return null; - } - - var usages = from node in doc.Descendants("obj") - where node.Descendants("name").First().Value == name - select new NfAcctUsage(node.Descendants("name").First().Value, - ulong.Parse(node.Descendants("pkts").First().Value), - ulong.Parse(node.Descendants("bytes").First().Value)); - - return usages.FirstOrDefault(); - } - - public NfAcctUsage Get(string name, bool reset = false) - { - var cmd = "get {0} xml"; - if (reset) cmd += " reset"; - - string output, error; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - - if (output.Trim().Length == 0) return null; - - return FromXml(output, name); - } - - public bool Exist(string name) - { - return Get(name) != null; - } - - public void Add(string name) - { - var cmd = "add {0}"; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - public void Delete(string name) - { - var cmd = "del {0}"; - using (var process = _system.StartProcess("/usr/sbin/nfacct", string.Format(cmd, name))) - { - string output, error; - ProcessHelper.ReadToEnd(process, out output, out error); - } - } - - public List List(bool reset = false) - { - var cmd = "list xml"; - if (reset) cmd += " reset"; - - string output, error; - using (var process = _system.StartProcess("/usr/sbin/nfacct", cmd)) - { - ProcessHelper.ReadToEnd(process, out output, out error); - } - - //No XML returned for empty - if (output.Trim().Length == 0) return new List(); - - var doc = XDocument.Parse(output); - var usages = from node in doc.Descendants("obj") - select new NfAcctUsage(node.Descendants("name").First().Value, - ulong.Parse(node.Descendants("bytes").First().Value), - ulong.Parse(node.Descendants("pkts").First().Value)); - - return usages.ToList(); + catch (XmlException ex) { throw new FormatException("Invalid nfacct XML", ex); } } + public NfAcctUsage Get(string name, bool reset = false) => + Parse(Execute(reset ? new[] { "get", name, "xml", "reset" } : new[] { "get", name, "xml" })).FirstOrDefault(x => x.Name == name); + public bool Exist(string name) => Get(name) != null; + public void Add(string name) => Execute("add", name); + public void Delete(string name) => Execute("del", name); + public List List(bool reset = false) => Parse(Execute(reset ? new[] { "list", "xml", "reset" } : new[] { "list", "xml" })); } -} \ No newline at end of file +} diff --git a/iptables-plans/17-nfacct-client.md b/iptables-plans/17-nfacct-client.md new file mode 100644 index 0000000..2e0c127 --- /dev/null +++ b/iptables-plans/17-nfacct-client.md @@ -0,0 +1,24 @@ +# Gap 17: Accounting XML and command execution + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +No tests instantiate [NfAcct](../IPTables.Net/NfAcct/NfAcct.cs). [SingleNfacctRuleParseTests](../IPTables.Net.Tests/SingleNfacctRuleParseTests.cs) covers the iptables match module only. `FromXml` supplies packets then bytes to [NfAcctUsage](../IPTables.Net/NfAcct/NfAcctUsage.cs), whose constructor expects bytes then packets; `List` uses the opposite order. This is a concrete source-level inconsistency needing regression coverage. + +## Tests to add + +- Feed identical XML to `Get` and `List` with distinct counts such as 7 packets and 900 bytes; assert named properties and consistency. +- Cover multiple names, unknown names, empty output, malformed XML, missing fields, large unsigned counters, and invalid counter text. +- Record `Get`/`List` reset arguments, `Exist`, add/delete commands, and handling of names requiring escaping. +- Configure nonzero exits and stderr; establish an explicit command-failure contract rather than accepting silent failures by default. + +## Completion + +Add `NfAcctTests.cs` using `ISystemFactory` fakes. Fast tests validate counter semantics and commands without requiring nfacct installation. Reproduce the count-order mismatch before fixing it. + +## Implementation record + +Unify accounting XML parsing and correct swapped byte/packet counters. Preserve unsigned counters, escape object names, and report malformed records and command failures consistently. Add Get/List/reset/existence and process-disposal coverage. + +Validation: Fast suite: 537 passed, 27 skipped, zero failures. From c5335a0c904f9a81fda9e5b46f8a789823c96ea9 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:49:35 +0000 Subject: [PATCH 18/28] test: implement testing gap 18 - route-rule-controllers Replace route smoke checks with field/export assertions; cover table selection, empty and malformed listings, flags and command failures. Preserve existing table output, avoid null table injection, validate rule priorities, and reject nonzero command exits. Validation: Fast suite: 551 passed, 27 skipped, zero failures. --- .../IpControllerBoundaryTests.cs | 48 +++++++++++++++++++ IPTables.Net.Tests/IpUtilsRouteTests.cs | 9 ++-- IPTables.Net/IpUtils/Utils/IpController.cs | 7 ++- .../IpUtils/Utils/IpRouteController.cs | 4 +- .../IpUtils/Utils/IpRuleController.cs | 7 ++- iptables-plans/18-route-rule-controllers.md | 24 ++++++++++ 6 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 IPTables.Net.Tests/IpControllerBoundaryTests.cs create mode 100644 iptables-plans/18-route-rule-controllers.md diff --git a/IPTables.Net.Tests/IpControllerBoundaryTests.cs b/IPTables.Net.Tests/IpControllerBoundaryTests.cs new file mode 100644 index 0000000..6cc3fc2 --- /dev/null +++ b/IPTables.Net.Tests/IpControllerBoundaryTests.cs @@ -0,0 +1,48 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class IpControllerBoundaryTests +{ + [Theory] + [InlineData(null, false)] [InlineData("blue", true)] [InlineData("default", false)] [InlineData("all", false)] + public void ListingAddsOnlyAnExplicitTableWithoutOverwritingOutput(string table, bool added) + { + var fake = new ScriptedSystem { Respond = (_, _) => new("default via 192.0.2.1 dev eth0\n192.0.2.0/24 dev eth1 table green\n") }; + var routes = new IpRouteController(fake).GetAll(table); + Assert.Equal(2, routes.Count); Assert.Equal(added, routes[0].Pairs.ContainsKey("table")); + if (added) Assert.Equal(table, routes[0].Pairs["table"]); + Assert.Equal("green", routes[1].Pairs["table"]); + Assert.Equal(table == null ? "route show" : "route show table " + table, Assert.Single(fake.Calls).Arguments); + } + [Fact] + public void EmptyListingsAndMulticastParsing() + { + var routes = new IpRouteController(new ScriptedSystem()); Assert.Empty(routes.GetAll()); + Assert.Empty(new IpRuleController(new ScriptedSystem()).GetAll()); + var route = routes.ParseObject("multicast 224.0.0.0/4 dev eth0 onlink"); + Assert.Equal("224.0.0.0/4", route.Pairs["multicast"]); Assert.Contains("onlink", route.Singles); + Assert.Equal("multicast 224.0.0.0/4 dev eth0 onlink", string.Join(" ", routes.ExportObject(route))); + } + [Theory] + [InlineData(false, "default dev")] [InlineData(false, "onlink")] + [InlineData(false, "default dev eth0 dev eth1")] [InlineData(true, "bad: from all")] + [InlineData(true, "100: from")] [InlineData(true, "not")] + public void MalformedListingIncludesOffendingLine(bool rule, string line) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(line) }; + var error = Assert.Throws(() => { if (rule) new IpRuleController(fake).GetAll(); else new IpRouteController(fake).GetAll(); }); + Assert.Contains(line, error.Message); Assert.NotNull(error.InnerException); + } + [Theory] + [InlineData(false, "", "")] [InlineData(true, "", "denied")] [InlineData(false, "failed", "")] + public void AllOperationsRejectNonzeroExit(bool rule, string output, string error) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(output, error, 2) }; + IpController controller = rule ? new IpRuleController(fake) : new IpRouteController(fake); + Assert.Contains("exited with 2", Assert.Throws(() => controller.Add("default")).Message); + Assert.Throws(() => controller.Delete("default")); + Assert.Throws(() => { if (rule) ((IpRuleController)controller).GetAll(); else ((IpRouteController)controller).GetAll(); }); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } +} diff --git a/IPTables.Net.Tests/IpUtilsRouteTests.cs b/IPTables.Net.Tests/IpUtilsRouteTests.cs index aea0921..0a72ce8 100644 --- a/IPTables.Net.Tests/IpUtilsRouteTests.cs +++ b/IPTables.Net.Tests/IpUtilsRouteTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -20,6 +20,9 @@ public void TestParseRule() var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("default via 199.19.225.1 dev eth0", "to"); var two = ipUtils.ParseObjectInternal("10.128.1.0/24 dev tap0 proto kernel scope link src 10.128.1.201", "to"); + Assert.Equal("default", one.Pairs["to"]); Assert.Equal("199.19.225.1", one.Pairs["via"]); + Assert.Equal("eth0", one.Pairs["dev"]); Assert.Equal("10.128.1.0/24", two.Pairs["to"]); + Assert.Equal("tap0", two.Pairs["dev"]); Assert.Equal("10.128.1.201", two.Pairs["src"]); } [Fact] @@ -28,7 +31,7 @@ public void TestParseRuleLocal() var systemFactory = new MockIptablesSystemFactory(); var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("local default dev lo table 100 scope host", "to"); - ipUtils.ExportObject(one); + Assert.Equal("local default dev lo table 100 scope host", string.Join(" ", ipUtils.ExportObject(one))); } [Fact] public void TestParseRuleAnycastV6() @@ -36,7 +39,7 @@ public void TestParseRuleAnycastV6() var systemFactory = new MockIptablesSystemFactory(); var ipUtils = new IpRouteController(systemFactory); var one = ipUtils.ParseObjectInternal("anycast fe80:: dev tap0 table local proto kernel metric 0 pref medium", "to"); - ipUtils.ExportObject(one); + Assert.Equal("fe80:: dev tap0 table local proto kernel metric 0 pref medium anycast", string.Join(" ", ipUtils.ExportObject(one))); } } } diff --git a/IPTables.Net/IpUtils/Utils/IpController.cs b/IPTables.Net/IpUtils/Utils/IpController.cs index ae52853..af0b0f8 100644 --- a/IPTables.Net/IpUtils/Utils/IpController.cs +++ b/IPTables.Net/IpUtils/Utils/IpController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using SystemInteract; @@ -56,6 +56,7 @@ protected virtual IpObject ParseObject(string str, string firstKey, char[] first if (firstKey != null) { + if (i >= strs.Length) throw new IpTablesNetException("Missing value for " + firstKey); var v = strs[i]; if (firstTrimChars != null) v = v.TrimEnd(firstTrimChars); ret.Pairs.Add(firstKey, v); @@ -91,9 +92,7 @@ protected string[] Command(string command, params string[] args) { string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - /*Console.WriteLine(cmd); - Console.WriteLine("output" + output); - Console.WriteLine("error" + error);*/ + if (process.ExitCode != 0) throw new IpControllerException($"ip {cmd.TrimEnd()} exited with {process.ExitCode}: {error} {output}"); return new string[] {output.Trim(), error.Trim()}; } } diff --git a/IPTables.Net/IpUtils/Utils/IpRouteController.cs b/IPTables.Net/IpUtils/Utils/IpRouteController.cs index d2b2462..b54bd72 100644 --- a/IPTables.Net/IpUtils/Utils/IpRouteController.cs +++ b/IPTables.Net/IpUtils/Utils/IpRouteController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using SystemInteract; using IPTables.Net.Exceptions; @@ -39,7 +39,7 @@ public List GetAll(string table = null) if (obj != null) { - if (table != "default" && table != "all") obj.Pairs.Add("table", table); + if (table != null && table != "default" && table != "all" && !obj.Pairs.ContainsKey("table")) obj.Pairs.Add("table", table); r.Add(obj); } } diff --git a/IPTables.Net/IpUtils/Utils/IpRuleController.cs b/IPTables.Net/IpUtils/Utils/IpRuleController.cs index fe61a76..9399741 100644 --- a/IPTables.Net/IpUtils/Utils/IpRuleController.cs +++ b/IPTables.Net/IpUtils/Utils/IpRuleController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using SystemInteract; using IPTables.Net.Exceptions; @@ -44,7 +44,10 @@ public List GetAll() public IpObject ParseObject(string str) { - return ParseObject(str, "pref", new[] {':'}); + var result = ParseObject(str, "pref", new[] { ':' }); + if (result != null && !uint.TryParse(result.Pairs["pref"], out _)) + throw new IpTablesNetException("Invalid rule priority: " + result.Pairs["pref"]); + return result; } internal override string[] ExportObject(IpObject obj) diff --git a/iptables-plans/18-route-rule-controllers.md b/iptables-plans/18-route-rule-controllers.md new file mode 100644 index 0000000..f70d5b5 --- /dev/null +++ b/iptables-plans/18-route-rule-controllers.md @@ -0,0 +1,24 @@ +# Gap 18: Route/rule controller assertions and command errors + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +All three [IpUtilsRouteTests](../IPTables.Net.Tests/IpUtilsRouteTests.cs) lack assertions. Rule tests cover basic successful commands, but [IpController](../IPTables.Net/IpUtils/Utils/IpController.cs), [IpRouteController](../IPTables.Net/IpUtils/Utils/IpRouteController.cs), and [IpRuleController](../IPTables.Net/IpUtils/Utils/IpRuleController.cs) contain untested listing, error, and malformed-output paths. `Command` discards exit status. + +## Tests to add + +- Replace route smoke checks with exact destination, gateway, device, flags, table, and exported-argument assertions, including local/multicast/anycast routes. +- Cover route `GetAll` with null, named, `default`, and `all` tables; include output already containing a table and empty/multiple lines. +- Exercise missing key values, flag-only input, duplicate keys, malformed priorities, and line-context wrapping for both controllers. +- Test add/delete/show failures with stderr, stdout, and nonzero exit with empty streams; specify a consistent failure contract. + +## Completion + +Extend route/rule tests with process fakes and run the fast suite. Tests must fail when destination/table data or an execution error is silently lost; no host routes should be modified. + +## Implementation record + +Replace route smoke checks with field/export assertions; cover table selection, empty and malformed listings, flags and command failures. Preserve existing table output, avoid null table injection, validate rule priorities, and reject nonzero command exits. + +Validation: Fast suite: 551 passed, 27 skipped, zero failures. From 5c6901e5786b97719417730bb3c125e19af37501 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:50:39 +0000 Subject: [PATCH 19/28] test: implement testing gap 19 - ip-object-sync Add reconciliation set-difference, duplicate, idempotency and failure-order tests plus object identity and clone ownership coverage. Snapshot and deduplicate current objects before mutation; compare and hash object fields independently of dictionary insertion order. Validation: Fast suite: 562 passed, 27 skipped, zero failures. --- IPTables.Net.Tests/DefaultIpuSyncTests.cs | 38 +++++++++++++++++++++ IPTables.Net.Tests/IpObjectTests.cs | 23 +++++++++++++ IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs | 4 +-- IPTables.Net/IpUtils/Utils/IpObject.cs | 16 ++++----- iptables-plans/19-ip-object-sync.md | 25 ++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 IPTables.Net.Tests/DefaultIpuSyncTests.cs create mode 100644 IPTables.Net.Tests/IpObjectTests.cs create mode 100644 iptables-plans/19-ip-object-sync.md diff --git a/IPTables.Net.Tests/DefaultIpuSyncTests.cs b/IPTables.Net.Tests/DefaultIpuSyncTests.cs new file mode 100644 index 0000000..e9be1bf --- /dev/null +++ b/IPTables.Net.Tests/DefaultIpuSyncTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.IpUtils.Sync; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class DefaultIpuSyncTests +{ + private static IpObject Item(string key) => new() { Pairs = new() { ["to"] = key } }; + private sealed class Controller : IpController + { + public List State = new(); public List Log = new(); public string Fail; + public Controller() : base("route", null) { } + public override void Add(IpObject item) { Log.Add("add " + item.Pairs["to"]); if (Fail == "add") throw new InvalidOperationException("add"); State.Add(item); } + public override void Delete(IpObject item) { Log.Add("del " + item.Pairs["to"]); if (Fail == "del") throw new InvalidOperationException("del"); State.RemoveAll(x => x.Equals(item)); } + } + [Theory] + [InlineData("", "", "")] [InlineData("a", "a", "")] + [InlineData("a", "b", "del a,add b")] [InlineData("a,b", "b,c", "del a,add c")] + [InlineData("a,a,b", "a,a", "del b")] [InlineData("", "a,a", "add a")] + public void SetDifferencesAndRepeatAreIdempotent(string current, string desired, string expected) + { + static List Parse(string input) => input.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(Item).ToList(); + var controller = new Controller { State = Parse(current) }; var sync = new DefaultIpuSync(controller, () => controller.State); + sync.Sync(Parse(desired)); Assert.Equal(expected, string.Join(",", controller.Log)); + controller.Log.Clear(); sync.Sync(Parse(desired)); Assert.Empty(controller.Log); + Assert.True(new HashSet(Parse(desired)).SetEquals(controller.State)); + } + [Theory] + [InlineData("get", "", "a")] [InlineData("del", "del a", "a")] [InlineData("add", "del a,add b", "")] + public void FailuresPropagateWithoutPretendingToRollback(string fail, string expected, string state) + { + var controller = new Controller { State = new() { Item("a") }, Fail = fail }; + var sync = new DefaultIpuSync(controller, () => fail == "get" ? throw new InvalidOperationException("get") : controller.State); + Assert.Equal(fail, Assert.Throws(() => sync.Sync(new[] { Item("b") })).Message); + Assert.Equal(expected, string.Join(",", controller.Log)); Assert.Equal(state, string.Join(",", controller.State.Select(x => x.Pairs["to"]))); + } +} diff --git a/IPTables.Net.Tests/IpObjectTests.cs b/IPTables.Net.Tests/IpObjectTests.cs new file mode 100644 index 0000000..f6ccc1a --- /dev/null +++ b/IPTables.Net.Tests/IpObjectTests.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using IPTables.Net.IpUtils.Utils; +namespace IPTables.Net.Tests; +public class IpObjectTests +{ + [Fact] + public void IdentityIgnoresInsertionOrderAndIncludesValuesAndFlags() + { + var a = new IpObject { Pairs = new() { ["to"] = "default", ["dev"] = "eth0" }, Singles = new() { "onlink", "linkdown" } }; + var b = new IpObject { Pairs = new() { ["dev"] = "eth0", ["to"] = "default" }, Singles = new() { "linkdown", "onlink" } }; + Assert.Equal(a, b); Assert.Equal(a.GetHashCode(), b.GetHashCode()); Assert.Contains(b, new HashSet { a }); + b.Pairs["dev"] = "eth1"; Assert.NotEqual(a, b); + b.Pairs["dev"] = "eth0"; b.Singles.Remove("onlink"); Assert.NotEqual(a, b); Assert.False(a.Equals(null)); + } + [Fact] + public void CloneOwnsBothCollections() + { + var source = new IpObject { Pairs = new() { ["dev"] = "eth0" }, Singles = new() { "onlink" } }; var clone = source.Clone(); + Assert.Equal(source, clone); Assert.NotSame(source.Pairs, clone.Pairs); Assert.NotSame(source.Singles, clone.Singles); + clone.Pairs["dev"] = "eth1"; clone.Singles.Clear(); + Assert.Equal("eth0", source.Pairs["dev"]); Assert.Contains("onlink", source.Singles); + } +} diff --git a/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs b/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs index d1d2f9b..f943e4f 100644 --- a/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs +++ b/IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.IpUtils.Utils; @@ -19,7 +19,7 @@ public void Sync(IEnumerable with) { var objects = new HashSet(with); - foreach (var ipobj in _getter()) + foreach (var ipobj in new HashSet(_getter())) if (!objects.Contains(ipobj)) _controller.Delete(ipobj); else diff --git a/IPTables.Net/IpUtils/Utils/IpObject.cs b/IPTables.Net/IpUtils/Utils/IpObject.cs index 7487e29..e2ec061 100644 --- a/IPTables.Net/IpUtils/Utils/IpObject.cs +++ b/IPTables.Net/IpUtils/Utils/IpObject.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +23,7 @@ public bool Equals(IpObject other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return Pairs.OrderBy((a) => a.GetHashCode()).SequenceEqual(other.Pairs.OrderBy((a) => a.GetHashCode())) && + return Pairs.OrderBy(a => a.Key, StringComparer.Ordinal).SequenceEqual(other.Pairs.OrderBy(a => a.Key, StringComparer.Ordinal)) && Singles.SetEquals(other.Singles); } @@ -37,11 +37,11 @@ public override bool Equals(object obj) public override int GetHashCode() { - unchecked - { - return Singles.OfType().Union(Pairs.OfType()).OrderBy((a) => a.GetHashCode()) - .Aggregate(13, (current, m) => current * 397 + m.GetHashCode()); - } + var hash = new HashCode(); + foreach (var pair in Pairs.OrderBy(a => a.Key, StringComparer.Ordinal)) + { hash.Add(pair.Key, StringComparer.Ordinal); hash.Add(pair.Value, StringComparer.Ordinal); } + foreach (var single in Singles.OrderBy(a => a, StringComparer.Ordinal)) hash.Add(single, StringComparer.Ordinal); + return hash.ToHashCode(); } } -} \ No newline at end of file +} diff --git a/iptables-plans/19-ip-object-sync.md b/iptables-plans/19-ip-object-sync.md new file mode 100644 index 0000000..2ff2457 --- /dev/null +++ b/iptables-plans/19-ip-object-sync.md @@ -0,0 +1,25 @@ +# Gap 19: Route/rule reconciliation and object identity + +Priority: P1. Status: implemented; see implementation record below. + +## Evidence + +No existing tests reference [DefaultIpuSync](../IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs). Its set-difference algorithm depends on [IpObject](../IPTables.Net/IpUtils/Utils/IpObject.cs) equality and hashing; those implementations order entries by hash and expose mutable collections. Parser tests do not establish reconciliation correctness. + +## Tests to add + +- Record adds/deletes for empty, identical, disjoint, and partially overlapping current/desired objects. +- Cover duplicate desired/current objects, reordered dictionary insertion, different flag order, and second-run idempotency. +- Verify equal objects produce equal hashes and work in a `HashSet`; different pair values or flags must remain distinguishable, including a controlled collision case if practical. +- Verify cloning copies both collections independently. +- Inject getter/add/delete failures and assert propagation and the operations completed before failure; do not assume rollback support. + +## Completion + +Add `DefaultIpuSyncTests.cs` and `IpObjectTests.cs` using a recording controller. Run the fast suite with exact operation/state assertions and no actual `ip` commands. + +## Implementation record + +Add reconciliation set-difference, duplicate, idempotency and failure-order tests plus object identity and clone ownership coverage. Snapshot and deduplicate current objects before mutation; compare and hash object fields independently of dictionary insertion order. + +Validation: Fast suite: 562 passed, 27 skipped, zero failures. From 12651d486b318878bbaf9c8491390914e44c5efb Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:52:01 +0000 Subject: [PATCH 20/28] test: implement testing gap 20 - address-port-boundaries Establish CIDR containment/count/rebase boundaries for both families, reject malformed prefixes and ranges, and preserve explicit zero-address prefixes. Keep generic uint ranges distinct from endpoint port limits; support bracketed IPv6 single endpoints while preserving the legacy invalid-input fallback. Validation: Fast suite: 596 passed, 27 skipped, zero failures. --- .../AddressPortBoundaryTests.cs | 59 +++++++++++++++++++ .../Iptables/DataTypes/IPPortOrRange.cs | 28 +++------ IPTables.Net/Iptables/DataTypes/IpCidr.cs | 47 +++++---------- IPTables.Net/Iptables/DataTypes/IpPort.cs | 27 +++------ .../Iptables/DataTypes/PortOrRange.cs | 4 +- iptables-plans/20-address-port-boundaries.md | 24 ++++++++ 6 files changed, 118 insertions(+), 71 deletions(-) create mode 100644 IPTables.Net.Tests/AddressPortBoundaryTests.cs create mode 100644 iptables-plans/20-address-port-boundaries.md diff --git a/IPTables.Net.Tests/AddressPortBoundaryTests.cs b/IPTables.Net.Tests/AddressPortBoundaryTests.cs new file mode 100644 index 0000000..8c73c98 --- /dev/null +++ b/IPTables.Net.Tests/AddressPortBoundaryTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Net; +using System.Numerics; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.DataTypes; +namespace IPTables.Net.Tests; +public class AddressPortBoundaryTests +{ + [Theory] + [InlineData("192.0.2.129/24", "192.0.2.0", "192.0.2.255", "192.0.3.0", 256)] + [InlineData("2001:db8::81/120", "2001:db8::", "2001:db8::ff", "2001:db8::100", 256)] + public void NetworkBoundariesAndRebase(string text, string start, string end, string outside, int count) + { + var cidr = IpCidr.Parse(text); Assert.Equal(new BigInteger(count), cidr.Addresses); + Assert.True(cidr.Contains(IPAddress.Parse(start))); Assert.True(cidr.Contains(IPAddress.Parse(end))); Assert.False(cidr.Contains(IPAddress.Parse(outside))); + Assert.True(cidr.Contains(IpCidr.Parse(end))); Assert.False(IpCidr.Parse(end).Contains(cidr)); + Assert.Equal(IPAddress.Parse(start), IpCidr.NewRebase(cidr.Address, cidr.Prefix).Address); + Assert.True(IpCidr.Parse(start).CompareTo(IpCidr.Parse(end)) < 0); + } + [Theory] + [InlineData("0.0.0.0/0", 32)] [InlineData("::/0", 128)] [InlineData("0.0.0.0/32", 0)] [InlineData("::1/128", 0)] + public void ZeroAndHostPrefixesPreserveCounts(string text, int power) + { var value = IpCidr.Parse(text); Assert.Equal(BigInteger.Pow(2, power), value.Addresses); Assert.Equal(value, IpCidr.Parse(value.ToString())); } + [Theory] + [InlineData("0.0.0.0/33")] [InlineData("1.2.3.4/-1")] [InlineData("::/129")] [InlineData("::/4294967296")] + [InlineData("1.2.3.4/24/1")] [InlineData("1.2.3.4/")] + public void InvalidPrefixesFail(string text) => Assert.Throws(() => IpCidr.Parse(text)); + [Fact] + public void FamiliesNeverContainOneAnotherAndRebaseValidatesPrefix() + { + Assert.False(IpCidr.Parse("0.0.0.0/0").Contains(IPAddress.IPv6Loopback)); + Assert.False(IpCidr.Parse("::/0").Contains(IpCidr.Any)); + Assert.Throws(() => IpCidr.NewRebase(IPAddress.Any, 33)); + Assert.Equal(24u, IpCidr.Parse("0.0.0.0/24").Prefix); + } + [Theory] + [InlineData("0")] [InlineData("65535")] [InlineData("65536")] [InlineData("4294967295")] [InlineData("0:65536")] + public void GenericNumericRangesAreNotLimitedToPorts(string text) => Assert.Equal(text, PortOrRange.Parse(text, ':').ToString()); + [Theory] + [InlineData("2:1")] [InlineData(":1")] [InlineData("1:")] [InlineData("1:2:3")] [InlineData("4294967296")] + public void InvalidNumericRangesFail(string text) => Assert.ThrowsAny(() => PortOrRange.Parse(text, ':')); + [Theory] + [InlineData("192.0.2.1:65535")] [InlineData("192.0.2.1-192.0.2.2:80-90")] + [InlineData("[2001:db8::1]:80")] [InlineData("[2001:db8::1]-[2001:db8::2]:80-90")] + [InlineData("2001:db8::1")] [InlineData("192.0.2.1")] + public void AddressRangesRoundTrip(string text) + { var parsed = IPPortOrRange.Parse(text); Assert.Equal(text, parsed.ToString()); Assert.Equal(parsed.Port, IPPortOrRange.Parse(parsed.ToString()).Port); } + [Theory] + [InlineData("192.0.2.1:65536")] [InlineData("192.0.2.2-192.0.2.1")] + [InlineData("[::1]:90-80")] [InlineData("999.1.1.1")] + public void InvalidAddressRangesFail(string text) => Assert.ThrowsAny(() => IPPortOrRange.Parse(text)); + [Fact] + public void LegacySingleEndpointFallbackAndIpv6Rendering() + { + foreach (var invalid in new[] { "bad", "192.0.2.1:65536", "192.0.2.1:abc", "192.0.2.1:1:2" }) Assert.Equal(IpPort.Any, IpPort.Parse(invalid)); + Assert.Equal("[::1]:80", IpPort.Parse("[::1]:80").ToString()); + Assert.Equal("192.0.2.1:0", IpPort.Parse("192.0.2.1").ToString()); + } +} diff --git a/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs b/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs index b0e20c8..965baf5 100644 --- a/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs +++ b/IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Net; +using System.Numerics; using System.Net.Sockets; using System.Text.RegularExpressions; using IPTables.Net.Exceptions; @@ -17,29 +18,18 @@ public struct IPPortOrRange public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress, PortOrRange port) { + if (lowerAddress.AddressFamily != upperAddress.AddressFamily || + new BigInteger(lowerAddress.GetAddressBytes(), true, true) > new BigInteger(upperAddress.GetAddressBytes(), true, true)) + throw new ArgumentException("Invalid address range"); + if (port.UpperPort > ushort.MaxValue) throw new ArgumentOutOfRangeException(nameof(port)); _lowerAddress = lowerAddress; _upperAddress = upperAddress; _port = port; } - public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress) - { - _lowerAddress = lowerAddress; - _upperAddress = upperAddress; - _port = PortOrRange.Any; - } - - public IPPortOrRange(IPAddress lowerAddress, PortOrRange port) - { - _upperAddress = _lowerAddress = lowerAddress; - _port = port; - } - - public IPPortOrRange(IPAddress lowerAddress) - { - _upperAddress = _lowerAddress = lowerAddress; - _port = PortOrRange.Any; - } + public IPPortOrRange(IPAddress lowerAddress, IPAddress upperAddress) : this(lowerAddress, upperAddress, PortOrRange.Any) { } + public IPPortOrRange(IPAddress lowerAddress, PortOrRange port) : this(lowerAddress, lowerAddress, port) { } + public IPPortOrRange(IPAddress lowerAddress) : this(lowerAddress, lowerAddress, PortOrRange.Any) { } public IPAddress LowerAddress => _lowerAddress; diff --git a/IPTables.Net/Iptables/DataTypes/IpCidr.cs b/IPTables.Net/Iptables/DataTypes/IpCidr.cs index 2a33866..a381261 100644 --- a/IPTables.Net/Iptables/DataTypes/IpCidr.cs +++ b/IPTables.Net/Iptables/DataTypes/IpCidr.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Net.Sockets; using System.Numerics; @@ -18,6 +18,9 @@ public struct IpCidr : IEquatable, IComparable, IComparable public IpCidr(IPAddress address, uint prefix) { + ArgumentNullException.ThrowIfNull(address); + if (prefix > (address.AddressFamily == AddressFamily.InterNetworkV6 ? 128u : 32u)) + throw new ArgumentOutOfRangeException(nameof(prefix)); Address = address; Prefix = prefix; } @@ -50,6 +53,7 @@ public bool Equals(IpCidr other) public static IpCidr Parse(string cidr) { var p = cidr.Split(new[] {'/'}); + if (p.Length > 2) throw new IpTablesNetException("Invalid CIDR components"); IPAddress ip; try { @@ -62,7 +66,6 @@ public static IpCidr Parse(string cidr) if (p.Length == 1) return new IpCidr(ip); - if (Equals(ip, IPAddress.Any)) return new IpCidr(ip, 0); try { @@ -129,25 +132,16 @@ public int CompareTo(object obj) public bool Contains(IpCidr cidr) { - var thisNetwork = GetIPNetwork(); - var innerNetwork = cidr.GetIPNetwork(); - - if (thisNetwork.Network.ToInt() <= innerNetwork.Network.ToInt() && - thisNetwork.Broadcast.ToInt() >= innerNetwork.Broadcast.ToInt()) - return true; - - return false; + return Address.AddressFamily == cidr.Address.AddressFamily && Prefix <= cidr.Prefix && Contains(cidr.Address); } public bool Contains(IPAddress addr) { - var thisNetwork = GetIPNetwork(); - var innerNetwork = addr.ToInt(); - if (thisNetwork.Network.ToInt() <= innerNetwork && - thisNetwork.Broadcast.ToInt() >= innerNetwork) - return true; - - return false; + if (Address.AddressFamily != addr.AddressFamily) return false; + var outer = Address.GetAddressBytes(); var inner = addr.GetAddressBytes(); + for (int bit = 0; bit < Prefix; bit++) + if ((outer[bit / 8] & (128 >> (bit % 8))) != (inner[bit / 8] & (128 >> (bit % 8)))) return false; + return true; } public override bool Equals(object obj) @@ -162,19 +156,10 @@ public override int GetHashCode() public static IpCidr NewRebase(IPAddress findAddress, uint u) { - // IPv4 - if (findAddress.AddressFamily == AddressFamily.InterNetwork) - { - if (u == 32) return new IpCidr(findAddress, u); - var iAddr = findAddress.ToInt() & ~(long) (Math.Pow(2, 32 - u) - 1); - var ip = IPAddressExtension.ToAddr(iAddr); - return new IpCidr(ip, u); - } - - // IPv6 - if (u == 128) return new IpCidr(findAddress, u); - var ipNet = IPNetwork2::System.Net.IPNetwork.Parse(findAddress.ToString(), (byte) u); - return new IpCidr(ipNet.Network, u); + _ = new IpCidr(findAddress, u); // Validate before changing address bits. + var bytes = findAddress.GetAddressBytes(); + for (int bit = (int)u; bit < bytes.Length * 8; bit++) bytes[bit / 8] &= (byte)~(128 >> (bit % 8)); + return new IpCidr(new IPAddress(bytes), u); } } -} \ No newline at end of file +} diff --git a/IPTables.Net/Iptables/DataTypes/IpPort.cs b/IPTables.Net/Iptables/DataTypes/IpPort.cs index ae89559..942de29 100644 --- a/IPTables.Net/Iptables/DataTypes/IpPort.cs +++ b/IPTables.Net/Iptables/DataTypes/IpPort.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; namespace IPTables.Net.Iptables.DataTypes @@ -17,32 +17,19 @@ public IpPort(IPAddress address, uint port) public static IpPort Parse(string ipPort) { - var p = ipPort.Split(new[] {':'}); - IPAddress ip; try { - ip = IPAddress.Parse(p[0]); - } - catch (Exception) - { - return Any; - } - - if (p.Length != 2) return new IpPort(ip, 0); - - try - { - return new IpPort(ip, uint.Parse(p[1])); - } - catch (Exception) - { - return Any; + var parsed = IPPortOrRange.Parse(ipPort); + if (!parsed.LowerAddress.Equals(parsed.UpperAddress) || parsed.Port.IsRange()) return Any; + return new IpPort(parsed.LowerAddress, parsed.Port.LowerPort); } + catch (Exception ex) when (ex is ArgumentException || ex is FormatException || ex is OverflowException) + { return Any; } } public override string ToString() { - return Address + ":" + Port; + return (Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? "[" + Address + "]" : Address.ToString()) + ":" + Port; } } } \ No newline at end of file diff --git a/IPTables.Net/Iptables/DataTypes/PortOrRange.cs b/IPTables.Net/Iptables/DataTypes/PortOrRange.cs index dc19644..7c3d0ad 100644 --- a/IPTables.Net/Iptables/DataTypes/PortOrRange.cs +++ b/IPTables.Net/Iptables/DataTypes/PortOrRange.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace IPTables.Net.Iptables.DataTypes { @@ -11,6 +11,7 @@ public struct PortOrRange : IEquatable public PortOrRange(uint lowerPort, uint upperPort, char splitChar = ':') { + if (lowerPort > upperPort) throw new ArgumentOutOfRangeException(nameof(upperPort)); _lowerPort = lowerPort; _upperPort = upperPort; _splitChar = splitChar; @@ -43,6 +44,7 @@ public static PortOrRange Parse(string getNextArg, char splitChar) var split = getNextArg.Split(new[] {splitChar}); if (split.Length == 1) return new PortOrRange(uint.Parse(split[0]), splitChar); + if (split.Length != 2) throw new FormatException("Expected one value or two range endpoints"); return new PortOrRange(uint.Parse(split[0]), uint.Parse(split[1]), splitChar); } diff --git a/iptables-plans/20-address-port-boundaries.md b/iptables-plans/20-address-port-boundaries.md new file mode 100644 index 0000000..54a4d5d --- /dev/null +++ b/iptables-plans/20-address-port-boundaries.md @@ -0,0 +1,24 @@ +# Gap 20: CIDR and address/port boundary contracts + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +[IpSetCidrTests](../IPTables.Net.Tests/IpSetCidrTests.cs) covers several IPv4 synchronization examples, and [PortRangeHelpersTests](../IPTables.Net.Tests/PortRangeHelpersTests.cs) covers compression/counting. These do not directly validate all contracts of [IpCidr](../IPTables.Net/Iptables/DataTypes/IpCidr.cs), [PortOrRange](../IPTables.Net/Iptables/DataTypes/PortOrRange.cs), [IpPort](../IPTables.Net/Iptables/DataTypes/IpPort.cs), and [IPPortOrRange](../IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs). + +## Tests to add + +- Check CIDR `/0`, host prefixes, exact network/broadcast endpoints, adjacent networks, rebasing, address counts, ordering, and mixed-family containment. +- Specify behavior for invalid/negative/overflow prefixes and extra slash components, including `0.0.0.0` with an explicit nonzero prefix. +- Cover port 0, 65535, 65536, reversed ranges, missing endpoints, extra separators, and numeric overflow. Distinguish port-specific validation from generic numeric range usage. +- Validate IPv4 and bracketed IPv6 address ranges, omitted ports, invalid addresses, and parse/render stability; define the legacy `IpPort` fallback contract separately. + +## Completion + +Add focused data-type theories with independently calculated results. Run the fast suite. Boundary expectations must be explicit before changing permissive parsing behavior. + +## Implementation record + +Establish CIDR containment/count/rebase boundaries for both families, reject malformed prefixes and ranges, and preserve explicit zero-address prefixes. Keep generic uint ranges distinct from endpoint port limits; support bracketed IPv6 single endpoints while preserving the legacy invalid-input fallback. + +Validation: Fast suite: 596 passed, 27 skipped, zero failures. From a0edb35bb821bf850ab1d26d8cd4a1962aa1759e Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:53:13 +0000 Subject: [PATCH 21/28] test: implement testing gap 21 - rule-generator-boundaries Cover multiport boundaries, TCP/UDP source/destination ports, goto targets, custom mangle base rules, ipset output and empty nested generators. Merge overlapping/duplicate ranges correctly, validate callbacks, support omitted jump setters and remove empty nested chains without dangling jumps; fix empty-group logging. Validation: Fast suite: 603 passed, 27 skipped, zero failures. --- .../RuleGeneratorBoundaryTests.cs | 79 +++++++++++++++++++ .../Iptables/Helpers/PortRangeHelpers.cs | 42 ++-------- .../Iptables/RuleGenerator/FeatureSplitter.cs | 15 ++-- .../RuleGenerator/MultiportAggregator.cs | 16 ++-- .../21-rule-generator-boundaries.md | 24 ++++++ 5 files changed, 127 insertions(+), 49 deletions(-) create mode 100644 IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs create mode 100644 iptables-plans/21-rule-generator-boundaries.md diff --git a/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs b/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs new file mode 100644 index 0000000..883d577 --- /dev/null +++ b/IPTables.Net.Tests/RuleGeneratorBoundaryTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using IPTables.Net.Exceptions; +using IPTables.Net.IpSet; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.DataTypes; +using IPTables.Net.Iptables.Helpers; +using IPTables.Net.Iptables.Modules.Core; +using IPTables.Net.Iptables.Modules.Tcp; +using IPTables.Net.Iptables.Modules.Udp; +using IPTables.Net.Iptables.RuleGenerator; +namespace IPTables.Net.Tests; +public class RuleGeneratorBoundaryTests +{ + [Theory] + [InlineData(14, false, false, false)] [InlineData(15, true, false, false)] + [InlineData(16, false, true, false)] [InlineData(14, true, true, true)] + public void SlotLimitsPreservePortsTargetsAndBaseMatches(int count, bool tcp, bool source, bool range) + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); + var rules = new IpTablesRuleSet(4, system); rules.AddChain("TARGET", "mangle"); + var inputs = Enumerable.Range(1, count).Select(x => new PortOrRange((uint)x * 10)).ToList(); + if (range) inputs.Add(new PortOrRange(200, 210)); + var batches = new List>(); var protocol = tcp ? "tcp" : "udp"; + var gen = new MultiportAggregator("INPUT", "mangle", _ => "group", + rule => tcp ? (source ? rule.GetModule("tcp").SourcePort.Value : rule.GetModule("tcp").DestinationPort.Value) : + (source ? rule.GetModule("udp").SourcePort.Value : rule.GetModule("udp").DestinationPort.Value), + (rule, ports) => { batches.Add(ports); if (source) PortRangeHelpers.SourcePortSetter(rule, ports); else PortRangeHelpers.DestinationPortSetter(rule, ports); }, + null, "gap", "-A INPUT -t mangle -i eth0"); + foreach (var port in inputs) gen.AddRule(IpTablesRule.Parse($"-A INPUT -t mangle -p {protocol} -m {protocol} --{(source ? "sport" : "dport")} {port} -g TARGET", system, rules.Chains)); + gen.Output(system, rules); + Assert.Equal(count == 16 || range ? 2 : 1, batches.Count); + Assert.All(batches, ports => Assert.InRange(ports.Sum(p => p.IsRange() ? 2 : 1), 1, 15)); + static IEnumerable Expand(IEnumerable ports) => ports.SelectMany(p => Enumerable.Range((int)p.LowerPort, (int)(p.UpperPort - p.LowerPort + 1)).Select(x => (uint)x)); + Assert.Equal(Expand(inputs).Order(), Expand(batches.SelectMany(x => x)).Order()); + foreach (var rule in rules.Rules) + { + var core = rule.GetModule("core"); Assert.Equal("eth0", core.InInterface.Value); + var target = core.TargetMode == TargetMode.Goto ? core.Goto : core.Jump; + Assert.True(rules.Chains.HasChain(target, "mangle")); + if (target == "TARGET") { Assert.Equal(TargetMode.Goto, core.TargetMode); Assert.Equal(protocol, core.Protocol.Value); } + } + Assert.Throws(() => gen.Output(system, rules)); + } + [Fact] + public void CompressionUnionsOverlapDuplicatesZeroAndUnsignedMaximum() + { + var compressed = PortRangeHelpers.CompressRanges(new() { new(0, 3), new(2, 5), new(5), new(6), new(uint.MaxValue) }); + Assert.Equal(new[] { new PortOrRange(0, 6), new PortOrRange(uint.MaxValue) }, compressed); + } + [Fact] + public void IpSetModeEmitsOneSetContainingEveryPort() + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); var rules = new IpTablesRuleSet(4, system); var sets = new IpSetSets(system); + var gen = new MultiportAggregator("INPUT", "filter", _ => "a", r => r.GetModule("udp").DestinationPort.Value, + (r, ports) => PortRangeHelpers.DestinationPortIpSetter(r, ports, "ports", sets), null, "gap", ipset: true); + for (int i = 1; i <= 16; i++) gen.AddRule(IpTablesRule.Parse($"-A INPUT -p udp -m udp --dport {i * 10} -j ACCEPT", system, rules.Chains)); + gen.Output(system, rules); + Assert.Equal(Enumerable.Range(1, 16).Select(x => (ushort)(x * 10)), Assert.Single(sets.Sets).Entries.Select(x => (ushort)x.Port).Order()); + Assert.Single(rules.Rules.Where(x => x.GetCommand().Contains("--match-set ports dst"))); + } + [Fact] + public void EmptyGroupsAndNestedGeneratorsLeaveNoDanglingJumps() + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); var rules = new IpTablesRuleSet(4, system); + var gen = new MultiportAggregator("INPUT", "filter", null, _ => new(80), (_, _) => { }, null, "gap"); + gen.Rules.Add("empty", new()); gen.Output(system, rules); Assert.Empty(rules.Chains); + var rule = IpTablesRule.Parse("-A INPUT -j ACCEPT", system, new IpTablesChainSet(4)); + Assert.Throws(() => gen.AddRule(rule)); + gen.AddRule(rule, "explicit"); gen.Output(system, rules); Assert.Single(rules.Rules); + var split = new FeatureSplitter("INPUT", "filter", _ => "key", (_, _) => { }, (_, _) => new Empty(), "gap"); + rules = new IpTablesRuleSet(4, system); split.AddRule(rule); split.Output(system, rules); Assert.Empty(rules.Rules); + Assert.All(rules.Chains, chain => Assert.Equal("INPUT", chain.Name)); + Assert.Throws(() => new MultiportAggregator("INPUT", "filter", null, null, (_, _) => { }, null, "gap")); + Assert.Throws(() => new FeatureSplitter("INPUT", "filter", null, (_, _) => { }, (_, _) => new Empty(), "gap")); + } + private sealed class Empty : IRuleGenerator { public void AddRule(IpTablesRule rule) { } public void Output(IpTablesSystem system, IpTablesRuleSet rules) { } } +} diff --git a/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs b/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs index 38443e1..a1e7d44 100644 --- a/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs +++ b/IPTables.Net/Iptables/Helpers/PortRangeHelpers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -17,43 +17,16 @@ public class PortRangeHelpers public static List CompressRanges(List ranges) { var ret = new List(); - var start = new PortOrRange(0); - int previous = -1, previousLower = -1; - foreach (var current in ranges.OrderBy((a) => a.LowerPort)) + foreach (var current in ranges.OrderBy(a => a.LowerPort).ThenBy(a => a.UpperPort)) { - if (current.LowerPort == previous + 1) - { - if (start.LowerPort == 0) start = new PortOrRange((uint) previousLower, current.UpperPort); - } + if (ret.Count == 0 || (ulong)current.LowerPort > (ulong)ret[ret.Count - 1].UpperPort + 1) + ret.Add(current); else { - if (start.UpperPort != 0) - { - ret.Add(new PortOrRange(start.LowerPort, (uint) previous)); - start = new PortOrRange(0); - } - else if (previous != -1) - { - ret.Add(new PortOrRange((uint) previousLower, (uint) previous)); - } + var previous = ret[ret.Count - 1]; + ret[ret.Count - 1] = new PortOrRange(previous.LowerPort, Math.Max(previous.UpperPort, current.UpperPort)); } - - previous = (int) current.UpperPort; - previousLower = (int) current.LowerPort; } - - if (start.UpperPort != 0) - { - ret.Add(new PortOrRange(start.LowerPort, (uint) previous)); - // ReSharper disable RedundantAssignment - start = new PortOrRange(0); - // ReSharper restore RedundantAssignment - } - else if (previous != -1) - { - ret.Add(new PortOrRange((uint) previousLower, (uint) previous)); - } - return ret; } @@ -92,8 +65,7 @@ public static void SortRangeFirstLowHigh(List ports) { if (a.IsRange() && b.IsRange() || !a.IsRange() && !b.IsRange()) { - if (a.LowerPort < b.LowerPort) return -1; - return 1; + return a.LowerPort.CompareTo(b.LowerPort); } if (a.IsRange()) return -1; diff --git a/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs b/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs index a74be97..f973a11 100644 --- a/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs +++ b/IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using IPTables.Net.Exceptions; using IPTables.Net.Iptables.Helpers; @@ -44,9 +44,9 @@ public FeatureSplitter(string chain, string table, Func extr { _chain = chain; _table = table; - _extractor = extractor; - _setter = setter; - _nestedGenerator = nestedGenerator; + _extractor = extractor ?? throw new ArgumentNullException(nameof(extractor)); + _setter = setter ?? throw new ArgumentNullException(nameof(setter)); + _nestedGenerator = nestedGenerator ?? throw new ArgumentNullException(nameof(nestedGenerator)); _commentPrefix = commentPrefix; } @@ -54,7 +54,7 @@ public void AddRule(IpTablesRule rule) { var key = _extractor(rule); if (!_protocols.ContainsKey(key)) - _protocols.Add(key, _nestedGenerator(ShortHash.HexHash(_chain + "_" + key), _table)); + _protocols.Add(key, _nestedGenerator(ShortHash.HexHash(_chain + "_" + key), _table) ?? throw new InvalidOperationException("Nested generator returned null")); var gen = _protocols[key]; @@ -76,12 +76,15 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) jumpRule.GetModuleOrLoad("core").Jump = chainName; jumpRule.GetModuleOrLoad("comment").CommentText = _commentPrefix + "|FS|" + description; _setter(jumpRule, p.Key); - ruleSet.AddRule(jumpRule); + //Nested output ruleSet.AddChain(chainName, _table); p.Value.Output(system, ruleSet); + var generated = ruleSet.Chains.GetChainOrDefault(chainName, _table); + if (generated != null && generated.Rules.Count > 0) ruleSet.AddRule(jumpRule); + else if (generated != null) ruleSet.Chains.RemoveChain(generated); } } } diff --git a/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs b/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs index c158a4c..60edac1 100644 --- a/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs +++ b/IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -54,8 +54,8 @@ public MultiportAggregator(string chain, string table, Func _chain = chain; _table = table; _extractKey = extractKey; - _extractPort = extractPort; - _setPort = setPort; + _extractPort = extractPort ?? throw new ArgumentNullException(nameof(extractPort)); + _setPort = setPort ?? throw new ArgumentNullException(nameof(setPort)); _setJump = setJump; _commentPrefix = commentPrefix; if (baseRule == null) baseRule = "-A " + chain + " -t " + table; @@ -102,9 +102,9 @@ private IpTablesRule OutputRulesForGroup(IpTablesRuleSet ruleSet, IpTablesSystem ruleComment.CommentText = _commentPrefix + "|" + chainName + "|" + ruleIdx; // Create just one rule if there is only one set of multiports - if (ruleCount == 1 && ranges.Count == 1 && _setJump != null) + if (ruleCount == 1 && ranges.Count == 1) { - _setJump(rule1, key); + _setJump?.Invoke(rule1, key); rule1.Chain = ruleSet.Chains.GetChainOrDefault(_chain, _table); } else @@ -181,7 +181,7 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) if (chain.Rules.Count != 0) { var jumpRule = IpTablesRule.Parse(_baseRule, system, ruleSet.Chains, ruleSet.IpVersion); - _setJump(jumpRule, p.Key); + _setJump?.Invoke(jumpRule, p.Key); //jumpRule. jumpRule.GetModuleOrLoad("core").Jump = chainName; jumpRule.GetModuleOrLoad("comment").CommentText = _commentPrefix + "|MA|" + @@ -190,13 +190,13 @@ public void Output(IpTablesSystem system, IpTablesRuleSet ruleSet) } else { - Log.Debug(string.Format("No rules in the chain \"{chainName}\", skipping jump from {chain}.", + Log.Debug(string.Format("No rules in the chain \"{0}\", skipping jump from {1}.", chainName, _chain)); } } else { - _setJump(singleRule, p.Key); + _setJump?.Invoke(singleRule, p.Key); //ruleSet.AddRule(singleRule); } diff --git a/iptables-plans/21-rule-generator-boundaries.md b/iptables-plans/21-rule-generator-boundaries.md new file mode 100644 index 0000000..1c14c21 --- /dev/null +++ b/iptables-plans/21-rule-generator-boundaries.md @@ -0,0 +1,24 @@ +# Gap 21: Rule generator limits and semantic preservation + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +[RuleBuilderMultiportTests](../IPTables.Net.Tests/RuleBuilderMultiportTests.cs) covers two examples; splitter/nested tests cover one each. [MultiportAggregator](../IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs) has range-slot boundaries, optional callbacks, an ipset mode, and target propagation. [FeatureSplitter](../IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs) has duplicate-chain and nested-output behavior not covered by those examples. + +## Tests to add + +- Exercise 14/15/16 slots, a range after 14 single ports, overlapping/adjacent ranges, duplicates, and empty groups. Assert every ordinary multiport rule respects the slot limit and preserves the intended port set. +- Cover source/destination ports, TCP/UDP, jump/goto targets, custom base rules, non-filter tables, and ipset mode. +- Verify generated chain references resolve, empty generated chains are handled, and preexisting/repeated generated names follow the duplicate policy. +- Exercise missing extractors/callback combinations and nested empty output with intentional errors rather than incidental null dereferences. + +## Completion + +Extend builder tests and run fast validation. Assert output semantics and complete generated rule sets in addition to representative command strings; do not merely duplicate the generator algorithm in test code. + +## Implementation record + +Cover multiport boundaries, TCP/UDP source/destination ports, goto targets, custom mangle base rules, ipset output and empty nested generators. Merge overlapping/duplicate ranges correctly, validate callbacks, support omitted jump setters and remove empty nested chains without dangling jumps; fix empty-group logging. + +Validation: Fast suite: 603 passed, 27 skipped, zero failures. From 3ca2de7479a25afbfb69a0b8fb5734ec5594545b Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:53:57 +0000 Subject: [PATCH 22/28] test: implement testing gap 22 - ruleset-clone-isolation Preserve counters when deep-cloning rulesets, including disabled counter values. Verify IPv4/IPv6 clone equality, ordered duplicates, multiple tables, empty chains, comments, system references and independent module/rule/chain mutations. Validation: Fast suite: 605 passed, 27 skipped, zero failures. --- IPTables.Net.Tests/RuleSetCloneTests.cs | 40 ++++++++++++++++++++ IPTables.Net/Iptables/IpTablesRuleSet.cs | 3 +- iptables-plans/22-ruleset-clone-isolation.md | 25 ++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 IPTables.Net.Tests/RuleSetCloneTests.cs create mode 100644 iptables-plans/22-ruleset-clone-isolation.md diff --git a/IPTables.Net.Tests/RuleSetCloneTests.cs b/IPTables.Net.Tests/RuleSetCloneTests.cs new file mode 100644 index 0000000..4b7d59e --- /dev/null +++ b/IPTables.Net.Tests/RuleSetCloneTests.cs @@ -0,0 +1,40 @@ +using System.Linq; +using IPTables.Net.Iptables; +using IPTables.Net.Iptables.Modules.Comment; +using IPTables.Net.Iptables.Modules.Core; +namespace IPTables.Net.Tests; +public class RuleSetCloneTests +{ + [Theory] + [InlineData(4)] [InlineData(6)] + public void ClonePreservesCountersOrderingTablesAndOwnsAllMutableState(int version) + { + var system = new IpTablesSystem(new ScriptedSystem(), new RecordingAdapter()); + var source = new IpTablesRuleSet(version, system); source.AddChain("empty", "raw"); source.AddChain("custom", "filter"); + var address = version == 4 ? "192.0.2.1" : "2001:db8::1"; + string command = $"-A custom -s {address} -m comment --comment \"owner's rule\" -j ACCEPT"; + source.AddRule(command).Counters = new PacketCounters(900, 7); + source.AddRule(command).Counters = new PacketCounters(1200, 9); + source.AddRule("-A OUTPUT -t mangle -j RETURN").Counters = new PacketCounters(-1, -1); + var clone = source.DeepClone(); + Assert.Equal(source, clone); Assert.Same(system, clone.System); Assert.Equal(version, clone.IpVersion); + Assert.Empty(clone.Chains.GetChain("empty", "raw").Rules); + var originals = source.Rules.ToArray(); var copies = clone.Rules.ToArray(); + Assert.Equal(3, copies.Length); + for (int i = 0; i < copies.Length; i++) + { + Assert.Equal(originals[i].GetActionCommand(), copies[i].GetActionCommand()); + Assert.Equal(originals[i].Counters, copies[i].Counters); Assert.NotSame(originals[i], copies[i]); + Assert.NotSame(originals[i].Chain, copies[i].Chain); Assert.Same(clone.Chains.GetChain(copies[i].Chain.Name, copies[i].Chain.Table), copies[i].Chain); + Assert.Same(system, copies[i].System); Assert.Equal(version, copies[i].IpVersion); + Assert.NotSame(originals[i].GetModule("core"), copies[i].GetModule("core")); + } + copies[0].GetModule("comment").CommentText = "changed"; + copies[0].Counters = new PacketCounters(1, 2); + var custom = clone.Chains.GetChain("custom", "filter"); custom.Rules.Reverse(); custom.Rules.RemoveAt(0); + clone.AddRule("-A custom -j DROP"); clone.Chains.RemoveChain(clone.Chains.GetChain("empty", "raw")); + Assert.Equal("owner's rule", originals[0].GetModule("comment").CommentText); + Assert.Equal(900, originals[0].Counters.Bytes); Assert.Equal(7, originals[0].Counters.Packets); + Assert.Equal(originals, source.Rules.ToArray()); Assert.True(source.Chains.HasChain("empty", "raw")); Assert.NotEqual(source, clone); + } +} diff --git a/IPTables.Net/Iptables/IpTablesRuleSet.cs b/IPTables.Net/Iptables/IpTablesRuleSet.cs index 580a781..4823c57 100644 --- a/IPTables.Net/Iptables/IpTablesRuleSet.cs +++ b/IPTables.Net/Iptables/IpTablesRuleSet.cs @@ -214,12 +214,13 @@ public override int GetHashCode() } } + /// Copies chains, ordered rules, module state and counters while retaining the system and IP version. public IpTablesRuleSet DeepClone() { var rs = new IpTablesRuleSet(IpVersion, System); foreach (var chain in _chains) rs.AddChain(chain.Name, chain.Table); - foreach (var rule in Rules) rs.AddRule(rule.GetActionCommand()); + foreach (var rule in Rules) rs.AddRule(rule.GetActionCommand()).Counters = rule.Counters; return rs; } diff --git a/iptables-plans/22-ruleset-clone-isolation.md b/iptables-plans/22-ruleset-clone-isolation.md new file mode 100644 index 0000000..a854952 --- /dev/null +++ b/iptables-plans/22-ruleset-clone-isolation.md @@ -0,0 +1,25 @@ +# Gap 22: Deep clone fidelity and independence + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +[IpTablesRuleSet.DeepClone](../IPTables.Net/Iptables/IpTablesRuleSet.cs) reconstructs rules through `GetActionCommand()` without requesting counters. [IPTablesRuleTests](../IPTables.Net.Tests/IPTablesRuleTests.cs) explicitly checks counter preservation only for `ShallowClone`. Restore tests call deep clone on zero-counter examples and do not mutate module state to verify independence. + +## Tests to add + +- Clone a ruleset with multiple tables, empty/custom chains, duplicate ordered rules, IPv6 rules, comments, and nonzero counters. +- Specify which metadata `DeepClone` promises to preserve, then assert it field by field; nonzero counters distinguish intentional reset from accidental loss. +- Change a cloned module, add/remove/reorder cloned rules, and alter counters; assert the source remains unchanged. +- Check that cloned rules reference cloned chains while retaining the intended system and IP version. +- Verify original/clone equality immediately after cloning under the chosen metadata contract, and inequality after a semantic mutation. + +## Completion + +Add `RuleSetCloneTests.cs` and run the fast suite. Keep existing shallow-clone coverage; these tests specifically establish the deeper ownership and fidelity guarantee. + +## Implementation record + +Preserve counters when deep-cloning rulesets, including disabled counter values. Verify IPv4/IPv6 clone equality, ordered duplicates, multiple tables, empty chains, comments, system references and independent module/rule/chain mutations. + +Validation: Fast suite: 605 passed, 27 skipped, zero failures. From 5293aebd5cd60b096dd338d97019c6401878b011 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:55:20 +0000 Subject: [PATCH 23/28] test: implement testing gap 23 - capability-detection Cover iptables and SYNPROXY version thresholds with fixed process responses. Parse IPv4/IPv6 binary banners and plain, distro and custom kernel releases; report process failures and reject malformed or overflowing versions. Document that version eligibility does not establish module availability. Validation: Fast suite: 627 passed, 27 skipped, zero failures. --- .../CapabilityDetectionTests.cs | 41 +++++++++++++++++++ .../Client/IPTablesBinaryAdapterClient.cs | 5 ++- .../Iptables/Helpers/SynProxyHelper.cs | 24 ++++------- iptables-plans/23-capability-detection.md | 24 +++++++++++ 4 files changed, 77 insertions(+), 17 deletions(-) create mode 100644 IPTables.Net.Tests/CapabilityDetectionTests.cs create mode 100644 iptables-plans/23-capability-detection.md diff --git a/IPTables.Net.Tests/CapabilityDetectionTests.cs b/IPTables.Net.Tests/CapabilityDetectionTests.cs new file mode 100644 index 0000000..bd17bfd --- /dev/null +++ b/IPTables.Net.Tests/CapabilityDetectionTests.cs @@ -0,0 +1,41 @@ +using System; +using IPTables.Net.Exceptions; +using IPTables.Net.Iptables.Adapter.Client; +using IPTables.Net.Iptables.Helpers; +namespace IPTables.Net.Tests; +public class CapabilityDetectionTests +{ + private static IPTablesBinaryAdapterClient Client(ScriptedSystem fake) => new(4, new IpTablesSystem(fake, new RecordingAdapter()), "iptables"); + [Theory] + [InlineData("iptables v1.4.20", false)] [InlineData("iptables v1.4.21", true)] [InlineData("iptables v1.4.22", true)] + [InlineData("iptables v1.8.11 (legacy)\n", true)] [InlineData(" \tip6tables v1.8.10 (nf_tables)\n", true)] + public void IptablesVersionsAndSynproxyBoundary(string output, bool supported) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(output) }; using var client = Client(fake); + Assert.Equal(supported, SynProxyHelper.IptablesSupported(client)); + Assert.Equal("-V", Assert.Single(fake.Calls).Arguments); Assert.True(fake.Calls[0].Process.IsDisposed); + } + [Theory] + [InlineData("")] [InlineData("iptables version unknown")] [InlineData("error iptables v1.8.0")] + [InlineData("iptables v1.8.0junk")] [InlineData("iptables v9999999999999.8.0")] + public void MalformedBinaryVersionsAreErrors(string output) + { using var client = Client(new ScriptedSystem { Respond = (_, _) => new(output) }); Assert.Throws(() => client.GetIptablesVersion()); } + [Theory] + [InlineData("3.11.99", false)] [InlineData("3.12", true)] [InlineData("3.12.0", true)] + [InlineData("3.12.0-1-generic", true)] [InlineData("6.12.4-custom+debug\n", true)] + [InlineData("6.12.4+", true)] [InlineData("3.11.0-999", false)] [InlineData("6.8.0-1018-azure", true)] + [InlineData("invalid", false)] [InlineData("version 6.12.0", false)] [InlineData("99999999999999.1.0", false)] + public void KernelVersionEligibilitySupportsDistroAndCustomReleases(string release, bool expected) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(release) }; Assert.Equal(expected, SynProxyHelper.KernelSupported(fake)); + Assert.Equal("uname", Assert.Single(fake.Calls).Binary); Assert.Equal("-r", fake.Calls[0].Arguments); + } + [Fact] + public void NonzeroExitIsNeverInterpretedAsSupported() + { + var fake = new ScriptedSystem { Respond = (_, _) => new("iptables v1.8.0", "permission denied", 2) }; + using var client = Client(fake); Assert.Throws(() => client.GetIptablesVersion()); + Assert.Contains("permission denied", Assert.Throws(() => SynProxyHelper.KernelSupported(fake)).Message); + Assert.All(fake.Calls, x => Assert.True(x.Process.IsDisposed)); + } +} diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs index ff72e04..30d5202 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs @@ -63,10 +63,11 @@ public override Version GetIptablesVersion() { string versionOutput, error; ExecutionHelper.ExecuteIptables(_iptables, "-V", _iptablesBinary, out versionOutput, out error); - var r = new Regex(@"iptables v([0-9]+\.[0-9]+\.[0-9]+)"); + var r = new Regex(@"^\s*ip6?tables\s+v([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)"); if (!r.IsMatch(versionOutput)) throw new IpTablesNetException("Unable to get version string"); var match = r.Match(versionOutput); - return new Version(match.Groups[1].Value); + if (!Version.TryParse(match.Groups[1].Value, out var version)) throw new IpTablesNetException("Unable to get version string"); + return version; } public override bool HasChain(string table, string chainName) diff --git a/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs b/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs index cc48cf1..9bc74b8 100644 --- a/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs +++ b/IPTables.Net/Iptables/Helpers/SynProxyHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -27,7 +27,7 @@ public static bool IptablesSupported(IIPTablesAdapterClient adapter) } /// - /// Does the Linux Kernel support SYNPROXY + /// Whether the kernel version is eligible for SYNPROXY; this does not probe module availability. /// /// /// @@ -38,20 +38,14 @@ public static bool KernelSupported(ISystemFactory system) { ProcessHelper.ReadToEnd(process, out output, out error); if (process.ExitCode != 0) - throw new IpTablesNetException("Unable to execute uname and retreive the kenel version"); + throw new IpTablesNetException($"Unable to retrieve kernel version: uname exited with {process.ExitCode}: {error}"); } - var regex = new Regex(@"([0-9]+)\.([0-9]+)\.([0-9]+)\-([0-9]+)"); - if (regex.IsMatch(output)) - { - var match = regex.Match(output); - var version = new Version(int.Parse(match.Groups[1].Value), int.Parse(match.Groups[2].Value), - int.Parse(match.Groups[3].Value), int.Parse(match.Groups[4].Value)); - - if (version >= new Version(3, 12)) return true; - } - - return false; + var match = Regex.Match(output, @"^\s*([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-[^\s]+|\+[^\s]*)?\s*$"); + if (!match.Success) return false; + var numeric = match.Groups[1].Value + "." + match.Groups[2].Value + "." + + (match.Groups[3].Success ? match.Groups[3].Value : "0"); + return Version.TryParse(numeric, out var version) && version >= new Version(3, 12); } } -} \ No newline at end of file +} diff --git a/iptables-plans/23-capability-detection.md b/iptables-plans/23-capability-detection.md new file mode 100644 index 0000000..32b963a --- /dev/null +++ b/iptables-plans/23-capability-detection.md @@ -0,0 +1,24 @@ +# Gap 23: Version parsing and SYNPROXY capability detection + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +No tests call [SynProxyHelper](../IPTables.Net/Iptables/Helpers/SynProxyHelper.cs) or adapter `GetIptablesVersion`. The [binary client's](../IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs) version regex and the helper's kernel-release regex control feature availability. The kernel regex requires a numeric component after a hyphen, excluding other release shapes regardless of their numeric version. + +## Tests to add + +- Parse representative iptables version output with legacy/nf_tables suffixes, whitespace, malformed text, and command failures. +- Assert the iptables support boundary immediately below, at, and above 1.4.21 using a fake adapter. +- Feed kernel releases below/at/above 3.12, plain semantic versions, distro suffixes, custom suffixes, malformed output, and nonzero exits. +- Define whether unrecognized version formats mean unsupported or an error; distinguish version-based eligibility from proof that a kernel target/module is available. + +## Completion + +Add `CapabilityDetectionTests.cs` using fixed strings and process fakes. Run the fast suite; results must be independent of the test host's installed binaries and kernel. + +## Implementation record + +Cover iptables and SYNPROXY version thresholds with fixed process responses. Parse IPv4/IPv6 binary banners and plain, distro and custom kernel releases; report process failures and reject malformed or overflowing versions. Document that version eligibility does not establish module availability. + +Validation: Fast suite: 627 passed, 27 skipped, zero failures. From 3bd4433472114800ee6676819524936ced1d4df1 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 01:57:40 +0000 Subject: [PATCH 24/28] test: implement testing gap 24 - test-runner-contracts Add a stubbed runner harness for modes, filters, backend restoration, failures and runtime environment propagation. Reject invalid modes, clear inherited system-test skips in full mode and preserve runtime limits through sudo. Restore executable entry points, align CI with stable/opt-in kernel policy, and require native TRX coverage with explicit skip reporting. Update the plan index with final validation. Validation: Runner harness: 17 cases passed; native-report checker: 4 tests passed; fast: 627 passed/27 skipped; isolated stable full: 645 passed/5 skipped. Two full skips are unavailable IPv6 kernel tests; three are preexisting disabled tests. CI checker correctly rejects the missing IPv6 coverage. Shell syntax, YAML parsing and whitespace checks passed. --- .github/workflows/main.yml | 77 ++++++++++-------- AGENTS.md | 3 + build.sh | 0 iptables-plans/24-test-runner-contracts.md | 25 ++++++ iptables-plans/README.md | 13 ++- scripts/check-native-results.py | 32 ++++++++ scripts/tests/test-native-results.py | 42 ++++++++++ scripts/tests/test-runner.sh | 93 ++++++++++++++++++++++ test.sh | 12 +++ 9 files changed, 262 insertions(+), 35 deletions(-) mode change 100644 => 100755 build.sh create mode 100644 iptables-plans/24-test-runner-contracts.md create mode 100644 scripts/check-native-results.py create mode 100644 scripts/tests/test-native-results.py create mode 100755 scripts/tests/test-runner.sh mode change 100644 => 100755 test.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 783622c..07b596b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,6 +9,12 @@ name: CI on: push: pull_request: + workflow_dispatch: + inputs: + run_unstable: + description: Run opt-in conntrack/kernel tests on a disposable runner + type: boolean + default: false env: NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages @@ -40,46 +46,49 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - - name: Install dependencies - run: dotnet restore - - - name: Build - id: build - run: dotnet build --configuration Release --no-restore - - - name: Install system dependencies - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: build-essential libxtables-dev libip6tc-dev libip4tc-dev libiptc-dev libnl-3-dev libnl-3-200 libnetfilter-conntrack3 libnfnetlink-dev libnfnetlink0 libpcap0.8 libpcap0.8-dev sudo - version: 1.0 + - name: Runner contract tests + run: | + ./scripts/tests/test-runner.sh + python3 scripts/tests/test-native-results.py - - name: Install dependencies - run: dotnet restore + - name: Managed tests + run: ./test.sh --fast - - name: Modprobe IP6 tables + - name: Stable native and system tests run: | - sudo modprobe ip6_tables - sudo modprobe ip6table_filter - sudo modprobe ip6table_mangle - - if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then - sudo update-alternatives --set iptables /usr/sbin/iptables-legacy - sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy - fi + mkdir -p artifacts + ./test.sh --full --iptables-backend legacy --logger "trx;LogFileName=$PWD/artifacts/stable.trx" - - name: NUnit Tests - run: SKIP_SYSTEM_TESTS=1 dotnet test IPTables.Net.Tests + - name: Require native coverage and report skips + if: always() + run: python3 scripts/check-native-results.py artifacts/stable.trx - - name: Install ipthelper dependency - run: | - cd ipthelper - CONFIG="Release" sudo -E bash install.sh - CONFIG="Debug" sudo -E bash install.sh - sudo ldconfig - cd .. + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-test-results + path: artifacts/*.trx - - name: All Tests - run: sudo dotnet test + unstable: + if: github.event_name == 'workflow_dispatch' && inputs.run_unstable + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Opt-in kernel tests + run: | + mkdir -p artifacts + RUN_UNSTABLE_SYSTEM_TESTS=1 timeout 10m ./test.sh --full --filter 'Category=NotWorkingOnTravis' --logger "trx;LogFileName=$PWD/artifacts/unstable.trx" + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: unstable-test-results + path: artifacts/*.trx upload: name: upload diff --git a/AGENTS.md b/AGENTS.md index 2e2568e..535efb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,9 @@ This repository contains a .NET 10 solution for working with Linux iptables from Both scripts will bootstrap a usable .NET SDK if `dotnet` is missing. By default they infer the needed SDK channel from the highest `TargetFramework` declared in the repo's `.csproj` files. On Linux they also build `libipthelper` and install missing native build dependencies through a supported package manager when needed. +- Run `./scripts/tests/test-runner.sh` and `python3 scripts/tests/test-native-results.py` when changing runner behavior. These use stubs and require no privileges. +- CI uses the same fast/full entry points. Stable CI requires all tests in the native lifecycle/family and system test classes to pass; skipped native cases fail the coverage check. Conntrack/kernel tests run only through the explicit `run_unstable` workflow input. + ## Native Helper Notes - `ipthelper/` builds `libipthelper.so`, which is required for `IPTablesLibAdapter`, `IptcInterface`, and the conntrack/native tests. diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 diff --git a/iptables-plans/24-test-runner-contracts.md b/iptables-plans/24-test-runner-contracts.md new file mode 100644 index 0000000..11683fa --- /dev/null +++ b/iptables-plans/24-test-runner-contracts.md @@ -0,0 +1,25 @@ +# Gap 24: Runner modes, cleanup, and CI parity + +Priority: P2. Status: implemented; see implementation record below. + +## Evidence + +[test.sh](../test.sh) chooses modes/backends, filters unstable tests, mutates alternatives, and restores state with an EXIT trap. [The CI workflow](../.github/workflows/main.yml) duplicates setup and invokes `sudo dotnet test` directly, bypassing the script's default unstable-test exclusion. No shell regression tests exercise this control flow. Both build/test scripts are tracked as mode `100644`; direct test-script invocation failed during this review. + +## Tests to add + +- Use a temporary PATH containing command stubs to cover fast/full/auto selection, invalid options, backend `current` no-switch behavior, and partial backend-switch failure. +- Fail test execution and verify both original alternatives are restored and cleanup is attempted; run stubs only, not real host firewall cleanup. +- Test default unstable exclusion, opt-in inclusion, and explicit filter forwarding. Check required environment propagation into privileged execution. +- Verify documented script entry points execute in a clean checkout and agree with CI's intended test modes. +- Add a CI check that expected native tests actually execute when prerequisites are provisioned; track skips explicitly rather than treating any green run as native coverage. + +## Completion + +Add a small shell test harness and align CI with a documented stable/unstable policy. Run the harness without privileges, then fast tests; validate full mode separately on a disposable Linux runner. Keep executable-bit repair and workflow changes in the later implementation, not this documentation-only review. + +## Implementation record + +Add a stubbed runner harness for modes, filters, backend restoration, failures and runtime environment propagation. Reject invalid modes, clear inherited system-test skips in full mode and preserve runtime limits through sudo. Restore executable entry points, align CI with stable/opt-in kernel policy, and require native TRX coverage with explicit skip reporting. Update the plan index with final validation. + +Validation: Runner harness: 17 cases passed; native-report checker: 4 tests passed; fast: 627 passed/27 skipped; isolated stable full: 645 passed/5 skipped. Two full skips are unavailable IPv6 kernel tests; three are preexisting disabled tests. CI checker correctly rejects the missing IPv6 coverage. Shell syntax, YAML parsing and whitespace checks passed. diff --git a/iptables-plans/README.md b/iptables-plans/README.md index 040f427..7fb6517 100644 --- a/iptables-plans/README.md +++ b/iptables-plans/README.md @@ -4,7 +4,7 @@ Reviewed the managed implementation, xUnit suite, test doubles, native integrati Baseline: `bash ./test.sh --fast` from the repository root passed with **386 passed, 15 skipped, 0 failed (401 total)**. Native dependencies were absent, so the helper build was skipped. Full/system tests were not run. Direct `./test.sh --fast` failed with permission denied; the tracked script mode is `100644` (see gap 24). -All plans are open. P1 means prioritize because the gap can conceal incorrect firewall changes, state loss, or native resource problems; P2 means important correctness or compatibility coverage. Existing happy-path tests should remain. Add assertions against independently specified results rather than simply replaying output through the same parser. +All 24 plans are implemented and committed individually. Each plan ends with an implementation record and validation results. P1 means prioritize because the gap can conceal incorrect firewall changes, state loss, or native resource problems; P2 means important correctness or compatibility coverage. Existing happy-path tests should remain. Add assertions against independently specified results rather than simply replaying output through the same parser. | Gap | Priority | Scope | | --- | --- | --- | @@ -34,3 +34,14 @@ All plans are open. P1 means prioritize because the gap can conceal incorrect fi | [24](24-test-runner-contracts.md) | P2 | Runner modes, cleanup, and CI parity | For managed plans, validate with `bash ./test.sh --fast`; once executable permissions are corrected, use the documented `./test.sh --fast`. Native plans specify separate Linux prerequisites. Use disposable hosts or isolated network namespaces for tests that mutate networking. Do not assume libiptc supports the nft backend. Shared process-fake improvements in gaps 01, 02, and 07 can be implemented once and reused. + +## Final validation + +- Fast suite: **627 passed, 27 skipped, zero failures** (654 total). +- Stable full suite in a disposable Linux network namespace: **645 passed, 5 skipped, zero failures** (650 total). +- The full-suite skips are three existing disabled Levenshtein tests and two IPv6 native tests: this host lacks the legacy IPv6 kernel tables. IPv4 native lifecycle and system tests executed successfully. The new CI coverage check correctly rejects this incomplete IPv6 coverage; provisioned CI runners must execute those tests. +- Conntrack selection in an isolated namespace: **18 passed**, including a seeded UDP flow, matching/nonmatching filters, native extraction, and deterministic cleanup tests. Unstable conntrack tests remain opt-in in ordinary full runs. +- Runner regression harness: **17 scenarios passed**. Native-result checker: **4 tests passed**, including missing and skipped coverage rejection. +- Shell syntax and Git whitespace checks passed. CI workflow syntax was parsed locally; remote CI has not been run. + +`build.sh` and `test.sh` are now executable. CI runs these entry points, uploads TRX results, requires stable native coverage, and offers a separate explicit workflow input for unstable kernel tests. diff --git a/scripts/check-native-results.py b/scripts/check-native-results.py new file mode 100644 index 0000000..6d584b0 --- /dev/null +++ b/scripts/check-native-results.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Require stable native coverage on provisioned CI runners, and report skips.""" +import sys +import xml.etree.ElementTree as ET + +REQUIRED = {"NativeFamilyTests": 3, "NativeLifecycleTests": 8, "IptcInterfaceTest": 7, "IpTableSystemTests": 1} + + +def check(path): + root = ET.parse(path).getroot() + results = [node for node in root.iter() if node.tag.rsplit("}", 1)[-1] == "UnitTestResult"] + counts = {} + for result in results: + outcome = result.get("outcome", "Unknown") + counts[outcome] = counts.get(outcome, 0) + 1 + print(f"Test outcomes: {counts}") + errors = [] + for name in REQUIRED: + selected = [r for r in results if name + "." in r.get("testName", "")] + passed = sum(r.get("outcome") == "Passed" for r in selected) + print(f"{name}: {passed}/{len(selected)} passed") + if len(selected) < REQUIRED[name] or passed != len(selected): + errors.append(f"{name}: native tests missing, skipped, or failed") + if errors: + raise ValueError("; ".join(errors)) + + +if __name__ == "__main__": + try: + check(sys.argv[1]) + except (ValueError, IndexError, OSError, ET.ParseError) as error: + sys.exit(str(error)) diff --git a/scripts/tests/test-native-results.py b/scripts/tests/test-native-results.py new file mode 100644 index 0000000..3d60a41 --- /dev/null +++ b/scripts/tests/test-native-results.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import importlib.util +from pathlib import Path +import tempfile +import sys +sys.dont_write_bytecode = True +import unittest + +spec = importlib.util.spec_from_file_location("checker", Path(__file__).parents[1] / "check-native-results.py") +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class NativeResultsTests(unittest.TestCase): + def check(self, outcomes): + with tempfile.NamedTemporaryFile(mode="w", suffix=".trx") as report: + report.write('') + for name, outcome in outcomes: + report.write(f'') + report.write('') + report.flush() + checker.check(report.name) + + def test_all_required_classes_pass(self): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() for _ in range(count)]) + + def test_skipped_native_case_is_an_error_even_with_passed_cases(self): + with self.assertRaisesRegex(ValueError, "NativeFamilyTests"): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() for _ in range(count)] + [("NativeFamilyTests", "NotExecuted")]) + + def test_partially_missing_class_is_an_error(self): + with self.assertRaisesRegex(ValueError, "NativeFamilyTests"): + self.check([(name, "Passed") for name, count in checker.REQUIRED.items() + for _ in range(count - (name == "NativeFamilyTests"))]) + + def test_missing_native_classes_are_an_error(self): + with self.assertRaisesRegex(ValueError, "missing"): + self.check([]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test-runner.sh b/scripts/tests/test-runner.sh new file mode 100755 index 0000000..05b27eb --- /dev/null +++ b/scripts/tests/test-runner.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Exercises the real entry points with fake process/build boundaries. No sudo or firewall access. +set -euo pipefail +repo="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +scratch="$(mktemp -d)" +trap 'rm -rf -- "$scratch"' EXIT +mkdir -p "$scratch/scripts" "$scratch/bin" +cp "$repo/test.sh" "$repo/build.sh" "$scratch/" +cp "$repo/scripts/common.sh" "$scratch/scripts/common.sh" +cat >> "$scratch/scripts/common.sh" <<'STUB' +is_linux() { [[ "${FAKE_LINUX:-1}" == 1 ]]; } +can_run_privileged() { [[ "${FAKE_PRIVILEGED:-1}" == 1 ]]; } +ensure_dotnet() { + echo ensure >> "$FAKE_LOG" + export DOTNET_ROOT=/fake NUGET_PACKAGES=/fake DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +} +helper_build_requirements_present() { return 1; } +build_ipthelper() { echo helper >> "$FAKE_LOG"; } +run_as_root() { + echo "root $*" >> "$FAKE_LOG" + case "$1" in + env|update-alternatives|modprobe|iptables*|ip6tables*) "$@" ;; + *) echo "Unexpected privileged command: $*" >&2; exit 99 ;; + esac +} +STUB +cat > "$scratch/bin/dotnet" <<'STUB' +#!/usr/bin/env bash +printf 'dotnet %s\n' "$*" >> "$FAKE_LOG" +if [[ "$1" == test ]]; then + echo "skip=${SKIP_SYSTEM_TESTS:-unset} cpu=${DOTNET_PROCESSOR_COUNT:-unset} heap=${DOTNET_GCHeapHardLimit:-unset}" >> "$FAKE_LOG" + exit "${FAKE_TEST_STATUS:-0}" +fi +STUB +cat > "$scratch/bin/update-alternatives" <<'STUB' +#!/usr/bin/env bash +printf 'alternatives %s\n' "$*" >> "$FAKE_LOG" +if [[ "$1" == --query ]]; then echo "Value: /original/$2"; fi +if [[ "${FAKE_SWITCH_FAIL:-0}" == 1 && "$*" == *'--set ip6tables '*legacy ]]; then exit 9; fi +STUB +for binary in iptables ip6tables iptables-legacy ip6tables-legacy iptables-nft ip6tables-nft modprobe; do + cat > "$scratch/bin/$binary" <<'STUB' +#!/usr/bin/env bash +printf '%s %s\n' "${0##*/}" "$*" >> "$FAKE_LOG" +STUB +done +chmod +x "$scratch/bin/"* +export PATH="$scratch/bin:$PATH" FAKE_LOG="$scratch/commands" +export DOTNET_PROCESSOR_COUNT=2 DOTNET_GCHeapHardLimit=0x40000000 +cases=0 +run() { + local expected="$1"; shift + : > "$FAKE_LOG" + local status=0 + "$@" > "$scratch/output" 2>&1 || status=$? + if [[ "$status" != "$expected" ]]; then cat "$scratch/output"; echo "Expected $expected, got $status: $*" >&2; exit 1; fi + cases=$((cases + 1)) +} +has() { grep -Fq -- "$1" "$FAKE_LOG" || { cat "$FAKE_LOG"; echo "Missing: $1" >&2; exit 1; }; } +lacks() { if grep -Fq -- "$1" "$FAKE_LOG"; then cat "$FAKE_LOG"; echo "Unexpected: $1" >&2; exit 1; fi; } +run 0 "$scratch/test.sh" --help +lacks ensure +run 0 "$scratch/build.sh" --help +lacks ensure +run 1 "$scratch/test.sh" --configuration +run 1 "$scratch/test.sh" --iptables-backend +run 1 "$scratch/test.sh" --iptables-backend bogus +run 1 env TEST_MODE=bogus "$scratch/test.sh" +lacks ensure +run 0 env SKIP_SYSTEM_TESTS=0 "$scratch/test.sh" --fast --filter 'Name=managed' +has 'skip=1'; has 'Name=managed'; lacks 'root '; lacks 'Category!=' +run 0 env TEST_MODE=auto FAKE_PRIVILEGED=0 "$scratch/test.sh" +has 'skip=1'; lacks 'root ' +run 0 env TEST_MODE=auto FAKE_LINUX=0 "$scratch/test.sh" +has 'skip=1'; lacks helper +run 0 env TEST_MODE=auto SKIP_SYSTEM_TESTS=1 "$scratch/test.sh" +has 'skip=unset'; has 'Category!=NotWorkingOnTravis'; has 'cpu=2 heap=0x40000000' +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables' +run 0 "$scratch/test.sh" --full --iptables-backend current +lacks 'alternatives '; has 'iptables -X test3' +run 0 env RUN_UNSTABLE_SYSTEM_TESTS=1 "$scratch/test.sh" --full --iptables-backend=current +lacks 'Category!=' +run 0 "$scratch/test.sh" --full --filter 'Name=native' +has 'Name=native'; lacks 'Category!=' +run 0 "$scratch/test.sh" --full --filter=Name=native --iptables-backend=nft +has '--filter=Name=native'; has 'ip6tables-nft'; lacks 'Category!=' +run 7 env FAKE_TEST_STATUS=7 "$scratch/test.sh" --full +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables'; has 'ip6tables-nft -X test3' +run 9 env FAKE_SWITCH_FAIL=1 "$scratch/test.sh" --full +has 'alternatives --set iptables /original/iptables'; has 'alternatives --set ip6tables /original/ip6tables'; has 'iptables -X test3'; lacks 'dotnet test' +run 0 "$scratch/build.sh" -p:Example=true +has 'dotnet build'; has '-p:Example=true'; has helper +printf 'Runner contracts: %s cases passed.\n' "$cases" diff --git a/test.sh b/test.sh old mode 100644 new mode 100755 index c74a82d..503de33 --- a/test.sh +++ b/test.sh @@ -155,6 +155,12 @@ cleanup_test_chains() { run_full_tests() { local results_dir local trap_command + unset SKIP_SYSTEM_TESTS + local -a runtime_env=() + local runtime_name + for runtime_name in DOTNET_PROCESSOR_COUNT DOTNET_GCHeapHardLimit; do + if [[ -n "${!runtime_name:-}" ]]; then runtime_env+=("${runtime_name}=${!runtime_name}"); fi + done local -a effective_test_args=("${DOTNET_TEST_ARGS[@]}") results_dir="$(mktemp -d)" trap_command="$(printf 'rm -rf -- %q; cleanup_test_chains; restore_iptables_backend' "$results_dir")" @@ -186,6 +192,7 @@ run_full_tests() { "DOTNET_SKIP_FIRST_TIME_EXPERIENCE=${DOTNET_SKIP_FIRST_TIME_EXPERIENCE}" \ "LD_LIBRARY_PATH=${LD_LIBRARY_PATH:-}" \ "HOME=/root" \ + "${runtime_env[@]}" \ dotnet test "${REPO_ROOT}/IPTables.Net.Tests/IPTables.Net.Tests.csproj" \ --configuration "$CONFIGURATION" \ --no-build \ @@ -249,6 +256,11 @@ EOF shift done +case "$TEST_MODE" in + auto|fast|full) ;; + *) die "Unsupported test mode '${TEST_MODE}'. Expected auto, fast or full." ;; +esac + IPTABLES_BACKEND="$(normalize_iptables_backend "$IPTABLES_BACKEND")" if [[ "$TEST_MODE" == "auto" ]]; then From b9e10e6df0e43162046c43af26cae8977da35f8a Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 02:09:17 +0000 Subject: [PATCH 25/28] remove plans --- .../RestoreAdapterCommitTests.cs | 21 +++++++++ .../Client/IPTablesRestoreAdapterClient.cs | 6 ++- iptables-plans/01-binary-process-failures.md | 24 ---------- iptables-plans/02-restore-commit-process.md | 25 ---------- .../03-restore-transaction-lifecycle.md | 25 ---------- iptables-plans/04-restore-output-integrity.md | 24 ---------- iptables-plans/05-ruleset-sync-retries.md | 25 ---------- iptables-plans/06-rule-sync-protection.md | 24 ---------- .../07-ipset-process-transactions.md | 24 ---------- iptables-plans/08-ipset-sync-modes.md | 25 ---------- iptables-plans/09-ipset-entry-identity.md | 25 ---------- iptables-plans/10-ipset-parser-fidelity.md | 24 ---------- iptables-plans/11-save-parser-errors.md | 24 ---------- .../12-rule-parser-invalid-input.md | 25 ---------- iptables-plans/13-managed-ipv6.md | 25 ---------- iptables-plans/14-native-ipv6.md | 24 ---------- iptables-plans/15-native-handle-lifecycle.md | 25 ---------- iptables-plans/16-conntrack-behavior.md | 25 ---------- iptables-plans/17-nfacct-client.md | 24 ---------- iptables-plans/18-route-rule-controllers.md | 24 ---------- iptables-plans/19-ip-object-sync.md | 25 ---------- iptables-plans/20-address-port-boundaries.md | 24 ---------- .../21-rule-generator-boundaries.md | 24 ---------- iptables-plans/22-ruleset-clone-isolation.md | 25 ---------- iptables-plans/23-capability-detection.md | 24 ---------- iptables-plans/24-test-runner-contracts.md | 25 ---------- iptables-plans/README.md | 47 ------------------- 27 files changed, 26 insertions(+), 636 deletions(-) delete mode 100644 iptables-plans/01-binary-process-failures.md delete mode 100644 iptables-plans/02-restore-commit-process.md delete mode 100644 iptables-plans/03-restore-transaction-lifecycle.md delete mode 100644 iptables-plans/04-restore-output-integrity.md delete mode 100644 iptables-plans/05-ruleset-sync-retries.md delete mode 100644 iptables-plans/06-rule-sync-protection.md delete mode 100644 iptables-plans/07-ipset-process-transactions.md delete mode 100644 iptables-plans/08-ipset-sync-modes.md delete mode 100644 iptables-plans/09-ipset-entry-identity.md delete mode 100644 iptables-plans/10-ipset-parser-fidelity.md delete mode 100644 iptables-plans/11-save-parser-errors.md delete mode 100644 iptables-plans/12-rule-parser-invalid-input.md delete mode 100644 iptables-plans/13-managed-ipv6.md delete mode 100644 iptables-plans/14-native-ipv6.md delete mode 100644 iptables-plans/15-native-handle-lifecycle.md delete mode 100644 iptables-plans/16-conntrack-behavior.md delete mode 100644 iptables-plans/17-nfacct-client.md delete mode 100644 iptables-plans/18-route-rule-controllers.md delete mode 100644 iptables-plans/19-ip-object-sync.md delete mode 100644 iptables-plans/20-address-port-boundaries.md delete mode 100644 iptables-plans/21-rule-generator-boundaries.md delete mode 100644 iptables-plans/22-ruleset-clone-isolation.md delete mode 100644 iptables-plans/23-capability-detection.md delete mode 100644 iptables-plans/24-test-runner-contracts.md delete mode 100644 iptables-plans/README.md diff --git a/IPTables.Net.Tests/RestoreAdapterCommitTests.cs b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs index 79c7bd0..8ce1d35 100644 --- a/IPTables.Net.Tests/RestoreAdapterCommitTests.cs +++ b/IPTables.Net.Tests/RestoreAdapterCommitTests.cs @@ -37,6 +37,9 @@ public void ProductionCommitReportsExitAndInput(int exit, string error) [Theory] [InlineData("--noclear", "", 0, true)] [InlineData("", "--noclear", 0, true)] [InlineData("", "", 0, false)] [InlineData("--noclear", "denied", 1, false)] + [InlineData("", "Usage: ip6tables-restore [ --noflush ]", 1, false)] + [InlineData("", "Usage: ip6tables-restore [ --noclear ]", 2, false)] + [InlineData("", "Unknown option '--noclear'", 1, false)] public void PatchedBinaryCheck(string output, string error, int exit, bool success) { var os = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; @@ -46,6 +49,24 @@ public void PatchedBinaryCheck(string output, string error, int exit, bool succe else Assert.Throws(() => adapter.CheckBinary(system, 4)); Assert.True(Assert.Single(os.Calls).Process.IsDisposed); } + [Theory] + [InlineData(4, false)] [InlineData(4, true)] + [InlineData(6, false)] [InlineData(6, true)] + public void PatchedBinaryHelpMayExitOne(int version, bool standardError) + { + var binary = version == 4 ? "iptables-restore" : "ip6tables-restore"; + var help = $"Usage: {binary} [ --noflush ]\n\t[ --noclear ]\n"; + var os = new ScriptedSystem + { + Respond = (_, _) => standardError ? new("", help, 1) : new(help, "", 1) + }; + var adapter = new IPTablesRestoreAdapter(); + adapter.CheckBinary(new IpTablesSystem(os, adapter), version); + var call = Assert.Single(os.Calls); + Assert.Equal(binary, call.Binary); + Assert.Equal("--help", call.Arguments); + Assert.True(call.Process.IsDisposed); + } [Fact] public void StartFailureCanBeRolledBack() { diff --git a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs index 0a95f7d..7e2cf13 100644 --- a/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs +++ b/IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs @@ -53,7 +53,11 @@ public void CheckBinary() { string output, error; ProcessHelper.ReadToEnd(process, out output, out error); - if (process.ExitCode != 0 || !(output + error).Contains(NoClearOption)) + var help = output + "\n" + error; + // Older patched restore binaries exit 1 after printing usage for --help. + var helpExit = process.ExitCode == 0 || + (process.ExitCode == 1 && Regex.IsMatch(help, @"(?m)^Usage:\s", RegexOptions.IgnoreCase)); + if (!helpExit || !help.Contains(NoClearOption)) throw new IpTablesNetException( "iptables-restore client is not compiled from patched source (patch-iptables-restore.diff)"); } diff --git a/iptables-plans/01-binary-process-failures.md b/iptables-plans/01-binary-process-failures.md deleted file mode 100644 index 17eb128..0000000 --- a/iptables-plans/01-binary-process-failures.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 01: Binary adapter process failures - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[ExecutionHelper](../IPTables.Net/Iptables/Helpers/ExecutionHelper.cs) maps exit codes 1, 2, and other failures to exceptions. [IPTablesBinaryAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs) also handles listing errors and catches exceptions in `HasChain`. [MockIptablesSystemProcess](../IPTables.Net.TestFramework/MockIptablesSystemProcess.cs) leaves `ExitCode` at zero with a private setter. Existing [binary synchronization tests](../IPTables.Net.Tests/IpTablesBinarySyncChainTests.cs) therefore do not exercise these branches. The system suite's invalid-table test provides limited real failure coverage, not this matrix. - -## Tests to add - -- Extend a process fake to configure exit codes, stdout/stderr, start failures, and disposal observation. -- Exercise add, replace, delete, and flush-before-delete with exit codes 0, 1, 2, and an unexpected value; assert exception context and command ordering. -- Test listing with empty output, stderr, nonzero exit, and partial output. Define whether unsuccessful partial dumps must be rejected. -- Distinguish missing-chain behavior from permission/tool failures in `HasChain`; decide the intended contract before encoding current broad exception suppression. - -## Completion - -Add `BinaryAdapterFailureTests.cs` under `IPTables.Net.Tests/`. Every configured failure must produce the intended caller-visible result, and processes must be disposed. Run the fast suite without invoking real iptables. - -## Implementation record - -Added configurable process responses and disposal assertions; covered mutation/listing failures and flush ordering. Preserve stderr and reject failed partial dumps. HasChain now suppresses only recognizable missing-chain errors. - -Validation: Fast suite: 395 passed, 15 skipped; compiler required DOTNET_PROCESSOR_COUNT=2 and a 1 GiB GC limit on this host. diff --git a/iptables-plans/02-restore-commit-process.md b/iptables-plans/02-restore-commit-process.md deleted file mode 100644 index d482ee9..0000000 --- a/iptables-plans/02-restore-commit-process.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 02: Real restore commit execution - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[MockIpTablesRestoreAdapterClient.EndTransactionCommit](../IPTables.Net.TestFramework/IpTablesRestore/MockIpTablesRestoreAdapterClient.cs) replaces production commit with serialization to memory. Consequently, [restore synchronization tests](../IPTables.Net.Tests/IpTablesRestoreSyncTests.cs) miss process startup, stdin completion, exit-code handling, and diagnostics in [IPTablesRestoreAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs). - -## Tests to add - -- Use the production client through `IPTablesRestoreAdapter` with an injectable `ISystemFactory` fake; do not override commit. -- Assert selected executable, `--noflush --noclear`, exact stdin content, closing stdin before waiting, and process disposal. -- Cover exits 0, 1 with and without `line N failed`, 2, and unexpected codes. Verify the reported rule for first/last/out-of-range line numbers. -- Preserve meaningful stderr in command-line failures; the implementation currently reads stderr again after `ReadToEnd` has consumed it. -- Exercise `CheckBinary` with and without the required patched option, and process start/read failures. - -## Completion - -Add `RestoreAdapterCommitTests.cs`. Fast tests execute the production commit body and prove both successful delivery and useful failures. Real patched-restore compatibility remains a separate optional integration check. - -## Implementation record - -Exercise the production restore commit with configurable process responses, exact input and disposal assertions. Preserve stderr for every exit code, attach failed-rule context, reject failed save output, and accept patched help on stdout or stderr. - -Validation: Fast suite: 407 passed, 15 skipped. diff --git a/iptables-plans/03-restore-transaction-lifecycle.md b/iptables-plans/03-restore-transaction-lifecycle.md deleted file mode 100644 index fb22142..0000000 --- a/iptables-plans/03-restore-transaction-lifecycle.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 03: Restore transaction reuse and binary fallback - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IPTablesRestoreAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesRestoreAdapterClient.cs) maintains a persistent builder and transaction flag. Commit does not explicitly clear the builder. Nontransactional `ReplaceRule` and `AddChain` fall through after binary execution, and the raw-string `AddRule` always queues. Existing restore tests use a fresh client for one successful synchronization. - -## Tests to add - -- Commit transaction A, then B on the same client; independently assert that B contains only its own commands. -- Roll back queued work, then commit new work; assert no rolled-back commands execute. -- Cover nested start, empty transaction, commit/rollback without start, and explicit disposal while active. -- After a failed commit, roll back and reuse the client; establish its recovery contract without assuming kernel changes were undone. -- Compare each rule/chain operation inside and outside a transaction, including raw-string add and `DeleteChain(flush: true)`. Follow an immediate operation with a transaction to detect unintended queued work. - -## Completion - -Add `RestoreAdapterLifecycleTests.cs` using production methods and process fakes. Assert external command logs and transaction outcomes in the fast suite. Treat the observations above as regression candidates, not verified failures. - -## Implementation record - -Clear successful restore transactions, prevent immediate replace/add-chain/raw-add commands from being queued, preserve flush-before-delete, and remove the throwing managed finalizer. Add reuse, rollback, failure recovery, nested-start, disposal, and IPv6 fallback tests. - -Validation: Fast suite: 410 passed, 15 skipped. diff --git a/iptables-plans/04-restore-output-integrity.md b/iptables-plans/04-restore-output-integrity.md deleted file mode 100644 index 7336fdd..0000000 --- a/iptables-plans/04-restore-output-integrity.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 04: Restore stream failures and quoted payloads - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IPTablesRestoreTableBuilder](../IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesRestoreTableBuilder.cs) replaces every apostrophe with a double quote and returns `true` from several failed-write paths. The [existing quote test](../IPTables.Net.Tests/IpTablesRestoreSyncTests.cs) only covers a space-containing comment. It cannot detect payload corruption or truncated restore input reported as successful output. - -## Tests to add - -- Preserve comment payloads containing apostrophes, literal double quotes, backslashes, empty text, and Unicode. Assert literal expected output and recovered payload separately. -- Inject nonwritable streams and write/flush exceptions at the table header, chain declaration, rule, and `COMMIT` boundaries. -- Assert that incomplete serialization is surfaced to the caller and cannot be reported as a successful commit; resolve the ambiguous boolean contract before adding assertions. -- Cover multiple tables, per-table `COMMIT`, built-in versus custom-chain declarations, duplicate chain rejection, and clear/rebuild behavior. - -## Completion - -Add `RestoreTableBuilderTests.cs` and connect stream failure coverage to gap 02. Run the fast suite. A deliberately truncated stream or changed comment must fail a meaningful assertion. - -## Implementation record - -Normalize syntactic single quotes without corrupting apostrophes inside double-quoted payloads. Reject unterminated quotes and propagate restore stream failures. Cover Unicode, escaped quotes, backslashes, empty arguments, per-table framing, duplicate chains, and writes failing at each boundary. - -Validation: Fast suite passed; native restore syntax compatibility remains covered by the separate integration work. diff --git a/iptables-plans/05-ruleset-sync-retries.md b/iptables-plans/05-ruleset-sync-retries.md deleted file mode 100644 index e866cdd..0000000 --- a/iptables-plans/05-ruleset-sync-retries.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 05: Whole-ruleset synchronization and retry recovery - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IpTablesRuleSet.Sync](../IPTables.Net/Iptables/IpTablesRuleSet.cs) coordinates chain discovery/creation, transactions, cleanup, errno-11 retries, and a native-adapter special case. [MockIptablesSystemFactory.TestSync](../IPTables.Net.TestFramework/MockIptablesSystemFactory.cs) synchronizes only the first chain. [IpTablesRuleSetTests](../IPTables.Net.Tests/IpTablesRuleSetTests.cs) tests construction rather than this orchestration. - -## Tests to add - -- Exercise `IpTablesRuleSet.Sync` directly with recording adapters across multiple chains and tables, including references to newly created chains. -- Inject errno 11 once and repeatedly; assert fresh discovery, bounded attempts, rollback, eventual success, and exhaustion. Cover `maxRetries = 0` and define rejection of negative values. -- Inject nonretryable errors and errors during chain creation, rule updates, commit, and deletion. Assert no accidental duplicate creation or masked original exception. -- Cover absent/false/selective `canDeleteChain`, preservation of unrelated chains, empty desired rulesets, and a second no-op synchronization. -- Verify the native adapter's separate chain-creation commit and configured deletion-table order using a suitable seam or isolated integration fixture. - -## Completion - -Add `RuleSetSyncTests.cs`; deterministic orchestration cases run fast. Native-specific commit behavior runs in the native suite. Assertions cover the complete operation sequence and resulting state, not just the first chain. - -## Implementation record - -Add direct whole-ruleset orchestration tests for multi-table creation, references, selective deletion, no-op updates, bounded retries, and original error preservation. Rebuild pending chain state on every attempt, reject negative retry budgets, and roll back commit failures without masking errors. - -Validation: Fast suite passed. Native ordered-commit ownership is validated in plan 15. diff --git a/iptables-plans/06-rule-sync-protection.md b/iptables-plans/06-rule-sync-protection.md deleted file mode 100644 index af2b03c..0000000 --- a/iptables-plans/06-rule-sync-protection.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 06: Deletion predicates and custom equality during rule sync - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[DefaultRuleSync](../IPTables.Net/Iptables/TableSync/DefaultRuleSync.cs) accepts `ShouldDelete`, `RuleComparerForUpdate`, and an equality comparer. [Binary](../IPTables.Net.Tests/IpTablesBinarySyncChainTests.cs) and [restore](../IPTables.Net.Tests/IpTablesRestoreSyncTests.cs) tests cover basic edits and comment-based updates, but do not supply a deletion predicate or custom equality comparer. Equal-length lists can trigger replacement independently of `ShouldDelete`. - -## Tests to add - -- Keep protected rules at the beginning, middle, and end while desired managed rules grow, shrink, reorder, or become empty. -- Explicitly define whether deletion protection also protects against replacement; test equal-length mismatches against that decision. -- Cover duplicate rules and duplicate update keys, plus a custom comparer that ignores selected metadata. -- Assert exact final order, preserved protected rules, valid evolving positions, and a second synchronization that emits no further commands where convergence is expected. - -## Completion - -Extend both adapter synchronization suites with a shared scenario matrix. Fast tests must detect unintended deletion/replacement and must not rely solely on rule counts. - -## Implementation record - -Protect rules against replacement as well as deletion, materialize desired sequences once, and replace at the actual current-chain position with model rollback on command failure. Test protected rules at every position through both binary and restore clients, convergence, empty desired rules, duplicate rules, and custom equality. - -Validation: Fast suite passed. diff --git a/iptables-plans/07-ipset-process-transactions.md b/iptables-plans/07-ipset-process-transactions.md deleted file mode 100644 index a4b1e05..0000000 --- a/iptables-plans/07-ipset-process-transactions.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 07: Real ipset restore and transaction execution - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[MockIpsetSystemFactory.TestSync](../IPTables.Net.TestFramework/MockIpsetSystemFactory.cs) explicitly calls `Sync(..., false)`. Existing synchronization coverage therefore bypasses the default transactional path in [IpSetBinaryAdapter](../IPTables.Net/IpSet/Adapter/IpSetBinaryAdapter.cs). `RestoreSets` currently returns true on a nonzero exit, making its success contract a specific regression candidate. - -## Tests to add - -- Drive the production adapter with configurable stdin/stdout/stderr and exit codes. Verify successful and failed `RestoreSets` results, complete create/add command syntax, and stream closure. -- Cover nonempty and empty transactions, successful commit, nonzero exit with and without stderr, and a broken stdin stream. -- Specify nested-start and post-failure reuse behavior; assert queued commands cannot disappear or leak into a later commit unnoticed. -- Cover immediate create, destroy, add, delete, and swap failures, plus failed/partial `SaveSets` output. - -## Completion - -Add `IpSetAdapterTests.cs`; production restore/commit methods execute in fast tests. Confirm the intended boolean meaning before changing behavior. Reuse the process fake from gap 01 and assert disposal on all paths. - -## Implementation record - -Execute real ipset restore and transaction methods with process fakes. Correct inverted restore success and missing create/timeout serialization, reject failed save output, add explicit rollback and nested-start protection, and clear transaction buffers even after failure. Cover immediate operation errors and recovery. - -Validation: Fast suite passed. diff --git a/iptables-plans/08-ipset-sync-modes.md b/iptables-plans/08-ipset-sync-modes.md deleted file mode 100644 index 798496e..0000000 --- a/iptables-plans/08-ipset-sync-modes.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 08: Set synchronization modes, deletion filters, and replacement - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IpSetSets.Sync](../IPTables.Net/IpSet/IpSetSets.cs) supports three synchronization modes, selective deletion, and replacement through a temporary `_S` set. [IpSetSyncTests](../IPTables.Net.Tests/IpSetSyncTests.cs) covers ordinary entries, some `SetOnly` cases, and a hash-size change. It does not cover `SetAndEntriesOnCreate` or selective/default deletion policy. [IpSetSet.SetEquals](../IPTables.Net/IpSet/IpSetSet.cs) does not compare `Family`. - -## Tests to add - -- Cross all synchronization modes with absent, unchanged, and replaced sets; assert when entries are preserved or populated. -- Test no deletion predicate, always-false, and selective predicates with unrelated existing sets. -- Change family, type, timeout, bucket size, bitmap range, and create options individually; specify which require replacement and assert the decision. -- Inject failures at create/swap/destroy/populate stages and cover an existing temporary-name collision. Assert the reported failure and observed state without promising transactional rollback from ipset. -- Reconcile an already-converged state and assert no commands. - -## Completion - -Extend `IpSetSyncTests.cs` using stateful fakes. Run fast tests for policy and command order; validate actual swap restrictions separately in a disposable Linux namespace. - -## Implementation record - -Cover all ipset sync modes with existing/missing sets and explicit deletion policies. Preserve entries during SetOnly replacement, carry bucket/init metadata, detect temporary-name collisions, reject incompatible family/type swaps before mutation, and discard queued transactions on failure. - -Validation: Fast suite passed. Kernel swap compatibility is exercised only on a disposable Linux host. diff --git a/iptables-plans/09-ipset-entry-identity.md b/iptables-plans/09-ipset-entry-identity.md deleted file mode 100644 index 584652c..0000000 --- a/iptables-plans/09-ipset-entry-identity.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 09: IpSet entry equality and collection membership - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IpSetEntryKeyComparer](../IPTables.Net/IpSet/IpSetEntryKeyComparer.cs) hashes timeout, while [IpSetEntry.KeyEquals](../IPTables.Net/IpSet/IpSetEntry.cs) ignores it. The entry parser [inserts entries before populating their fields](../IPTables.Net/IpSet/Parser/IpSetEntryParser.cs), and parsed/programmatically constructed sets use different collection initialization paths in [IpSetSet](../IPTables.Net/IpSet/IpSetSet.cs). Existing tests inspect parsed fields or command lists, not hash-set invariants. - -## Tests to add - -- For key-equal entries with different timeouts, assert equal comparer hashes and working `Contains`, `Remove`, deduplication, and dictionary lookup. -- Compare entries that differ only in the second address, protocol, port, or MAC; each must remain distinct when it changes the key. -- Parse duplicate entries and query/remove a fully populated equivalent entry after parsing. -- Compare parsed and programmatically constructed sets under the same operations; include entries whose metadata changes. -- Feed these cases into synchronization and assert no unnecessary additions/deletions and no silently merged distinct entries. - -## Completion - -Add `IpSetEntryIdentityTests.cs`. Fast tests cover collection behavior, not a prescribed hash formula. Reproduce the equality/hash inconsistency before fixing it. - -## Implementation record - -Make entry-key hashing agree with equality by excluding timeout and including the second address. Use the key comparer for parsed sets, add entries only after parsing completes, and implement full entry hashing. Test hash-set/dictionary lookup, deduplication, timeout updates, tuple distinctions, and constructor/parser parity. - -Validation: Fast suite passed. diff --git a/iptables-plans/10-ipset-parser-fidelity.md b/iptables-plans/10-ipset-parser-fidelity.md deleted file mode 100644 index 6d1b4f4..0000000 --- a/iptables-plans/10-ipset-parser-fidelity.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 10: IpSet parser fidelity and invalid input - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -[IpSetParseTest](../IPTables.Net.Tests/IpSetParseTest.cs) checks a limited set of positive inputs, often only selected fields. [IpSetSetParser](../IPTables.Net/IpSet/Parser/IpSetSetParser.cs) parses bucket size and initval; [IpSetEntryParser](../IPTables.Net/IpSet/Parser/IpSetEntryParser.cs) handles tuple components, timeout, and ignored counters. Invalid arity, missing values, and complete rendered entry fidelity lack direct assertions. - -## Tests to add - -- Verify parse/render/reparse for nondefault bucket size, initval, create options, entry timeout, MAC tuples, and two-address tuples; define intentionally omitted fields explicitly. -- Assert that ignored packet/byte counters do not alter the entry key, while timeout survives APIs that promise a full entry command. -- Cover unknown set references, missing set type, missing option values, too few/many tuple components, invalid numeric values, and extra tokens. -- After rejected entries, assert that the containing set has no partially initialized member. - -## Completion - -Extend `IpSetParseTest.cs` or add focused parser tests. Each positive case checks all relevant fields and output; negative cases check error context and unchanged collection state. Run the fast suite. IPv6 tuple cases are tracked in gap 13. - -## Implementation record - -Add set and entry round-trip metadata tests and invalid-input atomicity tests. Validate tuple arity, required option values and counter text; reject extra entry tokens, preserve initval and timeout serialization, and explicitly test rejection of unsupported MAC set types rather than adding unsupported type flags. - -Validation: Fast suite passed. Packet/byte counters remain intentionally observational and are not serialized as key data. diff --git a/iptables-plans/11-save-parser-errors.md b/iptables-plans/11-save-parser-errors.md deleted file mode 100644 index bafda46..0000000 --- a/iptables-plans/11-save-parser-errors.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 11: Save parser framing, counters, and recovery - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IpTablesSaveReadingTests](../IPTables.Net.Tests/IpTablesSaveReadingTests.cs) covers valid IPv4 filter/nat dumps and one rule-counter ordering case. [IPTablesSaveParser](../IPTables.Net/Iptables/Adapter/Client/Helper/IPTablesSaveParser.cs) has untested behavior for incomplete dumps, wrong table commits, malformed counters, and `ignoreErrors`, whose catch currently surrounds only counter-prefixed rule parsing. - -## Tests to add - -- Cover empty output, CRLF, comments, blank lines, missing `COMMIT`, wrong requested table, and multiple table blocks. Define the accepted input framing before asserting selection behavior. -- Test missing counter delimiters, wrong field counts, nonnumeric and overflowing counters, and large valid values. -- Supply the same invalid rule with and without counter prefixes under both `ignoreErrors` settings; specify consistent recovery and preserve subsequent valid rules. -- Verify chain association, complete rule order, and model fields rather than only chain counts. Document whether chain policy/counters are intentionally outside the model. - -## Completion - -Extend `IpTablesSaveReadingTests.cs`; fast tests distinguish malformed/incomplete dumps from valid empty tables and prevent silent loss of later rules. - -## Implementation record - -Validate save framing and counter syntax, distinguish incomplete dumps from valid empty tables, select the requested table from multi-table input, and apply ignoreErrors consistently to counter-prefixed and plain rules. Assert CRLF handling, exact rule order, large counters, and recovery of later valid rules. - -Validation: Fast suite passed. Chain policy/counters remain outside the existing model. diff --git a/iptables-plans/12-rule-parser-invalid-input.md b/iptables-plans/12-rule-parser-invalid-input.md deleted file mode 100644 index 375baf0..0000000 --- a/iptables-plans/12-rule-parser-invalid-input.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 12: Invalid commands and argument tokenization - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -The numerous `Single*ParseTests` mostly cover successful rendering. [CommandParser](../IPTables.Net/Iptables/Modules/CommandParser.cs) indexes following arguments directly and converts command offsets. [ArgumentHelper.SplitArguments](../IPTables.Net/Supporting/ArgumentHelper.cs) implements quote/space handling without focused edge-case tests. [EscapeHelperTests](../IPTables.Net.Tests/EscapeHelperTests.cs) tests output escaping, not the complete input-tokenization contract. - -## Tests to add - -- Cover empty/whitespace input, trailing `-A`, `-m`, `-j`, `-t`, and module options missing values; assert intentional errors with useful context. -- Exercise `-I`, `-R`, and `-D` at position 1, omitted positions where allowed, zero, negative, overflow, and missing rule bodies. -- Test unknown modules/options, duplicate module loading, standalone/repeated negation, and invalid numeric option values. -- Add tokenizer cases for empty quoted arguments, mixed quotes, literal apostrophes, escaped quotes/backslashes, tabs, and unmatched quotes. Define accepted syntax rather than silently treating accidental behavior as a contract. -- Rejected parsing must not leave a supplied chain collection partly mutated. - -## Completion - -Add `CommandParserErrorTests.cs` and `ArgumentHelperTests.cs`. Run fast tests with independent expected tokens and exception/state assertions. - -## Implementation record - -Add deterministic tokenization and invalid-command theories, preserving empty quoted arguments, whitespace and escaped payloads. Report missing option values, reject malformed offsets and dangling/repeated negation, accept optional insert positions, and retain the deliberate unknown-module polyfill contract. - -Validation: Fast suite passed. diff --git a/iptables-plans/13-managed-ipv6.md b/iptables-plans/13-managed-ipv6.md deleted file mode 100644 index c2de0ad..0000000 --- a/iptables-plans/13-managed-ipv6.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 13: Managed IPv6 parsing and adapter selection - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[RuleParseAssert](../IPTables.Net.Tests/RuleParseAssert.cs) defaults to IPv4, and current round-trip callers do not exercise version 6. The suite's IPv6 address equality and route smoke examples do not validate IPv6 iptables models. Production [IpCidr](../IPTables.Net/Iptables/DataTypes/IpCidr.cs), [IPPortOrRange](../IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs), and adapter factories expose IPv6 behavior. - -## Tests to add - -- Parameterize representative core/TCP/UDP rules and save dumps for version 6, including `::`, `::1`, compressed addresses, `/0`, `/64`, and `/128`. -- Check IPv6 NAT address/range serialization with and without bracketed ports where supported; assert parsed fields independently of rendering. -- Test family mismatch handling and ensure chain/rule version is preserved through parsing and generation. -- Record binary/save/restore executable selection for `GetTableAdapter(6)`. -- Parse and synchronize `family inet6` sets with IPv6 and two-address tuple entries, preserving address and protocol identity. - -## Completion - -Add `IPv6ManagedTests.cs` and extend existing parameterized suites. All cases run with the fast suite and no native libraries. Native family coverage is gap 14. - -## Implementation record - -Add IPv6 core/TCP/UDP and NAT round trips with independent address assertions, IPv6 save parsing, executable selection and inet6 tuple synchronization. Reject mismatched core address families. Fix CIDR lookup losing the second tuple address, which caused identical sets to delete and re-add entries. - -Validation: Fast suite passed; no native dependency required. diff --git a/iptables-plans/14-native-ipv6.md b/iptables-plans/14-native-ipv6.md deleted file mode 100644 index 780e656..0000000 --- a/iptables-plans/14-native-ipv6.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 14: IPv6 native helper integration - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IptablesSystemTestSupport.IpVersion](../IPTables.Net.Tests/IptablesSystemTestSupport.cs) is constant 4. [IptcInterfaceTest](../IPTables.Net.Tests/IptcInterfaceTest.cs) contains alternate IPv6 strings, but its fixture never selects them. [IptcInterface](../IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs) and [ipthelper](../ipthelper/ipthelper.c) expose separate IPv4/IPv6 entry points, so IPv4 success cannot validate IPv6 layout, serialization, or commit behavior. - -## Tests to add - -- Run independent IPv4 and IPv6 fixtures for chain discovery, rule output/input, add/insert/replace/delete, commit, and readback. -- Assert IPv6 source/destination prefixes, protocol/options, rule ordering, and counters using an independent `ip6tables-save` observation. -- Add IPv6-specific failed-commit diagnostics and verify no unintended installation. Do not reuse the IPv4 native entry layout from the incompatible-match test. -- Verify family switching after all handles are disposed; serialize fixtures around native global initialization. - -## Completion - -Use disposable Linux networking with root/capabilities, libipthelper, and compatible legacy binaries. Run the stable full suite for both families with explicit assertions that IPv6 tests executed; report unavailable prerequisites as skips. Do not merely change the shared constant and assume both families ran. - -## Implementation record - -Add separately parameterized IPv4/IPv6 native edit-and-commit tests with independent save/counter readback and an IPv6 ABI-specific incompatible-revision test. Probe family prerequisites and report missing kernel tables explicitly rather than entering unsupported native paths. - -Validation: Fast suite passed. Isolated full run: IPv4 passed; two IPv6 cases skipped because this host lacks ip6_tables. An initial constructor-failure crash is addressed in plan 15. diff --git a/iptables-plans/15-native-handle-lifecycle.md b/iptables-plans/15-native-handle-lifecycle.md deleted file mode 100644 index faf4fd1..0000000 --- a/iptables-plans/15-native-handle-lifecycle.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 15: Native handle and transaction cleanup - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[IptcInterfaceTest](../IPTables.Net.Tests/IptcInterfaceTest.cs) checks normal refcounts and one incompatible-match commit error. It does not cover failed construction, multiple live handles, family conflicts, or rollback/reuse. [IptcInterface](../IPTables.Net/Iptables/NativeLibrary/IptcInterface.cs) increments global initialization counts before opening a table. [IPTablesLibAdapterClient](../IPTables.Net/Iptables/Adapter/Client/IPTablesLibAdapterClient.cs) owns multiple interfaces and accepts an explicit commit order. - -## Tests to add - -- Fail table opening and verify a subsequent valid construction and disposal restore the initial refcount. -- Test simultaneous same-family handles, rejected mixed-family initialization, repeated dispose, and family switching after cleanup. -- Exercise rollback, commit then reuse, failed commit then recovery, and operations with no open handle. -- Cover multi-table commit orders containing missing, duplicate, or omitted tables; specify ownership/cleanup for every opened interface and preserve errno diagnostics. -- Cover `BpfCompile` success, invalid filter/link type, and insufficient output buffer in a separate native-only fixture; current BPF parsing tests do not compile programs. - -## Completion - -Extend native tests and inspect corresponding [helper allocation/commit code](../ipthelper/ipthelper.c). Use fault injection where kernel failures cannot be produced reliably. Run in disposable Linux environments, asserting resource/state recovery in addition to exceptions. - -## Implementation record - -Fix native table-init errors long-jumping without an active recovery frame, constructor/refcount cleanup, rejected-family finalization, and duplicate/omitted ordered commits. Bound BPF formatting and release allocations on all failures. Add native construction, disposal, rollback/reuse, commit-order and BPF buffer tests. - -Validation: Fast suite passed. Isolated native lifecycle/family tests passed where supported; IPv6 kernel-dependent cases skip because ip6_tables is absent. diff --git a/iptables-plans/16-conntrack-behavior.md b/iptables-plans/16-conntrack-behavior.md deleted file mode 100644 index c2a2419..0000000 --- a/iptables-plans/16-conntrack-behavior.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 16: Conntrack data integrity and failure cleanup - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -[ConntrackLibraryTests](../IPTables.Net.Tests/ConntrackLibraryTests.cs) asserts struct size; its two dump tests collect records without checking them. The class is categorized `NotWorkingOnTravis`. [ConntrackSystem](../IPTables.Net/Conntrack/ConntrackSystem.cs) additionally implements restore, mark overrides, extraction, caching, and cleanup against [ct.cpp](../ipthelper/ct.cpp). Dump reuses an equal-sized buffer, and restore-mark cleanup follows a possible throw. - -## Tests to add - -- Seed known flows and assert dump count/content, matching and nonmatching filters, family selection, and expectation-table behavior. -- Specify callback buffer ownership; retain multiple equal-sized records to detect unintended aliasing. -- Verify valid/unknown constants, successful/missing field extraction, and malformed record handling using controlled native inputs or a test shim. -- Test restore success, remaining-byte results, native errors, mark/mask application, and cleanup after failure. -- Throw from a dump callback, then repeat the operation to prove resources and filter state were released. Cover multiple instances if native state is shared. - -## Completion - -Keep deterministic marshaling/shim cases separate from unstable kernel tests. Run actual conntrack operations only in a disposable capable Linux environment with `RUN_UNSTABLE_SYSTEM_TESTS=1 bash ./test.sh --full`; use timeouts and assert contents, not just absence of exceptions. - -## Implementation record - -Correct conntrack native cleanup signatures, Linux family mapping, independent dump buffers, shared-state locking, and failure cleanup. Reject malformed extraction/restore lengths. Add deterministic native-boundary tests and seeded UDP filter assertions. - -Validation: Fast suite: 526 passed, 26 skipped. Isolated Linux conntrack selection: 18 passed, including seeded flow and native dump/extraction. diff --git a/iptables-plans/17-nfacct-client.md b/iptables-plans/17-nfacct-client.md deleted file mode 100644 index 2e0c127..0000000 --- a/iptables-plans/17-nfacct-client.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 17: Accounting XML and command execution - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -No tests instantiate [NfAcct](../IPTables.Net/NfAcct/NfAcct.cs). [SingleNfacctRuleParseTests](../IPTables.Net.Tests/SingleNfacctRuleParseTests.cs) covers the iptables match module only. `FromXml` supplies packets then bytes to [NfAcctUsage](../IPTables.Net/NfAcct/NfAcctUsage.cs), whose constructor expects bytes then packets; `List` uses the opposite order. This is a concrete source-level inconsistency needing regression coverage. - -## Tests to add - -- Feed identical XML to `Get` and `List` with distinct counts such as 7 packets and 900 bytes; assert named properties and consistency. -- Cover multiple names, unknown names, empty output, malformed XML, missing fields, large unsigned counters, and invalid counter text. -- Record `Get`/`List` reset arguments, `Exist`, add/delete commands, and handling of names requiring escaping. -- Configure nonzero exits and stderr; establish an explicit command-failure contract rather than accepting silent failures by default. - -## Completion - -Add `NfAcctTests.cs` using `ISystemFactory` fakes. Fast tests validate counter semantics and commands without requiring nfacct installation. Reproduce the count-order mismatch before fixing it. - -## Implementation record - -Unify accounting XML parsing and correct swapped byte/packet counters. Preserve unsigned counters, escape object names, and report malformed records and command failures consistently. Add Get/List/reset/existence and process-disposal coverage. - -Validation: Fast suite: 537 passed, 27 skipped, zero failures. diff --git a/iptables-plans/18-route-rule-controllers.md b/iptables-plans/18-route-rule-controllers.md deleted file mode 100644 index f70d5b5..0000000 --- a/iptables-plans/18-route-rule-controllers.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 18: Route/rule controller assertions and command errors - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -All three [IpUtilsRouteTests](../IPTables.Net.Tests/IpUtilsRouteTests.cs) lack assertions. Rule tests cover basic successful commands, but [IpController](../IPTables.Net/IpUtils/Utils/IpController.cs), [IpRouteController](../IPTables.Net/IpUtils/Utils/IpRouteController.cs), and [IpRuleController](../IPTables.Net/IpUtils/Utils/IpRuleController.cs) contain untested listing, error, and malformed-output paths. `Command` discards exit status. - -## Tests to add - -- Replace route smoke checks with exact destination, gateway, device, flags, table, and exported-argument assertions, including local/multicast/anycast routes. -- Cover route `GetAll` with null, named, `default`, and `all` tables; include output already containing a table and empty/multiple lines. -- Exercise missing key values, flag-only input, duplicate keys, malformed priorities, and line-context wrapping for both controllers. -- Test add/delete/show failures with stderr, stdout, and nonzero exit with empty streams; specify a consistent failure contract. - -## Completion - -Extend route/rule tests with process fakes and run the fast suite. Tests must fail when destination/table data or an execution error is silently lost; no host routes should be modified. - -## Implementation record - -Replace route smoke checks with field/export assertions; cover table selection, empty and malformed listings, flags and command failures. Preserve existing table output, avoid null table injection, validate rule priorities, and reject nonzero command exits. - -Validation: Fast suite: 551 passed, 27 skipped, zero failures. diff --git a/iptables-plans/19-ip-object-sync.md b/iptables-plans/19-ip-object-sync.md deleted file mode 100644 index 2ff2457..0000000 --- a/iptables-plans/19-ip-object-sync.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 19: Route/rule reconciliation and object identity - -Priority: P1. Status: implemented; see implementation record below. - -## Evidence - -No existing tests reference [DefaultIpuSync](../IPTables.Net/IpUtils/Sync/DefaultIpuSync.cs). Its set-difference algorithm depends on [IpObject](../IPTables.Net/IpUtils/Utils/IpObject.cs) equality and hashing; those implementations order entries by hash and expose mutable collections. Parser tests do not establish reconciliation correctness. - -## Tests to add - -- Record adds/deletes for empty, identical, disjoint, and partially overlapping current/desired objects. -- Cover duplicate desired/current objects, reordered dictionary insertion, different flag order, and second-run idempotency. -- Verify equal objects produce equal hashes and work in a `HashSet`; different pair values or flags must remain distinguishable, including a controlled collision case if practical. -- Verify cloning copies both collections independently. -- Inject getter/add/delete failures and assert propagation and the operations completed before failure; do not assume rollback support. - -## Completion - -Add `DefaultIpuSyncTests.cs` and `IpObjectTests.cs` using a recording controller. Run the fast suite with exact operation/state assertions and no actual `ip` commands. - -## Implementation record - -Add reconciliation set-difference, duplicate, idempotency and failure-order tests plus object identity and clone ownership coverage. Snapshot and deduplicate current objects before mutation; compare and hash object fields independently of dictionary insertion order. - -Validation: Fast suite: 562 passed, 27 skipped, zero failures. diff --git a/iptables-plans/20-address-port-boundaries.md b/iptables-plans/20-address-port-boundaries.md deleted file mode 100644 index 54a4d5d..0000000 --- a/iptables-plans/20-address-port-boundaries.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 20: CIDR and address/port boundary contracts - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -[IpSetCidrTests](../IPTables.Net.Tests/IpSetCidrTests.cs) covers several IPv4 synchronization examples, and [PortRangeHelpersTests](../IPTables.Net.Tests/PortRangeHelpersTests.cs) covers compression/counting. These do not directly validate all contracts of [IpCidr](../IPTables.Net/Iptables/DataTypes/IpCidr.cs), [PortOrRange](../IPTables.Net/Iptables/DataTypes/PortOrRange.cs), [IpPort](../IPTables.Net/Iptables/DataTypes/IpPort.cs), and [IPPortOrRange](../IPTables.Net/Iptables/DataTypes/IPPortOrRange.cs). - -## Tests to add - -- Check CIDR `/0`, host prefixes, exact network/broadcast endpoints, adjacent networks, rebasing, address counts, ordering, and mixed-family containment. -- Specify behavior for invalid/negative/overflow prefixes and extra slash components, including `0.0.0.0` with an explicit nonzero prefix. -- Cover port 0, 65535, 65536, reversed ranges, missing endpoints, extra separators, and numeric overflow. Distinguish port-specific validation from generic numeric range usage. -- Validate IPv4 and bracketed IPv6 address ranges, omitted ports, invalid addresses, and parse/render stability; define the legacy `IpPort` fallback contract separately. - -## Completion - -Add focused data-type theories with independently calculated results. Run the fast suite. Boundary expectations must be explicit before changing permissive parsing behavior. - -## Implementation record - -Establish CIDR containment/count/rebase boundaries for both families, reject malformed prefixes and ranges, and preserve explicit zero-address prefixes. Keep generic uint ranges distinct from endpoint port limits; support bracketed IPv6 single endpoints while preserving the legacy invalid-input fallback. - -Validation: Fast suite: 596 passed, 27 skipped, zero failures. diff --git a/iptables-plans/21-rule-generator-boundaries.md b/iptables-plans/21-rule-generator-boundaries.md deleted file mode 100644 index 1c14c21..0000000 --- a/iptables-plans/21-rule-generator-boundaries.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 21: Rule generator limits and semantic preservation - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -[RuleBuilderMultiportTests](../IPTables.Net.Tests/RuleBuilderMultiportTests.cs) covers two examples; splitter/nested tests cover one each. [MultiportAggregator](../IPTables.Net/Iptables/RuleGenerator/MultiportAggregator.cs) has range-slot boundaries, optional callbacks, an ipset mode, and target propagation. [FeatureSplitter](../IPTables.Net/Iptables/RuleGenerator/FeatureSplitter.cs) has duplicate-chain and nested-output behavior not covered by those examples. - -## Tests to add - -- Exercise 14/15/16 slots, a range after 14 single ports, overlapping/adjacent ranges, duplicates, and empty groups. Assert every ordinary multiport rule respects the slot limit and preserves the intended port set. -- Cover source/destination ports, TCP/UDP, jump/goto targets, custom base rules, non-filter tables, and ipset mode. -- Verify generated chain references resolve, empty generated chains are handled, and preexisting/repeated generated names follow the duplicate policy. -- Exercise missing extractors/callback combinations and nested empty output with intentional errors rather than incidental null dereferences. - -## Completion - -Extend builder tests and run fast validation. Assert output semantics and complete generated rule sets in addition to representative command strings; do not merely duplicate the generator algorithm in test code. - -## Implementation record - -Cover multiport boundaries, TCP/UDP source/destination ports, goto targets, custom mangle base rules, ipset output and empty nested generators. Merge overlapping/duplicate ranges correctly, validate callbacks, support omitted jump setters and remove empty nested chains without dangling jumps; fix empty-group logging. - -Validation: Fast suite: 603 passed, 27 skipped, zero failures. diff --git a/iptables-plans/22-ruleset-clone-isolation.md b/iptables-plans/22-ruleset-clone-isolation.md deleted file mode 100644 index a854952..0000000 --- a/iptables-plans/22-ruleset-clone-isolation.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 22: Deep clone fidelity and independence - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -[IpTablesRuleSet.DeepClone](../IPTables.Net/Iptables/IpTablesRuleSet.cs) reconstructs rules through `GetActionCommand()` without requesting counters. [IPTablesRuleTests](../IPTables.Net.Tests/IPTablesRuleTests.cs) explicitly checks counter preservation only for `ShallowClone`. Restore tests call deep clone on zero-counter examples and do not mutate module state to verify independence. - -## Tests to add - -- Clone a ruleset with multiple tables, empty/custom chains, duplicate ordered rules, IPv6 rules, comments, and nonzero counters. -- Specify which metadata `DeepClone` promises to preserve, then assert it field by field; nonzero counters distinguish intentional reset from accidental loss. -- Change a cloned module, add/remove/reorder cloned rules, and alter counters; assert the source remains unchanged. -- Check that cloned rules reference cloned chains while retaining the intended system and IP version. -- Verify original/clone equality immediately after cloning under the chosen metadata contract, and inequality after a semantic mutation. - -## Completion - -Add `RuleSetCloneTests.cs` and run the fast suite. Keep existing shallow-clone coverage; these tests specifically establish the deeper ownership and fidelity guarantee. - -## Implementation record - -Preserve counters when deep-cloning rulesets, including disabled counter values. Verify IPv4/IPv6 clone equality, ordered duplicates, multiple tables, empty chains, comments, system references and independent module/rule/chain mutations. - -Validation: Fast suite: 605 passed, 27 skipped, zero failures. diff --git a/iptables-plans/23-capability-detection.md b/iptables-plans/23-capability-detection.md deleted file mode 100644 index 32b963a..0000000 --- a/iptables-plans/23-capability-detection.md +++ /dev/null @@ -1,24 +0,0 @@ -# Gap 23: Version parsing and SYNPROXY capability detection - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -No tests call [SynProxyHelper](../IPTables.Net/Iptables/Helpers/SynProxyHelper.cs) or adapter `GetIptablesVersion`. The [binary client's](../IPTables.Net/Iptables/Adapter/Client/IPTablesBinaryAdapterClient.cs) version regex and the helper's kernel-release regex control feature availability. The kernel regex requires a numeric component after a hyphen, excluding other release shapes regardless of their numeric version. - -## Tests to add - -- Parse representative iptables version output with legacy/nf_tables suffixes, whitespace, malformed text, and command failures. -- Assert the iptables support boundary immediately below, at, and above 1.4.21 using a fake adapter. -- Feed kernel releases below/at/above 3.12, plain semantic versions, distro suffixes, custom suffixes, malformed output, and nonzero exits. -- Define whether unrecognized version formats mean unsupported or an error; distinguish version-based eligibility from proof that a kernel target/module is available. - -## Completion - -Add `CapabilityDetectionTests.cs` using fixed strings and process fakes. Run the fast suite; results must be independent of the test host's installed binaries and kernel. - -## Implementation record - -Cover iptables and SYNPROXY version thresholds with fixed process responses. Parse IPv4/IPv6 binary banners and plain, distro and custom kernel releases; report process failures and reject malformed or overflowing versions. Document that version eligibility does not establish module availability. - -Validation: Fast suite: 627 passed, 27 skipped, zero failures. diff --git a/iptables-plans/24-test-runner-contracts.md b/iptables-plans/24-test-runner-contracts.md deleted file mode 100644 index 11683fa..0000000 --- a/iptables-plans/24-test-runner-contracts.md +++ /dev/null @@ -1,25 +0,0 @@ -# Gap 24: Runner modes, cleanup, and CI parity - -Priority: P2. Status: implemented; see implementation record below. - -## Evidence - -[test.sh](../test.sh) chooses modes/backends, filters unstable tests, mutates alternatives, and restores state with an EXIT trap. [The CI workflow](../.github/workflows/main.yml) duplicates setup and invokes `sudo dotnet test` directly, bypassing the script's default unstable-test exclusion. No shell regression tests exercise this control flow. Both build/test scripts are tracked as mode `100644`; direct test-script invocation failed during this review. - -## Tests to add - -- Use a temporary PATH containing command stubs to cover fast/full/auto selection, invalid options, backend `current` no-switch behavior, and partial backend-switch failure. -- Fail test execution and verify both original alternatives are restored and cleanup is attempted; run stubs only, not real host firewall cleanup. -- Test default unstable exclusion, opt-in inclusion, and explicit filter forwarding. Check required environment propagation into privileged execution. -- Verify documented script entry points execute in a clean checkout and agree with CI's intended test modes. -- Add a CI check that expected native tests actually execute when prerequisites are provisioned; track skips explicitly rather than treating any green run as native coverage. - -## Completion - -Add a small shell test harness and align CI with a documented stable/unstable policy. Run the harness without privileges, then fast tests; validate full mode separately on a disposable Linux runner. Keep executable-bit repair and workflow changes in the later implementation, not this documentation-only review. - -## Implementation record - -Add a stubbed runner harness for modes, filters, backend restoration, failures and runtime environment propagation. Reject invalid modes, clear inherited system-test skips in full mode and preserve runtime limits through sudo. Restore executable entry points, align CI with stable/opt-in kernel policy, and require native TRX coverage with explicit skip reporting. Update the plan index with final validation. - -Validation: Runner harness: 17 cases passed; native-report checker: 4 tests passed; fast: 627 passed/27 skipped; isolated stable full: 645 passed/5 skipped. Two full skips are unavailable IPv6 kernel tests; three are preexisting disabled tests. CI checker correctly rejects the missing IPv6 coverage. Shell syntax, YAML parsing and whitespace checks passed. diff --git a/iptables-plans/README.md b/iptables-plans/README.md deleted file mode 100644 index 7fb6517..0000000 --- a/iptables-plans/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Testing gap plans - -Reviewed the managed implementation, xUnit suite, test doubles, native integration boundaries, and test runner on 2026-10-11. Each numbered document describes one actionable coverage gap. These are source-review findings, not a measured line/branch coverage report or a claim that every missing case has been enumerated. Suspected defects are identified as review observations until reproduced. - -Baseline: `bash ./test.sh --fast` from the repository root passed with **386 passed, 15 skipped, 0 failed (401 total)**. Native dependencies were absent, so the helper build was skipped. Full/system tests were not run. Direct `./test.sh --fast` failed with permission denied; the tracked script mode is `100644` (see gap 24). - -All 24 plans are implemented and committed individually. Each plan ends with an implementation record and validation results. P1 means prioritize because the gap can conceal incorrect firewall changes, state loss, or native resource problems; P2 means important correctness or compatibility coverage. Existing happy-path tests should remain. Add assertions against independently specified results rather than simply replaying output through the same parser. - -| Gap | Priority | Scope | -| --- | --- | --- | -| [01](01-binary-process-failures.md) | P1 | Binary adapter process failures | -| [02](02-restore-commit-process.md) | P1 | Real restore commit execution | -| [03](03-restore-transaction-lifecycle.md) | P1 | Restore transaction reuse and fallback | -| [04](04-restore-output-integrity.md) | P1 | Restore stream failures and quoted payloads | -| [05](05-ruleset-sync-retries.md) | P1 | Whole-ruleset synchronization and retries | -| [06](06-rule-sync-protection.md) | P1 | Deletion predicates and custom equality | -| [07](07-ipset-process-transactions.md) | P1 | Real ipset restore and transaction execution | -| [08](08-ipset-sync-modes.md) | P1 | Set modes, deletion filters, and replacement | -| [09](09-ipset-entry-identity.md) | P1 | Entry equality, hashing, and collection membership | -| [10](10-ipset-parser-fidelity.md) | P2 | Set/entry parser fidelity and invalid input | -| [11](11-save-parser-errors.md) | P1 | Save parser framing and recovery | -| [12](12-rule-parser-invalid-input.md) | P2 | Invalid commands and argument tokenization | -| [13](13-managed-ipv6.md) | P1 | Managed IPv6 parsing and adapter selection | -| [14](14-native-ipv6.md) | P1 | IPv6 native helper integration | -| [15](15-native-handle-lifecycle.md) | P1 | Native handle and transaction cleanup | -| [16](16-conntrack-behavior.md) | P1 | Conntrack data integrity and failure cleanup | -| [17](17-nfacct-client.md) | P1 | Accounting XML and commands | -| [18](18-route-rule-controllers.md) | P1 | Route/rule assertions and command errors | -| [19](19-ip-object-sync.md) | P1 | Route/rule reconciliation and object identity | -| [20](20-address-port-boundaries.md) | P2 | CIDR and address/port boundaries | -| [21](21-rule-generator-boundaries.md) | P2 | Generator limits and semantic preservation | -| [22](22-ruleset-clone-isolation.md) | P2 | Deep clone fidelity and independence | -| [23](23-capability-detection.md) | P2 | Version parsing and SYNPROXY detection | -| [24](24-test-runner-contracts.md) | P2 | Runner modes, cleanup, and CI parity | - -For managed plans, validate with `bash ./test.sh --fast`; once executable permissions are corrected, use the documented `./test.sh --fast`. Native plans specify separate Linux prerequisites. Use disposable hosts or isolated network namespaces for tests that mutate networking. Do not assume libiptc supports the nft backend. Shared process-fake improvements in gaps 01, 02, and 07 can be implemented once and reused. - -## Final validation - -- Fast suite: **627 passed, 27 skipped, zero failures** (654 total). -- Stable full suite in a disposable Linux network namespace: **645 passed, 5 skipped, zero failures** (650 total). -- The full-suite skips are three existing disabled Levenshtein tests and two IPv6 native tests: this host lacks the legacy IPv6 kernel tables. IPv4 native lifecycle and system tests executed successfully. The new CI coverage check correctly rejects this incomplete IPv6 coverage; provisioned CI runners must execute those tests. -- Conntrack selection in an isolated namespace: **18 passed**, including a seeded UDP flow, matching/nonmatching filters, native extraction, and deterministic cleanup tests. Unstable conntrack tests remain opt-in in ordinary full runs. -- Runner regression harness: **17 scenarios passed**. Native-result checker: **4 tests passed**, including missing and skipped coverage rejection. -- Shell syntax and Git whitespace checks passed. CI workflow syntax was parsed locally; remote CI has not been run. - -`build.sh` and `test.sh` are now executable. CI runs these entry points, uploads TRX results, requires stable native coverage, and offers a separate explicit workflow input for unstable kernel tests. From eb5aa5c545643a5106039ffcc801dc6d024eb3e3 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 02:10:48 +0000 Subject: [PATCH 26/28] fix: preserve missing-object accounting lookup behavior Recognize the nfacct get ENOENT diagnostic and return null/false for missing accounting objects. Keep permission, socket, process-start, partial-output and non-lookup failures visible. Add regression coverage for lookup-then-create and reset lookups. Validation: reproduced both missing-object failures before the fix; all 22 NfAcctTests now pass via test.sh --fast with a focused filter. --- IPTables.Net.Tests/NfAcctTests.cs | 44 +++++++++++++++++++++++++++++++ IPTables.Net/NfAcct/NfAcct.cs | 7 +++++ 2 files changed, 51 insertions(+) diff --git a/IPTables.Net.Tests/NfAcctTests.cs b/IPTables.Net.Tests/NfAcctTests.cs index 61f17bf..01fa2c2 100644 --- a/IPTables.Net.Tests/NfAcctTests.cs +++ b/IPTables.Net.Tests/NfAcctTests.cs @@ -41,4 +41,48 @@ public void EveryCommandReportsNonzeroExit(int operation) Assert.Contains("denied", Assert.Throws(actions[operation]).Message); Assert.True(Assert.Single(fake.Calls).Process.IsDisposed); } + [Theory] + [InlineData("nfacct v1.0.2: error: No such file or directory\n")] + [InlineData("nfacct v1.0.3: error: No such file or directory\n")] + public void MissingObjectReturnsNullOrFalseAndCanBeCreated(string error) + { + var fake = new ScriptedSystem { Respond = (_, args) => args.StartsWith("get ") ? new("", error, 1) : new() }; + var client = new NfAcct.NfAcct(fake); + Assert.Null(client.Get("missing")); + Assert.Null(client.Get("missing", true)); + if (!client.Exist("missing")) client.Add("missing"); + Assert.Equal(new[] { "get missing xml", "get missing xml reset", "get missing xml", "add missing" }, fake.Calls.Select(x => x.Arguments)); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } + [Theory] + [InlineData("", "nfacct v1.0.2: error: Operation not permitted", 1)] + [InlineData("", "nfacct v1.0.2: error: Permission denied", 1)] + [InlineData("", "nfacct v1.0.2: mnl_socket_open: No such file or directory", 1)] + [InlineData("", "/usr/sbin/nfacct: No such file or directory", 1)] + [InlineData("", "", 1)] + [InlineData("partial", "nfacct v1.0.2: error: No such file or directory", 1)] + [InlineData("", "nfacct v1.0.2: error: No such file or directory", 2)] + public void LookupFailuresAreNotMistakenForMissingObjects(string output, string error, int exit) + { + var fake = new ScriptedSystem { Respond = (_, _) => new(output, error, exit) }; + var client = new NfAcct.NfAcct(fake); + Assert.Throws(() => client.Get("a")); + Assert.Throws(() => client.Exist("a")); + Assert.All(fake.Calls, call => Assert.True(call.Process.IsDisposed)); + } + [Fact] + public void MissingObjectDiagnosticDoesNotHideOtherCommandFailures() + { + var fake = new ScriptedSystem { Respond = (_, _) => new("", "nfacct v1.0.2: error: No such file or directory", 1) }; + var client = new NfAcct.NfAcct(fake); + Assert.Throws(() => client.List()); + Assert.Throws(() => client.Add("a")); + Assert.Throws(() => client.Delete("a")); + } + [Fact] + public void LookupProcessStartFailurePropagates() + { + var fake = new ScriptedSystem { Respond = (_, _) => throw new System.ComponentModel.Win32Exception(2) }; + Assert.Throws(() => new NfAcct.NfAcct(fake).Exist("a")); + } } diff --git a/IPTables.Net/NfAcct/NfAcct.cs b/IPTables.Net/NfAcct/NfAcct.cs index bc94157..e0e11c6 100644 --- a/IPTables.Net/NfAcct/NfAcct.cs +++ b/IPTables.Net/NfAcct/NfAcct.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; using SystemInteract; @@ -16,6 +17,11 @@ private string Execute(params string[] arguments) { using var process = _system.StartProcess("/usr/sbin/nfacct", ShellHelper.BuildArgumentString(arguments)); ProcessHelper.ReadToEnd(process, out var output, out var error); + // nfacct reports a missing object as ENOENT with exit status 1. + // Match only its get-response diagnostic, not socket or process-start failures. + if (process.ExitCode == 1 && arguments[0] == "get" && string.IsNullOrWhiteSpace(output) && + Regex.IsMatch(error.Trim(), @"\Anfacct v[^:\r\n]+: error: No such file or directory\z")) + return ""; if (process.ExitCode != 0) throw new IpTablesNetException($"nfacct exited with {process.ExitCode}: {error} {output}"); return output; } @@ -35,6 +41,7 @@ private static List Parse(string output) } catch (XmlException ex) { throw new FormatException("Invalid nfacct XML", ex); } } + /// Gets accounting data, or null if the object does not exist. public NfAcctUsage Get(string name, bool reset = false) => Parse(Execute(reset ? new[] { "get", name, "xml", "reset" } : new[] { "get", name, "xml" })).FirstOrDefault(x => x.Name == name); public bool Exist(string name) => Get(name) != null; From cc2eb2f66158a9ef6492324699867c2b0a0b5403 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 02:11:43 +0000 Subject: [PATCH 27/28] fix: retain IPv6 scope when rebasing CIDR addresses Preserve the original IPv6 ScopeId when constructing a rebased address. Cover scoped host and subnet prefixes, unscoped IPv6, and IPv4 without changing the input address. Validation: reproduced scope loss in the /128 and /64 tests before the fix. Full fast suite passes: 650 passed, 27 skipped, zero failures. --- IPTables.Net.Tests/AddressPortBoundaryTests.cs | 17 +++++++++++++++++ IPTables.Net/Iptables/DataTypes/IpCidr.cs | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/IPTables.Net.Tests/AddressPortBoundaryTests.cs b/IPTables.Net.Tests/AddressPortBoundaryTests.cs index 8c73c98..5e596dc 100644 --- a/IPTables.Net.Tests/AddressPortBoundaryTests.cs +++ b/IPTables.Net.Tests/AddressPortBoundaryTests.cs @@ -56,4 +56,21 @@ public void LegacySingleEndpointFallbackAndIpv6Rendering() Assert.Equal("[::1]:80", IpPort.Parse("[::1]:80").ToString()); Assert.Equal("192.0.2.1:0", IpPort.Parse("192.0.2.1").ToString()); } + [Theory] + [InlineData("fe80::1234%7", 128u, "fe80::1234%7")] + [InlineData("fe80::1234%7", 64u, "fe80::%7")] + [InlineData("2001:db8::1234", 64u, "2001:db8::")] + [InlineData("192.0.2.129", 32u, "192.0.2.129")] + [InlineData("192.0.2.129", 24u, "192.0.2.0")] + public void RebasePreservesScopeAndAddressFamily(string original, uint prefix, string expected) + { + var address = IPAddress.Parse(original); + var result = IpCidr.NewRebase(address, prefix); + Assert.Equal(IPAddress.Parse(expected), result.Address); + Assert.Equal(prefix, result.Prefix); + Assert.Equal(address.AddressFamily, result.Address.AddressFamily); + Assert.Equal(original, address.ToString()); + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + Assert.Equal(address.ScopeId, result.Address.ScopeId); + } } diff --git a/IPTables.Net/Iptables/DataTypes/IpCidr.cs b/IPTables.Net/Iptables/DataTypes/IpCidr.cs index a381261..1de2a74 100644 --- a/IPTables.Net/Iptables/DataTypes/IpCidr.cs +++ b/IPTables.Net/Iptables/DataTypes/IpCidr.cs @@ -159,7 +159,10 @@ public static IpCidr NewRebase(IPAddress findAddress, uint u) _ = new IpCidr(findAddress, u); // Validate before changing address bits. var bytes = findAddress.GetAddressBytes(); for (int bit = (int)u; bit < bytes.Length * 8; bit++) bytes[bit / 8] &= (byte)~(128 >> (bit % 8)); - return new IpCidr(new IPAddress(bytes), u); + var network = findAddress.AddressFamily == AddressFamily.InterNetworkV6 + ? new IPAddress(bytes, findAddress.ScopeId) + : new IPAddress(bytes); + return new IpCidr(network, u); } } } From fa64a5d0f97027e89323ccee574f9e5d18c86718 Mon Sep 17 00:00:00 2001 From: Mathew Date: Sun, 11 Oct 2026 02:13:34 +0000 Subject: [PATCH 28/28] fix: synchronize explicit ipset initialization seeds Address PR #42 review comment 4239937010 by comparing nonzero desired InitVal values during set configuration comparison. Keep zero unspecified to avoid replacing sets with kernel-selected seeds. Add regression tests for mismatched, matching and unspecified seeds, replacement payloads, and repeat-sync idempotency. Validation: reproduced four failures before the fix. Full fast suite: 660 passed, 27 skipped, zero failures. --- IPTables.Net.Tests/IpSetModeTests.cs | 45 ++++++++++++++++++++++++++++ IPTables.Net/IpSet/IpSetSet.cs | 8 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/IPTables.Net.Tests/IpSetModeTests.cs b/IPTables.Net.Tests/IpSetModeTests.cs index 2ecbf5b..51c4e2a 100644 --- a/IPTables.Net.Tests/IpSetModeTests.cs +++ b/IPTables.Net.Tests/IpSetModeTests.cs @@ -5,6 +5,51 @@ namespace IPTables.Net.Tests; public class IpSetModeTests { + [Theory] + [InlineData(0u, 0u, true)] + [InlineData(16u, 0u, true)] + [InlineData(16u, 16u, true)] + [InlineData(0u, 32u, false)] + [InlineData(16u, 32u, false)] + public void SetComparisonHonorsOnlyExplicitDesiredSeeds(uint currentSeed, uint desiredSeed, bool equal) + { + var current = IpSetSet.Parse($"demo hash:ip initval {currentSeed}", null); + var desired = IpSetSet.Parse($"demo hash:ip initval {desiredSeed}", null); + Assert.Equal(equal, current.SetEquals(desired)); + Assert.Equal(equal, current.SetEquals(desired, size: false)); + } + + [Theory] + [InlineData(0u, 0u, false)] + [InlineData(16u, 0u, false)] + [InlineData(16u, 16u, false)] + [InlineData(0u, 32u, true)] + [InlineData(16u, 32u, true)] + public void SeedChangesReplaceExistingSetsAndConverge(uint currentSeed, uint desiredSeed, bool replace) + { + var saved = $"create demo hash:ip initval {currentSeed}\nadd demo 192.0.2.1\n"; + var os = new ScriptedSystem { Respond = (_, args) => new(args == "save" ? saved : "") }; + var system = new IpTablesSystem(os, null); + var desired = new IpSetSets(new[] { $"create demo hash:ip initval {desiredSeed}", "add demo 192.0.2.1" }, system); + + desired.Sync(); + + if (replace) + { + var restore = Assert.Single(os.Calls, call => call.Arguments == "restore"); + Assert.Equal($"create demo_S hash:ip family inet hashsize 1024 maxelem 65536 initval {desiredSeed}\n" + + "swap demo_S demo\ndestroy demo_S\nadd demo 192.0.2.1\n", restore.Text.Replace("\r", "")); + saved = $"create demo hash:ip initval {desiredSeed}\nadd demo 192.0.2.1\n"; + } + else + Assert.Equal("save", Assert.Single(os.Calls).Arguments); + + os.Calls.Clear(); + desired.Sync(); + Assert.Equal("save", Assert.Single(os.Calls).Arguments); + Assert.False(system.SetAdapter.InTransaction); + } + [Theory] [InlineData(IpSetSyncMode.SetOnly, false, false)] [InlineData(IpSetSyncMode.SetOnly, true, false)] [InlineData(IpSetSyncMode.SetAndEntries, false, true)] [InlineData(IpSetSyncMode.SetAndEntries, true, true)] diff --git a/IPTables.Net/IpSet/IpSetSet.cs b/IPTables.Net/IpSet/IpSetSet.cs index d40bd5a..1dc0400 100644 --- a/IPTables.Net/IpSet/IpSetSet.cs +++ b/IPTables.Net/IpSet/IpSetSet.cs @@ -217,6 +217,10 @@ public static IpSetSet Parse(string rule, IpTablesSystem system, int startOffset return Parse(arguments, system, startOffset); } + /// + /// Checks whether this set matches the desired configuration. + /// A zero InitVal in the desired set leaves the existing seed unspecified. + /// public bool SetEquals(IpSetSet set, bool size = true) { if (!(set.Family == Family && set.MaxElem == MaxElem && set.Name == Name && set.Timeout == Timeout && _bucketSize == set._bucketSize && @@ -224,6 +228,8 @@ public bool SetEquals(IpSetSet set, bool size = true) .SequenceEqual(CreateOptions.OrderBy(a => a)))) return false; + if (set.InitVal != 0 && set.InitVal != InitVal) return false; + if (size) return set.HashSize == HashSize; return true; @@ -317,4 +323,4 @@ public void SyncEntries(IpSetSet set) SyncEntries(set.Entries); } } -} \ No newline at end of file +}