fix(mqtt): runtime mappers no longer refuse leading-whitespace TagConfig — JsonDocument.Parse + ValueKind is the single framing authority (#559)

FromTagConfig and FromSparkplugTagConfig each carried a framing pre-check
(`tagConfig[0] != '{'`) evaluated on the UNTRIMMED string, so a TagConfig blob
with leading whitespace was refused by MQTT alone and the tag read
BadNodeIdUnknown forever — while the other six structural-parse drivers
(Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS) hand the identical blob to
JsonDocument.Parse, which skips leading whitespace per the JSON spec.

The clause is dropped (the blank guard stays). JsonDocument.Parse plus the
root-ValueKind check now decide framing, exactly as for the six siblings: an
array/number/string root fails ValueKind, non-JSON is a JsonException. The gate
was a validator, not a discriminator — no caller (MqttSubscriptionManager,
SparkplugIngestor, MqttTagConfigModel, Inspect) used the first byte to pick a
format — so verdicts are identical for every input except whitespace-padded
JSON objects, which now get their content's verdict.

Inspect needs no logic change (it has trimmed since #556 and hands the RAW
reference to the mappers), but its comments claimed the padded-blob refusal was
production-reachable; rewritten. That shape was also MQTT's only vehicle for the
catch-all backstop, which is now unreachable-today like FOCAS's — so its test is
replaced by the same count-relation pin, and the backstop code stays as
defence-in-depth.

Tests: both mappers pinned by padded-vs-unpadded DEFINITION equality (a record,
so field-wise), padded non-object/malformed negative controls, an Inspect
verdict+inspection parity theory over the accepted AND rejected corpus with an
anti-vacuity control, and a padded MQTT corpus entry in the AdminUI
authoring/runtime parity suite.
This commit is contained in:
Joseph Doherty
2026-08-10 03:36:46 -04:00
parent 15e65c86d0
commit 621a9e5dc2
5 changed files with 227 additions and 51 deletions
@@ -50,6 +50,13 @@ public sealed class TagConfigNonEnumParityTests
// ---------------------------------------------------------------------------------------------
private const string P = "(test)";
/// <summary>
/// Leading whitespace, in every form JSON allows before a value. Gitea #559: a CSV or API authoring
/// path can introduce it invisibly, and no author means it as semantic — so it must not change any
/// verdict on either side of the parity claim.
/// </summary>
private const string Pad = " \t\r\n ";
private static readonly IReadOnlyDictionary<string, Func<string, bool>> RuntimeParses =
new Dictionary<string, Func<string, bool>>(StringComparer.OrdinalIgnoreCase)
{
@@ -352,6 +359,14 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/a","qos":2}"""),
(DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""),
(DriverTypeNames.Mqtt, """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""),
// Gitea #559 — leading whitespace, in both shapes and in both verdicts. MQTT's runtime mappers used
// to carry their own untrimmed `[0] != '{'` framing gate, so these padded blobs were REFUSED by the
// driver while the authoring model (whose JsonNode.Parse skips whitespace) happily saved them: the
// absolute direction of this suite, broken. The mappers now let JsonDocument.Parse decide framing,
// like the six siblings, so the pad is inert on both sides.
(DriverTypeNames.Mqtt, Pad + """{"topic":"otopcua/fixture/a","dataType":"Float64"}"""),
(DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Filler/Speed"}"""),
(DriverTypeNames.Mqtt, Pad + """{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA"}"""),
(DriverTypeNames.Sql, "{}"),
(DriverTypeNames.Sql, """{"model":"KeyValue","table":"dbo.T","keyColumn":"k","valueColumn":"v","keyValue":"x"}"""),