e66b17fe5f
Resolves the 21 Major findings catalogued in
codereviews/2026-05-14/RemediationPlan.md (Wave 2). Tests: 370 pass / 0 fail
(baseline 363 + 7 new W2 regression tests).
Multiplexer / concurrency:
W2.1 ConfigReconciler.Attach now threads the live coalescingAccessor through
to add/restart-built supervisors so a hot-reload of
ReadCoalescing.{Enabled,MaxParties} propagates to PLCs added or
restarted via reload.
W2.2 PlcMultiplexer._disposed and UpstreamPipe._disposed are now volatile
for ARM/portability defense.
W2.3 ProxyWorker._supervisors / ConfigReconciler._supervisors switched from
Dictionary to ConcurrentDictionary; reconciler uses TryRemove. The
outer Apply is serialised by a semaphore but the inner Add/Remove/
Restart Task.WhenAll continuations run in parallel.
W2.4 Counter parity for cache miss + coalescing-saturation miss documented
inline (per-design contract; behavior unchanged).
W2.5 _disposeCts.Dispose() and _connectGate.Dispose() guarded against late
watchdog ticks.
W2.6 _connectGate disposed in DisposeAsync.
W2.7 Inline doc clarifying the post-rewriter FC byte read.
Cache / hot-reload:
W2.8 PlcListenerSupervisor.ReplaceContextAsync now calls Clear() to capture
the entry count, emits mbproxy.cache.flushed, then disposes the old
cache. Previously the event was defined but never emitted.
W2.9 Inline doc explaining the implicit "skip cache invalidation while
recovering" gating (no backend reader during recovery → no FC06/FC16
response → no invalidation).
W2.10 ReloadValidator now re-checks resolved per-tag CacheTtlMs against
Cache.AllowLongTtl after BcdTagMapBuilder folds the per-PLC default.
BCD rewriter:
W2.11 Duplicate addresses detected within Global itself and within the per-PLC
Add list itself, BEFORE the working dictionary collapses keys. Cross-list
collisions (Global vs Add) remain the documented width-override pattern.
Previously the DuplicateAddress error was unreachable dead code.
W2.12 OverlappingHighRegister reports each colliding pair exactly once
(canonicalised low/high pair tracked in a HashSet).
W2.13 FC16 32-bit write rejects clientLow > 9999 or clientHigh > 9999 BEFORE
the high*10000+low reconstruction. Without this guard, (high=9999,
low=9999) silently re-encoded as (high=9998, low=9999), losing 1 from
the high word.
W2.14 FC16 validates pdu.Length >= 6 + qty*2 upfront — no half-rewritten
requests when a malformed client claims more registers than it ships.
Supervisor:
W2.15 WaitForInitialBindAttemptAsync now backed by TaskCompletionSource
instead of 10ms busy-poll. Resolves race against fast Stopped→Bound→
Stopped transitions and hangs when the supervisor task throws.
W2.16 StartAsync refuses re-entry on a non-Stopped supervisor (was leaking
the previous _supervisorCts).
W2.17 New TransitionTo helper writes _state, _lastBindError, and (optionally)
_recoveryAttempts under one lock. Snapshot() reads under the same lock
so the status page never reports an inconsistent triple. Truncate
helper extracted (was copy-pasted across three sites).
W2.18 MbproxyOptionsValidator + ReloadValidator reject Connection.{Backend
ConnectTimeoutMs, BackendRequestTimeoutMs, GracefulShutdownTimeoutMs}
<= 0. Misconfigured 0 produces immediate CancelAfter(0) failures.
Hosting / diagnostics:
W2.20 ProxyWorker.StopAsync supervisor-stop deadline now reads from
IOptionsMonitor.CurrentValue.Connection.GracefulShutdownTimeoutMs
(was hard-coded 5s).
W2.21 src/Mbproxy/appsettings.json deleted; the published file is now a Link
to install/mbproxy.config.template.json so the binary ships with a
usable, fully-commented example config instead of an empty stub. Tests
strip the inherited file from their bin via an AfterTargets="Build"
Target so they don't pick up the template's example PLCs.
W2.22 invalidBcdWarnings (PlcPdusStatus) and codeOther (ExceptionCounts)
added to StatusDto, plumbed through StatusSnapshotBuilder, surfaced
in StatusHtmlRenderer table cells.
W2.23 EventLogBridge caches EventLog.SourceExists at construction so Emit
doesn't hit the registry on every Error+ log line.
New regression tests:
ReloadValidatorTests:
Validate_PerTagCacheTtl_Above60s_Without_AllowLongTtl_Fails
Validate_PerTagCacheTtl_Above60s_With_AllowLongTtl_Passes
Validate_ResolvedTtl_FromPerPlcDefault_AboveCap_Fails
Validate_ZeroBackendConnectTimeoutMs_Fails
Validate_NegativeGracefulShutdownTimeoutMs_Fails
BcdPduPipelineTests:
FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning
FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite
Reworked tests in BcdTagMapBuilderTests for the W2.11 contract (Global dup,
Add dup, Add-overrides-Global accepted as width override).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
644 lines
27 KiB
C#
644 lines
27 KiB
C#
using System.Collections.Frozen;
|
||
using Mbproxy.Bcd;
|
||
using Mbproxy.Proxy;
|
||
using Mbproxy.Proxy.Multiplexing;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Shouldly;
|
||
using Xunit;
|
||
|
||
namespace Mbproxy.Tests.Proxy;
|
||
|
||
/// <summary>
|
||
/// Unit tests for <see cref="BcdPduPipeline"/> using synthetic PDU byte arrays.
|
||
/// No network, no simulator. Each test builds a hand-rolled <see cref="BcdTagMap"/>,
|
||
/// calls <see cref="BcdPduPipeline.Process"/>, and asserts resulting bytes + counter deltas.
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class BcdPduPipelineTests
|
||
{
|
||
private static readonly BcdPduPipeline Pipeline = new();
|
||
|
||
// ── Factories ────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Builds a <see cref="PerPlcContext"/> from a set of BcdTag entries.
|
||
/// The context has a fresh <see cref="ProxyCounters"/> instance.
|
||
/// </summary>
|
||
private static PerPlcContext MakeContext(params BcdTag[] tags)
|
||
{
|
||
var frozen = tags
|
||
.ToDictionary(t => t.Address)
|
||
.ToFrozenDictionary();
|
||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||
|
||
return new PerPlcContext
|
||
{
|
||
PlcName = "TestPLC",
|
||
TagMap = map,
|
||
Counters = new ProxyCounters(),
|
||
Logger = NullLogger.Instance,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 9: the rewriter consumes <see cref="PerPlcContext.CurrentRequest"/> rather
|
||
/// than a per-pair last-request slot. Tests build a synthetic <see cref="InFlightRequest"/>
|
||
/// to drive response decoding.
|
||
/// </summary>
|
||
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
|
||
=> new(
|
||
UnitId: 1,
|
||
Fc: fc,
|
||
StartAddress: startAddress,
|
||
Qty: qty,
|
||
// Phase 9: always exactly one party. We don't have a real UpstreamPipe in
|
||
// pipeline unit tests; the rewriter never dereferences the party list, so a
|
||
// null-forgiving placeholder is safe.
|
||
InterestedParties: Array.Empty<InterestedParty>(),
|
||
SentAtUtc: DateTimeOffset.UtcNow);
|
||
|
||
/// <summary>FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]...</summary>
|
||
private static byte[] Fc03Response(params ushort[] registers)
|
||
{
|
||
var pdu = new byte[2 + registers.Length * 2];
|
||
pdu[0] = 0x03;
|
||
pdu[1] = (byte)(registers.Length * 2);
|
||
for (int i = 0; i < registers.Length; i++)
|
||
{
|
||
pdu[2 + i * 2] = (byte)(registers[i] >> 8);
|
||
pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||
}
|
||
return pdu;
|
||
}
|
||
|
||
/// <summary>FC04 response PDU: same shape as FC03 but fc=04.</summary>
|
||
private static byte[] Fc04Response(params ushort[] registers)
|
||
{
|
||
var pdu = Fc03Response(registers);
|
||
pdu[0] = 0x04;
|
||
return pdu;
|
||
}
|
||
|
||
/// <summary>FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo]</summary>
|
||
private static byte[] Fc03Request(ushort address, ushort qty)
|
||
=> [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)];
|
||
|
||
/// <summary>FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo]</summary>
|
||
private static byte[] Fc06Request(ushort address, ushort value)
|
||
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
|
||
|
||
/// <summary>FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...]</summary>
|
||
private static byte[] Fc16Request(ushort start, params ushort[] registers)
|
||
{
|
||
ushort qty = (ushort)registers.Length;
|
||
var pdu = new byte[6 + registers.Length * 2];
|
||
pdu[0] = 0x10;
|
||
pdu[1] = (byte)(start >> 8);
|
||
pdu[2] = (byte)(start & 0xFF);
|
||
pdu[3] = (byte)(qty >> 8);
|
||
pdu[4] = (byte)(qty & 0xFF);
|
||
pdu[5] = (byte)(registers.Length * 2);
|
||
for (int i = 0; i < registers.Length; i++)
|
||
{
|
||
pdu[6 + i * 2] = (byte)(registers[i] >> 8);
|
||
pdu[6 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||
}
|
||
return pdu;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Simulate sending an FC03/04 request then reading the response.
|
||
/// Phase 9: builds an <see cref="InFlightRequest"/> matching the request and attaches
|
||
/// it to the response-call context (replacing the per-pair last-request slot).
|
||
/// </summary>
|
||
private void SendRequestThenProcessResponse(
|
||
PerPlcContext ctx,
|
||
byte[] requestPdu,
|
||
byte[] responsePdu)
|
||
{
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, requestPdu.AsSpan(), ctx);
|
||
|
||
// Extract the request start/qty so we can build the InFlightRequest the multiplexer
|
||
// would attach to the response call.
|
||
byte fc = requestPdu[0];
|
||
ushort start = 0, qty = 0;
|
||
if (fc is 0x03 or 0x04 && requestPdu.Length >= 5)
|
||
{
|
||
start = (ushort)((requestPdu[1] << 8) | requestPdu[2]);
|
||
qty = (ushort)((requestPdu[3] << 8) | requestPdu[4]);
|
||
}
|
||
|
||
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(fc, start, qty));
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, responsePdu.AsSpan(), responseCtx);
|
||
}
|
||
|
||
// ── Helper to read a register pair from a response PDU ──────────────────
|
||
|
||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||
|
||
// ── FC03 response tests ──────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC03_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||
{
|
||
// Raw wire value 0x1234 → decoded binary 1234
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var req = Fc03Request(100, 1);
|
||
var rsp = Fc03Response(0x1234);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)1234);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC03_Full32BitBcdPair_WithinReadRange_DecodesNibbles()
|
||
{
|
||
// 32-bit BCD pair at 100/101: low=0x5678 (5678), high=0x1234 (1234)
|
||
// Decoded = 1234 * 10000 + 5678 = 12345678
|
||
// Binary: low 4 digits = 5678, high 4 digits = 1234
|
||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||
var req = Fc03Request(100, 2);
|
||
var rsp = Fc03Response(0x5678, 0x1234); // [0]=low, [1]=high
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)5678); // decoded low 4 digits
|
||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // decoded high 4 digits
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC03_Partial32Bit_LowOnly_qty1_AtLowAddr_PassesThroughRaw()
|
||
{
|
||
// Read qty=1 at the low address of a 32-bit pair — only half the pair is in range.
|
||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||
var req = Fc03Request(100, 1);
|
||
var rsp = Fc03Response(0x5678);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)0x5678); // unchanged
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC03_Partial32Bit_HighOnly_qty1_AtHighAddr_PassesThroughRaw()
|
||
{
|
||
// Read qty=1 starting at the HIGH register of a 32-bit pair (address 101 when tag is at 100).
|
||
// TryGetForRange returns OffsetWords = -1 for the hit (low register is before the range).
|
||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||
var req = Fc03Request(101, 1); // only reading high register
|
||
var rsp = Fc03Response(0x1234);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)0x1234); // unchanged (partial overlap)
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC03_Mixed_16BitBcd_And_NonBcd_InSameRead_OnlyBcdSlotRewritten()
|
||
{
|
||
// Registers: [0]=non-BCD at addr 99, [1]=BCD 16-bit at addr 100, [2]=non-BCD at addr 101
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var req = Fc03Request(99, 3);
|
||
var rsp = Fc03Response(0xABCD, 0x1234, 0xDEAD);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)0xABCD); // non-BCD, unchanged
|
||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // BCD decoded
|
||
ReadReg(rsp, 2).ShouldBe((ushort)0xDEAD); // non-BCD, unchanged
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC03_BadNibble_At16BitBcdSlot_PassesThroughRaw_AndIncrementsInvalidBcd()
|
||
{
|
||
// 0x12A4 has nibble 'A' which is not a valid BCD digit.
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var req = Fc03Request(100, 1);
|
||
var rsp = Fc03Response(0x12A4);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)0x12A4); // unchanged
|
||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── FC04 response tests ──────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC04_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(200, 16));
|
||
// FC04 request: same shape as FC03 but fc=04
|
||
var req = new byte[] { 0x04, 0x00, 0xC8, 0x00, 0x01 }; // addr=200, qty=1
|
||
var rsp = Fc04Response(0x9876);
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
ReadReg(rsp, 0).ShouldBe((ushort)9876);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||
}
|
||
|
||
// ── FC06 request tests ───────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC06_Write16BitBcd_EncodesClientBinaryToNibbles()
|
||
{
|
||
// Client writes binary 1234 → PLC should receive BCD 0x1234
|
||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||
var pdu = Fc06Request(300, 1234); // client sends binary 1234
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||
sentValue.ShouldBe((ushort)0x1234); // BCD nibbles
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC06_WriteToLowAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||
{
|
||
// FC06 can only write 1 register; if the target is the LOW addr of a 32-bit pair,
|
||
// that's a partial write — pass through raw.
|
||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||
var pdu = Fc06Request(400, 9999); // 400 is the low address of the 32-bit pair
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||
sentValue.ShouldBe((ushort)9999); // unchanged (raw binary)
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC06_WriteToHighAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||
{
|
||
// Writing to address 401 when the 32-bit pair is at 400/401 — high register only.
|
||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||
var pdu = Fc06Request(401, 0x1234); // 401 is the high address
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||
sentValue.ShouldBe((ushort)0x1234); // unchanged
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC06_WriteValueOutsideRange_InvalidBcd_PassesThroughRaw()
|
||
{
|
||
// Binary 10000 cannot be represented as 4-digit BCD (max 9999).
|
||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||
var pdu = Fc06Request(300, 10000); // 10000 > 9999
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||
sentValue.ShouldBe((ushort)10000); // raw passthrough
|
||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── FC16 request tests ───────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC16_WriteSingle16BitBcd_InMultiWrite_EncodesBcdSlotOnly()
|
||
{
|
||
// Registers 500, 501, 502, 503: only 502 is a BCD tag.
|
||
// Non-BCD registers should pass through unchanged.
|
||
var ctx = MakeContext(BcdTag.Create(502, 16));
|
||
var pdu = Fc16Request(500, 0x0010, 0x0020, 1234, 0x0040);
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
// Register at offset 0 (addr 500): unchanged
|
||
ushort r0 = (ushort)((pdu[6] << 8) | pdu[7]);
|
||
r0.ShouldBe((ushort)0x0010);
|
||
|
||
// Register at offset 2 (addr 502): binary 1234 → BCD 0x1234
|
||
ushort r2 = (ushort)((pdu[10] << 8) | pdu[11]);
|
||
r2.ShouldBe((ushort)0x1234);
|
||
|
||
// Register at offset 3 (addr 503): unchanged
|
||
ushort r3 = (ushort)((pdu[12] << 8) | pdu[13]);
|
||
r3.ShouldBe((ushort)0x0040);
|
||
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC16_WriteFull32BitBcdPair_EncodesAsNibbles()
|
||
{
|
||
// 32-bit BCD pair at 600/601: client sends 12345678 as CDAB binary.
|
||
// The proxy should encode to low=0x5678, high=0x1234.
|
||
// Client sends: low-4-digits=5678, high-4-digits=1234 (in CDAB order)
|
||
var ctx = MakeContext(BcdTag.Create(600, 32));
|
||
// Client sends binary: low register = low 4 digits = 5678, high register = high 4 digits = 1234
|
||
// But actually the pipeline needs to reconstruct the value:
|
||
// decoded = clientHigh * 10000 + clientLow = 1234 * 10000 + 5678 = 12345678
|
||
// Then encode: (bcdLow=0x5678, bcdHigh=0x1234)
|
||
var pdu = Fc16Request(600, 5678, 1234); // [0]=low-word=5678, [1]=high-word=1234
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
// After encoding: low=BCD(5678)=0x5678, high=BCD(1234)=0x1234
|
||
ushort sentLow = (ushort)((pdu[6] << 8) | pdu[7]);
|
||
ushort sentHigh = (ushort)((pdu[8] << 8) | pdu[9]);
|
||
sentLow.ShouldBe((ushort)0x5678);
|
||
sentHigh.ShouldBe((ushort)0x1234);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 12 (W2.13) — a client writing a 32-bit BCD value where either word exceeds
|
||
/// 9999 must NOT be silently mutated by the `high*10000+low` reconstruction. Validation
|
||
/// rejects the slot, increments invalidBcdWarnings, and passes the raw bytes through.
|
||
/// Without W2.13 the codec would accept e.g. (high=9999, low=9999) → 99_989_999 →
|
||
/// re-encode as (high=9998, low=9999), silently losing 1 from the high word.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_32Bit_ClientHighOrLowAbove9999_PassesThroughRaw_WithInvalidBcdWarning()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(800, 32));
|
||
// qty=2, low at offset 0, high at offset 1; both at 0xFFFF (= 65535, > 9999).
|
||
var pdu = Fc16Request(800, 0xFFFF, 0xFFFF);
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original, "OOR client values must pass through raw, not be silently mutated");
|
||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 12 (W2.14) — a malformed FC16 request that claims qty=N but ships fewer than
|
||
/// 6+N*2 bytes must NOT be partially rewritten. Without W2.14 each individual slot's
|
||
/// per-slot bounds check would skip the OOB slot, leaving early slots rewritten and late
|
||
/// slots untouched (a half-rewritten request reaching the PLC).
|
||
/// </summary>
|
||
[Fact]
|
||
public void FC16_TruncatedRegisterData_PassesThroughRaw_NoPartialRewrite()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(900, 16));
|
||
// Build a normal 1-register write, then trim 1 byte off the end so qty=1 but only
|
||
// 1 byte of register data remains.
|
||
var pdu = Fc16Request(900, 1234);
|
||
byte[] truncated = pdu.AsSpan(0, pdu.Length - 1).ToArray();
|
||
byte[] original = [..truncated];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, truncated.AsSpan(), ctx);
|
||
|
||
truncated.ShouldBe(original, "truncated FC16 must pass through raw");
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning()
|
||
{
|
||
// Write range 700–701 (2 regs), but 32-bit BCD tag is at 701/702.
|
||
// Only the low register (701) is in range; high register (702) is not.
|
||
var ctx = MakeContext(BcdTag.Create(701, 32));
|
||
var pdu = Fc16Request(700, 0xAAAA, 0xBBBB); // writes 700 and 701; tag needs 701 and 702
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
// The low register (at offset 1 in pdu, i.e., addr 701) should be unchanged.
|
||
ushort r1 = (ushort)((pdu[8] << 8) | pdu[9]);
|
||
r1.ShouldBe((ushort)0xBBBB);
|
||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── Pass-through FCs ─────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC01_Request_IsPassedThroughUnchanged()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var pdu = new byte[] { 0x01, 0x00, 0x64, 0x00, 0x08 }; // FC01 read coils
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC02_Request_IsPassedThroughUnchanged()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var pdu = new byte[] { 0x02, 0x00, 0x64, 0x00, 0x08 }; // FC02 read discrete inputs
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC05_Request_IsPassedThroughUnchanged()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var pdu = new byte[] { 0x05, 0x00, 0x64, 0xFF, 0x00 }; // FC05 write single coil
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void FC15_Request_IsPassedThroughUnchanged()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
var pdu = new byte[] { 0x0F, 0x00, 0x64, 0x00, 0x08, 0x01, 0xAB }; // FC15 write multiple coils
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── Exception response test ──────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FC03_ExceptionResponse_PassesThroughRaw_LogsPassthrough_IncrementsBackendException()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
// Exception response: [fc|0x80=0x83][exceptionCode=02]
|
||
var pdu = new byte[] { 0x83, 0x02 };
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original); // bytes unchanged
|
||
ctx.Counters.Snapshot().BackendException02.ShouldBe(1);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── Empty BcdTagMap tests ────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void EmptyTagMap_FC03Response_ProducesZeroRewrites()
|
||
{
|
||
var ctx = MakeContext(/* no tags */);
|
||
var req = Fc03Request(100, 3);
|
||
var rsp = Fc03Response(0x1234, 0x5678, 0x9ABC);
|
||
byte[] original = [..rsp];
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
rsp.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
[Fact]
|
||
public void EmptyTagMap_FC06Request_ProducesZeroRewrites()
|
||
{
|
||
var ctx = MakeContext(/* no tags */);
|
||
var pdu = Fc06Request(300, 1234);
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||
}
|
||
|
||
// ── Counter snapshot accuracy ────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void CounterSnapshot_ReflectsIncrementsExactly()
|
||
{
|
||
// Process 3 FC03 responses with one 16-bit BCD slot each, plus one bad-nibble.
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
|
||
for (int i = 0; i < 3; i++)
|
||
{
|
||
var req = Fc03Request(100, 1);
|
||
var rsp = Fc03Response(0x1234);
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
}
|
||
|
||
// One with a bad nibble.
|
||
{
|
||
var req = Fc03Request(100, 1);
|
||
var rsp = Fc03Response(0x12A4);
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
}
|
||
|
||
var snap = ctx.Counters.Snapshot();
|
||
snap.RewrittenSlots.ShouldBe(3); // 3 successful decodes
|
||
snap.InvalidBcdWarnings.ShouldBe(1); // 1 bad-nibble pass-through
|
||
// PdusForwarded = 4 requests + 4 responses = 8
|
||
snap.PdusForwarded.ShouldBe(8);
|
||
snap.Fc03.ShouldBe(8); // both request and response increment by FC (request FC03)
|
||
}
|
||
|
||
// ── PDU length invariant ─────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void PduLength_IsNeverChangedByRewriting()
|
||
{
|
||
// Build a response with two 16-bit BCD tags. After rewriting, the PDU must be
|
||
// exactly the same byte count as before.
|
||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(101, 16));
|
||
var req = Fc03Request(100, 2);
|
||
var rsp = Fc03Response(0x1234, 0x5678);
|
||
int originalLength = rsp.Length;
|
||
|
||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||
|
||
rsp.Length.ShouldBe(originalLength); // MBAP transparency contract
|
||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||
}
|
||
|
||
// ── FC counter tracking ──────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void FcCounters_IncrementCorrectly_ForEachFunctionCode()
|
||
{
|
||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||
|
||
// FC03 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc03Request(100, 1).AsSpan(), ctx);
|
||
// FC04 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx);
|
||
// FC06 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc06Request(300, 1234).AsSpan(), ctx);
|
||
// FC16 request
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx);
|
||
// FC01 (Other)
|
||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x01, 0x00, 0x00, 0x00, 0x01 }.AsSpan(), ctx);
|
||
|
||
var snap = ctx.Counters.Snapshot();
|
||
snap.Fc03.ShouldBe(1);
|
||
snap.Fc04.ShouldBe(1);
|
||
snap.Fc06.ShouldBe(1);
|
||
snap.Fc16.ShouldBe(1);
|
||
snap.FcOther.ShouldBe(1);
|
||
snap.PdusForwarded.ShouldBe(5);
|
||
}
|
||
|
||
// ── Extra coverage: backend exception codes ──────────────────────────────
|
||
|
||
[Fact]
|
||
public void BackendExceptions_AllCodes_TrackSeparately()
|
||
{
|
||
var ctx = MakeContext();
|
||
|
||
// Codes 1–4 get individual counters; code 5 goes to Other.
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x01 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x02 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x03 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x04 }.AsSpan(), ctx);
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||
new byte[] { 0x81, 0x05 }.AsSpan(), ctx); // code 5 → Other
|
||
|
||
var snap = ctx.Counters.Snapshot();
|
||
snap.BackendException01.ShouldBe(1);
|
||
snap.BackendException02.ShouldBe(1);
|
||
snap.BackendException03.ShouldBe(1);
|
||
snap.BackendException04.ShouldBe(1);
|
||
snap.BackendExceptionOther.ShouldBe(1);
|
||
}
|
||
|
||
// ── Plain PduContext (no BCD context) → no-op ────────────────────────────
|
||
|
||
[Fact]
|
||
public void PlainPduContext_IsPassedThroughWithoutError()
|
||
{
|
||
// If a plain PduContext is passed (not PerPlcContext), the pipeline must
|
||
// return cleanly without throwing, leaving bytes unchanged.
|
||
var ctx = new PduContext { PlcName = "Test" };
|
||
var pdu = Fc03Response(0x1234);
|
||
byte[] original = [..pdu];
|
||
|
||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||
|
||
pdu.ShouldBe(original);
|
||
}
|
||
}
|