review(Driver.Modbus): validate FC03 RMW response + correct write error mapping

Re-review at 7286d320. Modbus-013 (Low): bit RMW now routes the FC03 read through the
validated ReadRegisterBlockAsync (was raw-indexing readResp -> IndexOutOfRange on a truncated
PDU). Modbus-014 (Low): WriteAsync maps InvalidDataException to BadCommunicationError (was
BadInternalError), matching ReadAsync. + TDD.
This commit is contained in:
Joseph Doherty
2026-06-19 11:34:34 -04:00
parent f2bdd8bc1c
commit 6853a0430f
3 changed files with 131 additions and 6 deletions
@@ -153,4 +153,64 @@ public sealed class ModbusBitRmwTests
fake.HoldingRegisters[30].ShouldBe((ushort)((1 << 5) | (1 << 10)));
}
// ---- Driver.Modbus-013: RMW read-response not validated before indexing ----
/// <summary>
/// Transport that returns a too-short PDU for FC03 reads — simulates a buggy device
/// returning a malformed response during a BitInRegister read-modify-write. Pre-fix,
/// WriteBitInRegisterAsync indexed readResp[2]/[3] without checking resp.Length, causing
/// IndexOutOfRangeException. Post-fix it throws InvalidDataException which WriteAsync's
/// catch-all maps to BadInternalError.
/// </summary>
private sealed class TruncatedRmwTransport : IModbusTransport
{
/// <summary>Connects asynchronously (no-op for fake).</summary>
/// <param name="ct">Cancellation token (unused).</param>
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Returns a truncated FC03 response to trigger the validation path.</summary>
/// <param name="unitId">The Modbus unit ID (unused).</param>
/// <param name="pdu">The PDU to process.</param>
/// <param name="ct">Cancellation token (unused).</param>
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] == 0x03)
// Return only [fc][byte-count=2] — missing the actual register data bytes.
return Task.FromResult(new byte[] { 0x03, 0x02 });
// FC06 write echoes the PDU
return Task.FromResult(pdu);
}
/// <summary>Disposes asynchronously (no-op for fake).</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// Driver.Modbus-013/014: a truncated FC03 read response during a BitInRegister RMW must
/// surface as BadCommunicationError, not BadInternalError (a structural/driver-code
/// problem) — and not as an IndexOutOfRangeException that escapes WriteAsync uncaught.
/// </summary>
[Fact]
public async Task BitInRegister_write_with_truncated_FC03_response_returns_BadCommunicationError()
{
var truncated = new TruncatedRmwTransport();
var opts = new ModbusDriverOptions
{
Host = "fake",
Tags = [new ModbusTagDefinition("Bit5", ModbusRegion.HoldingRegisters, 10, ModbusDataType.BitInRegister, BitIndex: 5)],
Probe = new ModbusProbeOptions { Enabled = false },
};
var drv = new ModbusDriver(opts, "modbus-rmw-trunc", _ => truncated);
await drv.InitializeAsync("{}", CancellationToken.None);
// WriteAsync must complete (not throw) and return BadCommunicationError — the same
// status the ReadAsync path returns for a truncated response. BadInternalError (0x80020000)
// is wrong because the failure is a communication/protocol error, not a driver bug.
const uint BadCommunicationError = 0x80050000u;
var results = await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(BadCommunicationError,
"A truncated FC03 RMW response is a communication error, not an internal driver error");
}
}