merge: Mqtt Contracts+Browser code review — wildcard PUBLISH refusal (whole-driver outage path), Sparkplug segment shape rules, rebirth-scope wildcard gate; 12 findings
This commit is contained in:
@@ -720,6 +720,19 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether an operator-supplied scope segment may be used to <b>address</b> an NCMD. Everything
|
||||
/// <see cref="IsUsableLabel"/> requires, plus no MQTT wildcard: a group/edge-node id becomes a
|
||||
/// topic segment of the published command, and MQTT-3.3.2-2 forbids a wildcard in a PUBLISH
|
||||
/// topic. Kept separate from <see cref="IsUsableLabel"/> on purpose — that one also judges
|
||||
/// Sparkplug <i>metric</i> names, which are arbitrary publisher-chosen UTF-8 and are never part
|
||||
/// of a topic, so tightening it there would drop legitimate metrics from the tree.
|
||||
/// </summary>
|
||||
/// <param name="segment">The candidate scope segment.</param>
|
||||
/// <returns><see langword="true"/> when an NCMD may be addressed with it.</returns>
|
||||
private static bool IsUsableRebirthSegment(string segment) =>
|
||||
IsUsableLabel(segment) && segment.IndexOf('+') < 0 && segment.IndexOf('#') < 0;
|
||||
|
||||
/// <summary>Test seam over <see cref="Observe"/> that takes the payload as text.</summary>
|
||||
/// <param name="topic">The observed topic.</param>
|
||||
/// <param name="payload">The message body as text, or <see langword="null"/> for an empty payload.</param>
|
||||
@@ -895,7 +908,14 @@ internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
|
||||
}
|
||||
|
||||
// 3. A bare {group}/{edgeNode} the window has never seen — the prime rebirth target.
|
||||
if (segments.Length == 2 && IsUsableLabel(segments[0]) && IsUsableLabel(segments[1]))
|
||||
// ⚠ This is the ONE path that takes an id the session never parsed off the wire, so it is the
|
||||
// one that must check for a wildcard. IsUsableLabel deliberately permits '+'/'#' (a Sparkplug
|
||||
// METRIC name is arbitrary UTF-8 and could legitimately contain either), so it is not enough
|
||||
// here: `Plant1/#` would have assembled `spBv1.0/Plant1/NCMD/#`, a PUBLISH the MQTT spec
|
||||
// forbids, which a conformant broker answers by closing the browse connection — while `+`
|
||||
// would fan one operator click across every matching edge node. Cases 1 and 2 above cannot
|
||||
// reach this shape: their ids come off SparkplugTopic.TryParse, which rejects both characters.
|
||||
if (segments.Length == 2 && IsUsableRebirthSegment(segments[0]) && IsUsableRebirthSegment(segments[1]))
|
||||
{
|
||||
return [(segments[0], segments[1])];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The two shape rules an authored MQTT address must satisfy to be <em>publishable</em>, shared by
|
||||
/// the deploy-time inspector (<see cref="MqttTagDefinitionFactory.Inspect"/>) and the write encoder
|
||||
/// (<see cref="MqttWriteEncoder"/>) so the warning an operator is shown and the refusal the driver
|
||||
/// makes can never disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Neither rule gates the READ path, and that asymmetry is the point.</b> A tag whose
|
||||
/// subscription <c>topic</c> carries a wildcard still reads (ambiguously — hence
|
||||
/// <see cref="MqttTagDefinitionFactory.Inspect"/>'s long-standing advisory), and a Sparkplug tag
|
||||
/// with a malformed id still parses. What these rules decide is whether an <em>outbound</em>
|
||||
/// message could legally be addressed at all, so they belong to the write encoder and to the
|
||||
/// inspector, never to the mappers' accept gate — which must stay exactly as strict as the
|
||||
/// runtime reader (Gitea #551).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a wildcard write destination is a refusal rather than a best effort.</b> MQTT-3.3.2-2
|
||||
/// forbids a wildcard in a PUBLISH topic, and a conformant broker answers a protocol violation by
|
||||
/// closing the network connection — which for this driver is the one connection every
|
||||
/// subscription lives on. Publishing anyway would turn one mis-authored tag into a whole-driver
|
||||
/// outage; refusing costs that tag its write and nothing else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MqttAddressRules
|
||||
{
|
||||
/// <summary>The MQTT wildcard characters — illegal in any concrete (published or subscribed) topic.</summary>
|
||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Characters that cannot appear in a <b>single MQTT topic segment</b>, which is what a Sparkplug
|
||||
/// group / edge-node / device id is. <c>/</c> joins the wildcards here because it would silently
|
||||
/// re-shape the command topic into a different address rather than making it unpublishable — and
|
||||
/// because a decoded incoming id can never contain one, so an authored id that does is a binding
|
||||
/// that could never have resolved.
|
||||
/// </summary>
|
||||
private static readonly char[] SegmentIllegalChars = ['/', '+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="topic"/> carries an MQTT wildcard (<c>+</c> / <c>#</c>).
|
||||
/// </summary>
|
||||
/// <param name="topic">A topic string; a <see langword="null"/>/empty one carries none.</param>
|
||||
/// <returns><see langword="true"/> when a wildcard is present.</returns>
|
||||
internal static bool HasWildcard(string? topic) =>
|
||||
!string.IsNullOrEmpty(topic) && topic.IndexOfAny(TopicWildcards) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="id"/> is unusable as a single MQTT topic segment — i.e. as a Sparkplug
|
||||
/// group, edge-node or device id.
|
||||
/// </summary>
|
||||
/// <param name="id">The authored id; a <see langword="null"/>/empty one is not judged here (absence
|
||||
/// is handled by the mappers' own presence checks, and <c>deviceId</c> is legitimately absent).</param>
|
||||
/// <returns><see langword="true"/> when the id contains <c>/</c>, <c>+</c> or <c>#</c>.</returns>
|
||||
/// <remarks>
|
||||
/// Deliberately NOT applied to <c>metricName</c>: a metric name rides in the Sparkplug
|
||||
/// <em>payload</em>, not in the topic, and Sparkplug's own canonical names use <c>/</c> (e.g.
|
||||
/// <c>Node Control/Rebirth</c>). Applying it there would refuse every conformant node-control tag.
|
||||
/// </remarks>
|
||||
internal static bool IsIllegalTopicSegment(string? id) =>
|
||||
!string.IsNullOrEmpty(id) && id.IndexOfAny(SegmentIllegalChars) >= 0;
|
||||
}
|
||||
@@ -47,6 +47,14 @@ public static class MqttTagDefinitionFactory
|
||||
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
|
||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// The Sparkplug address keys that are single MQTT topic segments, and so cannot carry <c>/</c>,
|
||||
/// <c>+</c> or <c>#</c>. <see cref="MqttTagConfigKeys.MetricName"/> is deliberately absent — it
|
||||
/// rides in the payload, not the topic. See <see cref="MqttAddressRules.IsIllegalTopicSegment"/>.
|
||||
/// </summary>
|
||||
private static readonly string[] SparkplugSegmentKeys =
|
||||
[MqttTagConfigKeys.GroupId, MqttTagConfigKeys.EdgeNodeId, MqttTagConfigKeys.DeviceId];
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3),
|
||||
@@ -265,9 +273,13 @@ public static class MqttTagDefinitionFactory
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Advisories</b> — a <b>wildcard</b> tag topic (<c>+</c> / <c>#</c>), which the mapper
|
||||
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; and a
|
||||
/// present-but-invalid <c>payloadFormat</c> on an ACCEPTED blob, which can only mean the
|
||||
/// Sparkplug reader took it and the Plain reader would not (Gitea #557 item 2).
|
||||
/// deliberately ACCEPTS (ambiguous, not unparseable) so this runs either way; a wildcard
|
||||
/// <c>writeTopic</c>, which reads fine but can never be published to, so every write is
|
||||
/// refused <c>BadNotWritable</c>; a Sparkplug <c>groupId</c>/<c>edgeNodeId</c>/
|
||||
/// <c>deviceId</c> carrying a character no MQTT topic segment can hold, which makes the
|
||||
/// tag permanently unbindable; and a present-but-invalid <c>payloadFormat</c> on an
|
||||
/// ACCEPTED blob, which can only mean the Sparkplug reader took it and the Plain reader
|
||||
/// would not (Gitea #557 item 2).
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// Empty for a clean, readable blob, for a blank one, or for one that is not an equipment-tag
|
||||
@@ -370,6 +382,36 @@ public static class MqttTagDefinitionFactory
|
||||
"concrete, or the tag will be fed by every matching topic.");
|
||||
}
|
||||
|
||||
// The write destination gets the same treatment as the read topic, and for a sharper reason: a
|
||||
// wildcard cannot be PUBLISHED to at all (MQTT-3.3.2-2), so the tag reads perfectly while every
|
||||
// write is refused with BadNotWritable — a split nothing else surfaces. The AdminUI's typed
|
||||
// editor already refuses this, but it is only ONE authoring path (the raw-JSON textarea, CSV
|
||||
// import and the API all reach the deploy gate directly), so leaving it out here made the
|
||||
// authoring surface stricter than the deploy gate — the drift this inspector exists to close.
|
||||
var writeTopic = ReadString(root, MqttTagConfigKeys.WriteTopic);
|
||||
if (MqttAddressRules.HasWildcard(writeTopic))
|
||||
{
|
||||
advisories.Add(
|
||||
$"value '{writeTopic}' for 'writeTopic' contains an MQTT wildcard (+ or #); a wildcard "
|
||||
+ "cannot be published to, so the tag reads normally but every write is refused "
|
||||
+ "(BadNotWritable).");
|
||||
}
|
||||
|
||||
// Same shape, Sparkplug side: a group/edge-node/device id is a single MQTT topic segment, so a
|
||||
// decoded incoming id can never contain '/', '+' or '#'. An authored one that does is a binding
|
||||
// that can never resolve — the mapper accepts it (it checks presence, not shape), so this is the
|
||||
// only place it can be said. `metricName` is deliberately exempt: it rides in the payload, and
|
||||
// Sparkplug's own canonical names use '/'.
|
||||
foreach (var key in SparkplugSegmentKeys)
|
||||
{
|
||||
var value = ReadString(root, key);
|
||||
if (!MqttAddressRules.IsIllegalTopicSegment(value)) continue;
|
||||
advisories.Add(
|
||||
$"value '{value}' for '{key}' contains a character ('/', '+' or '#') that cannot appear "
|
||||
+ "in an MQTT topic segment; a decoded Sparkplug id never contains one, so this tag can "
|
||||
+ "never bind and will read BadNodeIdUnknown in Sparkplug B mode.");
|
||||
}
|
||||
|
||||
// Gitea #557 item 2 — the ONE key whose verdict depends on the DRIVER'S mode, reported as an
|
||||
// advisory because no tag-level gate can see that mode. A present-but-invalid payloadFormat is
|
||||
// fatal to the Plain parse and invisible to the Sparkplug one, so a union accept here can only
|
||||
|
||||
@@ -19,9 +19,11 @@ public enum MqttWriteEncodeError
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The tag declares no write destination: a Plain tag with no <c>writeTopic</c>. Deliberately a
|
||||
/// hard refusal — see <see cref="MqttWriteEncoder"/>'s remarks on why a silent no-op would be
|
||||
/// worse than the honest read-only behaviour this replaced.
|
||||
/// The tag declares no <b>usable</b> write destination: a Plain tag with no <c>writeTopic</c> or
|
||||
/// one carrying an MQTT wildcard, or a Sparkplug tag whose binding tuple is incomplete or whose
|
||||
/// group/edge-node/device id is not a legal topic segment (see <see cref="MqttAddressRules"/>).
|
||||
/// Deliberately a hard refusal — see <see cref="MqttWriteEncoder"/>'s remarks on why a silent
|
||||
/// no-op would be worse than the honest read-only behaviour this replaced.
|
||||
/// </summary>
|
||||
NoWriteDestination,
|
||||
|
||||
@@ -121,6 +123,17 @@ public static class MqttWriteEncoder
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
// A wildcard topic is not a destination — MQTT-3.3.2-2 forbids one in a PUBLISH, and a
|
||||
// conformant broker answers a protocol violation by CLOSING the connection, which is the same
|
||||
// connection every one of this driver's subscriptions lives on. So publishing anyway would turn
|
||||
// one mis-authored tag into a whole-driver outage. `NoWriteDestination` (→ BadNotWritable) is the
|
||||
// right verdict rather than a new error: the tag genuinely has nowhere a write could be sent, and
|
||||
// the read path is untouched. See MqttAddressRules.
|
||||
if (MqttAddressRules.HasWildcard(def.WriteTopic))
|
||||
{
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
// No MQTT payload means "the value is null" — a zero-length payload is a legitimate
|
||||
@@ -175,6 +188,18 @@ public static class MqttWriteEncoder
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
// …and an id that is not a legal topic SEGMENT is the same failure by a different route: the
|
||||
// tuple is assembled into the NCMD/DCMD topic, so a '/' would silently address a different node
|
||||
// than the tag names while '+'/'#' would make the PUBLISH a protocol violation (see the Plain
|
||||
// branch). `metricName` is exempt on purpose — it rides in the payload, and Sparkplug's own
|
||||
// canonical names use '/'.
|
||||
if (MqttAddressRules.IsIllegalTopicSegment(groupId)
|
||||
|| MqttAddressRules.IsIllegalTopicSegment(edgeNodeId)
|
||||
|| MqttAddressRules.IsIllegalTopicSegment(def.DeviceId))
|
||||
{
|
||||
return MqttWriteEncodeError.NoWriteDestination;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return MqttWriteEncodeError.ValueNotRepresentable;
|
||||
|
||||
Reference in New Issue
Block a user