fix(driver-modbus-addressing): resolve Low code-review findings (Driver.Modbus.Addressing-006,007,009)

- Driver.Modbus.Addressing-006: broaden the catch in TryParseFamilyNative
  so a future helper throwing a non-Argument/Overflow type still satisfies
  the try-parse contract.
- Driver.Modbus.Addressing-007: document that the address grammar does
  not carry ModbusStringByteOrder (the structured-tag path does);
  add a 'Grammar scope' bullet to docs/v2/dl205.md.
- Driver.Modbus.Addressing-009: reword the ModbusModiconAddress comments
  so they don't imply a leading-digit invariant the parser doesn't
  enforce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 08:18:15 -04:00
parent 1f29b215c8
commit 9263519852
6 changed files with 178 additions and 15 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 | | Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` | | Commit reviewed | `76d35d1` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 3 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -157,7 +157,7 @@ overwrite it.
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Location | `ModbusAddressParser.cs:297-301` | | Location | `ModbusAddressParser.cs:297-301` |
| Status | Open | | Status | Resolved |
**Description:** `TryParseFamilyNative` catches only `ArgumentException` and `OverflowException`. **Description:** `TryParseFamilyNative` catches only `ArgumentException` and `OverflowException`.
The current helpers throw only those (including `ArgumentOutOfRangeException`, which derives from The current helpers throw only those (including `ArgumentOutOfRangeException`, which derives from
@@ -171,7 +171,13 @@ depend on.
narrow catch, or broaden to a general catch-all that records the message — a try-parse method narrow catch, or broaden to a general catch-all that records the message — a try-parse method
should never throw. should never throw.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — broadened the `catch` filter in
`ModbusAddressParser.TryParseFamilyNative` from `ArgumentException or OverflowException` to a
general `catch (Exception ex)` so any future helper exception type is converted to a structured
`(false, error)` rather than escaping the `TryParse` method. Added `DL205_TryParse_NeverThrows`
and `MELSEC_TryParse_NeverThrows` parameterised regression tests in
`ModbusAddressEdgeCaseTests` covering ~20 pathological inputs (empty prefixes, octal/hex digit
violations, overflow inputs, unknown prefixes) to pin the defensive contract.
### Driver.Modbus.Addressing-007 ### Driver.Modbus.Addressing-007
@@ -180,7 +186,7 @@ should never throw.
| Severity | Low | | Severity | Low |
| Category | Design-document adherence | | Category | Design-document adherence |
| Location | `ModbusDataType.cs:91-95`, `docs/v2/dl205.md` section Strings | | Location | `ModbusDataType.cs:91-95`, `docs/v2/dl205.md` section Strings |
| Status | Open | | Status | Resolved |
**Description:** `ModbusStringByteOrder` (HighByteFirst / LowByteFirst) is defined in this **Description:** `ModbusStringByteOrder` (HighByteFirst / LowByteFirst) is defined in this
assembly and documented as the DL205 low-byte-first string-packing knob, but `ParsedModbusAddress` assembly and documented as the DL205 low-byte-first string-packing knob, but `ParsedModbusAddress`
@@ -193,7 +199,18 @@ unreachable from the parser, so the grammar cannot represent a known, documented
token for it, or document explicitly that DL205 string byte order is only configurable via the token for it, or document explicitly that DL205 string byte order is only configurable via the
structured tag form and is intentionally out of grammar scope. structured tag form and is intentionally out of grammar scope.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — chose the "document the limitation" branch of the
recommendation rather than adding a grammar token: the 3rd field slot is the multi-register
word/byte order and the 4th is the array count, so a 5th `:<order>` suffix would conflict with
the existing count-shape disambiguation; `ModbusStringByteOrder` is already plumbed through the
structured tag form (`ModbusDriverFactoryExtensions.ModbusTagDto.StringByteOrder`
`ModbusTagDefinition.StringByteOrder`) which is the canonical config path. Added an explicit
"Grammar scope" remarks block to `ModbusStringByteOrder` and to the `ModbusAddressParser`
`<remarks>` block stating that string byte order is configurable only via the structured tag
form. Added a corresponding bullet to `docs/v2/dl205.md` §Strings. Added two regression tests
(`Parser_STR_grammar_does_not_carry_StringByteOrder` reflecting on `ParsedModbusAddress`, and
`Parser_rejects_unknown_string_byte_order_token_in_grammar`) pinning the contract so a future
grammar change can't quietly add a conflicting token.
### Driver.Modbus.Addressing-008 ### Driver.Modbus.Addressing-008
@@ -226,7 +243,7 @@ finding -001.
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Location | `ModbusModiconAddress.cs:55-64`, `ModbusModiconAddress.cs:104-110` | | Location | `ModbusModiconAddress.cs:55-64`, `ModbusModiconAddress.cs:104-110` |
| Status | Open | | Status | Resolved |
**Description:** The comments on `ModbusModiconAddress.TryParse` are slightly inaccurate. The **Description:** The comments on `ModbusModiconAddress.TryParse` are slightly inaccurate. The
remark that 5-digit Modicon is always exactly 5 chars (40001..49999) and 6-digit is exactly 6 remark that 5-digit Modicon is always exactly 5 chars (40001..49999) and 6-digit is exactly 6
@@ -238,4 +255,11 @@ says the 5-digit form caps at 9999 by construction while the adjacent code path
**Recommendation:** Reword the range examples to cover all four region digits and drop the **Recommendation:** Reword the range examples to cover all four region digits and drop the
caps-at-9999 aside or restate it as a precise statement about trailing-digit count. caps-at-9999 aside or restate it as a precise statement about trailing-digit count.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — reworded the up-front range-check comment to describe all
four region digits (0/1/3/4) and give examples covering each region (coils 00001..09999 /
000001..065536, holding registers 40001..49999 / 400001..465536). Reworded the lower
`> 65536` comment to drop the misleading "5-digit form caps at 9999 by construction" framing and
state precisely that the check is reached only by the 6-digit form in practice, but applied to
both for safety rather than relying on the digit-count invariant. Pure documentation change —
no behavioural change; the existing `ModbusModiconAddressTests` already pin the cross-region
5-digit ranges (00001..09999 / 10001..19999 / 30001..39999 / 40001..49999).

View File

@@ -43,6 +43,15 @@ that a naive Modbus client will byte-swap [1][2].
really "read 10 consecutive holding registers starting at the Modbus address really "read 10 consecutive holding registers starting at the Modbus address
that V2000 translates to (see next section), unpack each register low-byte that V2000 translates to (see next section), unpack each register low-byte
then high-byte, stop at the first `0x00`." then high-byte, stop at the first `0x00`."
- **Grammar scope** (Driver.Modbus.Addressing-007): the
`ModbusStringByteOrder` knob (HighByteFirst / LowByteFirst) is **not**
expressible through the `ModbusAddressParser` grammar string — the 3rd grammar
field is the multi-register word/byte order (ABCD/CDAB/BADC/DCBA) and the 4th
is the array count, so there is no token slot for the per-string byte order.
Tags that need low-byte-first packing on DL205 must set
`ModbusTagDefinition.StringByteOrder = LowByteFirst` via the structured tag
form (the driver config DTO). The grammar default produces high-byte-first
strings (matches Ignition / Kepware default behaviour).
Test names: Test names:
`DL205_String_low_byte_first_within_register`, `DL205_String_low_byte_first_within_register`,

View File

@@ -29,6 +29,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <item><c>C100</c> — Coils[99] (mnemonic).</item> /// <item><c>C100</c> — Coils[99] (mnemonic).</item>
/// </list> /// </list>
/// </para> /// </para>
/// <para>
/// <b>Grammar scope — out of band (Driver.Modbus.Addressing-007):</b> per-string byte
/// order (<see cref="ModbusStringByteOrder"/>) is NOT expressible through this grammar.
/// The DL205 low-byte-first string-packing knob is configurable only via the structured
/// tag form (the driver's <c>ModbusTagDefinition.StringByteOrder</c> field). The 3rd
/// grammar field is the multi-register word/byte order (ABCD/CDAB/BADC/DCBA), not the
/// per-string byte order — adding a 5th token would conflict with the array-count slot.
/// See <see cref="ModbusStringByteOrder"/> and <c>docs/v2/dl205.md</c> §Strings.
/// </para>
/// </remarks> /// </remarks>
public static class ModbusAddressParser public static class ModbusAddressParser
{ {
@@ -341,8 +350,15 @@ public static class ModbusAddressParser
return false; return false;
} }
} }
catch (Exception ex) when (ex is ArgumentException or OverflowException) catch (Exception ex)
{ {
// Driver.Modbus.Addressing-006: a try-parse method must never throw, so any helper
// exception is converted to a structured error. The current helpers throw only
// ArgumentException (incl. ArgumentOutOfRangeException) and OverflowException, but
// catching narrowly would silently break the TryParse contract if a helper ever
// switches to e.g. FormatException from a ushort.Parse swap. Config-bind hot-path
// callers depend on TryParse returning a structured (false, error) rather than
// throwing an unhandled exception that escapes their TryParse wrapper.
error = $"Family-native parse for {family} failed on '{text}': {ex.Message}"; error = $"Family-native parse for {family} failed on '{text}': {ex.Message}";
return false; return false;
} }

View File

@@ -88,6 +88,25 @@ public enum ModbusByteOrder
/// each register. Word ordering across multiple registers is always ascending address for /// each register. Word ordering across multiple registers is always ascending address for
/// strings — only the byte order inside each register flips. /// strings — only the byte order inside each register flips.
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// <b>Grammar scope (Driver.Modbus.Addressing-007):</b> this enum is intentionally NOT
/// expressible through the <see cref="ModbusAddressParser"/> grammar string. The grammar
/// has no token form for it, and <see cref="ParsedModbusAddress"/> has no field for it —
/// a DL205 string tag parsed from the grammar always carries the driver's default order.
/// </para>
/// <para>
/// The string byte order is configurable only via the structured tag definition (the
/// driver's <c>ModbusTagDefinition.StringByteOrder</c> field). Adding a grammar token
/// was explicitly considered and rejected: the 3rd-field slot is the multi-register
/// word/byte order (ABCD/CDAB/BADC/DCBA) and the 4th-field slot is the array count, so
/// a fifth <c>:&lt;order&gt;</c> suffix would conflict with the count-shape disambiguation.
/// Sites that need per-tag low-byte-first strings must use the structured form. The
/// default high-byte-first matches the Modbus spec and Ignition / Kepware default
/// behaviour.
/// </para>
/// <para>See <c>docs/v2/dl205.md</c> §Strings for the DL205-specific rationale.</para>
/// </remarks>
public enum ModbusStringByteOrder public enum ModbusStringByteOrder
{ {
HighByteFirst, HighByteFirst,

View File

@@ -52,10 +52,14 @@ public static class ModbusModiconAddress
return false; return false;
} }
// Range check up-front — keeps the rest of the parser straight-line. 5-digit Modicon // Range check up-front — keeps the rest of the parser straight-line. Modicon addresses
// is always exactly 5 chars (40001..49999, with the lead digit selecting region), and // are exactly 5 or 6 characters: a leading region digit (0/1/3/4 — coils, discrete
// 6-digit is exactly 6 (400001..465536-shaped). Anything else is unambiguously // inputs, input registers, holding registers respectively) followed by 4 (5-digit form)
// malformed so we reject before doing the per-character work. // or 5 (6-digit form) trailing digits encoding the 1-based register number. The
// 5-digit form covers 1..9999 per region (e.g. coils 00001..09999, holding registers
// 40001..49999); the 6-digit form covers the full 1..65536 wire range (e.g. coils
// 000001..065536, holding 400001..465536). Anything else is unambiguously malformed so
// we reject before doing the per-character work.
var s = address.Trim(); var s = address.Trim();
if (s.Length is not (5 or 6)) if (s.Length is not (5 or 6))
{ {
@@ -100,9 +104,10 @@ public static class ModbusModiconAddress
return false; return false;
} }
// 5-digit form caps at 9999 by construction (4 trailing digits); reject if the parsed // Wire-protocol maximum is register number 65536 (PDU offset 65535). The 5-digit form's
// value exceeds the wire-protocol maximum of 65536 (i.e. PDU offset 65535). 6-digit // 4 trailing digits can only encode up to 9999, so this check is reached only by the
// form can address the full 65535-offset range. // 6-digit form in practice — but it is applied to both for safety / simplicity rather
// than relying on the digit-count invariant.
if (registerNumber > 65536) if (registerNumber > 65536)
{ {
error = $"Modicon register number {registerNumber} exceeds the wire maximum (65536 / PDU offset 65535)"; error = $"Modicon register number {registerNumber} exceeds the wire maximum (65536 / PDU offset 65535)";

View File

@@ -146,4 +146,94 @@ public sealed class ModbusAddressEdgeCaseTests
// D0 with a bank base that itself overflows: base 65535 + D1 = 65536. // D0 with a bank base that itself overflows: base 65535 + D1 = 65536.
Should.Throw<OverflowException>(() => MelsecAddress.DRegisterToHolding("D1", dBankBase: 65535)); Should.Throw<OverflowException>(() => MelsecAddress.DRegisterToHolding("D1", dBankBase: 65535));
} }
// ── TryParse never throws (Driver.Modbus.Addressing-006) ─────────────────────────────────
//
// The TryParse contract is that it converts every parse failure into a structured (false,
// error) return — config-bind hot paths depend on this. The family-native catch was previously
// narrow (ArgumentException / OverflowException only); any future helper change that threw a
// different exception type (e.g. FormatException from a ushort.Parse swap) would escape as an
// unhandled exception out of a TryParse method. These tests assert the defensive contract
// across a broad set of pathological inputs.
[Theory]
[InlineData("V")] // V prefix with no digits
[InlineData("V99999999999999")] // overflow in user V-memory octal decode
[InlineData("V200000")] // overflow in user V-memory octal decode
[InlineData("V77777777")] // octal way past 0xFFFF in system bank
[InlineData("Y")] // Y prefix with no digits
[InlineData("Y8888")] // non-octal digit
[InlineData("Y174000")] // octal offset overflows YOutputBaseCoil + value
[InlineData("C")] // C prefix alone
[InlineData("C99999999")] // overflow in C-relay
[InlineData("X")] // X prefix alone
[InlineData("X8")] // non-octal digit
[InlineData("SP")] // SP prefix alone
[InlineData("SP9")] // non-octal digit
[InlineData("Z123")] // unknown DL205 prefix
public void DL205_TryParse_NeverThrows_ReturnsStructuredError(string addr)
{
// Defensive contract: any helper failure must surface as (false, non-null error), never
// as an unhandled exception out of TryParse.
var ok = ModbusAddressParser.TryParse(addr, ModbusFamily.DL205, MelsecFamily.Q_L_iQR, out var result, out var error);
ok.ShouldBeFalse();
result.ShouldBeNull();
error.ShouldNotBeNullOrEmpty();
}
[Theory]
[InlineData("D")] // D prefix alone — no digits
[InlineData("D-1")] // negative — would fail ushort.TryParse, must not throw
[InlineData("D65536")] // overflow
[InlineData("DABC")] // non-decimal digits in D
[InlineData("MABC")] // non-decimal digits in M
[InlineData("X10000")] // hex overflow (Q-family)
[InlineData("XZZZZ")] // non-hex digit (Q-family)
[InlineData("Y10000")] // hex overflow (Q-family)
public void MELSEC_TryParse_NeverThrows_ReturnsStructuredError(string addr)
{
var ok = ModbusAddressParser.TryParse(addr, ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var result, out var error);
ok.ShouldBeFalse();
result.ShouldBeNull();
error.ShouldNotBeNullOrEmpty();
}
// ── ModbusStringByteOrder is grammar-out-of-scope (Driver.Modbus.Addressing-007) ────────
//
// ModbusStringByteOrder (HighByteFirst / LowByteFirst) is the DL205 low-byte-first packing
// knob. It is intentionally NOT expressible through the address grammar — there is no token
// form to set it and ParsedModbusAddress has no field for it. The string byte order is
// configurable only via the structured tag form (ModbusTagDefinition.StringByteOrder), which
// is the canonical config path. These tests pin that contract so a future grammar change
// can't quietly add a token that conflicts with the array-count slot.
[Fact]
public void Parser_STR_grammar_does_not_carry_StringByteOrder()
{
// STR20 parses fine — but the result has no StringByteOrder field (the property does
// not exist on ParsedModbusAddress). The string byte order must be set on the structured
// tag definition, not the grammar string.
var ok = ModbusAddressParser.TryParse("40001:STR20", out var result, out _);
ok.ShouldBeTrue();
result!.DataType.ShouldBe(ModbusDataType.String);
result.StringLength.ShouldBe((ushort)20);
// Compile-time assertion: ParsedModbusAddress does not expose StringByteOrder.
// Searching for a property by reflection would let us assert "no such field":
typeof(ParsedModbusAddress)
.GetProperty("StringByteOrder")
.ShouldBeNull();
}
[Fact]
public void Parser_rejects_unknown_string_byte_order_token_in_grammar()
{
// A user trying to express low-byte-first via a grammar suffix like "LOWB" or "HIGH" in
// the byte-order slot gets the standard "Unknown byte order" diagnostic — the parser is
// explicit that field 3 is the multi-register word/byte order, not the per-string byte
// order. The structured tag form is the only configuration path for ModbusStringByteOrder.
var ok = ModbusAddressParser.TryParse("40001:STR20:LOWB", out _, out var error);
ok.ShouldBeFalse();
error.ShouldNotBeNullOrEmpty();
error!.ShouldContain("byte order", Case.Insensitive);
}
} }