fix(mqtt): close #509, #510, #512, #513, #514

Five MQTT tracker items — four AdminUI/authoring defects plus the .Browser
layering exception. No runtime driver behaviour changes.

#513 payloadFormat in a Sparkplug blob. MqttTagConfigModel.ToJson wrote the key
unconditionally. Sharper than the issue states: PayloadFormat is the one Plain
field with a NON-NULLABLE typed backing, so the write did not preserve a retyped
tag's leftover — it INVENTED "payloadFormat":"Json" in a Sparkplug tag that never
had one. Now Plain-only; preserve-unknown-keys is pinned unchanged.

#509 slashed Sparkplug metric names. The mapper already carried metricName
verbatim; only the tag Name (a RawPath segment) tripped RawPaths.ValidateSegment,
which refused the batch whole — including Node Control/Rebirth, mandatory on
every Sparkplug edge node. ToRawSegment coerces the Name and leaves the address
untouched. Coercion is not injective, so DescribeNameCollisions refuses a
colliding set BY NAME: silently merging two metrics into one tag would be worse
than the refusal it replaces. Applies to every driver, not just MQTT — the
argument is about what a Name is, not about who produced it. The sanitiser is
pinned against RawPaths.ValidateSegment rather than a copied charset.

#510 stale rebirth affordance. _canRebirth was probed once at browse-open and
sessions are reaped for idleness, so a modal left open offered an action whose
token no longer resolved. Re-probed on arm AND on confirm (the reap can land
between the two, and confirm is the one path that publishes to a live broker);
the affordance retires with an explanation instead of vanishing or erroring.

#514 rebirth on a quiet plant. The tree is birth-driven and births are never
retained, so a plant that birthed before the window opened showed an empty tree —
and the scope came only from a tree selection, so the one action that would fill
the tree could not be armed. The session already accepted a bare {group}/{edgeNode}
("the prime rebirth target"); this adds the affordance. Edge-node scope only, not
a group broadcast. The rule is extracted to SparkplugRebirthScope so it is
testable — this repo has no bUnit — and is advisory: the session re-resolves and
refuses before publishing.

#512 .Browser -> .Driver layering. TWO extractions were needed, not the one the
issue anticipated: it costed this as "the client-options builder", but the browse
session decodes births itself, so moving only that would have left the reference
exactly where it was.
  - MqttClientOptionsFactory (TLS, PEM CA-pin validator, credentials, protocol
    mapping) -> new leaf .Transport project. NOT .Contracts: that is deliberately
    transport-free and MqttClientOptions is an MQTTnet type, so .Contracts would
    hand MQTTnet to ControlPlane. This is the fix the .Browser csproj comment had
    already prescribed, against the issue's suggestion.
  - The Sparkplug protocol layer (SparkplugTopic/MessageType, SparkplugCodec,
    RebirthRequester) -> .Contracts, beside the Tahu wire schema it encodes.
Still exactly one copy of the TLS/CA-pin logic; MqttBrowserLayeringTests guards
the graph both ways.

Measured, not assumed: Driver.Mqtt.dll, Polly.Core and Serilog are now absent
from the AdminUI's output. Core.dll remains, because Driver.Sql.Browser has the
SAME violation, undocumented — the old comment blamed this browser for Core and
was wrong. Filed separately rather than fixed in passing.

Full suite green apart from the 7 known pre-existing failures (4 AbLegacy
integration, compose profiles not up; 3 Host.IntegrationTests).

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 06:17:18 -04:00
parent 3207089f01
commit 7f53bd96ec
23 changed files with 1029 additions and 269 deletions
+1
View File
@@ -49,6 +49,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj" />
</Folder>
<Folder Name="/src/Drivers/Driver CLIs/">
@@ -23,12 +23,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
/// </para>
/// <para>
/// <b>Layering note.</b> Unlike every other bespoke browser here, this project references the
/// runtime <c>.Driver</c> project (to reuse <c>MqttConnection.BuildClientOptions</c> rather
/// than fork the TLS / CA-pin path). That is a known, deliberate exception with a documented
/// cost and a documented clean fix — see the comment on that <c>ProjectReference</c> in
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj</c> before copying this project as a
/// template.
/// <b>Layering note.</b> This project depends on the shared leaves only — <c>.Contracts</c> for
/// the Sparkplug wire schema and codec, <c>.Transport</c> for
/// <see cref="MqttClientOptionsFactory"/> — and NOT on the runtime <c>.Driver</c> project, which
/// it used to reference under a documented exception (Gitea #512). Reuse of the TLS / CA-pin path
/// is what that exception bought, and it is preserved: there is still exactly one copy, now in
/// <c>.Transport</c>. Do not fork it. Pinned by <c>MqttBrowserLayeringTests</c>.
/// </para>
/// </summary>
public sealed class MqttDriverBrowser : IDriverBrowser
@@ -73,7 +73,7 @@ public sealed class MqttDriverBrowser : IDriverBrowser
var filter = BuildRootFilter(opts);
var suffix = BuildBrowseClientIdSuffix();
var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
var clientOptions = MqttClientOptionsFactory.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
openCts.CancelAfter(OpenBudget(opts));
@@ -14,34 +14,36 @@
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<!--
┌──────────────────────────────────────────────────────────────────────────────────┐
│ KNOWN, DELIBERATE EXCEPTION to the .Browser → .Contracts pattern. │
│ DO NOT COPY THIS LINE INTO A NEW DRIVER'S BROWSER WITHOUT READING THIS. │
└──────────────────────────────────────────────────────────────────────────────────┘
The layering exception this project used to carry is GONE (Gitea #512). It referenced the
runtime .Driver project, and that reference reached the AdminUI, which references this
browser: the AdminUI's build/deploy graph pulled in the whole MQTT driver runtime.
Every other bespoke browser in this repo (OpcUaClient, Galaxy) references only its own
.Contracts project plus its transport package. This one also references the runtime
.Driver project. That is a real widening, not a free one, and it was accepted knowingly:
MEASURED, not assumed: after the change ZB.MOM.WW.OtOpcUa.Driver.Mqtt.dll, Polly.Core and
Serilog are all absent from the AdminUI's output. ZB.MOM.WW.OtOpcUa.Core.dll is still there,
because Driver.Sql.Browser ALSO references its runtime driver — the identical violation, in a
second driver, and undocumented (this one at least carried a comment). The comment that used
to live here claimed this browser was the sole cause of Core reaching the AdminUI; it was not.
Tracked separately rather than fixed in passing.
WHY: the browse CONNECT reuses MqttConnection.BuildClientOptions, which owns the TLS
posture, the PEM CA-pin chain validator, the credential handling and the protocol
version mapping (~100 lines). A second copy here would be a silent security
divergence — browse quietly not honouring a pinned CA while the runtime driver does.
The method is pure (no I/O, no network), so the dependency costs nothing at run time.
It took TWO extractions to remove, not the one the issue anticipated:
WHAT IT COSTS: the AdminUI's project graph does NOT otherwise include Core.csproj
(only Host.csproj and the runtime Driver.* projects do). Referencing this browser
from the AdminUI therefore pulls in Core + Polly.Core + Serilog at build/deploy time.
1. MqttClientOptionsFactory (TLS posture, PEM CA-pin chain validator, credentials,
protocol-version mapping) moved to the leaf .Transport project below — the fix the
comment that used to live here had already prescribed. It cannot live in .Contracts:
that project is deliberately transport-free, and BuildClientOptions returns
MqttClientOptions, an MQTTnet type.
THE CLEAN FIX (deferred, not blocked): a small leaf project — say
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport (net10, refs .Contracts + MQTTnet) — holding
BuildClientOptions/ConfigureTls/the CA-pin helpers, referenced by BOTH .Driver and
.Browser. Note the obvious-looking alternative does NOT work: those members cannot
move into .Contracts, because .Contracts is deliberately transport-free (Task 1
removed its MQTTnet reference to keep it so) and BuildClientOptions returns
MqttClientOptions, an MQTTnet type.
2. The Sparkplug PROTOCOL layer — SparkplugTopic/SparkplugMessageType, SparkplugCodec and
RebirthRequester — moved to .Contracts, which is where the Tahu wire schema they
encode/decode already lived. This half is easy to miss: the browse session decodes
births itself, so extracting only the options builder would have left the .Driver
reference exactly where it was.
Whatever else changes here, there must stay exactly ONE copy of the TLS/CA-pin logic. The
failure mode of a second copy is not a build break but a silent security divergence — browse
quietly not honouring a pinned CA while the runtime driver does.
-->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.csproj"/>
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,244 @@
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using MQTTnet;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// Assembles the MQTTnet client options for every broker connection this repo opens — the runtime
/// driver's, the browse session's and the Test-connect probe's alike.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is its own type in its own assembly (Gitea #512).</b> It owns the security posture of
/// an MQTT connection: whether TLS is on, whether an untrusted certificate is tolerated, and — when a
/// PEM CA is pinned — the custom chain validator that replaces the OS trust anchor. Both the runtime
/// driver and the browse session must connect with <em>identical</em> semantics, and the failure mode
/// of a second copy is not a crash but a silent divergence: browse quietly not honouring a pinned CA
/// while the driver does. Previously this lived on <c>MqttConnection</c> and <c>.Browser</c> took a
/// documented layering exception to reach it; the exception is gone, and this is the one copy.
/// </para>
/// <para>
/// <b>Pure.</b> No I/O, no network, nothing read from disk — so it is safe to call on a
/// config-validation path. A configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read
/// lazily by the returned validation callback at handshake time, not here.
/// </para>
/// </remarks>
public static class MqttClientOptionsFactory
{
/// <summary>
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
/// returned certificate-validation callback at handshake time, not here.
/// </summary>
/// <param name="options">Broker connection settings.</param>
/// <param name="clientIdSuffix">
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
/// the configured identity without colliding with the driver's own client id. When both are
/// empty MQTTnet generates a random client id.
/// </param>
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
public static MqttClientOptions BuildClientOptions(
MqttDriverOptions options,
string? clientIdSuffix,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(options.Host, options.Port)
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
.WithCleanSession(options.CleanSession)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
if (!string.IsNullOrWhiteSpace(clientId))
{
builder = builder.WithClientId(clientId);
}
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
if (!string.IsNullOrEmpty(options.Username))
{
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
}
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
return builder.Build();
}
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
{
if (!options.UseTls)
{
tls.UseTls(false);
return;
}
tls.UseTls(true)
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
.WithTargetHost(options.Host);
if (options.AllowUntrustedServerCertificate)
{
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
tls.WithAllowUntrustedCertificates(true)
.WithIgnoreCertificateChainErrors(true)
.WithCertificateValidationHandler(static _ => true);
return;
}
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
// No pin: MQTTnet's default handler validates against the OS trust store.
return;
}
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
}
/// <summary>
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
/// (i.e. during the TLS handshake) so that this — and therefore
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
/// up to the pinned CA rejects the certificate: fail closed.
/// </summary>
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
string caCertificatePath,
ILogger? logger)
{
var trustedRoots = new Lazy<X509Certificate2Collection?>(
() => LoadPemCa(caCertificatePath, logger),
LazyThreadSafetyMode.ExecutionAndPublication);
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
}
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
{
try
{
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(caCertificatePath);
if (collection.Count != 0)
{
return collection;
}
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
}
catch (Exception ex)
{
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
}
return null;
}
private static bool ValidateAgainstPinnedCa(
MqttClientCertificateValidationEventArgs args,
X509Certificate2Collection? trustedRoots,
string caCertificatePath,
ILogger? logger)
{
if (trustedRoots is null || trustedRoots.Count == 0)
{
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
return false;
}
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
// mismatch, an absent certificate — remains fatal.
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
{
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
return false;
}
if (args.Certificate is null)
{
return false;
}
var presented = args.Certificate as X509Certificate2;
X509Certificate2? owned = null;
if (presented is null)
{
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
presented = owned;
}
try
{
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
// Revocation is deliberately not checked: the pin exists precisely for self-issued
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
// would fail every such chain. The pin itself is the revocation story — remove the CA.
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
// Seed the intermediates the broker presented during the handshake. A leaf signed by
// an intermediate that chains up to the pinned root is a common broker topology, and
// that intermediate arrives on the wire rather than being installed locally — without
// it in the candidate pool a legitimate trust path simply cannot be built.
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
// self-signed cert smuggled in here cannot become an anchor.
// Both sources are read: platforms differ in whether the peer-supplied intermediates
// land in the incoming chain's elements, its ExtraStore, or both.
if (args.Chain is not null)
{
foreach (var element in args.Chain.ChainElements)
{
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
}
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
}
if (chain.Build(presented))
{
return true;
}
logger?.LogError(
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
caCertificatePath,
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
return false;
}
catch (CryptographicException ex)
{
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
// it here and refuse: an unverifiable certificate is a rejected certificate.
logger?.LogError(
ex,
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
caCertificatePath);
return false;
}
finally
{
owned?.Dispose();
}
}
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
=> version switch
{
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
};
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport</AssemblyName>
</PropertyGroup>
<!--
The leaf project the .Browser csproj's "KNOWN, DELIBERATE EXCEPTION" comment called for, now
that the exception is gone (Gitea #512).
It exists for exactly one reason: MqttClientOptionsFactory owns the TLS posture, the PEM CA-pin
chain validator, the credential handling and the protocol-version mapping — and BOTH the runtime
driver and the browse session have to open a broker connection with identical semantics. A second
copy would be a silent security divergence: browse quietly not honouring a pinned CA while the
driver does.
Why not .Contracts, the obvious home: .Contracts is deliberately transport-free (it carries the
Sparkplug wire schema + codec and has no MQTTnet reference), and BuildClientOptions returns
MqttClientOptions — an MQTTnet type. Putting it there would hand MQTTnet to every .Contracts
consumer, including ControlPlane, which has no business with a broker client.
Kept deliberately thin: this holds connection POLICY only, and nothing that connects. The live
client, the reconnect loop and the subscription bookkeeping stay in .Driver.
-->
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MQTTnet"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
</Project>
@@ -568,7 +568,7 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
MqttClientOptions clientOptions;
try
{
clientOptions = BuildClientOptions(_options, clientIdSuffix: null, _logger);
clientOptions = MqttClientOptionsFactory.BuildClientOptions(_options, clientIdSuffix: null, _logger);
}
catch (Exception ex)
{
@@ -1345,217 +1345,4 @@ public sealed class MqttConnection : IAsyncDisposable, IMqttSubscribeTransport,
}
}
/// <summary>
/// Assembles the MQTTnet client options from <paramref name="options"/>. Pure — it performs
/// no I/O and touches no network, so it is safe to call on a config-validation path. A
/// configured <see cref="MqttDriverOptions.CaCertificatePath"/> is read lazily by the
/// returned certificate-validation callback at handshake time, not here.
/// </summary>
/// <param name="options">Broker connection settings.</param>
/// <param name="clientIdSuffix">
/// Appended to <see cref="MqttDriverOptions.ClientId"/>, so a browse/probe session can share
/// the configured identity without colliding with the driver's own client id. When both are
/// empty MQTTnet generates a random client id.
/// </param>
/// <param name="logger">Optional logger for the deferred CA-pin load; never receives credentials.</param>
public static MqttClientOptions BuildClientOptions(
MqttDriverOptions options,
string? clientIdSuffix,
ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(options.Host, options.Port)
.WithProtocolVersion(MapProtocolVersion(options.ProtocolVersion))
.WithCleanSession(options.CleanSession)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(options.KeepAliveSeconds))
.WithTimeout(TimeSpan.FromSeconds(options.ConnectTimeoutSeconds));
var clientId = string.Concat(options.ClientId ?? string.Empty, clientIdSuffix ?? string.Empty);
if (!string.IsNullOrWhiteSpace(clientId))
{
builder = builder.WithClientId(clientId);
}
// An empty username means "connect anonymously"; MQTTnet would otherwise send an empty
// CONNECT username, which some brokers treat as a failed auth rather than as anonymous.
if (!string.IsNullOrEmpty(options.Username))
{
builder = builder.WithCredentials(options.Username, options.Password ?? string.Empty);
}
builder = builder.WithTlsOptions(tls => ConfigureTls(tls, options, logger));
return builder.Build();
}
private static void ConfigureTls(MqttClientTlsOptionsBuilder tls, MqttDriverOptions options, ILogger? logger)
{
if (!options.UseTls)
{
tls.UseTls(false);
return;
}
tls.UseTls(true)
// Explicit SNI / hostname-verification target. Left unset, name validation depends on
// how MQTTnet infers the host from the endpoint — pin it to the configured host.
.WithTargetHost(options.Host);
if (options.AllowUntrustedServerCertificate)
{
// Dev / on-prem escape hatch, off unless explicitly enabled — mirrors the
// ServerHistorian TLS knobs. This is the ONLY branch that accepts a bad certificate.
tls.WithAllowUntrustedCertificates(true)
.WithIgnoreCertificateChainErrors(true)
.WithCertificateValidationHandler(static _ => true);
return;
}
if (string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
// No pin: MQTTnet's default handler validates against the OS trust store.
return;
}
tls.WithCertificateValidationHandler(CreatePinnedCaValidator(options.CaCertificatePath, logger));
}
/// <summary>
/// Builds a certificate-validation callback that pins the broker chain to the PEM CA at
/// <paramref name="caCertificatePath"/>. The file is loaded lazily on first validation
/// (i.e. during the TLS handshake) so that this — and therefore
/// <see cref="BuildClientOptions"/> — stays free of I/O. Any failure to load or to chain
/// up to the pinned CA rejects the certificate: fail closed.
/// </summary>
private static Func<MqttClientCertificateValidationEventArgs, bool> CreatePinnedCaValidator(
string caCertificatePath,
ILogger? logger)
{
var trustedRoots = new Lazy<X509Certificate2Collection?>(
() => LoadPemCa(caCertificatePath, logger),
LazyThreadSafetyMode.ExecutionAndPublication);
return args => ValidateAgainstPinnedCa(args, trustedRoots.Value, caCertificatePath, logger);
}
private static X509Certificate2Collection? LoadPemCa(string caCertificatePath, ILogger? logger)
{
try
{
var collection = new X509Certificate2Collection();
collection.ImportFromPemFile(caCertificatePath);
if (collection.Count != 0)
{
return collection;
}
logger?.LogError("MQTT CA pin '{CaCertificatePath}' contains no certificates; broker TLS will be rejected.", caCertificatePath);
}
catch (Exception ex)
{
logger?.LogError(ex, "MQTT CA pin '{CaCertificatePath}' could not be loaded; broker TLS will be rejected.", caCertificatePath);
}
return null;
}
private static bool ValidateAgainstPinnedCa(
MqttClientCertificateValidationEventArgs args,
X509Certificate2Collection? trustedRoots,
string caCertificatePath,
ILogger? logger)
{
if (trustedRoots is null || trustedRoots.Count == 0)
{
// Unreadable pin ⇒ no trust anchor ⇒ refuse. Never silently degrade to the OS store.
return false;
}
// The pin replaces the trust anchor only. Anything else the platform flagged — a hostname
// mismatch, an absent certificate — remains fatal.
if ((args.SslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
{
logger?.LogError("MQTT broker certificate rejected: {SslPolicyErrors}.", args.SslPolicyErrors);
return false;
}
if (args.Certificate is null)
{
return false;
}
var presented = args.Certificate as X509Certificate2;
X509Certificate2? owned = null;
if (presented is null)
{
owned = X509CertificateLoader.LoadCertificate(args.Certificate.Export(X509ContentType.Cert));
presented = owned;
}
try
{
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
// Revocation is deliberately not checked: the pin exists precisely for self-issued
// dev / on-prem CAs, which typically publish no CRL or OCSP responder, so checking
// would fail every such chain. The pin itself is the revocation story — remove the CA.
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
// Seed the intermediates the broker presented during the handshake. A leaf signed by
// an intermediate that chains up to the pinned root is a common broker topology, and
// that intermediate arrives on the wire rather than being installed locally — without
// it in the candidate pool a legitimate trust path simply cannot be built.
// CustomRootTrust still means only `trustedRoots` may terminate the chain, so a rogue
// self-signed cert smuggled in here cannot become an anchor.
// Both sources are read: platforms differ in whether the peer-supplied intermediates
// land in the incoming chain's elements, its ExtraStore, or both.
if (args.Chain is not null)
{
foreach (var element in args.Chain.ChainElements)
{
chain.ChainPolicy.ExtraStore.Add(element.Certificate);
}
chain.ChainPolicy.ExtraStore.AddRange(args.Chain.ChainPolicy.ExtraStore);
}
if (chain.Build(presented))
{
return true;
}
logger?.LogError(
"MQTT broker certificate does not chain to the pinned CA '{CaCertificatePath}': {ChainStatus}.",
caCertificatePath,
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
return false;
}
catch (CryptographicException ex)
{
// X509Chain.Build throws on a malformed certificate or an unusable policy. An exception
// escaping a TLS validation callback surfaces as an opaque handshake crash, so classify
// it here and refuse: an unverifiable certificate is a rejected certificate.
logger?.LogError(
ex,
"MQTT broker certificate could not be validated against the pinned CA '{CaCertificatePath}'; rejecting.",
caCertificatePath);
return false;
}
finally
{
owned?.Dispose();
}
}
private static MQTTnet.Formatter.MqttProtocolVersion MapProtocolVersion(MqttProtocolVersion version)
=> version switch
{
MqttProtocolVersion.V311 => MQTTnet.Formatter.MqttProtocolVersion.V311,
MqttProtocolVersion.V500 => MQTTnet.Formatter.MqttProtocolVersion.V500,
_ => throw new ArgumentOutOfRangeException(nameof(version), version, "Unsupported MQTT protocol version."),
};
}
@@ -939,7 +939,7 @@ public sealed class MqttDriver
// ONLINE half without the Will is strictly worse than shipping neither: a crashed node
// would leave a retained ONLINE asserting forever that a dead host is alive, which is the
// exact state the mechanism exists to prevent. The Will belongs in
// MqttConnection.BuildClientOptions; until it is there this flag stays inert and said so.
// MqttClientOptionsFactory.BuildClientOptions; until it is there this flag stays inert and said so.
_logger?.LogWarning(
"MQTT driver '{DriverId}': Sparkplug actAsPrimaryHost is set but primary-host STATE "
+ "publishing is not implemented — this driver observes STATE and never publishes one. "
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// </summary>
/// <remarks>
/// <para>
/// <b>Reuses <see cref="MqttConnection.BuildClientOptions"/></b> for the connect (TLS,
/// <b>Reuses <see cref="MqttClientOptionsFactory.BuildClientOptions"/></b> for the connect (TLS,
/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second
/// connect path — a duplicate would be a security divergence. A distinct client-id suffix
/// (see <see cref="ProbeClientIdPrefix"/>) keeps a probe from colliding with the driver's
@@ -101,7 +101,7 @@ public sealed class MqttDriverProbe : IDriverProbe
MqttClientOptions clientOptions;
try
{
clientOptions = MqttConnection.BuildClientOptions(
clientOptions = MqttClientOptionsFactory.BuildClientOptions(
probeOptions,
ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8],
NullLogger.Instance);
@@ -1038,7 +1038,7 @@ public sealed class SparkplugIngestor
/// while their host is offline. Shipping the ONLINE half without the Will is strictly worse
/// than shipping neither: an OtOpcUa node that crashes would leave a retained ONLINE asserting
/// forever that a dead host is alive, which is precisely the state the mechanism exists to
/// prevent. The Will has to be set inside <c>MqttConnection.BuildClientOptions</c> — a
/// prevent. The Will has to be set inside <c>MqttClientOptionsFactory.BuildClientOptions</c> — a
/// different file, a different task — so this task implements the observation half and
/// <see cref="MqttDriver"/> warns when the flag is set. See the design doc §7/§8.
/// </para>
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -0,0 +1,80 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Validates an operator-typed Sparkplug rebirth scope for the raw browse modal (Gitea #514).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a typed scope exists at all.</b> The Sparkplug browse tree is <i>birth-driven</i>, and
/// births are never retained. On a quiet plant — every edge node birthed long before the browse
/// window opened, and is now publishing only NDATA/DDATA — the tree comes up empty. The
/// Request-rebirth scope was derived solely from a tree selection, so the one action that would
/// populate the tree could not be armed: the action needed the tree, and the tree needed the action.
/// </para>
/// <para>
/// <b>This is advisory, not the gate.</b> <c>MqttBrowseSession.ResolveRebirthTargets</c> re-resolves
/// the scope and refuses an unusable one <em>before any message leaves</em>, and the
/// <c>DriverOperator</c> policy is enforced server-side in <c>BrowserSessionService</c>. This exists
/// so the modal does not let an operator arm a two-click confirm that cannot succeed. Being the
/// looser of the two is therefore safe; being the stricter one would block a scope the session
/// accepts, which is the failure mode the repo's authoring surfaces avoid by convention.
/// </para>
/// <para>
/// <b>A group-only scope is rejected on purpose.</b> The session resolves a bare group by enumerating
/// the edge nodes it has <i>observed</i> — of which an empty tree has none — so it could not help the
/// case this input exists for, and on a populated tree it would fan one click across the whole group.
/// The tree-selection path still offers a group scope, with its own confirm-time cap and warning.
/// </para>
/// </remarks>
public static class SparkplugRebirthScope
{
/// <summary>MQTT wildcards. A rebirth addresses one edge node exactly; a wildcard cannot name one.</summary>
private static readonly char[] Wildcards = ['+', '#'];
/// <summary>
/// Describes why <paramref name="raw"/> is not a usable <c>{group}/{edgeNode}</c> rebirth scope.
/// </summary>
/// <param name="raw">The operator-typed scope, untrimmed; <see langword="null"/> is treated as blank.</param>
/// <returns>The operator-facing error text, or <see langword="null"/> when the scope is usable.</returns>
public static string? Describe(string? raw)
{
var scope = (raw ?? "").Trim();
if (scope.Length == 0) { return "Enter the edge node as 'group/edgeNode'."; }
if (scope.IndexOfAny(Wildcards) >= 0)
{
return "An MQTT wildcard (+ or #) cannot address an edge node; name one exactly.";
}
var segments = scope.Split('/');
if (segments.Length != 2 || Array.Exists(segments, string.IsNullOrWhiteSpace))
{
return "Use exactly 'group/edgeNode' — a group on its own would ask every edge node in it to "
+ "republish, which this window cannot enumerate while the tree is empty.";
}
return null;
}
/// <summary>True when <paramref name="raw"/> is a usable rebirth scope.</summary>
/// <param name="raw">The operator-typed scope.</param>
/// <returns><see langword="true"/> when <see cref="Describe"/> finds no fault.</returns>
public static bool IsUsable(string? raw) => Describe(raw) is null;
/// <summary>
/// Splits a scope already accepted by <see cref="Describe"/> into its two segments.
/// </summary>
/// <param name="raw">A scope for which <see cref="IsUsable"/> is <see langword="true"/>.</param>
/// <returns>The trimmed <c>(group, edgeNode)</c> pair.</returns>
/// <exception cref="ArgumentException">The scope is not usable.</exception>
public static (string GroupId, string EdgeNodeId) Split(string? raw)
{
if (Describe(raw) is { } error)
{
throw new ArgumentException($"'{raw}' is not a usable rebirth scope: {error}", nameof(raw));
}
var segments = raw!.Trim().Split('/');
return (segments[0].Trim(), segments[1].Trim());
}
}
@@ -126,9 +126,34 @@
@if (_rebirthTarget is null)
{
@* Gitea #514: the tree is BIRTH-driven, and births are never retained. On a
quiet plant — every edge node birthed long before this window opened and is
now publishing only NDATA/DDATA — the tree is empty, so a tree-selected
scope can never be armed and the one action that would fill the tree is
unreachable. Naming an edge node directly breaks that cycle. Deliberately
an EDGE NODE rather than a group broadcast: a group rebirth is a
self-inflicted broker storm on a large plant. *@
<div class="small text-muted mt-1">
Click a group, edge node, device or metric in the tree above to choose the scope.
Click a group, edge node, device or metric in the tree above to choose the
scope &mdash; or, if the tree is empty because every edge node birthed
before this window opened, name one directly:
</div>
<div class="input-group input-group-sm mt-1">
<span class="input-group-text">Edge node</span>
<input class="form-control mono" placeholder="group/edgeNode"
aria-label="Sparkplug edge-node scope"
value="@_manualScope"
@oninput="@(e => _manualScope = e.Value?.ToString() ?? "")" />
<button type="button" class="btn btn-outline-secondary"
disabled="@(ManualScopeError is not null)"
@onclick="ApplyManualScope">
Use this scope
</button>
</div>
@if (!string.IsNullOrWhiteSpace(_manualScope) && ManualScopeError is { } manualErr)
{
<div class="small text-danger mt-1">@manualErr</div>
}
}
else
{
@@ -179,6 +204,17 @@
}
</div>
}
else if (_rebirthExpired && _canOperate)
{
@* Gitea #510: the affordance is retired rather than left to fail on click. It is
drawn from a capability probed at browse-open, and sessions are reaped for
idleness — so a modal left open past the reap used to offer an action whose
token no longer resolved. Say what happened instead of silently vanishing. *@
<div class="alert alert-warning py-2 mt-2 mb-0 small">
This browse session expired while idle, so a rebirth can no longer be requested
through it. Close and re-open the browser to request one.
</div>
}
<div class="mt-2 small">
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
@@ -276,6 +312,18 @@
private string? _rebirthOutcome;
private string? _rebirthError;
/// <summary>
/// Set when a re-probe finds the session's rebirth capability gone (#510), so the retired
/// affordance can be explained rather than silently disappearing.
/// </summary>
private bool _rebirthExpired;
/// <summary>
/// Operator-typed <c>{group}/{edgeNode}</c> rebirth scope, for the birth-driven-tree deadlock
/// (#514). Seeded with the driver's configured group so only the edge node has to be typed.
/// </summary>
private string _manualScope = "";
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
@@ -316,6 +364,8 @@
_disabledReason = null;
ResetRebirthState();
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
_resolving = true;
StateHasChanged();
@@ -373,8 +423,16 @@
{
_token = result.Token;
// Capability, not authorization: a plain-MQTT window (and every non-MQTT browser) has no
// re-announce action at all, so the affordance must not be drawn for it.
// re-announce action at all, so the affordance must not be drawn for it. Re-probed per
// click as well — see RebirthStillAvailable (#510).
_canRebirth = BrowserService.CanRequestRebirth(_token);
_rebirthExpired = false;
// Only the edge node should have to be typed (#514): the group is the driver's entire
// subscription scope, so it is already in the config this browser was opened with.
if (_canRebirth && _manualScope.Length == 0 && TryReadConfiguredGroupId() is { } gid)
{
_manualScope = gid + "/";
}
}
else { _openError = result.Message; }
}
@@ -502,9 +560,64 @@
{
_rebirthOutcome = null;
_rebirthError = null;
// #510 — the capability was probed once at browse-open; re-probe before offering to act on it.
if (!RebirthStillAvailable()) { return; }
_rebirthConfirming = true;
}
/// <summary>
/// Re-probes the open session's rebirth capability, retiring the affordance when it has gone
/// (Gitea #510).
/// </summary>
/// <returns><see langword="true"/> when a rebirth can still be requested through this session.</returns>
/// <remarks>
/// <c>CanRequestRebirth</c> is a cheap, non-throwing, no-I/O probe that is false for a reaped or
/// unknown token, so re-running it per click costs nothing. It is a <b>capability</b> probe, not
/// an authorization check — the <c>DriverOperator</c> gate stays server-side on
/// <c>RequestRebirthAsync</c> and fails closed there, and <c>_canOperate</c> remains the
/// defence-in-depth half of the render condition.
/// </remarks>
private bool RebirthStillAvailable()
{
if (BrowserService.CanRequestRebirth(_token)) { return true; }
_canRebirth = false;
_rebirthExpired = true;
ResetRebirthState();
return false;
}
/// <summary>
/// Describes why <see cref="_manualScope"/> is not a usable edge-node scope, or <c>null</c> when
/// it is. Mirrors the session's own <c>{group}/{edgeNode}</c> rule (Gitea #514).
/// </summary>
/// <remarks>
/// Advisory only: <c>MqttBrowseSession.ResolveRebirthTargets</c> re-validates and refuses an
/// unresolvable scope <b>before publishing anything</b>, so this exists to keep the operator from
/// arming a confirm step that cannot succeed — not as the gate. A group-only scope is rejected on
/// purpose: the session would fan out to every observed edge node, and on a tree that is empty
/// (the case this input exists for) there are none to fan to.
/// </remarks>
private string? ManualScopeError => SparkplugRebirthScope.Describe(_manualScope);
/// <summary>Adopts the typed <c>{group}/{edgeNode}</c> as the rebirth scope (Gitea #514).</summary>
private void ApplyManualScope()
{
if (!RebirthStillAvailable()) { return; }
if (!SparkplugRebirthScope.IsUsable(_manualScope)) { return; }
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(_manualScope);
_rebirthTarget = new RebirthTarget(
$"{groupId}/{edgeNodeId}",
$"edge node {edgeNodeId}",
$"Edge node '{edgeNodeId}' in group '{groupId}' republishes its whole metric set. "
+ "This node was named directly, so it need not have appeared in the tree.",
IsGroup: false);
_rebirthConfirming = false;
_rebirthOutcome = null;
_rebirthError = null;
}
/// <summary>Disarms the confirm step without publishing anything.</summary>
private void CancelRebirth() => _rebirthConfirming = false;
@@ -516,6 +629,9 @@
private async Task ConfirmRebirthAsync()
{
if (_rebirthTarget is null) { return; }
// #510 again, at the last moment before publishing: the reap can land between arming the confirm
// step and clicking it, and this is the one path that reaches a live broker.
if (!RebirthStillAvailable()) { return; }
_rebirthBusy = true;
_rebirthOutcome = null;
_rebirthError = null;
@@ -558,6 +674,34 @@
ResetRebirthState();
}
/// <summary>
/// Reads the driver's configured Sparkplug group id out of the merged config, or <c>null</c> when
/// it is absent or the blob is unreadable. Purely a convenience seed for <see cref="_manualScope"/>
/// — never a gate, so any failure is simply "no seed".
/// </summary>
/// <returns>The configured group id, or <c>null</c>.</returns>
private string? TryReadConfiguredGroupId()
{
try
{
// Case-insensitive: the merged blob is assembled from the driver + device configs and this
// is a display convenience, not a binding — it must not depend on which one won.
var root = System.Text.Json.Nodes.JsonNode.Parse(_mergedConfig)?.AsObject();
var sparkplug = root?
.FirstOrDefault(p => string.Equals(p.Key, "Sparkplug", StringComparison.OrdinalIgnoreCase))
.Value?.AsObject();
var groupId = sparkplug?
.FirstOrDefault(p => string.Equals(p.Key, "GroupId", StringComparison.OrdinalIgnoreCase))
.Value?.GetValue<string>();
return string.IsNullOrWhiteSpace(groupId) ? null : groupId.Trim();
}
catch
{
return null;
}
}
/// <summary>Clears every rebirth-panel field (modal re-open, browser close).</summary>
private void ResetRebirthState()
{
@@ -586,12 +730,22 @@
.ToList();
if (_commitErrors.Count > 0) { return; }
var rows = _selected.Values
var selected = _selected.Values.ToList();
var rows = selected
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups, s.AddressFields))
.ToList();
// A leaf name is coerced into a legal RawPath segment (#509), and that coercion is not
// injective — 'A/B' and 'A_B' both land on 'A_B'. Refuse the colliding SET by name here:
// letting it through would silently merge two distinct metrics into one tag bound to
// whichever address survived, which is worse than the outright refusal #509 replaced.
_commitErrors = RawBrowseCommitMapper
.DescribeNameCollisions(selected.Select(s => s.Name).ToList(), rows)
.ToList();
if (_commitErrors.Count > 0) { return; }
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
if (outcome.Errors.Count > 0)
{
@@ -613,6 +767,8 @@
var t = _token;
_token = Guid.Empty;
_canRebirth = false;
_rebirthExpired = false;
_manualScope = "";
ResetRebirthState();
Visible = false;
_open = false;
@@ -25,7 +25,10 @@ public static class RawBrowseCommitMapper
/// <param name="driverType">The owning device's driver type (a <see cref="DriverTypeNames"/> value).</param>
/// <param name="fullName">The leaf's driver-side full reference (the <c>BrowseNode.NodeId</c> = the
/// captured <c>DriverAttributeInfo.FullName</c>) — becomes the driver-typed address field.</param>
/// <param name="browseName">The leaf's browse name — becomes the raw tag <c>Name</c> (a RawPath segment).</param>
/// <param name="browseName">The leaf's browse name. Becomes the raw tag <c>Name</c> after
/// <see cref="ToRawSegment"/> coerces it into a legal RawPath segment — a Sparkplug metric name
/// legitimately contains <c>/</c> and a Name may not (Gitea #509). The leaf's binding address is
/// unaffected: it travels separately into the <c>TagConfig</c>.</param>
/// <param name="driverDataType">The leaf's <see cref="DriverDataType"/> name (from the browse
/// attribute side-channel), or null when the browser cannot report a type (e.g. the OPC UA Client tree).</param>
/// <param name="defaultDataType">The OPC-UA built-in type name to fall back to when
@@ -52,6 +55,7 @@ public static class RawBrowseCommitMapper
{
var dataType = MapDataType(driverDataType) ?? defaultDataType;
var tagConfig = BuildTagConfig(driverType, fullName, addressFields);
var name = ToRawSegment(browseName);
var mirrored = createGroups && folderPath is { Count: > 0 }
? string.Join(RawPaths.Separator, folderPath)
: null;
@@ -61,9 +65,99 @@ public static class RawBrowseCommitMapper
// no poll-group (batching is authored later).
return new RawTagImportRow(
groupPath,
new RawTagInput(browseName, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
new RawTagInput(name, dataType, TagAccessLevel.Read, WriteIdempotent: false, PollGroupId: null, tagConfig));
}
/// <summary>
/// Coerces a browse leaf's name into a legal RawPath segment (Gitea #509): trims the ends and
/// replaces every <see cref="RawPaths.Separator"/> with <see cref="SeparatorReplacement"/>.
/// </summary>
/// <param name="browseName">The leaf's browse name, as the driver reported it.</param>
/// <returns>A <see cref="RawPaths.ValidateSegment"/>-clean segment; blank only if the input was blank.</returns>
/// <remarks>
/// <para>
/// <b>The Name and the address are two fields, and only the Name is constrained.</b> A tag Name is
/// a RawPath segment, so it may not contain <c>/</c>; the driver-side reference it binds by has no
/// such rule and is written into the <c>TagConfig</c> untouched by <see cref="BuildTagConfig"/>.
/// Refusing the commit outright conflated the two — and it refused a metric the Sparkplug B spec
/// makes <em>mandatory</em> on every edge node (<c>Node Control/Rebirth</c>), so the one metric an
/// operator is most likely to browse for was the one that could not be committed.
/// </para>
/// <para>
/// <b>This deliberately covers every driver, not just MQTT.</b> The argument is about what a Name
/// is, not about who produced it: any browser whose leaf name carries a <c>/</c> hits the same
/// wall, and for all of them the binding address travels separately and survives intact.
/// </para>
/// <para>
/// <b>The rule set is <see cref="RawPaths.ValidateSegment"/>'s, not an invented one</b> — empty,
/// an embedded separator, and leading/trailing whitespace are exactly what it rejects, so trimming
/// and replacing is total rather than a best-effort guess. <c>RawBrowseCommitMapperTests</c> pins
/// that by asserting the output validates, so a new rule there fails here instead of silently
/// letting an illegal Name through to the import.
/// </para>
/// <para>
/// Sanitising is not injective, so two distinct leaves can land on one Name — see
/// <see cref="DescribeNameCollisions"/>, which is why a caller must run that check rather than
/// trusting this in isolation.
/// </para>
/// </remarks>
public static string ToRawSegment(string? browseName)
{
var trimmed = (browseName ?? "").Trim();
return trimmed.Length == 0 ? "" : trimmed.Replace(RawPaths.Separator, SeparatorReplacement);
}
/// <summary>What <see cref="ToRawSegment"/> substitutes for a separator inside a browse name.</summary>
private const char SeparatorReplacement = '_';
/// <summary>
/// Names the browse leaves that <see cref="ToRawSegment"/> would land on one tag Name within one
/// target group, or an empty list when the mapped rows are all distinct.
/// </summary>
/// <param name="browseNames">The ORIGINAL leaf names, in the same order as <paramref name="rows"/>.</param>
/// <param name="rows">The mapped import rows.</param>
/// <returns>One operator-facing message per colliding set; empty when there is nothing to refuse.</returns>
/// <remarks>
/// Sanitising maps many names onto one — <c>A/B</c> and <c>A_B</c> both become <c>A_B</c> — and a
/// silent collision is materially worse than the refusal #509 replaced: two distinct metrics would
/// become one tag bound to whichever address the import happened to keep. So the coercion is only
/// safe when paired with this check.
/// <para>
/// Scoped per target group, because the Name only has to be unique within its group — the same
/// metric name under two devices is not a collision.
/// </para>
/// <para>
/// The message names the specific pair rather than failing the batch anonymously. The commit
/// itself stays all-or-nothing, matching the CSV import's staged-review semantics: nothing is
/// written while any row is unresolvable.
/// </para>
/// </remarks>
public static IReadOnlyList<string> DescribeNameCollisions(
IReadOnlyList<string> browseNames,
IReadOnlyList<RawTagImportRow> rows)
{
ArgumentNullException.ThrowIfNull(browseNames);
ArgumentNullException.ThrowIfNull(rows);
return rows
.Select((row, i) => (row, original: i < browseNames.Count ? browseNames[i] : row.Tag.Name))
.GroupBy(x => (x.row.TagGroupPath ?? "", x.row.Tag.Name), TupleComparer)
.Where(g => g.Count() > 1)
.Select(g =>
$"'{g.Key.Item2}' would be the name of {g.Count()} different tags"
+ (string.IsNullOrEmpty(g.Key.Item1) ? "" : $" in group '{g.Key.Item1}'")
+ $": {string.Join(", ", g.Select(x => $"'{x.original}'"))}. "
+ "Their names differ only by characters that are not legal in a tag name. Commit them "
+ "separately, or author them by manual entry with distinct names.")
.ToList();
}
/// <summary>Ordinal comparer for the (group path, name) collision key — RawPath comparison is ordinal.</summary>
private static readonly IEqualityComparer<(string, string)> TupleComparer =
EqualityComparer<(string, string)>.Create(
(a, b) => RawPaths.Comparer.Equals(a.Item1, b.Item1) && RawPaths.Comparer.Equals(a.Item2, b.Item2),
x => HashCode.Combine(RawPaths.Comparer.GetHashCode(x.Item1), RawPaths.Comparer.GetHashCode(x.Item2)));
/// <summary>
/// Maps a <see cref="DriverDataType"/> enum NAME (as reported by the browse attribute side-channel) to the
/// OPC-UA built-in type name a <see cref="RawTagInput.DataType"/> carries. Mirrors
@@ -196,10 +196,12 @@ public sealed class MqttTagConfigModel
/// <c>dataType</c> is the one key both modes write, from two different typed fields
/// (<see cref="DataType"/> for Plain — always present; <see cref="MetricDataType"/> for
/// Sparkplug — omitted unless the operator set an override): which field wins is decided by
/// <see cref="Mode"/> at write time. Every other key is unconditional — mode-inapplicable keys
/// (e.g. <c>topic</c> while Sparkplug, or <c>groupId</c> while Plain) are written/cleared exactly
/// as their typed field says, which naturally preserves a retyped tag's other-mode leftovers
/// untouched until the operator edits that field too.
/// <see cref="Mode"/> at write time. <c>payloadFormat</c> is the one key written for <b>Plain
/// only</b> (Gitea #513) — see its line below for why it alone needs the mode test. Every other
/// key is unconditional: mode-inapplicable keys (e.g. <c>topic</c> while Sparkplug, or
/// <c>groupId</c> while Plain) are written/cleared exactly as their typed field says, and because
/// each of those fields is blank-⇒-omit, an absent key stays absent while a retyped tag's real
/// other-mode leftovers survive untouched until the operator edits that field too.
/// </remarks>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
@@ -207,7 +209,14 @@ public sealed class MqttTagConfigModel
// Blank ⇒ omit, like every other optional here: toggling the shape dropdown on an untouched tag
// must not leave a stray "topic":"" behind.
TagConfigJson.Set(_bag, TopicKey, string.IsNullOrWhiteSpace(Topic) ? null : Topic.Trim());
TagConfigJson.Set(_bag, PayloadFormatKey, PayloadFormat);
// Plain-only (Gitea #513). Unlike every other Plain key here, PayloadFormat is a NON-NULLABLE
// typed field, so an unconditional write does not merely preserve a retyped tag's leftover — it
// INVENTS "payloadFormat":"Json" in the blob of a Sparkplug tag that never had one. The key names
// a decode path that Sparkplug does not use (MqttTagDefinitionFactory routes on the descriptor
// keys and never reads it), so it is a trap for anyone diagnosing a decode problem from the blob.
TagConfigJson.Set(_bag, PayloadFormatKey, Mode == MqttMode.SparkplugB ? null : (object)PayloadFormat);
TagConfigJson.Set(_bag, JsonPathKey, string.IsNullOrWhiteSpace(JsonPath) ? null : JsonPath.Trim());
TagConfigJson.Set(_bag, QosKey, Qos);
TagConfigJson.Set(_bag, RetainSeedKey, RetainSeed);
@@ -410,7 +410,7 @@ public sealed class MqttBrowseSessionTests
a.Length.ShouldBe("-browse-".Length + 8);
var opts = new MqttDriverOptions { ClientId = "otopcua-node1" };
MqttConnection.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
MqttClientOptionsFactory.BuildClientOptions(opts, a).ClientId.ShouldBe("otopcua-node1" + a);
}
[Fact]
@@ -0,0 +1,85 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Pins the MQTT project layering that Gitea #512 restored: <c>.Browser</c> depends on the shared
/// <c>.Contracts</c> + <c>.Transport</c> leaves, never on the runtime <c>.Driver</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is worth a test.</b> <c>.Browser</c> used to reference the runtime driver, and the
/// AdminUI references <c>.Browser</c> — so the AdminUI's build/deploy graph transitively contained
/// the whole MQTT driver runtime plus Core, Polly.Core and Serilog. Nothing failed; the cost was
/// invisible. A reference re-added out of convenience would be invisible in exactly the same way,
/// which is precisely what a guard is for.
/// </para>
/// <para>
/// <b>What it can and cannot catch.</b> It asserts on emitted assembly references, so it fires when
/// the browser actually <em>uses</em> a driver-runtime type — the case that matters. A
/// <c>ProjectReference</c> re-added but never used would not trip it (the compiler emits no
/// reference), so the csproj comment remains the primary record of the decision.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttBrowserLayeringTests
{
private const string BrowserAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser";
private const string DriverRuntimeAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt";
private const string ContractsAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts";
private const string TransportAssembly = "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport";
private static string[] BrowserReferences()
{
var browser = Assembly.Load(BrowserAssembly);
return [.. browser.GetReferencedAssemblies().Select(a => a.Name!)];
}
[Fact]
public void Browser_does_not_reference_the_runtime_driver()
=> BrowserReferences().ShouldNotContain(
DriverRuntimeAssembly,
"the MQTT browser is back to referencing the runtime driver — every other bespoke browser " +
"in this repo (OpcUaClient, Galaxy) depends on its .Contracts leaf only, and this reference " +
"reaches the AdminUI. If shared connection policy is needed, put it in .Transport; if shared " +
"protocol/codec is needed, put it in .Contracts. Do NOT copy the TLS/CA-pin logic.");
[Fact]
public void Browser_reaches_the_shared_leaves_instead()
{
// The positive control. Asserting only the absence above would also pass if the browser stopped
// referencing anything MQTT at all — e.g. if this test loaded the wrong assembly, or the type
// names drifted — so pin what it SHOULD depend on in the same breath.
var references = BrowserReferences();
references.ShouldContain(ContractsAssembly);
references.ShouldContain(TransportAssembly);
}
[Fact]
public void Transport_stays_a_leaf_that_does_not_reach_back_into_the_runtime_driver()
{
// .Transport holds connection POLICY only. If it ever referenced the runtime driver, routing the
// browser through it would restore the coupling this whole change removed, one hop further out.
var references = Assembly.Load(TransportAssembly)
.GetReferencedAssemblies()
.Select(a => a.Name!)
.ToArray();
references.ShouldNotContain(DriverRuntimeAssembly);
references.ShouldNotContain(BrowserAssembly);
}
[Fact]
public void Contracts_stays_transport_free()
=> Assembly.Load(ContractsAssembly)
.GetReferencedAssemblies()
.Select(a => a.Name!)
.ShouldNotContain(
"MQTTnet",
"Driver.Mqtt.Contracts is deliberately transport-free — it carries the Sparkplug wire " +
"schema and codec, and is referenced by ControlPlane, which has no business with a " +
"broker client. MQTTnet-typed connection policy belongs in .Transport.");
}
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Covers <see cref="MqttConnection"/>'s two Task-3 responsibilities: the pure
/// <see cref="MqttConnection.BuildClientOptions"/> options assembly (TLS / CA pin /
/// <see cref="MqttClientOptionsFactory.BuildClientOptions"/> options assembly (TLS / CA pin /
/// credentials / protocol knobs) and the bounded <see cref="MqttConnection.ConnectAsync"/>
/// deadline.
/// </summary>
@@ -189,7 +189,7 @@ public sealed class MqttConnectionTests
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
tls.CertificateValidationHandler.ShouldNotBeNull();
@@ -206,7 +206,7 @@ public sealed class MqttConnectionTests
try
{
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
using var stranger = SelfSignedLeaf(Host);
@@ -232,7 +232,7 @@ public sealed class MqttConnectionTests
try
{
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue();
@@ -259,7 +259,7 @@ public sealed class MqttConnectionTests
try
{
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue();
@@ -330,7 +330,7 @@ public sealed class MqttConnectionTests
ConnectTimeoutSeconds = 7,
};
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311);
built.ClientId.ShouldBe("otopcua-node-1");
@@ -347,12 +347,12 @@ public sealed class MqttConnectionTests
{
var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" };
MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
}
[Fact]
public void BuildClientOptions_NoUsername_SendsNoCredentials()
=> MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
=> MqttClientOptionsFactory.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
.Credentials.ShouldBeNull();
[Fact]
@@ -360,7 +360,7 @@ public sealed class MqttConnectionTests
{
var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" };
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
built.Credentials.ShouldNotBeNull();
built.Credentials.GetUserName(built).ShouldBe("svc");
@@ -1166,7 +1166,7 @@ public sealed class MqttConnectionTests
}
private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts)
=> MqttConnection.BuildClientOptions(opts, clientIdSuffix: null)
=> MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null)
.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1);
@@ -0,0 +1,84 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>
/// Unit tests for <see cref="SparkplugRebirthScope"/> — the operator-typed <c>{group}/{edgeNode}</c>
/// rebirth scope that breaks the birth-driven-tree deadlock (Gitea #514).
/// </summary>
/// <remarks>
/// The rule is deliberately advisory — <c>MqttBrowseSession.ResolveRebirthTargets</c> re-resolves the
/// scope and refuses an unusable one before publishing — so these tests pin the <em>affordance</em>
/// behaviour: an operator must not be able to arm a two-click confirm that cannot succeed, and must
/// not be blocked from a scope the session would accept.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SparkplugRebirthScopeTests
{
[Theory]
[InlineData("OtOpcUaSim/EdgeA")]
[InlineData(" OtOpcUaSim/EdgeA ")]
[InlineData("g/e")]
[InlineData("Plant 1/Edge Node 2")] // spaces are legal inside a Sparkplug id
public void Accepts_a_group_and_edge_node(string scope)
{
SparkplugRebirthScope.Describe(scope).ShouldBeNull();
SparkplugRebirthScope.IsUsable(scope).ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Rejects_a_blank_scope(string? scope)
=> SparkplugRebirthScope.Describe(scope).ShouldNotBeNullOrWhiteSpace();
[Fact]
public void Rejects_a_group_on_its_own()
{
// The session resolves a bare group by enumerating OBSERVED edge nodes, of which an empty tree
// has none — so accepting it here would arm a confirm step that resolves to nothing, in exactly
// the situation this input exists for.
var error = SparkplugRebirthScope.Describe("OtOpcUaSim");
error.ShouldNotBeNullOrWhiteSpace();
error!.ShouldContain("group/edgeNode");
}
[Theory]
[InlineData("g/e/extra")]
[InlineData("g//e")]
[InlineData("/EdgeA")]
[InlineData("OtOpcUaSim/")]
public void Rejects_a_scope_that_is_not_exactly_two_segments(string scope)
=> SparkplugRebirthScope.Describe(scope).ShouldNotBeNullOrWhiteSpace();
[Theory]
[InlineData("g/+")]
[InlineData("#/e")]
[InlineData("g/Edge#")]
public void Rejects_an_mqtt_wildcard(string scope)
{
// A rebirth NCMD is addressed to one edge node on a literal topic; a wildcard names no single
// node, and letting one through would build a topic the broker rejects.
var error = SparkplugRebirthScope.Describe(scope);
error.ShouldNotBeNullOrWhiteSpace();
error!.ShouldContain("wildcard");
}
[Fact]
public void Split_returns_the_trimmed_pair()
{
var (groupId, edgeNodeId) = SparkplugRebirthScope.Split(" OtOpcUaSim/EdgeA ");
groupId.ShouldBe("OtOpcUaSim");
edgeNodeId.ShouldBe("EdgeA");
}
[Fact]
public void Split_refuses_a_scope_Describe_would_reject()
=> Should.Throw<ArgumentException>(() => SparkplugRebirthScope.Split("OtOpcUaSim"));
}
@@ -160,6 +160,58 @@ public sealed class MqttTagConfigModelTests
json.ShouldNotContain("FullName", Case.Sensitive);
}
// ── payloadFormat is Plain-only (Gitea #513) ────────────────────────────────────────────────
//
// payloadFormat (Json/Raw/Scalar) selects a PLAIN-mode decode path. MqttTagDefinitionFactory routes
// a Sparkplug tag on its descriptor keys and never reads it, so a payloadFormat in a Sparkplug blob
// is not mis-binding anything — it is a key that looks like it selects a decode path and does not,
// in a blob the operator reads (the raw-JSON textarea, a config export). It was written because
// PayloadFormat is the one non-nullable Plain typed field, so "write what the field says" INVENTED
// the key rather than preserving one.
[Fact]
public void ToJson_omits_payloadFormat_for_a_sparkplug_tag()
{
var json = MqttTagConfigModel.FromJson(
"""{"groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""").ToJson();
JsonNode.Parse(json)!.AsObject().ContainsKey("payloadFormat").ShouldBeFalse(
"payloadFormat is a Plain-mode decode selector; a Sparkplug tag that never carried one must " +
"not gain it on save. Actual blob: " + json);
}
[Fact]
public void ToJson_still_writes_payloadFormat_for_a_plain_tag()
{
// The negative control for the test above: the omission must be mode-conditional, not a blanket
// removal. A Plain tag's decode path is genuinely selected by this key.
var json = new MqttTagConfigModel { Mode = MqttMode.Plain, Topic = "a/b", PayloadFormat = MqttPayloadFormat.Raw }
.ToJson();
JsonNode.Parse(json)!.AsObject()["payloadFormat"]!.GetValue<string>().ShouldBe("Raw");
}
[Fact]
public void ToJson_clears_a_stale_payloadFormat_when_a_plain_tag_is_retyped_to_sparkplug()
{
// The retype path is how a real blob acquires the stale key. Clearing it is legitimate precisely
// because payloadFormat is a key this editor OWNS — the unknown-key assertions below pin that the
// clearing stays surgical and does not start dropping foreign keys (the issue's explicit warning).
var json = MqttTagConfigModel.FromJson(
"""
{"topic":"a/b","payloadFormat":"Raw","groupId":"Plant1","edgeNodeId":"EdgeA","metricName":"Temp",
"isHistorized":true,"someFutureScalar":"keep me"}
""").ToJson();
var o = JsonNode.Parse(json)!.AsObject();
o.ContainsKey("payloadFormat").ShouldBeFalse();
// Descriptor keys intact, and the keys this editor does not own are untouched.
o["metricName"]!.GetValue<string>().ShouldBe("Temp");
o["isHistorized"]!.GetValue<bool>().ShouldBeTrue();
o["someFutureScalar"]!.GetValue<string>().ShouldBe("keep me");
}
// ── Validate: Sparkplug rules (Task 24) ─────────────────────────────────────────────────────
//
// Mirrors MqttTagDefinitionFactory.FromSparkplugTagConfig exactly: groupId/edgeNodeId/metricName
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
@@ -397,4 +398,128 @@ public sealed class RawBrowseCommitMapperTests
row.Tag.DataType.ShouldBe("Double");
JsonNode.Parse(row.Tag.TagConfig)!.AsObject()["nodeId"]!.GetValue<string>().ShouldBe("ns=2;s=Tag1");
}
// ---- ToRawSegment / name sanitisation (Gitea #509) --------------------------------------------
//
// A tag Name is a RawPath segment and may not contain '/'; a Sparkplug metric name legitimately
// may, and the spec-MANDATORY 'Node Control/Rebirth' does. Committing used to be refused whole.
// The address is a separate field and must survive the coercion untouched.
[Theory]
[InlineData("Node Control/Rebirth", "Node Control_Rebirth")]
[InlineData("Properties/Hardware Make", "Properties_Hardware Make")]
[InlineData("a/b/c", "a_b_c")]
[InlineData(" padded ", "padded")]
[InlineData("plain", "plain")]
public void ToRawSegment_coerces_a_browse_name_into_a_legal_segment(string browseName, string expected)
=> RawBrowseCommitMapper.ToRawSegment(browseName).ShouldBe(expected);
[Theory]
[InlineData("Node Control/Rebirth")]
[InlineData("/leading")]
[InlineData("trailing/")]
[InlineData(" /spaced/ ")]
[InlineData("a/b/c/d/e")]
[InlineData("no-change-needed")]
public void ToRawSegment_output_satisfies_the_RawPaths_segment_rule(string browseName)
{
// Pinned against the AUTHORITY, not against a charset copied into this test. RawPaths.ValidateSegment
// is the rule the import will apply; if it gains a rule the sanitiser does not implement, this fails
// here rather than letting an illegal Name reach ImportTagsAsync.
var segment = RawBrowseCommitMapper.ToRawSegment(browseName);
RawPaths.ValidateSegment(segment).ShouldBeNull(
$"'{browseName}' sanitised to '{segment}', which RawPaths still rejects");
}
[Fact]
public void MapLeaf_commits_a_slashed_sparkplug_metric_with_the_exact_metricName_preserved()
{
var row = RawBrowseCommitMapper.MapLeaf(
driverType: DriverTypeNames.Mqtt,
fullName: "OtOpcUaSim/EdgeA::Node Control/Rebirth",
browseName: "Node Control/Rebirth",
driverDataType: "Boolean",
defaultDataType: "String",
groupPrefix: null,
folderPath: null,
createGroups: false,
addressFields: new Dictionary<string, string>
{
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
[MqttTagConfigKeys.MetricName] = "Node Control/Rebirth",
});
// The Name is coerced...
row.Tag.Name.ShouldBe("Node Control_Rebirth");
RawPaths.ValidateSegment(row.Tag.Name).ShouldBeNull();
// ...and the binding address is NOT. This is the whole point: the two fields already existed
// separately, and only the Name derivation conflated them.
var o = JsonNode.Parse(row.Tag.TagConfig)!.AsObject();
o[MqttTagConfigKeys.MetricName]!.GetValue<string>().ShouldBe("Node Control/Rebirth");
o[MqttTagConfigKeys.GroupId]!.GetValue<string>().ShouldBe("OtOpcUaSim");
o[MqttTagConfigKeys.EdgeNodeId]!.GetValue<string>().ShouldBe("EdgeA");
}
// ---- DescribeNameCollisions ------------------------------------------------------------------
[Fact]
public void DescribeNameCollisions_refuses_two_metrics_that_sanitise_onto_one_name()
{
// 'A/B' and 'A_B' are distinct metrics that both land on 'A_B'. Silently merging them would be
// worse than the refusal #509 replaced — one tag bound to whichever address survived.
var names = new[] { "A/B", "A_B" };
var rows = names
.Select(n => RawBrowseCommitMapper.MapLeaf(
DriverTypeNames.Mqtt, $"g/e::{n}", n, "Float32", "Double", null, null, false,
new Dictionary<string, string>
{
[MqttTagConfigKeys.GroupId] = "g",
[MqttTagConfigKeys.EdgeNodeId] = "e",
[MqttTagConfigKeys.MetricName] = n,
}))
.ToList();
var errors = RawBrowseCommitMapper.DescribeNameCollisions(names, rows);
errors.Count.ShouldBe(1);
// The message must name the specific pair — an anonymous batch failure is what made the original
// refusal hard to act on.
errors[0].ShouldContain("A_B");
errors[0].ShouldContain("'A/B'");
}
[Fact]
public void DescribeNameCollisions_allows_the_same_metric_name_under_two_different_groups()
{
// Uniqueness is per target group, so this is NOT a collision. Without the scoping, mirroring a
// browse tree with a repeated metric name under two devices would be refused wholesale.
var rows = new[]
{
RawBrowseCommitMapper.MapLeaf(
DriverTypeNames.OpcUaClient, "ns=2;s=1", "Temp", null, "Double",
groupPrefix: "DeviceA", folderPath: null, createGroups: false),
RawBrowseCommitMapper.MapLeaf(
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temp", null, "Double",
groupPrefix: "DeviceB", folderPath: null, createGroups: false),
};
RawBrowseCommitMapper.DescribeNameCollisions(new[] { "Temp", "Temp" }, rows).ShouldBeEmpty();
}
[Fact]
public void DescribeNameCollisions_passes_a_clean_selection()
=> RawBrowseCommitMapper.DescribeNameCollisions(
new[] { "Node Control/Rebirth", "Temperature" },
new[]
{
RawBrowseCommitMapper.MapLeaf(
DriverTypeNames.OpcUaClient, "ns=2;s=1", "Node Control/Rebirth", null, "Double",
null, null, false),
RawBrowseCommitMapper.MapLeaf(
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temperature", null, "Double",
null, null, false),
}).ShouldBeEmpty();
}