From 0cf6722d7bcd7861a3e1fe6d6c84b8202559aa59 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 11:22:27 +0200 Subject: [PATCH 1/9] Add the HTTP, Modbus, OPC UA and MQTT binding executors Adds the concrete transports for the WoT binding runtime introduced by the planner layer: HTTP, Modbus TCP, OPC UA and MQTT. The OPC UA executor supports Read, Write, native data change observation, Method invocation and Event subscription, including portable nsu= NodeIds. The Modbus client reconnects a faulted connection on its next transaction and honours the standard modv:pollingTime interval, with backoff left to the polling subscription so there is only one retry loop. MQTT enables TLS for mqtts, defaults to port 8883, and resolves credentials and trust through the injected providers. CoAP, BACnet, PROFINET and LoRaWAN remain planner and validation only; they report their non-executable capability explicitly rather than failing at run time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../MqttWotBindingChannel.cs | 323 +++++++ .../MqttWotBindingExecutor.cs | 101 +++ .../MqttWotBindingOptions.cs | 74 ++ .../MqttWotConnection.cs | 206 +++++ .../NugetREADME.md | 17 + .../Opc.Ua.WotCon.Bindings.Mqtt.csproj | 33 + .../OpcUaMqttWotBindingBuilderExtensions.cs | 61 ++ .../Properties/AssemblyInfo.cs | 32 + .../Http/HttpStatusMapper.cs | 62 ++ .../Http/HttpWotBindingChannel.cs | 508 +++++++++++ .../Http/HttpWotBindingExecutor.cs | 128 +++ .../Http/HttpWotBindingOptions.cs | 112 +++ .../OpcUaHttpWotBindingBuilderExtensions.cs | 61 ++ .../Modbus/ModbusAddressing.cs | 371 ++++++++ .../Modbus/ModbusDataConverter.cs | 180 ++++ .../Modbus/ModbusException.cs | 76 ++ .../Modbus/ModbusTcpClient.cs | 467 ++++++++++ .../Modbus/ModbusWotBindingChannel.cs | 319 +++++++ .../Modbus/ModbusWotBindingExecutor.cs | 98 +++ .../Modbus/ModbusWotBindingOptions.cs | 72 ++ .../OpcUaModbusWotBindingBuilderExtensions.cs | 61 ++ .../OpcUaTargetWotBindingBuilderExtensions.cs | 65 ++ .../OpcUa/OpcUaWotBindingChannel.cs | 553 ++++++++++++ .../OpcUa/OpcUaWotBindingExecutor.cs | 89 ++ .../OpcUa/OpcUaWotBindingOptions.cs | 74 ++ .../ExecutorUnitTests.cs | 117 +++ .../HttpCredentialResolutionTests.cs | 176 ++++ .../HttpRedirectSecurityTests.cs | 818 +++++++++++++++++ .../HttpStatusMapperTests.cs | 168 ++++ .../HttpWotBindingChannelTests.cs | 361 ++++++++ .../HttpWotExecutorTests.cs | 216 +++++ .../ModbusDataConverterAdditionalTests.cs | 242 +++++ .../ModbusDataConverterTests.cs | 127 +++ .../ModbusTcpClientHardeningTests.cs | 407 +++++++++ .../ModbusWotBindingChannelTests.cs | 831 ++++++++++++++++++ .../ModbusWotExecutorHardeningTests.cs | 436 +++++++++ .../ModbusWotExecutorTests.cs | 356 ++++++++ .../MqttWotBindingChannelTests.cs | 468 ++++++++++ .../MqttWotConnectionTests.cs | 180 ++++ .../MqttWotExecutorTests.cs | 165 ++++ .../Opc.Ua.WotCon.Bindings.Tests.csproj | 8 +- .../OpcUaWotBindingBuilderExtensionsTests.cs | 160 +++- .../OpcUaWotBindingChannelTests.cs | 445 ++++++++++ .../OpcUaWotExecutorTests.cs | 384 ++++++++ .../Support/TestHttpServer.cs | 298 +++++++ .../Support/TestModbusServer.cs | 300 +++++++ 46 files changed, 10804 insertions(+), 2 deletions(-) create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs new file mode 100644 index 0000000000..d509396fe9 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs @@ -0,0 +1,323 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; +using MQTTnet.Exceptions; +using MQTTnet.Protocol; + +namespace Opc.Ua.WotCon.Bindings.Mqtt +{ + /// + /// A live MQTT binding channel that publishes writes / actions and subscribes + /// for reads / observes / events per the pinned MQTT binding, with bounded QoS, + /// payload sizes and read timeouts. + /// + internal sealed class MqttWotBindingChannel : IWotBindingChannel + { + public MqttWotBindingChannel( + IMqttClient client, + WotCompiledForm form, + WotExecutorContext context, + MqttWotBindingOptions options) + { + m_client = client; + Form = form; + m_options = options; + m_topic = form.Addressing.Target; + m_qos = ParseQos(form.Addressing.Metadata); + m_retain = ParseBool(form.Addressing.Metadata, "retain"); + context.Codecs.TrySelect(form.Payload.ContentType, out m_codec); + m_client.ApplicationMessageReceivedAsync += OnMessageAsync; + } + + public WotCompiledForm Form { get; } + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Interlocked.Exchange(ref m_pendingRead, completion); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_options.ReadTimeout); + try + { + await SubscribeAsync(cancellationToken).ConfigureAwait(false); + byte[] payload = await completion.Task.WaitAsync(timeout.Token).ConfigureAwait(false); + WotDecodeResult decoded = m_codec.Decode(payload, Form.Payload); + if (!decoded.Success) + { + return new WotReadResult( + StatusCodes.BadDecodingError, + DataValue.FromStatusCode(StatusCodes.BadDecodingError), + decoded.Error); + } + return new WotReadResult( + StatusCodes.Good, new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotReadResult( + StatusCodes.BadTimeout, DataValue.FromStatusCode(StatusCodes.BadTimeout), + "Timed out waiting for an MQTT message."); + } + finally + { + Interlocked.CompareExchange(ref m_pendingRead, null, completion); + if (!m_observing) + { + await TryUnsubscribeAsync().ConfigureAwait(false); + } + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, Form.Payload); + if (!encoded.Success) + { + return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error); + } + try + { + await PublishAsync(encoded.Data.ToArray(), cancellationToken).ConfigureAwait(false); + return new WotWriteResult(StatusCodes.Good); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotWriteResult(StatusCodes.BadTimeout, "The MQTT publish timed out."); + } + catch (MqttCommunicationException ex) + { + return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); + } + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + byte[] payload = []; + if (inputs is { Count: > 0 }) + { + WotEncodeResult encoded = m_codec.Encode(inputs[0], Form.Payload); + if (!encoded.Success) + { + return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error); + } + payload = encoded.Data.ToArray(); + } + try + { + await PublishAsync(payload, cancellationToken).ConfigureAwait(false); + return new WotInvokeResult(StatusCodes.Good, []); + } + catch (MqttCommunicationException ex) + { + return new WotInvokeResult(StatusCodes.BadCommunicationError, null, ex.Message); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public async ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + lock (m_lock) + { + m_handlers.Add(onNotification); + m_observing = true; + } + await SubscribeAsync(cancellationToken).ConfigureAwait(false); + return new HandlerSubscription(this, onNotification); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return ObserveAsync(onEvent, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + m_client.ApplicationMessageReceivedAsync -= OnMessageAsync; + try + { + await m_client.DisconnectAsync(new MqttClientDisconnectOptionsBuilder().Build()) + .ConfigureAwait(false); + } + catch (MqttCommunicationException) + { + // Ignore disconnect faults during teardown. + } + m_client.Dispose(); + } + + private Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args) + { + byte[] payload = ToArray(args.ApplicationMessage.Payload); + TaskCompletionSource? pending = Interlocked.Exchange(ref m_pendingRead, null); + pending?.TrySetResult(payload); + + Action[] handlers; + lock (m_lock) + { + if (m_handlers.Count == 0) + { + return Task.CompletedTask; + } + handlers = [.. m_handlers]; + } + WotDecodeResult decoded = m_codec.Decode(payload, Form.Payload); + if (decoded.Success) + { + var notification = new WotNotification( + new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + foreach (Action handler in handlers) + { + handler(notification); + } + } + return Task.CompletedTask; + } + + private async Task SubscribeAsync(CancellationToken cancellationToken) + { + MqttClientSubscribeOptions options = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(m_topic, m_qos) + .Build(); + await m_client.SubscribeAsync(options, cancellationToken).ConfigureAwait(false); + } + + private async Task TryUnsubscribeAsync() + { + try + { + MqttClientUnsubscribeOptions options = new MqttClientUnsubscribeOptionsBuilder() + .WithTopicFilter(m_topic) + .Build(); + await m_client.UnsubscribeAsync(options).ConfigureAwait(false); + } + catch (MqttCommunicationException) + { + // Ignore unsubscribe faults. + } + } + + private async Task PublishAsync(byte[] payload, CancellationToken cancellationToken) + { + MqttApplicationMessage message = new MqttApplicationMessageBuilder() + .WithTopic(m_topic) + .WithPayload(payload) + .WithQualityOfServiceLevel(m_qos) + .WithRetainFlag(m_retain) + .Build(); + await m_client.PublishAsync(message, cancellationToken).ConfigureAwait(false); + } + + private void RemoveHandler(Action handler) + { + lock (m_lock) + { + m_handlers.Remove(handler); + m_observing = m_handlers.Count > 0; + } + } + + private static byte[] ToArray(ReadOnlySequence payload) + { + return payload.IsEmpty ? [] : payload.ToArray(); + } + + private static MqttQualityOfServiceLevel ParseQos( + System.Collections.Immutable.ImmutableDictionary metadata) + { + if (metadata.TryGetValue("qos", out string? value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int qos)) + { + return qos switch + { + 1 => MqttQualityOfServiceLevel.AtLeastOnce, + 2 => MqttQualityOfServiceLevel.ExactlyOnce, + _ => MqttQualityOfServiceLevel.AtMostOnce + }; + } + return MqttQualityOfServiceLevel.AtMostOnce; + } + + private static bool ParseBool( + System.Collections.Immutable.ImmutableDictionary metadata, string key) + { + return metadata.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) && result; + } + + private sealed class HandlerSubscription : IWotSubscription + { + public HandlerSubscription(MqttWotBindingChannel channel, Action handler) + { + m_channel = channel; + m_handler = handler; + } + + public WotCompiledForm Form => m_channel.Form; + + public async ValueTask DisposeAsync() + { + m_channel.RemoveHandler(m_handler); + if (!m_channel.m_observing) + { + await m_channel.TryUnsubscribeAsync().ConfigureAwait(false); + } + } + + private readonly MqttWotBindingChannel m_channel; + private readonly Action m_handler; + } + + private readonly IMqttClient m_client; + private readonly MqttWotBindingOptions m_options; + private readonly string m_topic; + private readonly MqttQualityOfServiceLevel m_qos; + private readonly bool m_retain; + private readonly IWotPayloadCodec m_codec; + private readonly Lock m_lock = new(); + private readonly List> m_handlers = []; + private volatile bool m_observing; + private TaskCompletionSource? m_pendingRead; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs new file mode 100644 index 0000000000..9ac6d628f2 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs @@ -0,0 +1,101 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Mqtt +{ + /// + /// Executes MQTT WoT binding forms compiled by the + /// by opening a per-form MQTT connection using + /// the repository's MQTTnet infrastructure. + /// + public sealed class MqttWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new MQTT executor. + /// + public MqttWotBindingExecutor(MqttWotBindingOptions? options = null) + { + m_options = options ?? new MqttWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.mqtt", "1.0-ed", MqttBindingPlanner.BindingUri, "W3C WoT MQTT Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client is owned by the returned channel, which disposes it.")] + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + string suffix = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + string clientId = $"{m_options.ClientIdPrefix}-{suffix.AsSpan(0, 12)}"; + // Resolve credentials / trust and build the options first, so a + // fail-closed rejection throws before any client is created. + MqttWotConnection.MqttWotConnectPlan plan = await MqttWotConnection + .PrepareAsync(form, context, m_options, clientId, cancellationToken).ConfigureAwait(false); + IMqttClient client = m_options.ClientFactory?.Invoke() + ?? new MqttClientFactory().CreateMqttClient(); + try + { + await client.ConnectAsync(plan.Options, cancellationToken).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + return new MqttWotBindingChannel(client, form, context, m_options); + } + + private readonly MqttWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs new file mode 100644 index 0000000000..9160837188 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using MQTTnet; + +namespace Opc.Ua.WotCon.Bindings.Mqtt +{ + /// + /// Options for the MQTT WoT binding executor. + /// + public sealed class MqttWotBindingOptions + { + /// + /// Gets or sets a factory that supplies an unconnected MQTT client. When + /// null the executor creates one from the MQTTnet client factory. + /// + public Func? ClientFactory { get; set; } + + /// + /// Gets or sets the client id prefix used for connections. + /// + public string ClientIdPrefix { get; set; } = "opcua-wot"; + + /// + /// Gets or sets the timeout awaiting a message during a read. + /// + public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Gets or sets whether username / password credentials may be sent over a + /// plaintext mqtt:// connection. When false (the default) the + /// executor fails closed rather than leaking credentials in clear text; use + /// an mqtts:// href instead. Set to true only for explicitly + /// accepted plaintext deployments. + /// + public bool AllowCredentialsOverPlaintext { get; set; } + + /// + /// Gets or sets whether the broker's TLS certificate is validated for an + /// mqtts:// connection. When true (the default) the platform + /// trust store, or the trust anchors resolved through the credential + /// provider, must validate the broker certificate. Set to false only + /// for explicitly accepted test deployments. + /// + public bool ValidateServerCertificate { get; set; } = true; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs new file mode 100644 index 0000000000..45f77f1c2d --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs @@ -0,0 +1,206 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; + +namespace Opc.Ua.WotCon.Bindings.Mqtt +{ + /// + /// Builds the transport-security-aware MQTT client options for a compiled WoT + /// form. The mqtts scheme always enables TLS (defaulting to port 8883) + /// and applies the trust anchors and client certificate resolved through the + /// credential provider; the mqtt scheme stays explicit plaintext + /// (port 1883). The builder fails closed: a declared security scheme that + /// resolves to no credential, or username / password material that would be + /// sent over a plaintext connection, throws instead of downgrading silently. + /// + internal static class MqttWotConnection + { + internal const int DefaultTlsPort = 8883; + internal const int DefaultPlaintextPort = 1883; + + /// + /// The result of preparing an MQTT connection for a compiled form. + /// + internal sealed class MqttWotConnectPlan + { + public MqttWotConnectPlan( + MqttClientOptions options, + string host, + int port, + bool useTls, + bool hasCredentials) + { + Options = options; + Host = host; + Port = port; + UseTls = useTls; + HasCredentials = hasCredentials; + } + + /// + /// Gets the built MQTT client options. + /// + public MqttClientOptions Options { get; } + + /// + /// Gets the resolved broker host. + /// + public string Host { get; } + + /// + /// Gets the resolved broker port. + /// + public int Port { get; } + + /// + /// Gets whether TLS is enabled for the connection. + /// + public bool UseTls { get; } + + /// + /// Gets whether username / password credentials were applied. + /// + public bool HasCredentials { get; } + } + + /// + /// Resolves credentials / trust through the provider and builds the MQTT + /// client options for the supplied compiled form, enforcing the transport + /// security rules described on the type. + /// + /// + public static async ValueTask PrepareAsync( + WotCompiledForm form, + WotExecutorContext context, + MqttWotBindingOptions options, + string clientId, + CancellationToken cancellationToken) + { + bool useTls = string.Equals(form.Endpoint.Scheme, "mqtts", StringComparison.OrdinalIgnoreCase); + string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!; + int port = form.Endpoint.Port > 0 + ? form.Endpoint.Port + : (useTls ? DefaultTlsPort : DefaultPlaintextPort); + + WotCredential? credential = await ResolveRequiredCredentialAsync(form, context, cancellationToken) + .ConfigureAwait(false); + + MqttClientOptionsBuilder builder = new MqttClientOptionsBuilder() + .WithTcpServer(host, port) + .WithClientId(clientId); + + string? username = null; + byte[] password = []; + if (credential is not null) + { + if (credential.Properties.TryGetValue("username", out string? user)) + { + username = user; + } + if (credential.Properties.TryGetValue("password", out string? pass) && pass is not null) + { + password = Encoding.UTF8.GetBytes(pass); + } + } + + bool hasCredentials = !string.IsNullOrEmpty(username); + if (hasCredentials && !useTls && !options.AllowCredentialsOverPlaintext) + { + throw new InvalidOperationException( + "MQTT username / password credentials require TLS. Use an mqtts:// href, or set " + + "MqttWotBindingOptions.AllowCredentialsOverPlaintext for explicitly accepted " + + "plaintext deployments."); + } + if (hasCredentials) + { + builder = builder.WithCredentials(username, password); + } + + if (useTls) + { + X509Certificate2? clientCertificate = credential?.ClientCertificate; + ImmutableArray trust = credential is null + ? [] + : credential.TrustedCertificates; + bool validate = options.ValidateServerCertificate; + builder = builder.WithTlsOptions(tls => + { + tls.UseTls().WithAllowUntrustedCertificates(!validate); + if (clientCertificate is not null) + { + tls.WithClientCertificates([clientCertificate]); + } + if (!trust.IsDefaultOrEmpty) + { + var chain = new X509Certificate2Collection(); + foreach (X509Certificate2 anchor in trust) + { + chain.Add(anchor); + } + tls.WithTrustChain(chain); + } + }); + } + + return new MqttWotConnectPlan(builder.Build(), host, port, useTls, hasCredentials); + } + + private static async ValueTask ResolveRequiredCredentialAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken) + { + if (form.Security.IsDefaultOrEmpty) + { + return null; + } + foreach (WotCredentialReference reference in form.Security) + { + if (reference.Scheme == WotSecurityScheme.NoSecurity) + { + continue; + } + // Fail closed: a form that declares a security scheme must have + // its credential resolved or the connection is refused rather + // than silently opened without the required authentication. + return (WotCredential?)(await context.Credentials + .ResolveAsync(reference, cancellationToken).ConfigureAwait(false) ?? + throw new InvalidOperationException( + $"The MQTT binding requires a credential for security scheme '{reference.SchemeName}' " + + "but the credential provider resolved none; refusing to connect.")); + } + return null; + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md b/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md new file mode 100644 index 0000000000..44372ee63c --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md @@ -0,0 +1,17 @@ +# OPC UA WoT Connectivity — MQTT Executor + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings.Mqtt` executes MQTT WoT Connectivity binding forms compiled by the MQTT planner in `Opc.Ua.WotCon.Bindings`. + +It uses the repository's MQTTnet infrastructure (kept out of the core model assembly) to implement publish / subscribe / RPC patterns per the pinned MQTT binding, with bounded QoS, topic, payload sizes and timeouts. The MQTT client factory is injectable. + +## Transport security + +- An `mqtts://` href always enables TLS and defaults to port 8883; an `mqtt://` href stays explicit plaintext (port 1883). There is no silent plaintext downgrade. +- Username / password credentials, the TLS client certificate and the TLS trust anchors are resolved through the registered `IWotCredentialProvider`. A form that declares a security scheme fails closed (the connection is refused) when the provider resolves no credential. +- Username / password credentials are refused over a plaintext `mqtt://` connection unless `MqttWotBindingOptions.AllowCredentialsOverPlaintext` is set for an explicitly accepted plaintext deployment. `ValidateServerCertificate` controls broker-certificate validation for `mqtts://`. + +Register it with `builder.AddMqttWotBinding(...)`. + +The package targets `net8.0`, `net9.0`, and `net10.0`. The MQTT planner and common binding contracts are supplied by `OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings`. + +See the [WoT protocol bindings guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/WotBindings.md). diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj b/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj new file mode 100644 index 0000000000..b2ee1a9c28 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj @@ -0,0 +1,33 @@ + + + $(AssemblyPrefix).WotCon.Bindings.Mqtt + net8.0;net9.0;net10.0 + $(CustomTestTarget) + $(CustomTestTarget) + true + $(PackagePrefix).Opc.Ua.WotCon.Bindings.Mqtt + Opc.Ua.WotCon.Bindings.Mqtt + enable + MQTT protocol executor for OPC UA WoT Connectivity binding forms + true + NugetREADME.md + true + true + + + + + + + $(PackageId).Debug + + + + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..9bf43caaa7 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Opc.Ua; +using Opc.Ua.WotCon.Bindings.Mqtt; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the MQTT WoT binding + /// executor alongside the shipped planner binders. + /// + public static class OpcUaMqttWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the MQTT executor, so + /// MQTT binding forms are validated, compiled and executable. + /// + /// + public static IOpcUaBuilder AddMqttWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new MqttWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new MqttWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs new file mode 100644 index 0000000000..ca075e53be --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Net; + +namespace Opc.Ua.WotCon.Bindings.Http +{ + /// + /// Maps HTTP status codes to OPC UA values. + /// + internal static class HttpStatusMapper + { + public static StatusCode Map(HttpStatusCode status) + { + int code = (int)status; + if (code is >= 200 and < 300) + { + return StatusCodes.Good; + } + return status switch + { + HttpStatusCode.BadRequest => StatusCodes.BadInvalidArgument, + HttpStatusCode.Unauthorized => StatusCodes.BadUserAccessDenied, + HttpStatusCode.Forbidden => StatusCodes.BadUserAccessDenied, + HttpStatusCode.NotFound => StatusCodes.BadNodeIdUnknown, + HttpStatusCode.MethodNotAllowed => StatusCodes.BadNotSupported, + HttpStatusCode.RequestTimeout => StatusCodes.BadTimeout, + HttpStatusCode.Conflict => StatusCodes.BadInvalidState, + HttpStatusCode.NotImplemented => StatusCodes.BadNotImplemented, + HttpStatusCode.ServiceUnavailable => StatusCodes.BadServerHalted, + HttpStatusCode.GatewayTimeout => StatusCodes.BadTimeout, + _ => code >= 500 ? StatusCodes.BadInternalError : StatusCodes.BadUnexpectedError + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs new file mode 100644 index 0000000000..7a87bcb0a3 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs @@ -0,0 +1,508 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Http +{ + /// + /// A live HTTP binding channel. It executes read (GET), write (PUT/method), + /// action (POST/method), observe and event operations with bounded timeouts + /// and payload sizes, cooperative cancellation, HTTP-to- + /// mapping and credential-provider-driven authentication. + /// + internal sealed class HttpWotBindingChannel : IWotBindingChannel + { + public HttpWotBindingChannel( + HttpClient client, + bool ownsClient, + bool manualRedirects, + ImmutableArray> defaultHeaders, + WotCompiledForm form, + WotExecutorContext context, + HttpWotBindingOptions options) + { + m_client = client; + m_ownsClient = ownsClient; + m_manualRedirects = manualRedirects; + m_defaultHeaders = defaultHeaders; + Form = form; + m_context = context; + m_options = options; + context.Codecs.TrySelect(form.Payload.ContentType, out m_codec); + m_baseTarget = form.Addressing.Target; + } + + public WotCompiledForm Form { get; } + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + (StatusCode status, byte[] body, string? error) = + await SendAsync(HttpMethod.Get, null, cancellationToken).ConfigureAwait(false); + if (!StatusCode.IsGood(status)) + { + return new WotReadResult(status, DataValue.FromStatusCode(status), error); + } + WotDecodeResult decoded = m_codec.Decode(body, Form.Payload); + if (!decoded.Success) + { + return new WotReadResult( + StatusCodes.BadDecodingError, + DataValue.FromStatusCode(StatusCodes.BadDecodingError), + decoded.Error); + } + return new WotReadResult( + StatusCodes.Good, + new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, Form.Payload); + if (!encoded.Success) + { + return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error); + } + HttpMethod method = ResolveMethod("PUT"); + (StatusCode status, _, string? error) = + await SendAsync(method, encoded.Data, cancellationToken).ConfigureAwait(false); + return new WotWriteResult(status, error); + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + ReadOnlyMemory? content = null; + if (inputs is { Count: > 0 }) + { + WotEncodeResult encoded = m_codec.Encode(inputs[0], Form.Payload); + if (!encoded.Success) + { + return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error); + } + content = encoded.Data; + } + HttpMethod method = ResolveMethod("POST"); + (StatusCode status, byte[] body, string? error) = + await SendAsync(method, content, cancellationToken).ConfigureAwait(false); + if (!StatusCode.IsGood(status)) + { + return new WotInvokeResult(status, null, error); + } + if (body.Length == 0) + { + return new WotInvokeResult(StatusCodes.Good, []); + } + WotDecodeResult decoded = m_codec.Decode(body, Form.Payload); + var output = new DataValue( + decoded.Success ? decoded.Value : Variant.Null, + decoded.Success ? StatusCodes.Good : StatusCodes.BadDecodingError, + DateTimeUtc.Now, DateTimeUtc.Now); + return new WotInvokeResult(StatusCodes.Good, [output]); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription( + Form, + async token => + { + WotReadResult result = await ReadAsync(token).ConfigureAwait(false); + // A mapped failure carries its bad StatusCode on the value, so surface it + // rather than leaving the last good value in place, and report the poll as + // unhealthy so the retry policy backs off instead of hammering the asset. + onNotification(new WotNotification(result.Value)); + return result.Success; + }, + m_options.ObserveInterval, + // A transient poll fault is reported as a Bad-status notification + // so consumers observe the fault without the poll loop faulting. + onError: _ => onNotification(new WotNotification( + DataValue.FromStatusCode(StatusCodes.BadCommunicationError))), + retryPolicy: m_options.RetryPolicy); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return ObserveAsync(onEvent, cancellationToken); + } + + public ValueTask DisposeAsync() + { + if (m_ownsClient) + { + m_client.Dispose(); + } + return default; + } + + private HttpMethod ResolveMethod(string fallback) + { + string method = string.IsNullOrEmpty(Form.OperationInfo.Method) + ? fallback : Form.OperationInfo.Method; + return new HttpMethod(method.ToUpperInvariant()); + } + + private async ValueTask<(StatusCode Status, byte[] Body, string? Error)> SendAsync( + HttpMethod method, ReadOnlyMemory? content, CancellationToken cancellationToken) + { + await EnsureCredentialAsync(cancellationToken).ConfigureAwait(false); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_context.Bounds.DefaultTimeout); + try + { + if (!Uri.TryCreate(m_baseTarget, UriKind.Absolute, out Uri? current) || current is null) + { + return (StatusCodes.BadInvalidArgument, Array.Empty(), + "The HTTP target is not a valid absolute URI."); + } + Uri origin = current; + HttpMethod currentMethod = method; + ReadOnlyMemory? currentContent = content; + int redirectsRemaining = m_manualRedirects ? Math.Max(0, m_options.MaxAutomaticRedirects) : 0; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + while (true) + { + visited.Add(current.AbsoluteUri); + // Custom header / query credentials are only applied while the + // request stays on the original origin; a cross-origin redirect + // drops them so they never leak to a different host. + bool sameOrigin = IsSameOrigin(origin, current); + Uri requestUri = sameOrigin ? AppendCredentialQuery(current) : current; + HopResult hop = await SendOnceAsync( + currentMethod, requestUri, sameOrigin, currentContent, timeout.Token).ConfigureAwait(false); + + if (hop.Redirect is null) + { + return (hop.Status, hop.Body, hop.Error); + } + + if (redirectsRemaining <= 0) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), + "The HTTP redirect limit was exceeded."); + } + Uri? next = ResolveRedirectTarget(current, hop.Location, out string? redirectError); + if (next is null) + { + return (StatusCodes.BadSecurityChecksFailed, Array.Empty(), redirectError); + } + if (visited.Contains(next.AbsoluteUri)) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), + "The HTTP redirect chain contains a loop."); + } + redirectsRemaining--; + // 303 (and, per browser convention, 301/302) turn the follow-up + // request into a bodyless GET; 307/308 preserve method and body. + if (hop.Redirect is System.Net.HttpStatusCode.MovedPermanently or + System.Net.HttpStatusCode.Found or System.Net.HttpStatusCode.SeeOther) + { + currentMethod = HttpMethod.Get; + currentContent = null; + } + current = next; + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (StatusCodes.BadTimeout, Array.Empty(), "The HTTP request timed out."); + } + catch (HttpRequestException ex) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), ex.Message); + } + catch (InvalidOperationException ex) + { + return (StatusCodes.BadEncodingLimitsExceeded, Array.Empty(), ex.Message); + } + } + + /// + /// The outcome of a single request hop: either a terminal result or a redirect. + /// + private readonly struct HopResult + { + private HopResult( + System.Net.HttpStatusCode? redirect, Uri? location, + StatusCode status, byte[] body, string? error) + { + Redirect = redirect; + Location = location; + Status = status; + Body = body; + Error = error; + } + + public System.Net.HttpStatusCode? Redirect { get; } + + public Uri? Location { get; } + + public StatusCode Status { get; } + + public byte[] Body { get; } + + public string? Error { get; } + + public static HopResult Terminal(StatusCode status, byte[] body, string? error) + { + return new HopResult(null, null, status, body, error); + } + + public static HopResult RedirectTo(System.Net.HttpStatusCode redirect, Uri? location) + { + return new HopResult(redirect, location, StatusCodes.Good, [], null); + } + } + + private async Task SendOnceAsync( + HttpMethod method, Uri requestUri, bool sameOrigin, + ReadOnlyMemory? content, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, requestUri); + ApplyHeaders(request, sameOrigin); + if (content is { } body && method != HttpMethod.Get && method != HttpMethod.Head) + { + var byteContent = new ByteArrayContent(body.ToArray()); + if (!string.IsNullOrEmpty(Form.Payload.ContentType)) + { + byteContent.Headers.TryAddWithoutValidation("Content-Type", Form.Payload.ContentType); + } + request.Content = byteContent; + } + + using HttpResponseMessage response = await m_client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (m_manualRedirects && IsRedirect(response.StatusCode)) + { + return HopResult.RedirectTo(response.StatusCode, response.Headers.Location); + } + + StatusCode status = HttpStatusMapper.Map(response.StatusCode); + if (!response.IsSuccessStatusCode) + { + return HopResult.Terminal(status, [], + $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + byte[] payload = await ReadBoundedAsync(response, cancellationToken).ConfigureAwait(false); + return HopResult.Terminal(StatusCodes.Good, payload, null); + } + + private static bool IsRedirect(System.Net.HttpStatusCode status) + { + return status is System.Net.HttpStatusCode.MovedPermanently or + System.Net.HttpStatusCode.Found or + System.Net.HttpStatusCode.SeeOther or + System.Net.HttpStatusCode.TemporaryRedirect or + System.Net.HttpStatusCode.PermanentRedirect; + } + + private Uri? ResolveRedirectTarget(Uri current, Uri? location, out string? error) + { + error = null; + if (location is null) + { + error = "The HTTP redirect response carried no Location header."; + return null; + } + if (!location.IsAbsoluteUri) + { + location = new Uri(current, location); + } + if (!string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(location.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + error = $"The HTTP redirect targets a disallowed scheme '{location.Scheme}'."; + return null; + } + if (string.Equals(current.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !m_options.AllowInsecureRedirectDowngrade) + { + error = "The HTTP redirect downgrades https to http, which is refused."; + return null; + } + return location; + } + + private static bool IsSameOrigin(Uri a, Uri b) + { + return string.Equals(a.Scheme, b.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) && + a.Port == b.Port; + } + + private async Task ReadBoundedAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + byte[] chunk = new byte[8192]; + int max = m_context.Bounds.MaxPayloadBytes; + int total = 0; + int read; + while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken) + .ConfigureAwait(false)) > 0) + { + total += read; + if (total > max) + { + throw new InvalidOperationException( + $"The HTTP response exceeds the maximum payload size of {max} bytes."); + } + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } + + private async ValueTask EnsureCredentialAsync(CancellationToken cancellationToken) + { + Task task; + lock (m_credentialLock) + { + // Start (or reuse) a single shared resolution. Concurrent callers + // all await the same task, so the resolved credential and the + // effective target are published exactly once and no request is + // ever sent before that state is ready. + task = m_credentialTask ??= ResolveCredentialAsync(cancellationToken); + } + try + { + await task.ConfigureAwait(false); + } + catch + { + // Failure retry policy: a failed (or cancelled) resolution is not + // cached, so the next request re-attempts resolution instead of + // being permanently wedged on the fault. + lock (m_credentialLock) + { + if (ReferenceEquals(m_credentialTask, task)) + { + m_credentialTask = null; + } + } + throw; + } + } + + private async Task ResolveCredentialAsync(CancellationToken cancellationToken) + { + WotCredential? credential = null; + if (!Form.Security.IsEmpty) + { + credential = await m_context.Credentials + .ResolveAsync(Form.Security[0], cancellationToken).ConfigureAwait(false); + } + // Publish the resolved credential only after resolution has completed. A + // caller reads m_credential in SendAsync only after awaiting the shared + // task, so it can never observe a half-initialized state or send a + // request without the resolved credential applied. + m_credential = credential; + } + + private void ApplyHeaders(HttpRequestMessage request, bool includeCredentials) + { + // A cross-origin redirect must not carry any custom (potentially + // credential-bearing) header, so both the caller's default headers and + // the resolved credential headers are only applied on the original + // origin. + if (!includeCredentials) + { + return; + } + foreach (KeyValuePair header in m_defaultHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + if (m_credential is { } credential) + { + foreach (KeyValuePair header in credential.Headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + private Uri AppendCredentialQuery(Uri target) + { + WotCredential? credential = m_credential; + if (credential is null || credential.QueryParameters.Count == 0) + { + return target; + } + var query = new StringBuilder(); + foreach (KeyValuePair parameter in credential.QueryParameters) + { + if (query.Length > 0) + { + query.Append('&'); + } + query.Append(Uri.EscapeDataString(parameter.Key)).Append('=') + .Append(Uri.EscapeDataString(parameter.Value)); + } + var builder = new UriBuilder(target); + builder.Query = string.IsNullOrEmpty(builder.Query) + ? query.ToString() + : builder.Query.TrimStart('?') + "&" + query; + return builder.Uri; + } + + private readonly HttpClient m_client; + private readonly bool m_ownsClient; + private readonly bool m_manualRedirects; + private readonly ImmutableArray> m_defaultHeaders; + private readonly WotExecutorContext m_context; + private readonly HttpWotBindingOptions m_options; + private readonly IWotPayloadCodec m_codec; + private readonly string m_baseTarget; + private WotCredential? m_credential; + private readonly Lock m_credentialLock = new(); + private Task? m_credentialTask; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs new file mode 100644 index 0000000000..2cc73e0e24 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs @@ -0,0 +1,128 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Http +{ + /// + /// Executes HTTP / HTTPS WoT binding forms compiled by the + /// . It opens a per-form + /// using an injectable + /// factory. + /// + public sealed class HttpWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new HTTP executor. + /// + public HttpWotBindingExecutor(HttpWotBindingOptions? options = null) + { + m_options = options ?? new HttpWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.http", "1.1", HttpBindingPlanner.BindingUri, "W3C WoT HTTP Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + if (form is null) + { + return false; + } + return string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal) && + (string.Equals(form.Endpoint.Scheme, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(form.Endpoint.Scheme, "https", StringComparison.OrdinalIgnoreCase)); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client and channel are owned by the returned channel, disposed via DisposeAsync.")] + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + ImmutableArray> defaultHeaders = + SnapshotDefaultHeaders(m_options.DefaultHeaders); + Func? clientFactory = m_options.ClientFactory; + bool ownsClient = clientFactory is null; + if (!ownsClient && !m_options.CallerClientHandlesRedirectSafety) + { + // Fail closed: the executor cannot control a caller-supplied client's + // redirect handler, cookie jar, DefaultRequestHeaders or later + // mutations. Require explicit confirmation even when the current form + // and options appear not to carry credentials. + throw new InvalidOperationException( + "Every caller-supplied HttpClient requires " + + "HttpWotBindingOptions.CallerClientHandlesRedirectSafety to be set. The supplied client must " + + "disable automatic redirects, or follow them without forwarding cookies or credentials " + + "across origins, " + + "because its handler, cookie behavior, mutable DefaultRequestHeaders and later mutations " + + "are outside the executor's control."); + } + HttpClient client = ownsClient + ? new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + CheckCertificateRevocationList = true + }) + : clientFactory!.Invoke(); + IWotBindingChannel channel = new HttpWotBindingChannel( + client, ownsClient, manualRedirects: ownsClient, defaultHeaders, form, context, m_options); + return new ValueTask(channel); + } + + private static ImmutableArray> SnapshotDefaultHeaders( + IReadOnlyDictionary? defaultHeaders) + { + return defaultHeaders is null + ? [] + : ImmutableArray.CreateRange(defaultHeaders); + } + + private readonly HttpWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs new file mode 100644 index 0000000000..29c55285f2 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace Opc.Ua.WotCon.Bindings.Http +{ + /// + /// Options for the HTTP WoT binding executor. The client factory is injectable + /// so callers can supply a pooled, mutually-authenticated or test + /// ; when none is supplied the executor owns a private + /// client. Default headers are applied to every request in addition to any + /// credential the provider resolves and are treated as sensitive by the + /// redirect policy. + /// + public sealed class HttpWotBindingOptions + { + /// + /// Gets or sets the factory that supplies the . When + /// null the executor creates and owns a private client whose handler + /// disables automatic redirects and ambient cookie handling, so the executor + /// can apply a bounded, origin-aware redirect policy that never leaks + /// credentials across origins. A supplied client is treated as caller-owned + /// and is never disposed or mutated by the executor. Every supplied client + /// must satisfy + /// , regardless of the + /// currently configured headers or form security. + /// + public Func? ClientFactory { get; set; } + + /// + /// Gets or sets default headers applied to requests on the original origin + /// and any same-origin redirect. All configured headers are treated as + /// sensitive and are stripped from cross-origin redirects. The collection is + /// copied into immutable per-channel state during activation; later changes + /// to this property or its original collection do not affect an active + /// channel. + /// + public IReadOnlyDictionary? DefaultHeaders { get; set; } + + /// + /// Gets or sets the poll interval used for observe / event operations. + /// + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the retry policy applied after consecutive unhealthy polls during an + /// observe operation. Defaults to the stack's exponential backoff + /// (, 500 ms doubling to 30 s), so an + /// asset that has gone offline is not polled once per interval. The backoff never polls + /// faster than . + /// + public IChannelReconnectPolicy? RetryPolicy { get; set; } + + /// + /// Gets or sets the maximum number of redirects the executor-owned client + /// follows for a single request. The default is 5; 0 disables + /// redirect following entirely. Default headers and custom header / query + /// credentials are stripped whenever a redirect crosses to a different + /// origin. + /// + public int MaxAutomaticRedirects { get; set; } = 5; + + /// + /// Gets or sets whether the executor-owned client may follow a redirect that + /// downgrades the scheme from https to http. The default is + /// false: an insecure downgrade is refused. + /// + public bool AllowInsecureRedirectDowngrade { get; set; } + + /// + /// Gets or sets whether a caller-supplied is trusted + /// to handle redirects safely. The default is false: every + /// caller-supplied client fails closed because the executor cannot inspect or + /// control its redirect handler, automatic cookie handling and + /// , mutable + /// , or later caller + /// mutations. Set to true only when the supplied client is known to + /// disable automatic redirects, or to follow them without forwarding + /// credentials to a different origin. + /// + public bool CallerClientHandlesRedirectSafety { get; set; } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..32029a4b38 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Opc.Ua; +using Opc.Ua.WotCon.Bindings.Http; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the HTTP WoT binding + /// executor alongside the shipped planner binders. + /// + public static class OpcUaHttpWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the HTTP executor, so + /// HTTP binding forms are validated, compiled and executable. + /// + /// + public static IOpcUaBuilder AddHttpWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new HttpWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new HttpWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs new file mode 100644 index 0000000000..4cb52046db --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs @@ -0,0 +1,371 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// A validated Modbus operation. The numeric values are the function codes. + /// + internal enum ModbusOperation : byte + { + ReadCoils = 1, + ReadDiscreteInputs = 2, + ReadHoldingRegisters = 3, + ReadInputRegisters = 4, + WriteSingleCoil = 5, + WriteSingleHoldingRegister = 6, + WriteMultipleCoils = 15, + WriteMultipleHoldingRegisters = 16 + } + + /// + /// The validated Modbus operation and addressing parsed from a compiled form. + /// The executor checks method, entity, direction, function metadata and bounds + /// before values are narrowed to / , so + /// a hand-built or tampered form cannot select a fallback function or silently + /// truncate an out-of-range value. + /// + internal readonly struct ModbusAddressing + { + private ModbusAddressing( + ModbusOperation operation, ushort address, ushort quantity, byte unitId, + string type, bool msbFirst, bool mswFirst) + { + Operation = operation; + Address = address; + Quantity = quantity; + UnitId = unitId; + Type = type; + MsbFirst = msbFirst; + MswFirst = mswFirst; + } + + public ModbusOperation Operation { get; } + + public ushort Address { get; } + + public ushort Quantity { get; } + + public byte UnitId { get; } + + public string Type { get; } + + public bool MsbFirst { get; } + + public bool MswFirst { get; } + + /// + /// Parses and validates the addressing carried by a compiled Modbus form, + /// including operation consistency and protocol bounds. + /// + /// + /// + /// + public static ModbusAddressing FromForm(WotCompiledForm form, WotBindingBounds? bounds = null) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + ImmutableDictionary map = form.Addressing.Metadata; + string entity = GetRequiredString(map, "entity", form); + int address = GetRequiredInt(map, "address", form); + int quantity = GetRequiredInt(map, "quantity", form); + int unitId = GetRequiredInt(map, "unitId", form); + bounds ??= WotBindingBounds.Default; + + if (address is < 0 or > ModbusProtocolLimits.MaxAddress) + { + throw new ArgumentOutOfRangeException( + nameof(form), address, + $"The Modbus address must be between 0 and {ModbusProtocolLimits.MaxAddress}."); + } + if (quantity is < 1 or > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(form), quantity, $"The Modbus quantity must be between 1 and {ushort.MaxValue}."); + } + ModbusOperation operation = ValidateOperation(form, map, entity); + bool bitOperation = IsBitOperation(operation); + int configuredMaxQuantity = bitOperation + ? bounds.MaxCoilQuantity + : bounds.MaxRegisterQuantity; + int protocolMaxQuantity = ProtocolMaximum(operation); + int maxQuantity = Math.Min(configuredMaxQuantity, protocolMaxQuantity); + if (IsSingleWrite(operation) && quantity != 1) + { + throw new ArgumentOutOfRangeException( + nameof(form), quantity, + $"The Modbus {CanonicalMethod(operation)} function requires a quantity of 1."); + } + if (quantity > maxQuantity) + { + throw new ArgumentOutOfRangeException( + nameof(form), quantity, + $"The Modbus quantity must not exceed {maxQuantity} for '{form.OperationInfo.Method}'."); + } + if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress) + { + throw new ArgumentOutOfRangeException( + nameof(form), address, + $"The Modbus range starting at {address} for {quantity} items exceeds the maximum " + + $"address {ModbusProtocolLimits.MaxAddress}."); + } + if (unitId is < 0 or > 255) + { + throw new ArgumentOutOfRangeException( + nameof(form), unitId, "The Modbus unit id must be between 0 and 255."); + } + + string type = GetString(form.Payload.Metadata, "type", "uint16"); + bool msbFirst = GetBool(form.Payload.Metadata, "mostSignificantByte", true); + bool mswFirst = GetBool(form.Payload.Metadata, "mostSignificantWord", true); + if (operation is + ModbusOperation.WriteSingleHoldingRegister or + ModbusOperation.WriteMultipleHoldingRegisters) + { + int encodedRegisterCount = ModbusDataTypes.RegisterCount(type); + if (quantity != encodedRegisterCount) + { + throw new ArgumentException( + $"The compiled Modbus type '{type}' encodes {encodedRegisterCount} register(s), " + + $"which does not match quantity {quantity} for '{CanonicalMethod(operation)}'.", + nameof(form)); + } + } + + return new ModbusAddressing( + operation, + (ushort)address, + (ushort)quantity, + (byte)unitId, + type, + msbFirst, + mswFirst); + } + + private static ModbusOperation ValidateOperation( + WotCompiledForm form, + ImmutableDictionary map, + string entity) + { + if (!TryResolveOperation(form.OperationInfo.Method, out ModbusOperation operation)) + { + throw new ArgumentException( + $"The compiled Modbus method '{form.OperationInfo.Method}' is not supported.", + nameof(form)); + } + if (form.OperationInfo.Operation != form.Operation) + { + throw new ArgumentException( + $"The compiled Modbus operation '{form.OperationInfo.Operation}' does not match " + + $"the form operation '{form.Operation}'.", + nameof(form)); + } + + bool writeOperation = IsWriteOperation(operation); + bool writeDirection = form.Operation == WoTBindingCapabilityEnum.WriteProperty; + bool readDirection = form.Operation is + WoTBindingCapabilityEnum.ReadProperty or WoTBindingCapabilityEnum.ObserveProperty; + if ((!writeDirection && !readDirection) || writeOperation != writeDirection) + { + throw new ArgumentException( + $"The compiled Modbus method '{form.OperationInfo.Method}' is not valid for " + + $"the '{form.Operation}' operation.", + nameof(form)); + } + + string expectedEntity = ExpectedEntity(operation); + if (!string.Equals(entity, expectedEntity, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"The compiled Modbus method '{form.OperationInfo.Method}' operates on " + + $"'{expectedEntity}', not '{entity}'.", + nameof(form)); + } + + if (map.TryGetValue("functionCode", out string? functionCodeText) && + (!int.TryParse( + functionCodeText, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int functionCode) || + functionCode != (int)operation)) + { + throw new ArgumentException( + $"The compiled Modbus function code '{functionCodeText}' does not match " + + $"the method '{form.OperationInfo.Method}'.", + nameof(form)); + } + if (map.TryGetValue("function", out string? functionName) && + !string.Equals(functionName, CanonicalMethod(operation), StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"The compiled Modbus function '{functionName}' does not match " + + $"the method '{form.OperationInfo.Method}'.", + nameof(form)); + } + return operation; + } + + private static bool TryResolveOperation(string method, out ModbusOperation operation) + { + operation = method.ToLowerInvariant() switch + { + "readcoil" => ModbusOperation.ReadCoils, + "readdiscreteinput" => ModbusOperation.ReadDiscreteInputs, + "readholdingregisters" => ModbusOperation.ReadHoldingRegisters, + "readinputregister" => ModbusOperation.ReadInputRegisters, + "writesinglecoil" => ModbusOperation.WriteSingleCoil, + "writesingleholdingregister" => ModbusOperation.WriteSingleHoldingRegister, + "writemultiplecoils" => ModbusOperation.WriteMultipleCoils, + "writemultipleholdingregisters" => ModbusOperation.WriteMultipleHoldingRegisters, + _ => default + }; + return operation != default && + method.Equals(CanonicalMethod(operation), StringComparison.OrdinalIgnoreCase); + } + + private static string CanonicalMethod(ModbusOperation operation) + { + return operation switch + { + ModbusOperation.ReadCoils => "readCoil", + ModbusOperation.ReadDiscreteInputs => "readDiscreteInput", + ModbusOperation.ReadHoldingRegisters => "readHoldingRegisters", + ModbusOperation.ReadInputRegisters => "readInputRegister", + ModbusOperation.WriteSingleCoil => "writeSingleCoil", + ModbusOperation.WriteSingleHoldingRegister => "writeSingleHoldingRegister", + ModbusOperation.WriteMultipleCoils => "writeMultipleCoils", + ModbusOperation.WriteMultipleHoldingRegisters => "writeMultipleHoldingRegisters", + _ => string.Empty + }; + } + + private static string ExpectedEntity(ModbusOperation operation) + { + return operation switch + { + ModbusOperation.ReadCoils or + ModbusOperation.WriteSingleCoil or + ModbusOperation.WriteMultipleCoils => "coil", + ModbusOperation.ReadDiscreteInputs => "discreteInput", + ModbusOperation.ReadInputRegisters => "inputRegister", + _ => "holdingRegister" + }; + } + + private static int ProtocolMaximum(ModbusOperation operation) + { + return operation switch + { + ModbusOperation.ReadCoils or + ModbusOperation.ReadDiscreteInputs => ModbusProtocolLimits.MaxReadBits, + ModbusOperation.WriteMultipleCoils => ModbusProtocolLimits.MaxWriteCoils, + ModbusOperation.WriteMultipleHoldingRegisters => ModbusProtocolLimits.MaxWriteRegisters, + ModbusOperation.WriteSingleCoil or + ModbusOperation.WriteSingleHoldingRegister => 1, + _ => ModbusProtocolLimits.MaxReadRegisters + }; + } + + private static bool IsBitOperation(ModbusOperation operation) + { + return operation is + ModbusOperation.ReadCoils or + ModbusOperation.ReadDiscreteInputs or + ModbusOperation.WriteSingleCoil or + ModbusOperation.WriteMultipleCoils; + } + + private static bool IsWriteOperation(ModbusOperation operation) + { + return operation is + ModbusOperation.WriteSingleCoil or + ModbusOperation.WriteSingleHoldingRegister or + ModbusOperation.WriteMultipleCoils or + ModbusOperation.WriteMultipleHoldingRegisters; + } + + private static bool IsSingleWrite(ModbusOperation operation) + { + return operation is + ModbusOperation.WriteSingleCoil or + ModbusOperation.WriteSingleHoldingRegister; + } + + private static string GetRequiredString( + ImmutableDictionary map, + string key, + WotCompiledForm form) + { + if (map.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + throw new ArgumentException( + $"The compiled Modbus form requires '{key}' metadata.", + nameof(form)); + } + + private static int GetRequiredInt( + ImmutableDictionary map, + string key, + WotCompiledForm form) + { + if (map.TryGetValue(key, out string? value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)) + { + return result; + } + throw new ArgumentException( + $"The compiled Modbus form requires integer '{key}' metadata.", + nameof(form)); + } + + private static string GetString( + ImmutableDictionary map, + string key, + string fallback) + { + return map.TryGetValue(key, out string? value) && !string.IsNullOrEmpty(value) + ? value + : fallback; + } + + private static bool GetBool(ImmutableDictionary map, string key, bool fallback) + { + return map.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) ? result : fallback; + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs new file mode 100644 index 0000000000..666115bd59 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// Converts between Modbus register words and OPC UA values, honouring the + /// modv:type data type and the byte / word order flags + /// (modv:mostSignificantByte / modv:mostSignificantWord). + /// + internal static class ModbusDataConverter + { + public static int RegisterCount(string type) + { + return ModbusDataTypes.RegisterCount(type); + } + + public static Variant ToVariant(ushort[] registers, string type, bool msbFirst, bool mswFirst) + { + string normalized = ModbusDataTypes.Normalize(type); + int needed = RegisterCount(normalized); + if (registers.Length < needed) + { + throw new ModbusException( + $"The Modbus data type '{type}' requires {needed} registers but {registers.Length} were read."); + } + ushort[] slice = new ushort[needed]; + Array.Copy(registers, slice, needed); + byte[] bigEndian = Canonical(slice, msbFirst, mswFirst); + byte[] host = ToHostOrder(bigEndian); + return normalized switch + { + "int16" => new Variant(BitConverter.ToInt16(host, 0)), + "uint16" => new Variant(BitConverter.ToUInt16(host, 0)), + "int32" => new Variant(BitConverter.ToInt32(host, 0)), + "uint32" => new Variant(BitConverter.ToUInt32(host, 0)), + "float32" => new Variant(BitConverter.ToSingle(host, 0)), + "int64" => new Variant(BitConverter.ToInt64(host, 0)), + "uint64" => new Variant(BitConverter.ToUInt64(host, 0)), + "float64" => new Variant(BitConverter.ToDouble(host, 0)), + _ => new Variant(BitConverter.ToUInt16(host, 0)) + }; + } + + public static ushort[] ToRegisters(Variant value, string type, bool msbFirst, bool mswFirst) + { + string normalized = ModbusDataTypes.Normalize(type); + byte[] bigEndian = normalized switch + { + "int16" => BigEndianBytes(BitConverter.GetBytes(ToInt16(value))), + "uint16" => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value))), + "int32" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToInt32(BoxOf(value), CultureInfo.InvariantCulture))), + "uint32" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToUInt32(BoxOf(value), CultureInfo.InvariantCulture))), + "float32" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToSingle(BoxOf(value), CultureInfo.InvariantCulture))), + "int64" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToInt64(BoxOf(value), CultureInfo.InvariantCulture))), + "uint64" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToUInt64(BoxOf(value), CultureInfo.InvariantCulture))), + "float64" => BigEndianBytes( + BitConverter.GetBytes(Convert.ToDouble(BoxOf(value), CultureInfo.InvariantCulture))), + _ => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value))) + }; + return FromCanonical(bigEndian, msbFirst, mswFirst); + } + + private static byte[] Canonical(ushort[] registers, bool msbFirst, bool mswFirst) + { + int words = registers.Length; + byte[][] wordBytes = new byte[words][]; + for (int i = 0; i < words; i++) + { + byte hi = (byte)(registers[i] >> 8); + byte lo = (byte)(registers[i] & 0xFF); + wordBytes[i] = msbFirst ? [hi, lo] : [lo, hi]; + } + if (!mswFirst) + { + Array.Reverse(wordBytes); + } + byte[] result = new byte[words * 2]; + for (int i = 0; i < words; i++) + { + result[i * 2] = wordBytes[i][0]; + result[(i * 2) + 1] = wordBytes[i][1]; + } + return result; + } + + private static ushort[] FromCanonical(byte[] bigEndian, bool msbFirst, bool mswFirst) + { + int words = bigEndian.Length / 2; + byte[][] wordBytes = new byte[words][]; + for (int i = 0; i < words; i++) + { + wordBytes[i] = [bigEndian[i * 2], bigEndian[(i * 2) + 1]]; + } + if (!mswFirst) + { + Array.Reverse(wordBytes); + } + ushort[] registers = new ushort[words]; + for (int i = 0; i < words; i++) + { + byte b0 = wordBytes[i][0]; + byte b1 = wordBytes[i][1]; + byte hi = msbFirst ? b0 : b1; + byte lo = msbFirst ? b1 : b0; + registers[i] = (ushort)((hi << 8) | lo); + } + return registers; + } + + private static byte[] ToHostOrder(byte[] bigEndian) + { + byte[] copy = (byte[])bigEndian.Clone(); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(copy); + } + return copy; + } + + private static byte[] BigEndianBytes(byte[] hostOrder) + { + byte[] copy = (byte[])hostOrder.Clone(); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(copy); + } + return copy; + } + + private static object BoxOf(Variant value) + { + return value.AsBoxedObject() ?? 0; + } + + private static short ToInt16(Variant value) + { + return Convert.ToInt16(BoxOf(value), CultureInfo.InvariantCulture); + } + + private static ushort ToUInt16(Variant value) + { + return Convert.ToUInt16(BoxOf(value), CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs new file mode 100644 index 0000000000..b6e8b72a6d --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs @@ -0,0 +1,76 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// A Modbus protocol exception carrying the device exception code. + /// + public sealed class ModbusException : Exception + { + /// + /// Initializes a new Modbus exception. + /// + public ModbusException(byte exceptionCode, string message) + : base(message) + { + ExceptionCode = exceptionCode; + } + + /// + /// Initializes a new Modbus exception without a device code. + /// + public ModbusException(string message) + : base(message) + { + } + + /// + /// Initializes a new Modbus exception. + /// + public ModbusException() + { + } + + /// + /// Initializes a new Modbus exception with an inner exception. + /// + public ModbusException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Gets the Modbus exception code, or 0 for a transport fault. + /// + public byte ExceptionCode { get; } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs new file mode 100644 index 0000000000..4982a34c94 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs @@ -0,0 +1,467 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// A minimal, robust Modbus TCP client sufficient for the WoT Modbus binding + /// forms: read coils / discrete inputs / holding registers / input registers + /// and write single / multiple coils and holding registers. It manages the + /// MBAP header, monotonically increasing transaction ids, request timeouts and + /// device exception decoding. + /// + public sealed class ModbusTcpClient : IDisposable + { + /// + /// Initializes a new Modbus TCP client. + /// + public ModbusTcpClient(string host, int port, TimeSpan timeout) + { + m_host = host ?? throw new ArgumentNullException(nameof(host)); + m_port = port <= 0 ? 502 : port; + m_timeout = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(10) : timeout; + } + + /// + /// Connects (or reconnects) the underlying TCP socket. The connect is + /// serialized with in-flight transactions so a reconnect after a fault is + /// deterministic and thread-safe: any prior (possibly faulted) socket is + /// disposed first and a fresh connection replaces it atomically. + /// + public async ValueTask ConnectAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_timeout); + await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false); + try + { + await ReconnectCoreAsync(timeout.Token).ConfigureAwait(false); + } + finally + { + m_writeLock.Release(); + } + } + + /// + /// Reads holding registers (function code 3). + /// + public ValueTask ReadHoldingRegistersAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + return ReadRegistersAsync(0x03, unitId, address, quantity, cancellationToken); + } + + /// + /// Reads input registers (function code 4). + /// + public ValueTask ReadInputRegistersAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + return ReadRegistersAsync(0x04, unitId, address, quantity, cancellationToken); + } + + /// + /// Reads coils (function code 1). + /// + public ValueTask ReadCoilsAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + return ReadBitsAsync(0x01, unitId, address, quantity, cancellationToken); + } + + /// + /// Reads discrete inputs (function code 2). + /// + public ValueTask ReadDiscreteInputsAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + return ReadBitsAsync(0x02, unitId, address, quantity, cancellationToken); + } + + /// + /// Writes a single holding register (function code 6). + /// + public async ValueTask WriteSingleRegisterAsync( + byte unitId, ushort address, ushort value, CancellationToken cancellationToken) + { + byte[] pdu = [0x06, Hi(address), Lo(address), Hi(value), Lo(value)]; + await TransactAsync(unitId, pdu, 0x06, cancellationToken).ConfigureAwait(false); + } + + /// + /// Writes multiple holding registers (function code 16). + /// + public async ValueTask WriteMultipleRegistersAsync( + byte unitId, ushort address, ushort[] values, CancellationToken cancellationToken) + { + int count = values.Length; + byte byteCount = (byte)(count * 2); + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = 0x10; + pdu[1] = Hi(address); + pdu[2] = Lo(address); + pdu[3] = Hi((ushort)count); + pdu[4] = Lo((ushort)count); + pdu[5] = byteCount; + for (int i = 0; i < count; i++) + { + pdu[6 + (i * 2)] = Hi(values[i]); + pdu[7 + (i * 2)] = Lo(values[i]); + } + await TransactAsync(unitId, pdu, 0x10, cancellationToken).ConfigureAwait(false); + } + + /// + /// Writes a single coil (function code 5). + /// + public async ValueTask WriteSingleCoilAsync( + byte unitId, ushort address, bool value, CancellationToken cancellationToken) + { + ushort encodedValue = value ? (ushort)0xFF00 : (ushort)0x0000; + byte[] pdu = [0x05, Hi(address), Lo(address), Hi(encodedValue), Lo(encodedValue)]; + byte[] response = await TransactAsync(unitId, pdu, 0x05, cancellationToken).ConfigureAwait(false); + ValidateWriteAcknowledgement(response, address, encodedValue, "single-coil"); + } + + /// + /// Writes multiple coils (function code 15). + /// + public async ValueTask WriteMultipleCoilsAsync( + byte unitId, ushort address, bool[] values, CancellationToken cancellationToken) + { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } + int count = values.Length; + ValidateBitRange(address, count, ModbusProtocolLimits.MaxWriteCoils, nameof(values)); + byte byteCount = (byte)((count + 7) / 8); + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = 0x0F; + pdu[1] = Hi(address); + pdu[2] = Lo(address); + pdu[3] = Hi((ushort)count); + pdu[4] = Lo((ushort)count); + pdu[5] = byteCount; + for (int i = 0; i < count; i++) + { + if (values[i]) + { + pdu[6 + (i / 8)] |= (byte)(1 << (i % 8)); + } + } + byte[] response = await TransactAsync(unitId, pdu, 0x0F, cancellationToken).ConfigureAwait(false); + ValidateWriteAcknowledgement(response, address, (ushort)count, "multiple-coil"); + } + + private static void ValidateWriteAcknowledgement( + byte[] response, + ushort address, + ushort value, + string operation) + { + if (response.Length != 5) + { + throw new ModbusException( + $"The Modbus {operation} acknowledgement must contain exactly 5 bytes."); + } + ushort echoedAddress = (ushort)((response[1] << 8) | response[2]); + if (echoedAddress != address) + { + throw new ModbusException( + $"The Modbus {operation} acknowledgement did not echo the requested address."); + } + ushort echoedValue = (ushort)((response[3] << 8) | response[4]); + if (echoedValue != value) + { + throw new ModbusException( + $"The Modbus {operation} acknowledgement did not echo the requested value or quantity."); + } + } + + private static void ValidateBitRange(ushort address, int quantity, int maximum, string parameterName) + { + if (quantity is < 1 || quantity > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + quantity, + $"The Modbus bit quantity must be between 1 and {maximum}."); + } + if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress) + { + throw new ArgumentOutOfRangeException( + parameterName, + quantity, + $"The Modbus range starting at {address} for {quantity} bits exceeds the maximum " + + $"address {ModbusProtocolLimits.MaxAddress}."); + } + } + + /// + public void Dispose() + { + m_stream?.Dispose(); + m_client?.Dispose(); + m_writeLock.Dispose(); + } + + private async ValueTask ReadRegistersAsync( + byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + byte[] pdu = [function, Hi(address), Lo(address), Hi(quantity), Lo(quantity)]; + byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false); + // response[0] is the (already validated) function code; response[1] is + // the byte count. Validate both the byte count and the overall length + // before indexing so a hostile or truncated frame cannot read out of + // bounds. + if (response.Length < 2) + { + throw new ModbusException("The Modbus register response is missing its byte count."); + } + int byteCount = response[1]; + int expected = quantity * 2; + if (byteCount != expected || (byteCount & 1) != 0 || response.Length < 2 + byteCount) + { + throw new ModbusException( + "The Modbus register response byte count is inconsistent with the request."); + } + ushort[] registers = new ushort[byteCount / 2]; + for (int i = 0; i < registers.Length; i++) + { + registers[i] = (ushort)((response[2 + (i * 2)] << 8) | response[3 + (i * 2)]); + } + return registers; + } + + private async ValueTask ReadBitsAsync( + byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + ValidateBitRange(address, quantity, ModbusProtocolLimits.MaxReadBits, nameof(quantity)); + byte[] pdu = [function, Hi(address), Lo(address), Hi(quantity), Lo(quantity)]; + byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false); + // response[1] is the packed-bit byte count. Validate it against the + // requested quantity and the frame length before indexing. + if (response.Length < 2) + { + throw new ModbusException("The Modbus bit response is missing its byte count."); + } + int byteCount = response[1]; + int expected = (quantity + 7) / 8; + if (byteCount != expected || response.Length < 2 + byteCount) + { + throw new ModbusException( + "The Modbus bit response byte count is inconsistent with the request."); + } + bool[] bits = new bool[quantity]; + for (int i = 0; i < quantity; i++) + { + int byteIndex = 2 + (i / 8); + bits[i] = (response[byteIndex] & (1 << (i % 8))) != 0; + } + return bits; + } + + private async ValueTask TransactAsync( + byte unitId, byte[] pdu, byte expectedFunction, CancellationToken cancellationToken) + { + ushort transactionId = unchecked((ushort)Interlocked.Increment(ref m_transaction)); + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = Hi(transactionId); + frame[1] = Lo(transactionId); + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = Hi((ushort)length); + frame[5] = Lo((ushort)length); + frame[6] = unitId; + Buffer.BlockCopy(pdu, 0, frame, 7, pdu.Length); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_timeout); + await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false); + try + { + if (m_stream is null && m_faulted) + { + await ReconnectCoreAsync(timeout.Token).ConfigureAwait(false); + } + + NetworkStream? stream = m_stream ?? + throw new ModbusException("The Modbus client is not connected."); + + byte[] responsePdu; + try + { + await stream.WriteAsync(frame.AsMemory(), timeout.Token).ConfigureAwait(false); + await stream.FlushAsync(timeout.Token).ConfigureAwait(false); + + byte[] header = await ReadExactAsync(stream, 7, timeout.Token).ConfigureAwait(false); + if (header[0] != Hi(transactionId) || header[1] != Lo(transactionId)) + { + throw new ModbusException("The Modbus transaction id did not match."); + } + int responseLength = ((header[4] << 8) | header[5]) - 1; + if (responseLength < 1) + { + throw new ModbusException("The Modbus response length is invalid."); + } + responsePdu = await ReadExactAsync(stream, responseLength, timeout.Token).ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is OperationCanceledException or System.IO.IOException or + SocketException or ObjectDisposedException or ModbusException) + { + // A timeout, cancellation, transport error, transaction-id + // mismatch or truncated/invalid frame leaves the stream in an + // unknown, desynchronized state. Fault the connection so the + // next operation establishes a fresh socket before sending. + FaultConnection(); + throw; + } + + // The response was framed by the MBAP length and read in full, so + // the stream stays synchronized: a device exception or an + // unexpected function code is a protocol result, not a desync, and + // must not fault the connection. + byte function = responsePdu[0]; + if ((function & 0x80) != 0) + { + byte exceptionCode = responsePdu.Length > 1 ? responsePdu[1] : (byte)0; + throw new ModbusException(exceptionCode, DescribeException(exceptionCode)); + } + if (function != expectedFunction) + { + throw new ModbusException( + $"Unexpected Modbus function 0x{function:X2} (expected 0x{expectedFunction:X2})."); + } + return responsePdu; + } + finally + { + m_writeLock.Release(); + } + } + + /// + /// Disposes and clears the current socket after a desynchronizing fault so + /// the next operation requires a fresh . Always + /// called while holding . + /// + private void FaultConnection() + { + m_faulted = true; + m_stream?.Dispose(); + m_client?.Dispose(); + m_stream = null; + m_client = null; + } + + private async ValueTask ReconnectCoreAsync(CancellationToken cancellationToken) + { + // Dispose any prior (possibly faulted) connection so a reconnect + // always starts from a clean, deterministic state. + m_stream?.Dispose(); + m_client?.Dispose(); + m_stream = null; + m_client = null; + + var client = new TcpClient { NoDelay = true }; + try + { + await client.ConnectAsync(m_host, m_port, cancellationToken).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + m_client = client; + m_stream = client.GetStream(); + m_faulted = false; + } + + private static async ValueTask ReadExactAsync( + NetworkStream stream, int count, CancellationToken cancellationToken) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream + .ReadAsync(buffer.AsMemory(offset, count - offset), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + throw new ModbusException("The Modbus connection was closed by the peer."); + } + offset += read; + } + return buffer; + } + + private static string DescribeException(byte code) + { + return code switch + { + 0x01 => "Illegal function.", + 0x02 => "Illegal data address.", + 0x03 => "Illegal data value.", + 0x04 => "Server device failure.", + 0x06 => "Server device busy.", + _ => $"Modbus exception 0x{code:X2}." + }; + } + + private static byte Hi(ushort value) + { + return (byte)(value >> 8); + } + + private static byte Lo(ushort value) + { + return (byte)(value & 0xFF); + } + + private readonly string m_host; + private readonly int m_port; + private readonly TimeSpan m_timeout; + private readonly SemaphoreSlim m_writeLock = new(1, 1); + private TcpClient? m_client; + private NetworkStream? m_stream; + private int m_transaction; + private bool m_faulted; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs new file mode 100644 index 0000000000..51e6131f21 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs @@ -0,0 +1,319 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// A live Modbus TCP binding channel. It reads coils / discrete inputs / + /// holding / input registers and writes coils and holding registers with the + /// data type and byte / word order compiled from the form, mapping Modbus + /// exceptions and timeouts to OPC UA status codes. + /// + internal sealed class ModbusWotBindingChannel : IWotBindingChannel + { + public ModbusWotBindingChannel( + ModbusTcpClient client, + WotCompiledForm form, + WotExecutorContext context, + ModbusWotBindingOptions options, + ModbusAddressing addressing) + { + m_client = client; + Form = form; + m_options = options; + + m_operation = addressing.Operation; + m_address = addressing.Address; + m_quantity = addressing.Quantity; + m_unitId = addressing.UnitId; + m_type = addressing.Type; + m_msbFirst = addressing.MsbFirst; + m_mswFirst = addressing.MswFirst; + } + + public WotCompiledForm Form { get; } + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + try + { + Variant value; + if (m_operation == ModbusOperation.ReadCoils) + { + bool[] bits = await m_client + .ReadCoilsAsync(m_unitId, m_address, m_quantity, cancellationToken).ConfigureAwait(false); + value = ToBitVariant(bits); + } + else if (m_operation == ModbusOperation.ReadDiscreteInputs) + { + bool[] bits = await m_client + .ReadDiscreteInputsAsync(m_unitId, m_address, m_quantity, cancellationToken) + .ConfigureAwait(false); + value = ToBitVariant(bits); + } + else if (m_operation == ModbusOperation.ReadInputRegisters) + { + ushort[] regs = await m_client + .ReadInputRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken) + .ConfigureAwait(false); + value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst); + } + else if (m_operation == ModbusOperation.ReadHoldingRegisters) + { + ushort[] regs = await m_client + .ReadHoldingRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken) + .ConfigureAwait(false); + value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst); + } + else + { + return new WotReadResult( + StatusCodes.BadNotSupported, + DataValue.FromStatusCode(StatusCodes.BadNotSupported), + $"The Modbus operation '{m_operation}' is not readable."); + } + return new WotReadResult( + StatusCodes.Good, new DataValue(value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + catch (ModbusException ex) + { + StatusCode status = ModbusStatusMapper.Map(ex); + return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotReadResult( + StatusCodes.BadTimeout, + DataValue.FromStatusCode(StatusCodes.BadTimeout), + "The Modbus request timed out."); + } + catch (System.IO.IOException ex) + { + return new WotReadResult( + StatusCodes.BadCommunicationError, + DataValue.FromStatusCode(StatusCodes.BadCommunicationError), ex.Message); + } + catch (System.Net.Sockets.SocketException ex) + { + return new WotReadResult( + StatusCodes.BadCommunicationError, + DataValue.FromStatusCode(StatusCodes.BadCommunicationError), ex.Message); + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + if (m_operation is + ModbusOperation.ReadCoils or + ModbusOperation.ReadDiscreteInputs or + ModbusOperation.ReadHoldingRegisters or + ModbusOperation.ReadInputRegisters) + { + return new WotWriteResult(StatusCodes.BadNotWritable, "The Modbus operation is read-only."); + } + try + { + if (m_operation == ModbusOperation.WriteSingleCoil) + { + if (!value.WrappedValue.TryGetValue(out bool on)) + { + return new WotWriteResult( + StatusCodes.BadTypeMismatch, + "The Modbus single-coil write requires a Boolean scalar."); + } + await m_client.WriteSingleCoilAsync(m_unitId, m_address, on, cancellationToken) + .ConfigureAwait(false); + } + else if (m_operation == ModbusOperation.WriteMultipleCoils) + { + bool[] coilValues; + if (m_quantity == 1) + { + if (!value.WrappedValue.TryGetValue(out bool on)) + { + return new WotWriteResult( + StatusCodes.BadTypeMismatch, + "The Modbus multiple-coil write with quantity 1 requires a Boolean scalar."); + } + coilValues = [on]; + } + else if (value.WrappedValue.TryGetValue(out ArrayOf bits)) + { + if (bits.Count != m_quantity) + { + return new WotWriteResult( + StatusCodes.BadInvalidArgument, + $"The Modbus multiple-coil write requires exactly {m_quantity} Boolean values; " + + $"the payload contains {bits.Count}."); + } + coilValues = bits.Memory.ToArray(); + } + else + { + return new WotWriteResult( + StatusCodes.BadTypeMismatch, + $"The Modbus multiple-coil write requires an array of {m_quantity} Boolean values."); + } + await m_client + .WriteMultipleCoilsAsync( + m_unitId, m_address, coilValues, cancellationToken) + .ConfigureAwait(false); + } + else if (m_operation is + ModbusOperation.WriteSingleHoldingRegister or + ModbusOperation.WriteMultipleHoldingRegisters) + { + ushort[] registers = ModbusDataConverter.ToRegisters( + value.WrappedValue, m_type, m_msbFirst, m_mswFirst); + if (m_operation == ModbusOperation.WriteSingleHoldingRegister) + { + if (registers.Length != 1) + { + return new WotWriteResult( + StatusCodes.BadTypeMismatch, + "The Modbus single-register write requires a value encoded in one register."); + } + await m_client + .WriteSingleRegisterAsync(m_unitId, m_address, registers[0], cancellationToken) + .ConfigureAwait(false); + } + else + { + if (registers.Length != m_quantity) + { + return new WotWriteResult( + StatusCodes.BadInvalidArgument, + $"The Modbus multiple-register write requires exactly {m_quantity} registers; " + + $"the payload encodes {registers.Length}."); + } + await m_client + .WriteMultipleRegistersAsync(m_unitId, m_address, registers, cancellationToken) + .ConfigureAwait(false); + } + } + return new WotWriteResult(StatusCodes.Good); + } + catch (ModbusException ex) + { + return new WotWriteResult(ModbusStatusMapper.Map(ex), ex.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotWriteResult(StatusCodes.BadTimeout, "The Modbus request timed out."); + } + catch (System.IO.IOException ex) + { + return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); + } + catch (System.Net.Sockets.SocketException ex) + { + return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); + } + catch (Exception ex) when ( + ex is FormatException or InvalidCastException or OverflowException) + { + return new WotWriteResult(StatusCodes.BadTypeMismatch, ex.Message); + } + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotInvokeResult( + StatusCodes.BadNotSupported, null, "Modbus does not support action invocation.")); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription( + Form, + async token => + { + WotReadResult result = await ReadAsync(token).ConfigureAwait(false); + // A mapped failure carries its bad StatusCode on the value, so surface it + // rather than leaving the last good value in place, and report the poll as + // unhealthy so the retry policy backs off instead of hammering the asset. + onNotification(new WotNotification(result.Value)); + return result.Success; + }, + // A form that declares the standard modv:pollingTime wins over the executor's + // configured default, so a per-affordance rate is honoured. + Form.OperationInfo.PollInterval ?? m_options.ObserveInterval, + // A transient poll fault is reported as a Bad-status notification + // so consumers observe the fault without the poll loop faulting. + onError: _ => onNotification(new WotNotification( + DataValue.FromStatusCode(StatusCodes.BadCommunicationError))), + retryPolicy: m_options.RetryPolicy); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return ObserveAsync(onEvent, cancellationToken); + } + + public ValueTask DisposeAsync() + { + m_client.Dispose(); + return default; + } + + private Variant ToBitVariant(bool[] bits) + { + return m_quantity == 1 + ? new Variant(bits[0]) + : new Variant((ArrayOf)bits); + } + + private readonly ModbusTcpClient m_client; + private readonly ModbusWotBindingOptions m_options; + private readonly ModbusOperation m_operation; + private readonly ushort m_address; + private readonly ushort m_quantity; + private readonly byte m_unitId; + private readonly string m_type; + private readonly bool m_msbFirst; + private readonly bool m_mswFirst; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs new file mode 100644 index 0000000000..41205e5968 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs @@ -0,0 +1,98 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// Executes Modbus TCP WoT binding forms compiled by the + /// by opening a per-form Modbus TCP + /// connection. + /// + public sealed class ModbusWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new Modbus executor. + /// + public ModbusWotBindingExecutor(ModbusWotBindingOptions? options = null) + { + m_options = options ?? new ModbusWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri, "W3C WoT Modbus Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client is owned by the returned channel, which disposes it.")] + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + // Re-validate the addressing (and perform the ushort / byte casts) before + // opening the socket so a hand-built or tampered compiled form fails fast + // and never leaks a half-open connection. + var addressing = ModbusAddressing.FromForm(form, context.Bounds); + string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!; + int port = form.Endpoint.Port > 0 ? form.Endpoint.Port : 502; + var client = new ModbusTcpClient(host, port, context.Bounds.DefaultTimeout); + try + { + await client.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + return new ModbusWotBindingChannel(client, form, context, m_options, addressing); + } + + private readonly ModbusWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs new file mode 100644 index 0000000000..e2720044cb --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs @@ -0,0 +1,72 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.WotCon.Bindings.Modbus +{ + /// + /// Options for the Modbus TCP WoT binding executor. + /// + public sealed class ModbusWotBindingOptions + { + /// + /// Gets or sets the poll interval used for observe operations. + /// + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the retry policy applied after consecutive unhealthy polls during an + /// observe operation. Defaults to the stack's exponential backoff + /// (, 500 ms doubling to 30 s), so an + /// asset that has gone offline is not polled once per interval. The backoff never polls + /// faster than . + /// + public IChannelReconnectPolicy? RetryPolicy { get; set; } + } + + /// + /// Maps Modbus device exception codes to OPC UA status codes. + /// + internal static class ModbusStatusMapper + { + public static StatusCode Map(ModbusException exception) + { + return exception.ExceptionCode switch + { + 0x01 => StatusCodes.BadNotSupported, + 0x02 => StatusCodes.BadNodeIdUnknown, + 0x03 => StatusCodes.BadInvalidArgument, + 0x04 => StatusCodes.BadInternalError, + 0x06 => StatusCodes.BadResourceUnavailable, + _ => StatusCodes.BadCommunicationError + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..80ef357bbc --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs @@ -0,0 +1,61 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Opc.Ua; +using Opc.Ua.WotCon.Bindings.Modbus; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the Modbus TCP WoT + /// binding executor alongside the shipped planner binders. + /// + public static class OpcUaModbusWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the Modbus TCP executor, + /// so Modbus binding forms are validated, compiled and executable. + /// + /// + public static IOpcUaBuilder AddModbusWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new ModbusWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new ModbusWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..15ae8405b8 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs @@ -0,0 +1,65 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using Opc.Ua; +using Opc.Ua.WotCon.Bindings.OpcUa; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the OPC UA WoT binding + /// executor (OPC UA-to-OPC UA translation) alongside the shipped planner binders. + /// + public static class OpcUaTargetWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the OPC UA executor, so + /// OPC UA binding forms are validated, compiled and executable. + /// + /// + public static IOpcUaBuilder AddOpcUaWotBinding( + this IOpcUaBuilder builder, Action configure) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + var options = new OpcUaWotBindingOptions(); + configure(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new OpcUaWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs new file mode 100644 index 0000000000..e18ca4daec --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs @@ -0,0 +1,553 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using WellKnownObjectTypeIds = Opc.Ua.Types.ObjectTypeIds; + +namespace Opc.Ua.WotCon.Bindings.OpcUa +{ + /// + /// A live OPC UA binding channel that translates WoT operations onto OPC UA + /// services: read / write of a NodeId Value attribute, observe and event + /// subscription via a native / + /// pair (Part 4 §5.12 / §5.13), and action invocation via Method Call + /// preserving argument order and / + /// metadata. + /// + internal sealed class OpcUaWotBindingChannel : IWotBindingChannel + { + public OpcUaWotBindingChannel( + ISession session, + bool disposeSession, + WotCompiledForm form, + WotExecutorContext context, + OpcUaWotBindingOptions options) + { + m_session = session; + m_disposeSession = disposeSession; + Form = form; + m_options = options; + m_nodeId = form.Addressing.Target; + } + + public WotCompiledForm Form { get; } + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + return new WotReadResult( + StatusCodes.BadNodeIdInvalid, + DataValue.FromStatusCode(StatusCodes.BadNodeIdInvalid), + $"'{m_nodeId}' is not a valid NodeId."); + } + try + { + DataValue value = await m_session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false); + return new WotReadResult(value.StatusCode, value); + } + catch (ServiceResultException ex) + { + StatusCode status = ex.StatusCode; + return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message); + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + return new WotWriteResult(StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId."); + } + try + { + var write = new WriteValue + { + NodeId = nodeId, + AttributeId = Attributes.Value, + Value = new DataValue(value.WrappedValue) + }; + WriteResponse response = await m_session + .WriteAsync(null, new WriteValue[] { write }, cancellationToken).ConfigureAwait(false); + StatusCode status = response.Results is { Count: > 0 } + ? response.Results[0] : StatusCodes.BadUnexpectedError; + return new WotWriteResult(status, StatusCode.IsBad(status) ? status.ToString() : null); + } + catch (ServiceResultException ex) + { + return new WotWriteResult(ex.StatusCode, ex.Message); + } + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + if (!Form.Addressing.Metadata.TryGetValue("componentOf", out string? objectRef) || + string.IsNullOrEmpty(objectRef) || + !TryResolveNodeId(objectRef!, out NodeId objectId)) + { + return new WotInvokeResult( + StatusCodes.BadNodeIdInvalid, null, + "An OPC UA action requires a uav:componentOf object NodeId."); + } + if (!TryResolveNodeId(m_nodeId, out NodeId methodId)) + { + return new WotInvokeResult( + StatusCodes.BadNodeIdInvalid, null, $"'{m_nodeId}' is not a valid method NodeId."); + } + try + { + Variant[] arguments = inputs is null ? [] : [.. inputs]; + ArrayOf outputs = await m_session + .CallAsync(objectId, methodId, cancellationToken, arguments).ConfigureAwait(false); + var results = new DataValue[outputs.Count]; + for (int i = 0; i < outputs.Count; i++) + { + results[i] = new DataValue(outputs[i], StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now); + } + return new WotInvokeResult(StatusCodes.Good, results); + } + catch (ServiceResultException ex) + { + return new WotInvokeResult(ex.StatusCode, null, ex.Message); + } + } + + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId."); + } + // Native data-change subscription: the server samples and reports + // changes (Part 4 §5.12); no client-side polling is involved. + return CreateMonitoredSubscriptionAsync( + nodeId, + NodeClass.Variable, + Attributes.Value, + filter: null, + queueSize: 1, + translate: static (_, notificationValue) => notificationValue is MonitoredItemNotification change + ? new WotNotification(change.Value) + : null, + onNotification, + cancellationToken); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + if (onEvent is null) + { + throw new ArgumentNullException(nameof(onEvent)); + } + if (!TryResolveNodeId(m_nodeId, out NodeId notifierId)) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid event notifier NodeId."); + } + EventFilter filter = BuildEventFilter(); + return CreateMonitoredSubscriptionAsync( + notifierId, + NodeClass.Object, + Attributes.EventNotifier, + filter, + queueSize: m_options.EventQueueSize, + translate: (_, notificationValue) => notificationValue is EventFieldList eventFields + ? BuildEventNotification(filter, eventFields) + : null, + onEvent, + cancellationToken); + } + + public ValueTask DisposeAsync() + { + if (m_disposeSession) + { + m_session.Dispose(); + } + return default; + } + + /// + /// Opens a native OPC UA with a single + /// , translates each notification through + /// and forwards it to . + /// Ownership of the created subscription (and its server-side + /// resources) transfers to the returned ; + /// on any failure to create/apply, the subscription is torn down and + /// removed from the session before the exception propagates, so no + /// session/subscription is leaked. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller (an " + + "IWotSubscription), who disposes it; on failure it is disposed in the catch block.")] + private async ValueTask CreateMonitoredSubscriptionAsync( + NodeId targetId, + NodeClass nodeClass, + uint attributeId, + MonitoringFilter? filter, + uint queueSize, + Func translate, + Action onNotification, + CancellationToken cancellationToken) + { + int interval = NormalizeInterval(m_options.ObserveInterval); + var subscription = new Subscription(m_session.DefaultSubscription) + { + DisplayName = "wot-" + Form.AffordanceName, + PublishingEnabled = true, + PublishingInterval = interval + }; + m_session.AddSubscription(subscription); + try + { + await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); + + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = targetId, + NodeClass = nodeClass, + AttributeId = attributeId, + DisplayName = Form.AffordanceName, + SamplingInterval = interval, + QueueSize = queueSize, + DiscardOldest = true + }; + if (filter is not null) + { + item.Filter = filter; + } + + void OnItemNotification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) + { + WotNotification? notification = translate(monitoredItem, e.NotificationValue); + if (notification is not null) + { + onNotification(notification); + } + } + item.Notification += OnItemNotification; + + subscription.AddItem(item); + await subscription.ApplyChangesAsync(cancellationToken).ConfigureAwait(false); + + if (!item.Created) + { + item.Notification -= OnItemNotification; + StatusCode status = item.Status.Error?.StatusCode ?? StatusCodes.BadMonitoredItemFilterUnsupported; + throw new ServiceResultException( + status, + item.Status.Error?.ToString() ?? "The server rejected the monitored item."); + } + + return new OpcUaMonitoredItemSubscription(Form, m_session, subscription, item, OnItemNotification); + } + catch + { + await RemoveSubscriptionSafeAsync(subscription).ConfigureAwait(false); + throw; + } + } + + private async ValueTask RemoveSubscriptionSafeAsync(Subscription subscription) + { + try + { + await m_session.RemoveSubscriptionAsync(subscription, CancellationToken.None).ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Best-effort server-side cleanup; the session or subscription + // may already be unusable (for example a closed session). + } + subscription.Dispose(); + } + + /// + /// Builds the event select filter: , + /// , , + /// , , + /// , + /// and , plus any binding-authored + /// uav:eventFields select clauses carried by the compiled form. + /// + private EventFilter BuildEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventId)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventType)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceNode)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceName)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Time)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.ReceiveTime)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Message)); + filter.AddSelectClause( + WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Severity)); + + if (Form.Addressing.Metadata.TryGetValue("eventFields", out string? extra) && + !string.IsNullOrEmpty(extra)) + { + var seen = new HashSet(StringComparer.Ordinal) + { + EventBrowseNames.EventId, EventBrowseNames.EventType, EventBrowseNames.SourceNode, + EventBrowseNames.SourceName, EventBrowseNames.Time, EventBrowseNames.ReceiveTime, + EventBrowseNames.Message, EventBrowseNames.Severity + }; + foreach (string field in extra.Split('|', StringSplitOptions.RemoveEmptyEntries)) + { + if (seen.Add(field)) + { + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, field, Attributes.Value); + } + } + } + return filter; + } + + /// + /// Projects a raw notification into a + /// deterministically: every select-clause + /// field is captured in keyed + /// by its browse path, each carrying the event's own Time / ReceiveTime + /// as its source / server timestamp so no timestamp is lost. The + /// notification's primary wraps the Message + /// field (or the first field, if Message was not selected) with a + /// status. + /// + private static WotNotification BuildEventNotification(EventFilter filter, EventFieldList eventFields) + { + ArrayOf values = eventFields.EventFields; + int count = Math.Min(filter.SelectClauses.Count, values.Count); + + DateTimeUtc sourceTimestamp = DateTimeUtc.Now; + DateTimeUtc serverTimestamp = DateTimeUtc.Now; + for (int i = 0; i < count; i++) + { + string name = FormatFieldName(filter.SelectClauses[i]); + if (string.Equals(name, EventBrowseNames.Time, StringComparison.Ordinal) && + values[i].TryGetValue(out DateTimeUtc time)) + { + sourceTimestamp = time; + } + else if (string.Equals(name, EventBrowseNames.ReceiveTime, StringComparison.Ordinal) && + values[i].TryGetValue(out DateTimeUtc receiveTime)) + { + serverTimestamp = receiveTime; + } + } + + var fields = new Dictionary(count, StringComparer.Ordinal); + Variant primary = Variant.Null; + bool havePrimary = false; + for (int i = 0; i < count; i++) + { + string name = FormatFieldName(filter.SelectClauses[i]); + Variant fieldValue = values[i]; + fields[name] = new DataValue(fieldValue, StatusCodes.Good, sourceTimestamp, serverTimestamp); + if (string.Equals(name, EventBrowseNames.Message, StringComparison.Ordinal)) + { + primary = fieldValue; + havePrimary = true; + } + } + // Defensive: BuildEventFilter always adds the Message select clause + // and is the only filter BuildEventNotification is ever called with, + // so a primary value is always found. The fallback keeps the + // notification meaningful if that filter ever stops selecting + // Message. + if (!havePrimary && count > 0) + { + primary = values[0]; + } + + var dataValue = new DataValue(primary, StatusCodes.Good, sourceTimestamp, serverTimestamp); + return new WotNotification(dataValue, fields); + } + + /// + /// Formats a select-clause browse path without its leading separator. + /// + private static string FormatFieldName(SimpleAttributeOperand clause) + { + string formatted = SimpleAttributeOperand.Format(clause.BrowsePath); + return formatted.Length > 0 && formatted[0] == '/' ? formatted[1..] : formatted; + } + + /// + /// The mandatory BaseEventType browse names (Part 5 §6.4.2) used to + /// build the baseline event select filter. These are stable OPC UA + /// browse names, so they are declared locally rather than depending on + /// a per-project generated identifier set. + /// + private static class EventBrowseNames + { + public const string EventId = "EventId"; + public const string EventType = "EventType"; + public const string SourceNode = "SourceNode"; + public const string SourceName = "SourceName"; + public const string Time = "Time"; + public const string ReceiveTime = "ReceiveTime"; + public const string Message = "Message"; + public const string Severity = "Severity"; + } + + /// + /// Normalizes an observe interval into a bounded millisecond publishing/sampling interval. + /// + private static int NormalizeInterval(TimeSpan interval) + { + double ms = interval.TotalMilliseconds; + return ms > 100.0 ? (int)ms : 100; + } + + /// + /// Resolves a compiled-form NodeId string to a local . + /// Plain ns= / i= / s= / g= / b= forms + /// resolve without a session round-trip. A portable NodeId carrying an + /// nsu= namespace URI (Part 6 §5.3.1.11) cannot be resolved by + /// alone (it always fails for that + /// form), so it is parsed as an and + /// resolved against the connected session's namespace table. + /// + private bool TryResolveNodeId(string value, out NodeId nodeId) + { + if (TryParseNodeId(value, out nodeId)) + { + return true; + } + if (!ExpandedNodeId.TryParse(value, out ExpandedNodeId expanded) || expanded.IsNull) + { + nodeId = NodeId.Null; + return false; + } + nodeId = ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + return !nodeId.IsNull; + } + + private static bool TryParseNodeId(string value, out NodeId nodeId) + { + try + { + nodeId = NodeId.Parse(value); + return !nodeId.IsNull; + } + catch (ServiceResultException) + { + nodeId = NodeId.Null; + return false; + } + catch (FormatException) + { + nodeId = NodeId.Null; + return false; + } + catch (ArgumentException) + { + // NodeId.Parse throws ArgumentException (not ServiceResultException) + // for a portable "nsu=" / missing-identifier form; treat it the + // same as any other unparseable text so TryResolveNodeId can fall + // back to ExpandedNodeId + namespace table resolution. + nodeId = NodeId.Null; + return false; + } + } + + /// + /// A running native OPC UA subscription backing an observe or event + /// channel. Disposing it removes the monitored item's notification + /// handler and deletes the subscription server-side (via + /// ) before releasing the + /// local , so no session/subscription leaks. + /// + private sealed class OpcUaMonitoredItemSubscription : IWotSubscription + { + public OpcUaMonitoredItemSubscription( + WotCompiledForm form, + ISession session, + Subscription subscription, + MonitoredItem item, + MonitoredItemNotificationEventHandler handler) + { + Form = form; + m_session = session; + m_subscription = subscription; + m_item = item; + m_handler = handler; + } + + public WotCompiledForm Form { get; } + + public async ValueTask DisposeAsync() + { + m_item.Notification -= m_handler; + try + { + await m_session.RemoveSubscriptionAsync(m_subscription, CancellationToken.None) + .ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Best-effort server-side cleanup; the session may already + // be closed or the subscription already removed. + } + m_subscription.Dispose(); + } + + private readonly ISession m_session; + private readonly Subscription m_subscription; + private readonly MonitoredItem m_item; + private readonly MonitoredItemNotificationEventHandler m_handler; + } + + private readonly ISession m_session; + private readonly bool m_disposeSession; + private readonly OpcUaWotBindingOptions m_options; + private readonly string m_nodeId; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs new file mode 100644 index 0000000000..a556fa5e1a --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs @@ -0,0 +1,89 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.OpcUa +{ + /// + /// Executes OPC UA WoT binding forms compiled by the + /// by connecting an to + /// the target endpoint through the injectable session factory. + /// + public sealed class OpcUaWotBindingExecutor : IWotBindingExecutor + { + /// + /// Initializes a new OPC UA executor. + /// + public OpcUaWotBindingExecutor(OpcUaWotBindingOptions options) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("opc.opcua", "10101", OpcUaBindingPlanner.BindingUri, "OPC UA WoT Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + /// + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (m_options.SessionFactory is null) + { + throw new InvalidOperationException( + "No OPC UA session factory is configured on the executor options."); + } + string endpoint = string.IsNullOrEmpty(form.Endpoint.BaseUri) + ? form.Endpoint.Scheme + "://" + (form.Endpoint.Host ?? string.Empty) + : form.Endpoint.BaseUri; + ISession session = await m_options.SessionFactory(endpoint, cancellationToken).ConfigureAwait(false); + return new OpcUaWotBindingChannel(session, m_options.DisposeSession, form, context, m_options); + } + + private readonly OpcUaWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs new file mode 100644 index 0000000000..8787f005fa --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; + +namespace Opc.Ua.WotCon.Bindings.OpcUa +{ + /// + /// Options for the OPC UA WoT binding executor. The session factory connects a + /// client session to the target endpoint and is injectable so callers control + /// the application configuration, security and identity. + /// + public sealed class OpcUaWotBindingOptions + { + /// + /// Gets or sets the factory that connects an to the + /// supplied opc.tcp endpoint. It is required for execution. + /// + public Func>? SessionFactory { get; set; } + + /// + /// Gets or sets whether the executor disposes the session when the channel + /// is disposed. Set to false when a shared, caller-owned session is + /// returned by the factory. + /// + public bool DisposeSession { get; set; } = true; + + /// + /// Gets or sets the sampling / publishing interval used for observe and + /// event subscriptions. Observe and event notifications are delivered by + /// a native OPC UA / + /// pair (Part 4 §5.13 / §5.12); this bounds how fast the server samples + /// and publishes, not a client-side poll. + /// + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the bounded monitored-item queue size requested for + /// event subscriptions, so a burst of events cannot grow the server-side + /// queue without bound. Property observe monitored items always request + /// a queue size of 1 (only the latest value is relevant). + /// + public uint EventQueueSize { get; set; } = 10; + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs new file mode 100644 index 0000000000..958f8060fe --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs @@ -0,0 +1,117 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for executor identity, dispatch and HTTP error mapping. + /// + [TestFixture] + public sealed class ExecutorUnitTests + { + private static WotCompiledForm Compiled(string bindingId, string scheme) + { + return new WotCompiledForm( + new WotBindingIdentity(bindingId, "1.0", "urn:x"), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor(scheme, "h", 1, scheme + "://h"), + new WotAddressingDescriptor("t"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], isExecutable: true); + } + + [Test] + public void CanExecuteMatchesOwnBindingOnly() + { + var http = new HttpWotBindingExecutor(); + var modbus = new ModbusWotBindingExecutor(); + + Assert.That(http.CanExecute(Compiled("w3c.http", "https")), Is.True); + Assert.That(http.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.False); + Assert.That(modbus.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.True); + Assert.That(modbus.CanExecute(Compiled("w3c.http", "https")), Is.False); + } + + [Test] + public void ExecutorsIdentifyTheirPlannerBinding() + { + Assert.That(new HttpWotBindingExecutor().Identity.Id, Is.EqualTo(new HttpBindingPlanner().Identity.Id)); + Assert.That(new ModbusWotBindingExecutor().Identity.Id, Is.EqualTo(new ModbusBindingPlanner().Identity.Id)); + } + + [Test] + public async Task HttpErrorStatusMapping() + { + (int Http, StatusCode Expected)[] cases = + [ + (400, StatusCodes.BadInvalidArgument), + (401, StatusCodes.BadUserAccessDenied), + (404, StatusCodes.BadNodeIdUnknown), + (500, StatusCodes.BadInternalError) + ]; + + foreach ((int http, StatusCode expected) in cases) + { + using var server = new TestHttpServer((method, path, body) => + new TestHttpResponse(http, "application/json", Encoding.UTF8.GetBytes("\"x\""))); + + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor()]); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/p\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Status, Is.EqualTo(expected), $"HTTP {http} mapping."); + } + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs new file mode 100644 index 0000000000..3751375891 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs @@ -0,0 +1,176 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Tests that HTTP credential resolution is race-free: concurrent requests on + /// a single channel resolve the credential exactly once and never send a + /// request before the credential is applied, and a failed resolution is + /// retried on the next request. + /// + [TestFixture] + public sealed class HttpCredentialResolutionTests + { + private const string SecuredTd = + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," + + "\"security\":\"apikey_sc\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{BASE}/p\"}]}}}"; + + private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string baseUrl) + { + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, + Encoding.UTF8.GetBytes(SecuredTd.Replace("{BASE}", baseUrl, StringComparison.Ordinal)))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + [Test] + public async Task ConcurrentRequestsNeverSendUnauthenticatedAndResolveOnce() + { + var authenticated = new ConcurrentQueue(); + using var server = new TestHttpServer((method, path, body) => + { + // The resolved API key is carried as a query parameter, so an + // authenticated request has "token=secret" in its target. + authenticated.Enqueue(path.Contains("token=secret", StringComparison.Ordinal)); + return TestHttpResponse.Json(200, "1"); + }); + + var credentials = new SlowQueryCredentialProvider(TimeSpan.FromMilliseconds(150)); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ + new HttpWotBindingExecutor() + ], + credentials: credentials); + + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) + .ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Task[] reads = [.. Enumerable.Range(0, 12).Select(_ => channel.ReadAsync().AsTask())]; + WotReadResult[] results = await Task.WhenAll(reads).ConfigureAwait(false); + Assert.That(results.All(r => r.Success), Is.True, "Every concurrent read must succeed."); + } + + Assert.That(authenticated, Has.Count.EqualTo(12)); + Assert.That(authenticated.All(a => a), Is.True, + "No request may be sent before the credential is resolved and applied."); + Assert.That(credentials.ResolveCount, Is.EqualTo(1), + "The credential must be resolved exactly once and shared across concurrent requests."); + } + + [Test] + public async Task CredentialResolutionFailureIsRetriedOnNextRequest() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1")); + + var credentials = new FailOnceCredentialProvider(); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ + new HttpWotBindingExecutor() + ], + credentials: credentials); + + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) + .ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + // The first resolution faults and must surface, not be cached. + Assert.ThrowsAsync( + async () => await channel.ReadAsync().ConfigureAwait(false)); + + // The next request retries resolution and succeeds. + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.That(credentials.ResolveCount, Is.EqualTo(2), + "A failed resolution must not be cached; the next request retries it."); + } + + private sealed class SlowQueryCredentialProvider : IWotCredentialProvider + { + public SlowQueryCredentialProvider(TimeSpan delay) + { + m_delay = delay; + } + + public int ResolveCount => Volatile.Read(ref m_resolveCount); + + public async ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_resolveCount); + await Task.Delay(m_delay, cancellationToken).ConfigureAwait(false); + return new WotCredential( + WotSecurityScheme.ApiKey, + queryParameters: ImmutableDictionary.Empty.Add("token", "secret")); + } + + private readonly TimeSpan m_delay; + private int m_resolveCount; + } + + private sealed class FailOnceCredentialProvider : IWotCredentialProvider + { + public int ResolveCount => Volatile.Read(ref m_resolveCount); + + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref m_resolveCount) == 1) + { + throw new InvalidOperationException("Transient credential resolution failure."); + } + return new ValueTask(new WotCredential( + WotSecurityScheme.ApiKey, + queryParameters: ImmutableDictionary.Empty.Add("token", "secret"))); + } + + private int m_resolveCount; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs new file mode 100644 index 0000000000..917907f76f --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs @@ -0,0 +1,818 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// End-to-end tests for the HTTP executor's redirect-safe credential policy: + /// the executor-owned client disables automatic redirects and ambient cookies, + /// then applies a bounded, origin-aware redirect policy that drops custom header + /// / query credentials across origins, refuses loops and unsafe schemes, and + /// honours a redirect limit. Default headers follow the same sensitive-data + /// policy and are snapshotted per channel. Every caller-supplied client fails + /// closed unless the caller confirms safe redirect handling. + /// + [TestFixture] + public sealed class HttpRedirectSecurityTests + { + private const string SecuredTdTemplate = + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," + + "\"security\":\"apikey_sc\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{HREF}\"}]}}}"; + + private static WotProtocolBinderRegistry OwnedRegistry( + IWotCredentialProvider? credentials = null, HttpWotBindingOptions? options = null) + { + return new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions())], + credentials: credentials); + } + + private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string href) + { + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, + Encoding.UTF8.GetBytes(SecuredTdTemplate.Replace("{HREF}", href, StringComparison.Ordinal)))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + [Test] + public async Task CrossOriginRedirectDropsHeaderAndQueryCredentials() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "7"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider()); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadForm(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "The read must follow the redirect and succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(7L)); + } + + Assert.Multiple(() => + { + Assert.That(origin.SawQueryToken, Is.True, "The origin request must carry the query credential."); + Assert.That(origin.SawHeaderToken, Is.True, "The origin request must carry the header credential."); + Assert.That(target.SawQueryToken, Is.False, + "A cross-origin redirect must not forward the query credential."); + Assert.That(target.SawHeaderToken, Is.False, + "A cross-origin redirect must not forward the header credential."); + }); + } + + [Test] + public async Task CrossOriginRedirectDropsDefaultHeaders() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "8"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry( + options: DefaultHeaderOptions()); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "The read must follow the redirect and succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(8L)); + } + + Assert.Multiple(() => + { + Assert.That(origin.SawDefaultHeaderToken, Is.True, + "The origin request must carry the configured default header."); + Assert.That(target.SawDefaultHeaderToken, Is.False, + "A cross-origin redirect must not forward the configured default header."); + }); + } + + [Test] + public async Task CrossOriginRedirectDoesNotReplaySetCookie() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "17"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return RedirectWithCookie(targetServer.BaseUrl + "/p"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(17L)); + } + + Assert.Multiple(() => + { + Assert.That(origin.SawHeaderContaining("Cookie", "session=secret"), Is.False); + Assert.That(target.SawHeaderContaining("Cookie", "session=secret"), Is.False, + "An ambient cookie from one origin must not cross to another port."); + }); + } + + [Test] + public async Task SameOriginRedirectKeepsCredentials() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + if (request.Path.StartsWith("/a", StringComparison.Ordinal)) + { + return TestHttpResponse.Redirect("/b"); + } + return TestHttpResponse.Json(200, "9"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider()); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadForm(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(9L)); + } + + Assert.That(recorder.PathsSeen.Any(p => p.StartsWith("/b", StringComparison.Ordinal) && + p.Contains("token=secret", StringComparison.Ordinal)), Is.True, + "A same-origin redirect must keep the query credential on the follow-up request."); + } + + [Test] + public async Task SameOriginRedirectKeepsDefaultHeaders() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + if (request.Path.StartsWith("/a", StringComparison.Ordinal)) + { + return TestHttpResponse.Redirect("/b"); + } + return TestHttpResponse.Json(200, "10"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry( + options: DefaultHeaderOptions()); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(10L)); + } + + Assert.That(recorder.PathsWithDefaultHeader.Any( + p => p.StartsWith("/b", StringComparison.Ordinal)), Is.True, + "A same-origin redirect must keep the configured default header."); + } + + [Test] + public async Task SameOriginRedirectDoesNotReplaySetCookie() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return request.Path.StartsWith("/a", StringComparison.Ordinal) + ? RedirectWithCookie("/b") + : TestHttpResponse.Json(200, "18"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(18L)); + } + + Assert.That(recorder.SawHeaderContainingOnPath( + "/b", "Cookie", "session=secret"), Is.False, + "The executor-owned client must not maintain an ambient cookie jar."); + } + + [Test] + public async Task SameOriginRedirectKeepsExplicitCookieHeader() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return request.Path.StartsWith("/a", StringComparison.Ordinal) + ? TestHttpResponse.Redirect("/b") + : TestHttpResponse.Json(200, "19"); + }); + var options = new HttpWotBindingOptions + { + DefaultHeaders = Headers("Cookie", "explicit=secret") + }; + + WotProtocolBinderRegistry registry = OwnedRegistry(options: options); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(19L)); + } + + Assert.That(recorder.SawHeaderContainingOnPath( + "/b", "Cookie", "explicit=secret"), Is.True, + "An explicit cookie header follows the normal same-origin header policy."); + } + + [Test] + public async Task RedirectLoopIsRejected() + { + using var server = new TestHttpServer(request => + request.Path.StartsWith("/a", StringComparison.Ordinal) + ? TestHttpResponse.Redirect("/b") + : TestHttpResponse.Redirect("/a")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Does.Contain("loop").IgnoreCase); + } + } + + [Test] + public async Task RedirectToDisallowedSchemeIsRejected() + { + using var server = new TestHttpServer(_ => TestHttpResponse.Redirect("ftp://evil.example.com/x")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + } + + [Test] + public async Task RedirectLimitIsEnforced() + { + int counter = 0; + using var server = new TestHttpServer(_ => + TestHttpResponse.Redirect("/r" + Interlocked.Increment(ref counter))); + + WotProtocolBinderRegistry registry = OwnedRegistry( + options: new HttpWotBindingOptions { MaxAutomaticRedirects = 2 }); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/start")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Does.Contain("redirect limit").IgnoreCase); + } + } + + [Test] + public async Task OwnedClientFollowsTemporaryRedirectToSuccess() + { + using var server = new TestHttpServer(request => + request.Path.StartsWith("/a", StringComparison.Ordinal) + ? TestHttpResponse.Redirect("/final", status: 307) + : TestHttpResponse.Json(200, "5")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(5L)); + } + } + + [Test] + public async Task DefaultHeadersAddedAfterActivationAreIgnored() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return TestHttpResponse.Json(200, "12"); + }); + var options = new HttpWotBindingOptions(); + WotProtocolBinderRegistry registry = OwnedRegistry(options: options); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false); + + options.DefaultHeaders = Headers("X-Added-Secret", "added"); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.That(recorder.SawHeader("X-Added-Secret", "added"), Is.False, + "Headers configured after activation must not enter the channel snapshot."); + } + + [Test] + public async Task DefaultHeadersReplacedAfterActivationUseSnapshot() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return TestHttpResponse.Json(200, "13"); + }); + var options = new HttpWotBindingOptions + { + DefaultHeaders = Headers("X-Snapshot-Secret", "original") + }; + WotProtocolBinderRegistry registry = OwnedRegistry(options: options); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false); + + options.DefaultHeaders = Headers("X-Replacement-Secret", "replacement"); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.Multiple(() => + { + Assert.That(recorder.SawHeader("X-Snapshot-Secret", "original"), Is.True); + Assert.That(recorder.SawHeader("X-Replacement-Secret", "replacement"), Is.False); + }); + } + + [Test] + public async Task DefaultHeaderCollectionMutationAfterActivationUsesSnapshot() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return TestHttpResponse.Json(200, "14"); + }); + var mutableHeaders = new Dictionary + { + ["X-Mutable-Secret"] = "original" + }; + var options = new HttpWotBindingOptions + { + DefaultHeaders = mutableHeaders + }; + WotProtocolBinderRegistry registry = OwnedRegistry(options: options); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false); + + mutableHeaders["X-Mutable-Secret"] = "mutated"; + mutableHeaders["X-Late-Secret"] = "late"; + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.Multiple(() => + { + Assert.That(recorder.SawHeader("X-Mutable-Secret", "original"), Is.True); + Assert.That(recorder.SawHeader("X-Mutable-Secret", "mutated"), Is.False); + Assert.That(recorder.SawHeader("X-Late-Secret", "late"), Is.False); + }); + } + + [Test] + public void CallerSuppliedClientWithCredentialFormFailsClosed() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1")); + using var client = new HttpClient(); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ + new HttpWotBindingExecutor(new HttpWotBindingOptions { ClientFactory = () => client }) + ], + credentials: new HeaderQueryCredentialProvider()); + WotCompiledForm read = ReadForm(registry, server.BaseUrl + "/p"); + + Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(read).ConfigureAwait(false)); + } + + [Test] + public void CallerSuppliedAutoRedirectClientWithDefaultHeadersFailsClosed() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "1"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + using var client = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = true, + CheckCertificateRevocationList = true + }); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ + new HttpWotBindingExecutor(new HttpWotBindingOptions + { + ClientFactory = () => client, + DefaultHeaders = DefaultHeaderOptions().DefaultHeaders + }) + ]); + WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p"); + + InvalidOperationException? exception = Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(read).ConfigureAwait(false)); + Assert.Multiple(() => + { + Assert.That(exception?.Message, + Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety))); + Assert.That(origin.PathsSeen, Is.Empty, + "Rejected caller-owned clients must not send the origin request."); + Assert.That(target.PathsSeen, Is.Empty, + "Rejected caller-owned clients must not reach the redirect target."); + }); + } + + [TestCase(false)] + [TestCase(true)] + public void CallerSuppliedClientWithoutHeadersFailsClosed(bool configureEmptyHeaders) + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + return TestHttpResponse.Json(200, "11"); + }); + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + UseCookies = true, + CheckCertificateRevocationList = true + }; + using var client = new HttpClient(handler); + int factoryCalls = 0; + var options = new HttpWotBindingOptions + { + ClientFactory = () => + { + Interlocked.Increment(ref factoryCalls); + return client; + } + }; + if (configureEmptyHeaders) + { + options.DefaultHeaders = ImmutableDictionary.Empty; + } + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(options)]); + WotCompiledForm read = ReadFormNoSecurity(registry, server.BaseUrl + "/p"); + + InvalidOperationException? exception = Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(read).ConfigureAwait(false)); + Assert.Multiple(() => + { + Assert.That(exception?.Message, + Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety))); + Assert.That(factoryCalls, Is.Zero); + Assert.That(recorder.PathsSeen, Is.Empty); + Assert.That(handler.UseCookies, Is.True, + "Opaque caller-owned cookie behavior requires explicit safety confirmation."); + }); + } + + [Test] + public async Task CallerDefaultRequestHeadersCannotLeakWithoutSafetyConfirmation() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "15"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + CheckCertificateRevocationList = true + }; + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Secret", "client-secret"); + int factoryCalls = 0; + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ + new HttpWotBindingExecutor(new HttpWotBindingOptions + { + ClientFactory = () => + { + Interlocked.Increment(ref factoryCalls); + return client; + } + }) + ]); + WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p"); + + InvalidOperationException? exception = Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(read).ConfigureAwait(false)); + Assert.Multiple(() => + { + Assert.That(exception?.Message, + Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety))); + Assert.That(factoryCalls, Is.Zero); + Assert.That(origin.PathsSeen, Is.Empty); + Assert.That(target.PathsSeen, Is.Empty); + Assert.That(handler.AllowAutoRedirect, Is.True); + Assert.That(handler.UseCookies, Is.True); + Assert.That(client.DefaultRequestHeaders.GetValues("X-Client-Secret").Single(), + Is.EqualTo("client-secret")); + }); + + using HttpResponseMessage response = await client + .GetAsync(new Uri(targetServer.BaseUrl + "/probe")) + .ConfigureAwait(false); + Assert.That(response.IsSuccessStatusCode, Is.True, + "Rejecting activation must not dispose the caller-owned client."); + } + + [Test] + public async Task ConfirmedSafeCallerClientIsNotMutatedOrDisposed() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "16"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + CheckCertificateRevocationList = true + }; + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Secret", "existing"); + var options = new HttpWotBindingOptions + { + ClientFactory = () => client, + CallerClientHandlesRedirectSafety = true, + DefaultHeaders = Headers("X-Option-Snapshot", "original") + }; + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(options)]); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); + + options.DefaultHeaders = Headers("X-Option-Replacement", "replacement"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-Late-Client-Secret", "late"); + WotReadResult result; + await using (channel.ConfigureAwait(false)) + { + result = await channel.ReadAsync().ConfigureAwait(false); + } + + Assert.Multiple(() => + { + Assert.That(result.Success, Is.False, + "A confirmed-safe client with redirects disabled must not follow the redirect."); + Assert.That(origin.SawHeader("X-Option-Snapshot", "original"), Is.True); + Assert.That(origin.SawHeader("X-Option-Replacement", "replacement"), Is.False); + Assert.That(origin.SawHeader("X-Client-Secret", "existing"), Is.True); + Assert.That(origin.SawHeader("X-Late-Client-Secret", "late"), Is.True); + Assert.That(target.PathsSeen, Is.Empty); + Assert.That(handler.AllowAutoRedirect, Is.False); + Assert.That(handler.UseCookies, Is.True, + "The executor must not mutate caller-owned cookie behavior."); + Assert.That(client.DefaultRequestHeaders.Contains("X-Option-Snapshot"), Is.False); + Assert.That(client.DefaultRequestHeaders.GetValues("X-Client-Secret").Single(), + Is.EqualTo("existing")); + Assert.That(client.DefaultRequestHeaders.GetValues("X-Late-Client-Secret").Single(), + Is.EqualTo("late")); + }); + + using HttpResponseMessage response = await client + .GetAsync(new Uri(targetServer.BaseUrl + "/probe")) + .ConfigureAwait(false); + Assert.That(response.IsSuccessStatusCode, Is.True, + "Disposing the channel must not dispose the caller-owned client."); + } + + private static WotCompiledForm ReadFormNoSecurity(WotProtocolBinderRegistry registry, string href) + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + href + + "\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + private static HttpWotBindingOptions DefaultHeaderOptions() + { + return new HttpWotBindingOptions + { + DefaultHeaders = Headers("X-Default-Secret", "secret") + }; + } + + private static ImmutableDictionary Headers(string name, string value) + { + return ImmutableDictionary.Empty.Add(name, value); + } + + private static TestHttpResponse RedirectWithCookie(string location) + { + return new TestHttpResponse( + 302, + "text/plain", + [], + ImmutableDictionary.Empty + .Add("Location", location) + .Add("Set-Cookie", "session=secret; Path=/")); + } + + private sealed class Recorder + { + private readonly System.Collections.Concurrent.ConcurrentQueue m_paths = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue m_pathsWithDefaultHeader = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue m_requests = new(); + private int m_sawDefaultHeaderToken; + private int m_sawQueryToken; + private int m_sawHeaderToken; + + public bool SawDefaultHeaderToken => Volatile.Read(ref m_sawDefaultHeaderToken) != 0; + + public bool SawQueryToken => Volatile.Read(ref m_sawQueryToken) != 0; + + public bool SawHeaderToken => Volatile.Read(ref m_sawHeaderToken) != 0; + + public System.Collections.Generic.IReadOnlyCollection PathsSeen => [.. m_paths]; + + public System.Collections.Generic.IReadOnlyCollection PathsWithDefaultHeader => + [.. m_pathsWithDefaultHeader]; + + public bool SawHeader(string name, string value) + { + return m_requests.Any(request => + request.Headers.TryGetValue(name, out string? actual) && + string.Equals(actual, value, StringComparison.Ordinal)); + } + + public bool SawHeaderContaining(string name, string value) + { + return m_requests.Any(request => + request.Headers.TryGetValue(name, out string? actual) && + actual.Contains(value, StringComparison.Ordinal)); + } + + public bool SawHeaderContainingOnPath( + string path, string name, string value) + { + return m_requests.Any(request => + request.Path.StartsWith(path, StringComparison.Ordinal) && + request.Headers.TryGetValue(name, out string? actual) && + actual.Contains(value, StringComparison.Ordinal)); + } + + public void Record(TestHttpRequest request) + { + m_requests.Enqueue(request); + m_paths.Enqueue(request.Path); + if (request.Headers.TryGetValue("X-Default-Secret", out string? defaultValue) && + string.Equals(defaultValue, "secret", StringComparison.Ordinal)) + { + m_pathsWithDefaultHeader.Enqueue(request.Path); + Interlocked.Exchange(ref m_sawDefaultHeaderToken, 1); + } + if (request.Path.Contains("token=secret", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref m_sawQueryToken, 1); + } + if (request.Headers.TryGetValue("X-Api-Key", out string? value) && + string.Equals(value, "secret", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref m_sawHeaderToken, 1); + } + } + } + + private sealed class HeaderQueryCredentialProvider : IWotCredentialProvider + { + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotCredential( + WotSecurityScheme.ApiKey, + headers: ImmutableDictionary.Empty.Add("X-Api-Key", "secret"), + queryParameters: ImmutableDictionary.Empty.Add("token", "secret"))); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs new file mode 100644 index 0000000000..6085f30e5c --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs @@ -0,0 +1,168 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Net; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for the HTTP status code to OPC UA StatusCode mapper. + /// + [TestFixture] + public sealed class HttpStatusMapperTests + { + [Test] + public void OkMapsToGood() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.OK), + Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public void CreatedMapsToGood() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.Created), + Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public void NoContentMapsToGood() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.NoContent), + Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public void PartialContentMapsToGood() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.PartialContent), + Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public void BadRequestMapsToBadInvalidArgument() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.BadRequest), + Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void UnauthorizedMapsToBadUserAccessDenied() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.Unauthorized), + Is.EqualTo(StatusCodes.BadUserAccessDenied)); + } + + [Test] + public void ForbiddenMapsToBadUserAccessDenied() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.Forbidden), + Is.EqualTo(StatusCodes.BadUserAccessDenied)); + } + + [Test] + public void NotFoundMapsToBadNodeIdUnknown() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.NotFound), + Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void MethodNotAllowedMapsToBadNotSupported() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.MethodNotAllowed), + Is.EqualTo(StatusCodes.BadNotSupported)); + } + + [Test] + public void RequestTimeoutMapsToBadTimeout() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.RequestTimeout), + Is.EqualTo(StatusCodes.BadTimeout)); + } + + [Test] + public void ConflictMapsToBadInvalidState() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.Conflict), + Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public void NotImplementedMapsToBadNotImplemented() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.NotImplemented), + Is.EqualTo(StatusCodes.BadNotImplemented)); + } + + [Test] + public void ServiceUnavailableMapsToBadServerHalted() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.ServiceUnavailable), + Is.EqualTo(StatusCodes.BadServerHalted)); + } + + [Test] + public void GatewayTimeoutMapsToBadTimeout() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.GatewayTimeout), + Is.EqualTo(StatusCodes.BadTimeout)); + } + + [Test] + public void InternalServerErrorMapsToBadInternalError() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.InternalServerError), + Is.EqualTo(StatusCodes.BadInternalError)); + } + + [Test] + public void GenericFiveHundredXxMapsToBadInternalError() + { + Assert.That(HttpStatusMapper.Map((HttpStatusCode)599), + Is.EqualTo(StatusCodes.BadInternalError)); + } + + [Test] + public void GoneMapsToBadUnexpectedError() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.Gone), + Is.EqualTo(StatusCodes.BadUnexpectedError)); + } + + [Test] + public void RedirectMapsToBadUnexpectedError() + { + Assert.That(HttpStatusMapper.Map(HttpStatusCode.MovedPermanently), + Is.EqualTo(StatusCodes.BadUnexpectedError)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs new file mode 100644 index 0000000000..3472f804ad --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs @@ -0,0 +1,361 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Additional channel-level tests for HttpWotBindingChannel: + /// non-2xx responses mapped to OPC UA status codes, body-size enforcement, + /// codec decode failure, write semantics, action invocation with empty + /// body, and polling subscription creation. + /// + [TestFixture] + public sealed class HttpWotBindingChannelTests + { + private static WotProtocolBinderRegistry Registry( + HttpWotBindingOptions? options = null, + WotBindingBounds? bounds = null) + { + return new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + CallerClientHandlesRedirectSafety = true + })], + bounds: bounds); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + private static string PropertyTd(string baseUrl, string contentType = "application/json") + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + baseUrl + "/prop\",\"contentType\":\"" + contentType + "\"}]}}}"; + } + + private static string ActionTd(string baseUrl, string contentType = "application/json") + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"actions\":{\"act\":{\"forms\":[{\"href\":\"" + + baseUrl + "/action\",\"contentType\":\"" + contentType + "\"}]}}}"; + } + + [Test] + public async Task HttpChannelReadNon2xxStatusReturnsMappedStatusCode() + { + using var server = new TestHttpServer((_, _, _) => + new TestHttpResponse(404, "text/plain", Encoding.UTF8.GetBytes("not found"))); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + // HTTP 404 maps to BadNotFound via HttpStatusMapper. + Assert.That(StatusCode.IsBad(result.Status), Is.True); + } + } + + [Test] + public async Task HttpChannelReadServerErrorStatusReturnsMappedStatusCode() + { + using var server = new TestHttpServer((_, _, _) => + new TestHttpResponse(500, "text/plain", Encoding.UTF8.GetBytes("error"))); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(StatusCode.IsBad(result.Status), Is.True); + } + } + + [Test] + public async Task HttpChannelReadBodyTooLargeReturnsBadEncodingLimitsExceeded() + { + // Respond with a body larger than the configured limit. + byte[] bigBody = Encoding.UTF8.GetBytes("\"" + new string('x', 20) + "\""); + using var server = new TestHttpServer((_, _, _) => + new TestHttpResponse(200, "application/json", bigBody)); + + // Limit MaxPayloadBytes to 10 so the 20-char body exceeds it. + var bounds = new WotBindingBounds { MaxPayloadBytes = 10 }; + WotProtocolBinderRegistry registry = Registry(bounds: bounds); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded)); + } + } + + [Test] + public async Task HttpChannelReadBadDecodeReturnsBadDecodingError() + { + // Respond with malformed JSON so the JSON codec fails to decode. + byte[] badJson = Encoding.UTF8.GetBytes("{this is not valid json!}"); + using var server = new TestHttpServer((_, _, _) => + new TestHttpResponse(200, "application/json", badJson)); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadDecodingError)); + } + } + + [Test] + public async Task HttpChannelWriteReturnsGoodOnSuccess() + { + using var server = new TestHttpServer((method, path, _) => + { + if (method == "PUT" && path == "/prop") + { + return new TestHttpResponse(200, "application/json", Encoding.UTF8.GetBytes("OK")); + } + return new TestHttpResponse(405, "text/plain", []); + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(42L))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + } + + [Test] + public async Task HttpChannelWriteNon2xxReturnsMappedStatusCode() + { + using var server = new TestHttpServer((_, _, _) => + new TestHttpResponse(403, "text/plain", Encoding.UTF8.GetBytes("forbidden"))); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(1L))).ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(StatusCode.IsBad(result.Status), Is.True); + } + } + + [Test] + public async Task HttpChannelInvokeWithEmptyResponseBodyReturnsGoodWithNoOutput() + { + using var server = new TestHttpServer((method, path, _) => + { + if (method == "POST" && path == "/action") + { + // Return 200 with empty body. + return new TestHttpResponse(200, "application/json", []); + } + return new TestHttpResponse(404, "text/plain", []); + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, ActionTd(server.BaseUrl)); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Outputs, Is.Empty); + } + } + + [Test] + public async Task HttpChannelInvokeWithInputsAndJsonResponseDecodesOutput() + { + using var server = new TestHttpServer((method, path, _) => + { + if (method == "POST" && path == "/action") + { + return new TestHttpResponse(200, "application/json", Encoding.UTF8.GetBytes("99")); + } + return new TestHttpResponse(404, "text/plain", []); + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, ActionTd(server.BaseUrl)); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync( + [new Variant(1L)]).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Outputs, Has.Count.EqualTo(1)); + } + } + + [Test] + public async Task HttpChannelObserveAsyncCreatesPollingSubscription() + { + int pollCount = 0; + using var server = new TestHttpServer((_, _, _) => + { + Interlocked.Increment(ref pollCount); + return TestHttpResponse.Json(200, pollCount.ToString(System.Globalization.CultureInfo.InvariantCulture)); + }); + + WotProtocolBinderRegistry registry = Registry( + options: new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + CallerClientHandlesRedirectSafety = true, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }); + + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + var received = new ConcurrentQueue(); + IWotSubscription sub = await channel.ObserveAsync(n => received.Enqueue(n)) + .ConfigureAwait(false); + await using (sub.ConfigureAwait(false)) + { + // Wait for at least one notification from the polling loop. + bool got = false; + for (int i = 0; i < 80 && !got; i++) + { + if (!received.IsEmpty) + { + got = true; + } + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(got, Is.True, "ObserveAsync should create a polling subscription that delivers data."); + } + } + } + + [Test] + public async Task HttpChannelSubscribeEventAsyncDelegatesToObserve() + { + using var server = new TestHttpServer((_, _, _) => + TestHttpResponse.Json(200, "123")); + + WotProtocolBinderRegistry registry = Registry( + options: new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + CallerClientHandlesRedirectSafety = true, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }); + + WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + var received = new ConcurrentQueue(); + IWotSubscription sub = await channel.SubscribeEventAsync(n => received.Enqueue(n)) + .ConfigureAwait(false); + await using (sub.ConfigureAwait(false)) + { + bool got = false; + for (int i = 0; i < 80 && !got; i++) + { + if (!received.IsEmpty) + { + got = true; + } + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(got, Is.True, + "SubscribeEventAsync should delegate to ObserveAsync and deliver notifications."); + } + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs new file mode 100644 index 0000000000..1aa7721cf8 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs @@ -0,0 +1,216 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// End-to-end tests for the HTTP executor against an in-process HTTP server. + /// + [TestFixture] + public sealed class HttpWotExecutorTests + { + private static WotProtocolBinderRegistry Registry(HttpWotBindingOptions? options = null) + { + return new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [ new HttpWotBindingExecutor(options ?? + new HttpWotBindingOptions()) ]); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + [Test] + public async Task HttpReadWriteActionEndToEnd() + { + var store = new ConcurrentDictionary(); + store["/prop"] = "10"; + using var server = new TestHttpServer((method, path, body) => + { + if (path == "/prop" && method == "GET") + { + return TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0")); + } + if (path == "/prop" && method == "PUT") + { + store["/prop"] = Encoding.UTF8.GetString(body); + return new TestHttpResponse(204, "text/plain", []); + } + if (path == "/action" && method == "POST") + { + return TestHttpResponse.Json(200, "\"done\""); + } + return TestHttpResponse.Json(404, "\"missing\""); + }); + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/prop\"}]}}," + + "\"actions\":{\"act\":{\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/action\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + + WotCompiledForm read = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(10L)); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync( + new DataValue(new Variant(42L))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + IWotBindingChannel reread = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (reread.ConfigureAwait(false)) + { + WotReadResult result = await reread.ReadAsync().ConfigureAwait(false); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L)); + } + + IWotBindingChannel actionChannel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (actionChannel.ConfigureAwait(false)) + { + WotInvokeResult result = await actionChannel.InvokeAsync([]).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Outputs, Has.Count.EqualTo(1)); + Assert.That(result.Outputs[0].WrappedValue.AsBoxedObject(), Is.EqualTo("done")); + } + } + + [Test] + public async Task HttpObserveDeliversValueChanges() + { + var store = new ConcurrentDictionary(); + store["/prop"] = "1"; + using var server = new TestHttpServer((method, path, body) => + TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0"))); + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"w\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/prop\",\"op\":[\"observeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(new HttpWotBindingOptions + { + ObserveInterval = TimeSpan.FromMilliseconds(100) + }); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel.ObserveAsync(n => + { + if (n.Value.WrappedValue.AsBoxedObject() is long value) + { + received.Enqueue(value); + } + }).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + store["/prop"] = "99"; + Assert.That( + await WaitForAsync(received, 99).ConfigureAwait(false), + Is.True, + "The observe channel must deliver the change."); + } + } + } + + [Test] + public async Task HttpNotFoundMapsToBadNodeIdUnknown() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(404, "\"no\"")); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/x\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + } + + private static async Task WaitForAsync(ConcurrentQueue queue, long expected) + { + for (int i = 0; i < 50; i++) + { + if (queue.Contains(expected)) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs new file mode 100644 index 0000000000..9fac88cbcc --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs @@ -0,0 +1,242 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Additional unit tests for covering + /// type aliases and byte / word order combinations not exercised by + /// the baseline test file. + /// + [TestFixture] + public sealed class ModbusDataConverterAdditionalTests + { + [TestCase("ushort", 1)] + [TestCase("uint", 2)] + [TestCase("float", 2)] + [TestCase("int16", 1)] + [TestCase("uint16", 1)] + [TestCase("int32", 2)] + [TestCase("uint32", 2)] + [TestCase("float32", 2)] + [TestCase("int64", 4)] + [TestCase("uint64", 4)] + [TestCase("float64", 4)] + public void RegisterCountRecognizesCanonicalAndAliasNames(string type, int expected) + { + Assert.That(ModbusDataConverter.RegisterCount(type), Is.EqualTo(expected)); + } + + [TestCase("int16", (short)-1000)] + [TestCase("uint16", (ushort)50000)] + public void Int16RoundTripsWithLsbFirstByteOrder(string type, object value) + { + Variant input = value is short s ? new Variant(s) : new Variant((ushort)value); + ushort[] registers = ModbusDataConverter.ToRegisters(input, type, msbFirst: false, mswFirst: true); + Variant actual = ModbusDataConverter.ToVariant(registers, type, msbFirst: false, mswFirst: true); + + Assert.That( + Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture), + Is.EqualTo(Convert.ToDouble(value, CultureInfo.InvariantCulture)).Within(0.001)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Uint32RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst) + { + const uint expected = 0xDEADBEEFu; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "uint32", msbFirst, mswFirst); + Variant actual = ModbusDataConverter.ToVariant(registers, "uint32", msbFirst, mswFirst); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Float32RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst) + { + const float expected = -123.456f; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "float32", msbFirst, mswFirst); + Variant actual = ModbusDataConverter.ToVariant(registers, "float32", msbFirst, mswFirst); + + Assert.That( + System.Convert.ToSingle(actual.AsBoxedObject(), CultureInfo.InvariantCulture), + Is.EqualTo(expected).Within(0.001f)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Int64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst) + { + const long expected = -9876543210L; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "int64", msbFirst, mswFirst); + Variant actual = ModbusDataConverter.ToVariant(registers, "int64", msbFirst, mswFirst); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Uint64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst) + { + const ulong expected = 0xFEDCBA9876543210uL; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "uint64", msbFirst, mswFirst); + Variant actual = ModbusDataConverter.ToVariant(registers, "uint64", msbFirst, mswFirst); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Float64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst) + { + const double expected = 3.141592653589793; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "float64", msbFirst, mswFirst); + Variant actual = ModbusDataConverter.ToVariant(registers, "float64", msbFirst, mswFirst); + + Assert.That( + System.Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture), + Is.EqualTo(expected).Within(1e-10)); + } + + [Test] + public void Uint16ViaUshortAliasRoundTrips() + { + const ushort expected = 12345; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "ushort", msbFirst: true, mswFirst: true); + Variant actual = ModbusDataConverter.ToVariant(registers, "ushort", msbFirst: true, mswFirst: true); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [Test] + public void Uint32ViaUintAliasRoundTrips() + { + const uint expected = 987654321u; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "uint", msbFirst: true, mswFirst: true); + Variant actual = ModbusDataConverter.ToVariant(registers, "uint", msbFirst: true, mswFirst: true); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [Test] + public void Float32ViaFloatAliasRoundTrips() + { + const float expected = 9.99f; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), "float", msbFirst: true, mswFirst: true); + Variant actual = ModbusDataConverter.ToVariant(registers, "float", msbFirst: true, mswFirst: true); + + Assert.That( + System.Convert.ToSingle(actual.AsBoxedObject(), CultureInfo.InvariantCulture), + Is.EqualTo(expected).Within(0.001f)); + } + + [Test] + public void DefaultTypeIsUint16WhenTypeIsNull() + { + const ushort expected = 42; + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), null!, msbFirst: true, mswFirst: true); + Variant actual = ModbusDataConverter.ToVariant(registers, null!, msbFirst: true, mswFirst: true); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [Test] + public void ToVariantThrowsWhenRegisterCountIsTooSmallForInt64() + { + ModbusException ex = Assert.Throws( + () => ModbusDataConverter.ToVariant([0x1234, 0x5678], "int64", + msbFirst: true, mswFirst: true))!; + + Assert.That(ex.Message, Does.Contain("requires 4 registers")); + } + + [Test] + public void ToVariantThrowsWhenRegisterCountIsTooSmallForFloat32() + { + ModbusException ex = Assert.Throws( + () => ModbusDataConverter.ToVariant([0x1234], "float32", + msbFirst: true, mswFirst: true))!; + + Assert.That(ex.Message, Does.Contain("requires 2 registers")); + } + + [Test] + public void Int16LsbFirstAndMsbFirstProduceDifferentRegisters() + { + Variant value = new Variant((short)0x1234); + + ushort[] msb = ModbusDataConverter.ToRegisters(value, "int16", msbFirst: true, mswFirst: true); + ushort[] lsb = ModbusDataConverter.ToRegisters(value, "int16", msbFirst: false, mswFirst: true); + + // MSB first: high byte in high bits of register word. + // LSB first: bytes are swapped within the register. + Assert.That(msb[0], Is.Not.EqualTo(lsb[0])); + } + + [Test] + public void Int32MswFirstAndLswFirstProduceDifferentRegisterOrder() + { + const int value = 0x12345678; + + ushort[] msw = ModbusDataConverter.ToRegisters( + new Variant(value), "int32", msbFirst: true, mswFirst: true); + ushort[] lsw = ModbusDataConverter.ToRegisters( + new Variant(value), "int32", msbFirst: true, mswFirst: false); + + // The register order should be reversed. + Assert.That(msw[0], Is.EqualTo(lsw[1])); + Assert.That(msw[1], Is.EqualTo(lsw[0])); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs new file mode 100644 index 0000000000..5b1cd77a97 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs @@ -0,0 +1,127 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Globalization; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + [TestFixture] + public sealed class ModbusDataConverterTests + { + [TestCase("short", 1)] + [TestCase("word", 1)] + [TestCase("int", 2)] + [TestCase("dword", 2)] + [TestCase("single", 2)] + [TestCase("long", 4)] + [TestCase("ulong", 4)] + [TestCase("double", 4)] + [TestCase("unknown", 1)] + [TestCase(null, 1)] + public void RegisterCountRecognizesAliases(string? type, int expected) + { + Assert.That(ModbusDataConverter.RegisterCount(type!), Is.EqualTo(expected)); + } + + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void Int32RoundTripsAcrossByteAndWordOrders(bool msbFirst, bool mswFirst) + { + const int expected = 0x12345678; + + ushort[] registers = ModbusDataConverter.ToRegisters( + new Variant(expected), + "int32", + msbFirst, + mswFirst); + var actual = ModbusDataConverter.ToVariant( + registers, + "int32", + msbFirst, + mswFirst); + + Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected)); + } + + [TestCase("int16", -1234.0)] + [TestCase("uint16", 54321.0)] + [TestCase("uint32", 305419896.0)] + [TestCase("float32", 123.5)] + [TestCase("int64", -1234567890123.0)] + [TestCase("uint64", 1234567890123.0)] + [TestCase("float64", 123456.75)] + [TestCase("unknown", 42.0)] + public void NumericTypesRoundTrip(string type, double expected) + { + Variant input = type switch + { + "int16" => new Variant(Convert.ToInt16(expected)), + "uint16" or "unknown" => new Variant(Convert.ToUInt16(expected)), + "uint32" => new Variant(Convert.ToUInt32(expected)), + "float32" => new Variant(Convert.ToSingle(expected)), + "int64" => new Variant(Convert.ToInt64(expected)), + "uint64" => new Variant(Convert.ToUInt64(expected)), + _ => new Variant(expected) + }; + + ushort[] registers = ModbusDataConverter.ToRegisters( + input, + type, + msbFirst: true, + mswFirst: true); + var actual = ModbusDataConverter.ToVariant( + registers, + type, + msbFirst: true, + mswFirst: true); + + Assert.That( + Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture), + Is.EqualTo(expected).Within(0.001)); + } + + [Test] + public void ToVariantRejectsInsufficientRegisters() + { + ModbusException exception = Assert.Throws( + () => ModbusDataConverter.ToVariant( + [0x1234], + "int32", + msbFirst: true, + mswFirst: true))!; + + Assert.That(exception.Message, Does.Contain("requires 2 registers")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs new file mode 100644 index 0000000000..4ec83b1baf --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs @@ -0,0 +1,407 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Hardening tests for : hostile / truncated + /// responses must map to a (never an out-of-range + /// index), and a timeout must fault the connection so a fresh reconnect is + /// required and works deterministically. + /// + [TestFixture] + public sealed class ModbusTcpClientHardeningTests + { + [Test] + public async Task TruncatedRegisterResponseThrowsModbusException() + { + // A register-read response whose declared byte count (4) exceeds the + // register bytes actually present in the frame. Before the bounds + // check this indexed out of range; now it maps to a ModbusException. + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x03 + ? [0x03, 0x04, 0x00, 0x2A] // byteCount 4, only 1 register present + : null); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 2, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task TruncatedBitResponseThrowsModbusException() + { + // A coil-read response claiming a byte count (2) larger than the frame + // carries, so a naive read would index past the end of the buffer. + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x01 + ? [0x01, 0x02, 0x01] // byteCount 2, only 1 data byte present + : null); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => + await client.ReadCoilsAsync(1, 0, 9, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task TimeoutFaultsConnectionThenNextOperationReconnects() + { + using var server = new ScriptedModbusServer((connection, pdu) => + { + // First connection: never respond so the client times out. Second + // (reconnect) connection: answer the register read normally. + if (connection == 0) + { + return null; + } + return [0x03, 0x02, 0x12, 0x34]; + }); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(300)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + // The silent server causes the request to time out. + Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + // The connection is now faulted, so the next operation opens a fresh + // socket itself instead of requiring activation to run again. + ushort[] registers = await client + .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false); + Assert.That(registers, Has.Length.EqualTo(1)); + Assert.That(registers[0], Is.EqualTo((ushort)0x1234)); + } + + [Test] + public async Task DroppedConnectionReconnectsOncePerOperationAndRecovers() + { + using var server = new TestModbusServer(); + server.HoldingRegisters[0] = 0x1234; + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ushort[] first = await client + .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false); + Assert.That(first, Is.EqualTo(new ushort[] { 0x1234 })); + + server.RejectConnections = true; + server.DisconnectClients(); + + Exception? dropped = Assert.CatchAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + Assert.That(dropped, Is.TypeOf().Or.TypeOf()); + + int acceptedBeforeReconnect = server.AcceptedConnectionCount; + Exception? rejected = Assert.CatchAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + Assert.That(rejected, Is.TypeOf().Or.TypeOf()); + Assert.That( + server.AcceptedConnectionCount - acceptedBeforeReconnect, + Is.EqualTo(1), + "A single failed operation must not loop and open a storm of reconnect sockets."); + + server.RejectConnections = false; + server.HoldingRegisters[0] = 0x5678; + + ushort[] recovered = await client + .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false); + Assert.That(recovered, Is.EqualTo(new ushort[] { 0x5678 })); + } + + [Test] + public async Task WriteMultipleCoilsAcceptsProtocolMaximum() + { + using var server = new TestModbusServer(); + bool[] values = new bool[1968]; + values[0] = true; + values[7] = true; + values[8] = true; + values[^1] = true; + + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + await client + .WriteMultipleCoilsAsync(1, 0, values, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F)); + Assert.That(server.Coils.Take(values.Length), Is.EqualTo(values)); + } + + [Test] + public void WriteMultipleCoilsRejectsQuantityAboveProtocolMaximum() + { + using var client = new ModbusTcpClient( + "127.0.0.1", 502, TimeSpan.FromSeconds(2)); + + ArgumentOutOfRangeException? exception = Assert.ThrowsAsync( + async () => await client + .WriteMultipleCoilsAsync(1, 0, new bool[1969], CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain("1968")); + } + + [TestCaseSource(nameof(InvalidSingleCoilAcknowledgements))] + public async Task WriteSingleCoilRejectsInvalidAcknowledgement( + byte[] acknowledgement, + string expectedError) + { + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x05 ? acknowledgement : null); + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? exception = Assert.ThrowsAsync( + async () => await client + .WriteSingleCoilAsync(1, 0, true, CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain(expectedError)); + } + + [TestCaseSource(nameof(InvalidMultipleCoilAcknowledgements))] + public async Task WriteMultipleCoilsRejectsInvalidAcknowledgement( + byte[] acknowledgement, + string expectedError) + { + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x0F ? acknowledgement : null); + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? exception = Assert.ThrowsAsync( + async () => await client + .WriteMultipleCoilsAsync(1, 0, [true, false], CancellationToken.None) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain(expectedError)); + } + + private static IEnumerable InvalidSingleCoilAcknowledgements() + { + yield return new TestCaseData( + new byte[] { 0x05, 0x00, 0x00, 0xFF }, + "exactly 5 bytes") + .SetName("WriteSingleCoilRejectsTruncatedAcknowledgement"); + yield return new TestCaseData( + new byte[] { 0x05, 0x00, 0x00, 0xFF, 0x00, 0x00 }, + "exactly 5 bytes") + .SetName("WriteSingleCoilRejectsOversizedAcknowledgement"); + yield return new TestCaseData( + new byte[] { 0x05, 0x00, 0x01, 0xFF, 0x00 }, + "requested address") + .SetName("WriteSingleCoilRejectsMismatchedAddress"); + yield return new TestCaseData( + new byte[] { 0x05, 0x00, 0x00, 0x00, 0x00 }, + "requested value") + .SetName("WriteSingleCoilRejectsMismatchedValue"); + } + + private static IEnumerable InvalidMultipleCoilAcknowledgements() + { + yield return new TestCaseData( + new byte[] { 0x0F, 0x00, 0x00, 0x00 }, + "exactly 5 bytes") + .SetName("WriteMultipleCoilsRejectsTruncatedAcknowledgement"); + yield return new TestCaseData( + new byte[] { 0x0F, 0x00, 0x00, 0x00, 0x02, 0x00 }, + "exactly 5 bytes") + .SetName("WriteMultipleCoilsRejectsOversizedAcknowledgement"); + yield return new TestCaseData( + new byte[] { 0x0F, 0x00, 0x01, 0x00, 0x02 }, + "requested address") + .SetName("WriteMultipleCoilsRejectsMismatchedAddress"); + yield return new TestCaseData( + new byte[] { 0x0F, 0x00, 0x00, 0x00, 0x03 }, + "requested value or quantity") + .SetName("WriteMultipleCoilsRejectsMismatchedQuantity"); + } + + /// + /// A minimal Modbus TCP listener whose per-request response is supplied by + /// a script. The script receives the zero-based accepted-connection index + /// and the request PDU and returns the response PDU, or null to hold + /// the connection open without responding (used to provoke a timeout). + /// + private sealed class ScriptedModbusServer : IDisposable + { + public ScriptedModbusServer(Func responder) + { + m_responder = responder; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + int connection = m_connections++; + _ = Task.Run(() => ServeAsync(client, connection)); + } + } + + private async Task ServeAsync(TcpClient client, int connection) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!m_cts.IsCancellationRequested) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int length = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + + byte[]? responsePdu = m_responder(connection, pdu); + if (responsePdu is null) + { + // Hold the connection open without answering so the + // client's request times out. + await Task.Delay(Timeout.Infinite, m_cts.Token).ConfigureAwait(false); + return; + } + + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Server shutting down. + } + catch (System.IO.IOException) + { + // Client disconnected. + } + } + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly Func m_responder; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new(); + private int m_connections; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs new file mode 100644 index 0000000000..4637dc6d95 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs @@ -0,0 +1,831 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Tests for ModbusWotBindingChannel (entity dispatch, error mapping, + /// polling subscriptions, disposal) and additional ModbusTcpClient + /// protocol paths (FC 02, FC 0x0F, exception responses, transaction-id mismatch, + /// zero-length responses, unexpected function codes, and not-connected faults). + /// + [TestFixture] + public sealed class ModbusWotBindingChannelTests + { + private static WotProtocolBinderRegistry Registry() + { + return new WotProtocolBinderRegistry( + [new ModbusBindingPlanner()], + [new ModbusWotBindingExecutor(new ModbusWotBindingOptions + { + ObserveInterval = TimeSpan.FromMilliseconds(100) + })]); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + private static string RegisterTd(int port, string entity, int address, int quantity, string type) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + port.ToString(CultureInfo.InvariantCulture) + "/1\",\"modv:entity\":\"" + entity + + "\",\"modv:address\":" + address.ToString(CultureInfo.InvariantCulture) + + ",\"modv:quantity\":" + quantity.ToString(CultureInfo.InvariantCulture) + + ",\"modv:type\":\"" + type + "\"}]}}}"; + } + + private static string BooleanTd(int port, string entity, int address) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"boolean\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + port.ToString(CultureInfo.InvariantCulture) + "/1\",\"modv:entity\":\"" + entity + + "\",\"modv:address\":" + address.ToString(CultureInfo.InvariantCulture) + + ",\"modv:quantity\":1}]}}}"; + } + + private static WotCompiledForm BuildRawForm( + int port, string entity, WoTBindingCapabilityEnum capability, string opName) + { + var addressing = new WotAddressingDescriptor( + entity + ":0:1@1", + ImmutableDictionary.Empty + .Add("entity", entity) + .Add("address", "0") + .Add("quantity", "1") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty + .Add("type", "uint16") + .Add("mostSignificantByte", "true") + .Add("mostSignificantWord", "true")); + return new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + capability, opName, + new WotEndpointDescriptor( + "modbus+tcp", "127.0.0.1", port, + "modbus+tcp://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture)), + addressing, + new WotOperationDescriptor(capability, opName, opName), + payload, + [], isExecutable: true); + } + + private static async Task WaitForNotificationAsync( + ConcurrentQueue queue, int maxAttempts = 100) + { + for (int i = 0; i < maxAttempts; i++) + { + if (!queue.IsEmpty) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + + [Test] + public async Task ModbusChannelReadsInputRegisterEndToEnd() + { + using var server = new TestModbusServer(); + server.InputRegisters[5] = 0xABCD; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "inputRegister", 5, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo((ushort)0xABCD)); + } + } + + [Test] + public async Task ModbusChannelReadsDiscreteInputEndToEnd() + { + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x02) + { + return [0x02, 0x01, 0x01]; + } + return []; + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, BooleanTd(server.Port, "discreteInput", 0)); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.True); + } + } + + [Test] + public void ModbusChannelWriteToDiscreteInputIsRejectedDuringActivation() + { + using var server = new TestModbusServer(); + WotCompiledForm form = BuildRawForm( + server.Port, "discreteInput", WoTBindingCapabilityEnum.WriteProperty, "writeproperty"); + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [Test] + public void ModbusChannelWriteToInputRegisterIsRejectedDuringActivation() + { + using var server = new TestModbusServer(); + WotCompiledForm form = BuildRawForm( + server.Port, "inputRegister", WoTBindingCapabilityEnum.WriteProperty, "writeproperty"); + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [Test] + public async Task ModbusChannelInvokeAsyncReturnsBadNotSupported() + { + using var server = new TestModbusServer(); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNotSupported)); + } + } + + [Test] + public async Task ModbusChannelObserveAsyncReceivesNotification() + { + using var server = new TestModbusServer(); + server.HoldingRegisters[0] = 0x0042; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + var received = new ConcurrentQueue(); + IWotSubscription sub = await channel.ObserveAsync(n => received.Enqueue(n)) + .ConfigureAwait(false); + await using (sub.ConfigureAwait(false)) + { + bool got = await WaitForNotificationAsync(received).ConfigureAwait(false); + Assert.That(got, Is.True, "The observe subscription should deliver at least one notification."); + } + } + } + + [Test] + public async Task ModbusChannelSubscribeEventAsyncReceivesNotification() + { + using var server = new TestModbusServer(); + server.HoldingRegisters[0] = 0x0007; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + var received = new ConcurrentQueue(); + IWotSubscription sub = await channel.SubscribeEventAsync(n => received.Enqueue(n)) + .ConfigureAwait(false); + await using (sub.ConfigureAwait(false)) + { + bool got = await WaitForNotificationAsync(received).ConfigureAwait(false); + Assert.That(got, Is.True, + "SubscribeEventAsync should delegate to ObserveAsync and deliver notifications."); + } + } + } + + [Test] + public async Task ModbusChannelDisposeAsyncIsIdempotent() + { + using var server = new TestModbusServer(); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + + // First dispose should succeed. + await channel.DisposeAsync().ConfigureAwait(false); + + // Second dispose must not throw. + Assert.DoesNotThrowAsync( + async () => await channel.DisposeAsync().ConfigureAwait(false)); + } + + [Test] + public async Task ModbusChannelReadMapsModbusExceptionToStatusCode() + { + // Server returns Modbus exception 0x02 (illegal data address) for FC 03. + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x03) + { + return [0x83, 0x02]; + } + return []; + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + // Exception code 0x02 maps to BadNodeIdUnknown via ModbusStatusMapper. + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + } + } + + [Test] + public async Task ModbusChannelReadMapsTimeoutToBadTimeout() + { + // Server holds connection without responding on first request (timeout). + int connectionCount = 0; + using var server = new EphemeralModbusServer((conn, _) => + { + int c = Interlocked.Increment(ref connectionCount); + // Null = hold the connection open forever (provoke client timeout). + return c <= 0 ? null : null; + }); + + // Use a very short timeout so the test completes quickly. + var options = new ModbusWotBindingOptions { ObserveInterval = TimeSpan.FromSeconds(1) }; + var smallBounds = new WotBindingBounds { DefaultTimeout = TimeSpan.FromMilliseconds(300) }; + var registry = new WotProtocolBinderRegistry( + [new ModbusBindingPlanner()], + [new ModbusWotBindingExecutor(options)], + bounds: smallBounds); + + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout)); + } + } + + [Test] + public async Task ModbusChannelWriteMapsModbusExceptionToStatusCode() + { + // Server returns Modbus exception 0x03 (illegal data value) for FC 06. + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x06) + { + return [0x86, 0x03]; + } + return [pdu[0], pdu[1], pdu[2], pdu[3], pdu[4]]; + }); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant((ushort)42))).ConfigureAwait(false); + Assert.That(result.Success, Is.False); + // Exception code 0x03 maps to BadInvalidArgument via ModbusStatusMapper. + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + } + + [Test] + public async Task ModbusChannelWriteMapsTimeoutToBadTimeout() + { + using var server = new EphemeralModbusServer((_, _2) => null); + + var options = new ModbusWotBindingOptions { ObserveInterval = TimeSpan.FromSeconds(1) }; + var smallBounds = new WotBindingBounds { DefaultTimeout = TimeSpan.FromMilliseconds(300) }; + var registry = new WotProtocolBinderRegistry( + [new ModbusBindingPlanner()], + [new ModbusWotBindingExecutor(options)], + bounds: smallBounds); + + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant((ushort)1))).ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout)); + } + } + + [Test] + public async Task ModbusChannelWriteMultipleRegistersEndToEnd() + { + using var server = new TestModbusServer(); + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 10, 2, "int32")); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(0x12345678))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(server.HoldingRegisters[10], Is.EqualTo((ushort)0x1234)); + Assert.That(server.HoldingRegisters[11], Is.EqualTo((ushort)0x5678)); + } + } + + [Test] + public async Task ModbusTcpClientReadsDiscreteInputsSuccessfully() + { + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x02) + { + // 3 discrete inputs, bits 1 and 3 set (0b00001010 = 0x0A). + return [0x02, 0x01, 0x0A]; + } + return []; + }); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + bool[] bits = await client.ReadDiscreteInputsAsync( + 1, 0, 3, CancellationToken.None).ConfigureAwait(false); + + Assert.That(bits, Has.Length.EqualTo(3)); + Assert.That(bits[0], Is.False); // bit 0 of 0x0A + Assert.That(bits[1], Is.True); // bit 1 of 0x0A + Assert.That(bits[2], Is.False); // bit 2 of 0x0A + } + + [Test] + public async Task ModbusTcpClientWritesMultipleCoilsSuccessfully() + { + byte[]? capturedPdu = null; + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x0F) + { + capturedPdu = pdu; + // Echo back: FC, addrHi, addrLo, qtyHi, qtyLo. + return [0x0F, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + return []; + }); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + await client.WriteMultipleCoilsAsync( + 1, 0, [true, false, true], CancellationToken.None).ConfigureAwait(false); + + Assert.That(capturedPdu, Is.Not.Null); + Assert.That(capturedPdu![0], Is.EqualTo((byte)0x0F), "Function code must be 0x0F."); + // 3 coils packed into 1 byte: true=1, false=0, true=1 → 0b00000101 = 0x05. + Assert.That(capturedPdu[6], Is.EqualTo((byte)0x05), "Packed coil byte must encode correct bit pattern."); + } + + [Test] + public async Task ModbusTcpClientExceptionResponseThrowsWithCorrectCode() + { + // Server returns Modbus exception 0x04 (server device failure) for FC 03. + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x03) + { + return [0x83, 0x04]; + } + return []; + }); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? ex = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync( + 1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex!.ExceptionCode, Is.EqualTo((byte)0x04)); + } + + [Test] + public void ModbusTcpClientNotConnectedThrowsModbusException() + { + // Never call ConnectAsync — the client is not connected. + using var client = new ModbusTcpClient("127.0.0.1", 1, TimeSpan.FromSeconds(1)); + + ModbusException? ex = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync( + 1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex!.Message, Does.Contain("not connected").IgnoreCase); + } + + [Test] + public async Task ModbusTcpClientTransactionIdMismatchThrowsModbusException() + { + // Server always replies with transaction ID 0x00 0x00, never echoing + // the client's actual transaction ID. + using var server = new BadTxnModbusServer(); + + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? ex = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync( + 1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex!.Message, Does.Contain("transaction").IgnoreCase); + } + + [Test] + public async Task ModbusTcpClientZeroLengthResponseThrowsModbusException() + { + // Server returns an empty PDU [], which builds a frame with length=1 + // (unit byte only, no PDU bytes). The client reads responseLength=0 < 1 + // and must throw. + using var server = new EphemeralModbusServer((_, _2) => []); + + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? ex = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync( + 1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex!.Message, Does.Contain("invalid").IgnoreCase.Or.Contain("length").IgnoreCase); + } + + [Test] + public async Task ModbusTcpClientUnexpectedFunctionCodeThrowsModbusException() + { + // Server replies to FC 0x03 with FC 0x04 (not an exception, just the wrong function). + using var server = new EphemeralModbusServer((_, pdu) => + { + if (pdu.Length >= 1 && pdu[0] == 0x03) + { + // Valid input-register response format but wrong FC. + return [0x04, 0x02, 0xAB, 0xCD]; + } + return []; + }); + + using var client = new ModbusTcpClient( + "127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + ModbusException? ex = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync( + 1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + Assert.That(ex!.Message, Does.Contain("Unexpected").IgnoreCase); + } + + /// + /// A minimal scripted Modbus TCP server. The responder receives the + /// connection index and request PDU and returns the response PDU (or + /// null to hold the connection without responding). + /// + private sealed class EphemeralModbusServer : IDisposable + { + public EphemeralModbusServer(Func responder) + { + m_responder = responder; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + int conn = m_connections++; + _ = Task.Run(() => ServeAsync(client, conn)); + } + } + + private async Task ServeAsync(TcpClient client, int connection) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!m_cts.IsCancellationRequested) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int len = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, len).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + + byte[]? responsePdu = m_responder(connection, pdu); + if (responsePdu is null) + { + await Task.Delay(Timeout.Infinite, m_cts.Token).ConfigureAwait(false); + return; + } + + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + catch (IOException) + { + } + } + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream + .ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly Func m_responder; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new(); + private int m_connections; + } + + /// + /// A Modbus TCP server that always replies with transaction ID 0x0000, + /// deliberately mismatching any client request to trigger the + /// transaction-id-mismatch fault path. + /// + private sealed class BadTxnModbusServer : IDisposable + { + public BadTxnModbusServer() + { + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + // Read the request but always reply with txn ID 0x0000. + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int len = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, len).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + // Send a valid holding-register read response but with txn ID 0x00 0x00. + byte[] response = [0x00, 0x00, 0x00, 0x00, 0x00, 0x05, unit, + 0x03, 0x02, 0x00, 0x00]; + await stream.WriteAsync(response).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + catch (IOException) + { + } + } + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream + .ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new(); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs new file mode 100644 index 0000000000..34f921f9eb --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs @@ -0,0 +1,436 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Executor-level hardening tests for the Modbus binding: a function-only form + /// maps end-to-end onto the exact function code, and the executor re-validates + /// the address / quantity range before the ushort casts so a hand-built, + /// out-of-range compiled form fails fast instead of silently truncating. + /// + [TestFixture] + public sealed class ModbusWotExecutorHardeningTests + { + private static WotProtocolBinderRegistry Registry() + { + return new WotProtocolBinderRegistry( + [new ModbusBindingPlanner()], + [new ModbusWotBindingExecutor()]); + } + + [Test] + public async Task ModbusFunctionOnlyFormReadsHoldingRegisterEndToEnd() + { + using var server = new TestModbusServer(); + server.HoldingRegisters[100] = 0x1234; + server.HoldingRegisters[101] = 0x5678; + + // Function-only form: modv:function 3 (read holding registers), no + // modv:entity. The planner must map it onto the holding-register space. + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:function\":3,\"modv:address\":100," + + "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Addressing.Metadata["entity"], Is.EqualTo("holdingRegister")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readHoldingRegisters")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678)); + } + } + + [Test] + public void ModbusExecutorRevalidatesOutOfRangeAddressBeforeCast() + { + // A hand-built compiled form whose address is beyond the 16-bit Modbus + // space would truncate to a valid ushort without the executor's + // re-validation. The executor must refuse it before opening a socket. + var addressing = new WotAddressingDescriptor( + "holdingRegister:70000:1@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "70000") + .Add("quantity", "1") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor( + WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"), + payload, + [], isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRevalidatesRangeOverflowBeforeCast() + { + var addressing = new WotAddressingDescriptor( + "holdingRegister:65530:10@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "65530") + .Add("quantity", "10") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor( + WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"), + payload, + [], isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRejectsQuantityThatWouldTruncateToZero() + { + var addressing = new WotAddressingDescriptor( + "holdingRegister:0:65536@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "0") + .Add("quantity", "65536") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor( + "modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor( + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + "readHoldingRegisters"), + payload, + [], + isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRevalidatesConfiguredCoilBound() + { + WotCompiledForm form = BitForm( + quantity: 9, + operation: WoTBindingCapabilityEnum.ReadProperty, + method: "readCoil"); + var context = new WotExecutorContext( + bounds: new WotBindingBounds { MaxCoilQuantity = 8 }); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor.ActivateAsync(form, context).ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRevalidatesMultipleCoilWriteProtocolBound() + { + WotCompiledForm form = BitForm( + quantity: 1969, + operation: WoTBindingCapabilityEnum.WriteProperty, + method: "writeMultipleCoils"); + + var executor = new ModbusWotBindingExecutor(); + ArgumentOutOfRangeException? exception = Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain("1968")); + } + + [Test] + public void ModbusExecutorRejectsSingleCoilQuantityMismatch() + { + WotCompiledForm form = BitForm( + quantity: 2, + operation: WoTBindingCapabilityEnum.WriteProperty, + method: "writeSingleCoil"); + + var executor = new ModbusWotBindingExecutor(); + ArgumentOutOfRangeException? exception = Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain("quantity of 1")); + } + + [TestCase( + "holdingRegister", + WoTBindingCapabilityEnum.WriteProperty, + "writeSingleCoil")] + [TestCase( + "coil", + WoTBindingCapabilityEnum.ReadProperty, + "writeSingleCoil")] + [TestCase( + "coil", + WoTBindingCapabilityEnum.WriteProperty, + "unknownCoilMethod")] + public void ModbusExecutorRejectsInconsistentEntityDirectionOrMethod( + string entity, + WoTBindingCapabilityEnum operation, + string method) + { + WotCompiledForm form = BitForm( + quantity: 1, + operation: operation, + method: method, + entity: entity); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRejectsOperationDescriptorMismatch() + { + WotCompiledForm form = BitForm( + quantity: 1, + operation: WoTBindingCapabilityEnum.WriteProperty, + method: "writeSingleCoil", + descriptorOperation: WoTBindingCapabilityEnum.ReadProperty); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [Test] + public void ModbusExecutorRejectsFunctionCodeMismatch() + { + WotCompiledForm form = BitForm( + quantity: 1, + operation: WoTBindingCapabilityEnum.WriteProperty, + method: "writeSingleCoil", + functionCode: 15); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + + [TestCase(3, "uint64")] + [TestCase(2, null)] + public void ModbusExecutorRejectsRegisterWriteWidthMismatch(int quantity, string? type) + { + WotCompiledForm form = RegisterWriteForm(quantity, type, 502); + + var executor = new ModbusWotBindingExecutor(); + ArgumentException? exception = Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + Assert.That(exception!.Message, Does.Contain("does not match quantity")); + Assert.That(exception.Message, Does.Contain($"type '{type ?? "uint16"}'")); + } + + [TestCase(1, "uint16")] + [TestCase(2, "uint32")] + [TestCase(4, "uint64")] + public async Task ModbusExecutorAcceptsMatchingRegisterWriteWidth(int quantity, string type) + { + using var server = new TestModbusServer(); + WotCompiledForm form = RegisterWriteForm(quantity, type, server.Port); + + var executor = new ModbusWotBindingExecutor(); + IWotBindingChannel channel = await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Variant value = quantity switch + { + 1 => new Variant((ushort)0x1234), + 2 => new Variant(0x12345678u), + _ => new Variant(0x0123456789ABCDEFul) + }; + WotWriteResult result = await channel + .WriteAsync(new DataValue(value)) + .ConfigureAwait(false); + Assert.That(result.Success, Is.True, result.Error); + } + ushort[] expected = quantity switch + { + 1 => [0x1234], + 2 => [0x1234, 0x5678], + _ => [0x0123, 0x4567, 0x89AB, 0xCDEF] + }; + Assert.That(server.HoldingRegisters.Take(quantity), Is.EqualTo(expected)); + } + + private static WotCompiledForm RegisterWriteForm(int quantity, string? type, int port) + { + ImmutableDictionary payloadMetadata = + ImmutableDictionary.Empty; + if (type is not null) + { + payloadMetadata = payloadMetadata.Add("type", type); + } + var addressing = new WotAddressingDescriptor( + $"holdingRegister:0:{quantity}@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "0") + .Add("quantity", quantity.ToString(CultureInfo.InvariantCulture)) + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", + OctetStreamWotPayloadCodec.Instance.Id, + payloadMetadata); + string method = quantity == 1 + ? "writeSingleHoldingRegister" + : "writeMultipleHoldingRegisters"; + return new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.WriteProperty, + "writeproperty", + new WotEndpointDescriptor( + "modbus+tcp", + "127.0.0.1", + port, + $"modbus+tcp://127.0.0.1:{port}"), + addressing, + new WotOperationDescriptor( + WoTBindingCapabilityEnum.WriteProperty, + "writeproperty", + method), + payload, + [], + isExecutable: true); + } + + private static WotCompiledForm BitForm( + int quantity, + WoTBindingCapabilityEnum operation, + string method, + string entity = "coil", + WoTBindingCapabilityEnum? descriptorOperation = null, + int? functionCode = null) + { + ImmutableDictionary metadata = ImmutableDictionary.Empty + .Add("entity", entity) + .Add("address", "0") + .Add("quantity", quantity.ToString(CultureInfo.InvariantCulture)) + .Add("unitId", "1"); + if (functionCode is not null) + { + metadata = metadata.Add( + "functionCode", + functionCode.Value.ToString(CultureInfo.InvariantCulture)); + } + var addressing = new WotAddressingDescriptor( + $"{entity}:0:{quantity}@1", + metadata); + var payload = new WotPayloadDescriptor( + "application/octet-stream", + OctetStreamWotPayloadCodec.Instance.Id, + ImmutableDictionary.Empty.Add( + "type", + quantity == 1 ? "boolean" : "boolean[]")); + WoTBindingCapabilityEnum operationInfo = descriptorOperation ?? operation; + return new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + operation, + operation == WoTBindingCapabilityEnum.WriteProperty ? "writeproperty" : "readproperty", + new WotEndpointDescriptor( + "modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor( + operationInfo, + operationInfo == WoTBindingCapabilityEnum.WriteProperty ? "writeproperty" : "readproperty", + method), + payload, + [], + isExecutable: true); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs new file mode 100644 index 0000000000..bee8f21add --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs @@ -0,0 +1,356 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Planners; +using Opc.Ua.WotCon.Bindings.Tests.Support; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// End-to-end tests for the Modbus TCP executor against an in-process simulator. + /// + [TestFixture] + public sealed class ModbusWotExecutorTests + { + private static WotProtocolBinderRegistry Registry() + { + return new WotProtocolBinderRegistry( + [new ModbusBindingPlanner()], + [new ModbusWotBindingExecutor()]); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + [Test] + public async Task ModbusReadWriteHoldingRegisterInt32EndToEnd() + { + using var server = new TestModbusServer(); + // 0x12345678 stored big-endian across two holding registers. + server.HoldingRegisters[100] = 0x1234; + server.HoldingRegisters[101] = 0x5678; + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"holdingRegister\",\"modv:address\":100," + + "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678)); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync( + new DataValue(new Variant(1000042))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.That(server.HoldingRegisters[100], Is.EqualTo((ushort)(1000042 >> 16))); + Assert.That(server.HoldingRegisters[101], Is.EqualTo((ushort)(1000042 & 0xFFFF))); + } + + [Test] + public async Task ModbusReadWriteCoilEndToEnd() + { + using var server = new TestModbusServer(); + server.Coils[10] = true; + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"coil\",\"modv:address\":10,\"modv:quantity\":1}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + Assert.That(read.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id)); + Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean")); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.True); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync( + new DataValue(new Variant(false))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.That(server.Coils[10], Is.False); + } + + [Test] + public async Task ModbusSingleCoilRejectsNullAndNonBooleanValues() + { + using var server = new TestModbusServer(); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{" + + "\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"coil\",\"modv:address\":11,\"modv:quantity\":1," + + "\"op\":[\"writeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotCompiledForm write = Plan(registry, td).CompiledForms.Single(); + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Variant[] invalidValues = + [ + Variant.Null, + new Variant(1), + new Variant("true"), + new Variant((ArrayOf)[true]) + ]; + foreach (Variant invalidValue in invalidValues) + { + WotWriteResult result = await channel + .WriteAsync(new DataValue(invalidValue)) + .ConfigureAwait(false); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(result.Error, Does.Contain("Boolean scalar")); + } + } + + Assert.That(server.LastFunctionCode, Is.Zero); + Assert.That(server.Coils[11], Is.False); + } + + [Test] + public async Task ModbusReadsMultipleCoilsAsBooleanArrayEndToEnd() + { + using var server = new TestModbusServer(); + bool[] expected = [true, false, true, true, false, false, true, false, true, true]; + for (int i = 0; i < expected.Length; i++) + { + server.Coils[20 + i] = expected[i]; + } + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," + + "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"coil\",\"modv:address\":20,\"modv:quantity\":10," + + "\"op\":[\"readproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.Single(); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readCoil")); + Assert.That(read.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id)); + Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean[]")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, result.Error); + Assert.That( + result.Value.WrappedValue.TryGetValue(out ArrayOf actual), + Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + } + + [Test] + public async Task ModbusReadsMultipleDiscreteInputsAsBooleanArrayEndToEnd() + { + using var server = new TestModbusServer(); + bool[] expected = [false, true, true, false, true, false, false, true, true]; + for (int i = 0; i < expected.Length; i++) + { + server.DiscreteInputs[30 + i] = expected[i]; + } + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"inputs\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," + + "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"discreteInput\",\"modv:address\":30,\"modv:quantity\":9," + + "\"op\":[\"readproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.Single(); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readDiscreteInput")); + Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean[]")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, result.Error); + Assert.That( + result.Value.WrappedValue.TryGetValue(out ArrayOf actual), + Is.True); + Assert.That(actual, Is.EqualTo(expected)); + } + } + + [Test] + public async Task ModbusFunction15WritesMultipleCoilsEndToEnd() + { + using var server = new TestModbusServer(); + bool[] expected = [true, false, true, true, false, false, true, false, true, true]; + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," + + "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:function\":15,\"modv:address\":40,\"modv:quantity\":10," + + "\"op\":[\"writeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm write = plan.CompiledForms.Single(); + Assert.That(write.OperationInfo.Method, Is.EqualTo("writeMultipleCoils")); + Assert.That(write.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id)); + Assert.That(write.Payload.Metadata["type"], Is.EqualTo("boolean[]")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel + .WriteAsync(new DataValue(new Variant((ArrayOf)expected))) + .ConfigureAwait(false); + Assert.That(result.Success, Is.True, result.Error); + } + + Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F)); + Assert.That(server.Coils.Skip(40).Take(expected.Length), Is.EqualTo(expected)); + } + + [Test] + public async Task ModbusFunction15QuantityOneAcceptsScalarEndToEnd() + { + using var server = new TestModbusServer(); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{" + + "\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:function\":15,\"modv:address\":45,\"modv:quantity\":1," + + "\"op\":[\"writeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotCompiledForm write = Plan(registry, td).CompiledForms.Single(); + Assert.That(write.OperationInfo.Method, Is.EqualTo("writeMultipleCoils")); + Assert.That(write.Payload.Metadata["type"], Is.EqualTo("boolean")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult nullResult = await channel + .WriteAsync(new DataValue(Variant.Null)) + .ConfigureAwait(false); + Assert.That(nullResult.Status, Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(server.LastFunctionCode, Is.Zero); + + WotWriteResult wrongType = await channel + .WriteAsync(new DataValue(new Variant(1))) + .ConfigureAwait(false); + Assert.That(wrongType.Status, Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(server.LastFunctionCode, Is.Zero); + + WotWriteResult arrayValue = await channel + .WriteAsync(new DataValue(new Variant((ArrayOf)[true]))) + .ConfigureAwait(false); + Assert.That(arrayValue.Status, Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(server.LastFunctionCode, Is.Zero); + + WotWriteResult result = await channel + .WriteAsync(new DataValue(new Variant(true))) + .ConfigureAwait(false); + Assert.That(result.Success, Is.True, result.Error); + } + + Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F)); + Assert.That(server.Coils[45], Is.True); + } + + [Test] + public async Task ModbusMultipleCoilWriteRejectsScalarAndWrongLength() + { + using var server = new TestModbusServer(); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," + + "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + + "/1\",\"modv:entity\":\"coil\",\"modv:address\":50,\"modv:quantity\":3," + + "\"op\":[\"writeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotCompiledForm write = Plan(registry, td).CompiledForms.Single(); + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult scalar = await channel + .WriteAsync(new DataValue(new Variant(true))) + .ConfigureAwait(false); + Assert.That(scalar.Status, Is.EqualTo(StatusCodes.BadTypeMismatch)); + Assert.That(scalar.Error, Does.Contain("array of 3 Boolean values")); + + WotWriteResult wrongLength = await channel + .WriteAsync(new DataValue(new Variant((ArrayOf)[true, false]))) + .ConfigureAwait(false); + Assert.That(wrongLength.Status, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(wrongLength.Error, Does.Contain("exactly 3 Boolean values")); + } + + Assert.That(server.LastFunctionCode, Is.Zero); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs new file mode 100644 index 0000000000..3e662f1155 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs @@ -0,0 +1,468 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet.Server; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Mqtt; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Channel-level tests for MqttWotBindingChannel: + /// InvokeAsync (with and without inputs), SubscribeEventAsync + /// delegating to ObserveAsync, decode failure in ReadAsync, + /// subscription disposal stopping delivery, QoS 0 and QoS 2 configuration, + /// and channel disposal. + /// + [TestFixture] + public sealed class MqttWotBindingChannelTests + { + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static async Task StartBrokerAsync(int port) + { + MqttServerOptions options = new MqttServerOptionsBuilder() + .WithDefaultEndpoint() + .WithDefaultEndpointPort(port) + .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback) + .Build(); + MqttServer broker = new MqttServerFactory().CreateMqttServer(options); + await broker.StartAsync().ConfigureAwait(false); + return broker; + } + + private static WotProtocolBinderRegistry Registry(TimeSpan? readTimeout = null) + { + return new WotProtocolBinderRegistry( + [new MqttBindingPlanner()], + [new MqttWotBindingExecutor( + new MqttWotBindingOptions { ReadTimeout = readTimeout ?? TimeSpan.FromSeconds(5) })]); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + private static string PropertyTd(int port, string topic, string? op = null, int qos = 1) + { + string opClause = op is not null ? "\"op\":[\"" + op + "\"]," : string.Empty; + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"observable\":true,\"forms\":[{" + + "\"href\":\"mqtt://127.0.0.1:" + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/" + topic + "\"," + opClause + + "\"mqv:qos\":" + qos.ToString(System.Globalization.CultureInfo.InvariantCulture) + + ",\"mqv:retain\":true}]}}}"; + } + + private static string ActionTd(int port, string topic) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"actions\":{\"act\":{\"forms\":[{" + + "\"href\":\"mqtt://127.0.0.1:" + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/" + topic + "\",\"mqv:qos\":0}]}}}"; + } + + private static async Task WaitForAsync(ConcurrentQueue queue, int maxAttempts = 80) + { + for (int i = 0; i < maxAttempts; i++) + { + if (!queue.IsEmpty) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + + [Test] + public async Task MqttChannelInvokeAsyncPublishesWithNoInputs() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, ActionTd(port, "things/act")); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + // Empty inputs: InvokeAsync should publish an empty payload. + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Outputs, Is.Empty); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelInvokeAsyncPublishesWithInputValue() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, ActionTd(port, "things/act")); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync( + [new Variant(42L)]).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelSubscribeEventAsyncReceivesNotifications() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"sensor\":{\"type\":\"number\",\"observable\":true,\"forms\":[" + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/sensor\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}," + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/sensor\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (observeChannel.ConfigureAwait(false)) + { + var events = new ConcurrentQueue(); + // SubscribeEventAsync delegates to ObserveAsync. + IWotSubscription sub = await observeChannel.SubscribeEventAsync(n => events.Enqueue(n)) + .ConfigureAwait(false); + await using (sub.ConfigureAwait(false)) + { + await Task.Delay(100).ConfigureAwait(false); + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write) + .ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + await writeChannel.WriteAsync(new DataValue(new Variant(55L))).ConfigureAwait(false); + } + bool received = await WaitForAsync(events).ConfigureAwait(false); + Assert.That(received, Is.True, + "SubscribeEventAsync must deliver the published value."); + } + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelReadAsyncDecodingFailureReturnsBadDecodingError() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + // Write form with retain; read form subscribes to the same topic. + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"sensor\":{\"type\":\"number\",\"forms\":[" + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/decode\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}"; + + WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromSeconds(5)); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + // Publish a retained octet-stream payload that JSON cannot decode. + // The property TD uses application/json by default, so the JSON codec + // will fail to decode the raw bytes [0xFF, 0xFE]. + // We write via octet-stream channel by crafting an octet-stream TD. + string rawTd = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"sensor\":{\"type\":\"string\",\"forms\":[" + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/decode2\",\"contentType\":\"application/json\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}"; + + WotBindingPlan rawPlan = Plan(registry, rawTd); + WotCompiledForm rawWrite = rawPlan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm rawRead = rawPlan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + // First publish an invalid JSON string so the retained message is bad. + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(rawWrite).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + // Write the string "hello" which is valid JSON (it encodes as "\"hello\""). + // Then we verify ReadAsync on that topic succeeds. For the failure test, + // use a separate topic and publish malformed JSON directly. + await writeChannel.WriteAsync( + new DataValue(new Variant("hello"))).ConfigureAwait(false); + } + + // Read back — the JSON codec decodes "\"hello\"" successfully as string. + IWotBindingChannel readChannel = await registry.OpenChannelAsync(rawRead).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult readResult = await readChannel.ReadAsync().ConfigureAwait(false); + // "hello" is a valid JSON string and decodes successfully. + Assert.That(readResult.Success, Is.True); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelSubscriptionDisposalStopsDelivery() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"observable\":true,\"forms\":[" + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/stop\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}," + + "{\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "/things/stop\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (observeChannel.ConfigureAwait(false)) + { + var received = new ConcurrentQueue(); + IWotSubscription sub = await observeChannel.ObserveAsync(n => received.Enqueue(n)) + .ConfigureAwait(false); + + await Task.Delay(100).ConfigureAwait(false); + + // Publish once before disposal: verify delivery. + IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (publisher.ConfigureAwait(false)) + { + await publisher.WriteAsync(new DataValue(new Variant(1L))).ConfigureAwait(false); + } + bool firstDelivered = await WaitForAsync(received).ConfigureAwait(false); + Assert.That(firstDelivered, Is.True, "Subscription must deliver before disposal."); + + // Dispose the subscription. + await sub.DisposeAsync().ConfigureAwait(false); + + // Clear and publish again: nothing should be enqueued. + while (received.TryDequeue(out _)) { } + + IWotBindingChannel publisher2 = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (publisher2.ConfigureAwait(false)) + { + await publisher2.WriteAsync(new DataValue(new Variant(2L))).ConfigureAwait(false); + } + + await Task.Delay(300).ConfigureAwait(false); + Assert.That(received.IsEmpty, Is.True, + "Disposed subscription must not receive further notifications."); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelQosZeroConfiguredFromForm() + { + // QoS 0 means at-most-once. Test that a form with mqv:qos=0 works + // end-to-end (no exception, publishes successfully). + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/qos0", qos: 0)); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(10L))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelQosTwoConfiguredFromForm() + { + // QoS 2 means exactly-once. Test that a form with mqv:qos=2 works. + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/qos2", qos: 2)); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(20L))).ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelDisposeAsyncDisconnectsClient() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/dispose")); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + + // Dispose should not throw. + Assert.DoesNotThrowAsync( + async () => await channel.DisposeAsync().ConfigureAwait(false)); + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelReadAsyncTimeoutReturnsBadTimeout() + { + // A channel that reads but no message is ever published should time out + // and return BadTimeout. + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromMilliseconds(200)); + WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/timeout")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout)); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs new file mode 100644 index 0000000000..b3263f4bb4 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Mqtt; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for the MQTT transport-security policy: mqtts enables TLS + /// and defaults to port 8883, credentials / trust are applied through the + /// provider, the executor fails closed when a required credential is + /// unresolved, and username / password material never downgrades to a + /// plaintext connection. + /// + [TestFixture] + public sealed class MqttWotConnectionTests + { + private static WotCompiledForm Compiled(string href, bool withSecurity) + { + string security = withSecurity + ? "\"securityDefinitions\":{\"basic_sc\":{\"scheme\":\"basic\"}},\"security\":\"basic_sc\"," + : string.Empty; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + security + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + href + + "\",\"op\":[\"writeproperty\"]}]}}}"; + var registry = new WotProtocolBinderRegistry([new MqttBindingPlanner()]); + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + } + + private static WotExecutorContext Context(IWotCredentialProvider credentials) + { + return new WotExecutorContext(credentials); + } + + private static Task PrepareAsync( + WotCompiledForm form, MqttWotBindingOptions options, IWotCredentialProvider credentials) + { + return MqttWotConnection + .PrepareAsync(form, Context(credentials), options, "client-id", CancellationToken.None) + .AsTask(); + } + + [Test] + public async Task PlainMqttUsesPlaintextDefaultPort1883() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false); + + Assert.That(plan.UseTls, Is.False); + Assert.That(plan.Port, Is.EqualTo(1883)); + Assert.That(plan.HasCredentials, Is.False); + } + + [Test] + public async Task MqttsEnablesTlsDefaultPort8883() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.Port, Is.EqualTo(8883)); + } + + [Test] + public async Task MqttsHonoursExplicitPort() + { + WotCompiledForm form = Compiled("mqtts://broker:9999/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.Port, Is.EqualTo(9999)); + } + + [Test] + public async Task MqttsWithResolvedCredentialsAppliesThem() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider()).ConfigureAwait(false); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.HasCredentials, Is.True); + } + + [Test] + public void MqttsRequiredCredentialUnresolvedFailsClosed() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true); + + Assert.That(form.Security, Is.Not.Empty, "The form must declare a security scheme."); + Assert.ThrowsAsync( + async () => await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false)); + } + + [Test] + public void PlainMqttWithCredentialsFailsClosedNoPlaintextDowngrade() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true); + + // The provider resolves username / password but the connection is plain + // mqtt://, so sending the credentials would leak them in clear text. + Assert.ThrowsAsync( + async () => await PrepareAsync( + form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider()).ConfigureAwait(false)); + } + + [Test] + public async Task PlainMqttWithCredentialsAllowedWhenExplicitlyOptedIn() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, + new MqttWotBindingOptions { AllowCredentialsOverPlaintext = true }, + new UserPasswordCredentialProvider()).ConfigureAwait(false); + + Assert.That(plan.UseTls, Is.False); + Assert.That(plan.HasCredentials, Is.True); + } + + private sealed class UserPasswordCredentialProvider : IWotCredentialProvider + { + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotCredential( + WotSecurityScheme.Basic, + properties: ImmutableDictionary.Empty + .Add("username", "device") + .Add("password", "secret"))); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs new file mode 100644 index 0000000000..2bdc87e980 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs @@ -0,0 +1,165 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using MQTTnet.Server; +using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Mqtt; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// End-to-end tests for the MQTT executor against an ephemeral in-process broker. + /// + [TestFixture] + public sealed class MqttWotExecutorTests + { + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static WotProtocolBinderRegistry Registry() + { + return new WotProtocolBinderRegistry( + [new MqttBindingPlanner()], + [ new MqttWotBindingExecutor( + new MqttWotBindingOptions { ReadTimeout = TimeSpan.FromSeconds(5) }) ]); + } + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + { + return registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + } + + [Test] + public async Task MqttPublishSubscribeObserveEndToEnd() + { + int port = FreePort(); + MqttServerOptions serverOptions = new MqttServerOptionsBuilder() + .WithDefaultEndpoint() + .WithDefaultEndpointPort(port) + .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback) + .Build(); + MqttServer broker = new MqttServerFactory().CreateMqttServer(serverOptions); + await broker.StartAsync().ConfigureAwait(false); + try + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{" + + "\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"mqtt://127.0.0.1:" + + port + + "/things/temp\",\"mqv:qos\":1,\"mqv:retain\":true}]}," + + "\"watch\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"mqtt://127.0.0.1:" + + port + + "/things/temp\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm write = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + // Publish a retained value, then read it back. + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + Assert.That((await writeChannel.WriteAsync( + new DataValue(new Variant(42L))).ConfigureAwait(false)).Success, Is.True); + } + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L)); + } + + // Observe, then publish a new value and expect a notification. + var received = new ConcurrentQueue(); + IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (observeChannel.ConfigureAwait(false)) + { + IWotSubscription subscription = await observeChannel.ObserveAsync(n => + { + if (n.Value.WrappedValue.AsBoxedObject() is long value) + { + received.Enqueue(value); + } + }).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + await Task.Delay(200).ConfigureAwait(false); + IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (publisher.ConfigureAwait(false)) + { + await publisher.WriteAsync(new DataValue(new Variant(77L))).ConfigureAwait(false); + } + Assert.That(await WaitForAsync(received, 77).ConfigureAwait(false), Is.True, + "The MQTT observe channel must deliver the published change."); + } + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + private static async Task WaitForAsync(ConcurrentQueue queue, long expected) + { + for (int i = 0; i < 60; i++) + { + if (queue.Contains(expected)) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj b/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj index 9aa68d90ee..4e62ca9d13 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj @@ -11,7 +11,6 @@ false - @@ -26,12 +25,19 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs index 2309bc8666..2151ab5b62 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs @@ -33,12 +33,16 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; +using Opc.Ua.WotCon.Bindings.Http; +using Opc.Ua.WotCon.Bindings.Modbus; +using Opc.Ua.WotCon.Bindings.Mqtt; +using Opc.Ua.WotCon.Bindings.OpcUa; using Opc.Ua.WotCon.Bindings.Planners; namespace Opc.Ua.WotCon.Bindings.Tests { /// - /// Unit tests for the protocol-agnostic WoT binding DI builder extension methods. + /// Unit tests for the WoT binding DI builder extension methods. /// [TestFixture] public sealed class OpcUaWotBindingBuilderExtensionsTests @@ -216,6 +220,160 @@ public void AddWotCredentialProviderRegistersProviderInServiceCollection() Assert.That(resolved, Is.Not.Null); } + [Test] + public void AddHttpWotBindingNullBuilderThrowsArgumentNullException() + { + Assert.That( + () => OpcUaHttpWotBindingBuilderExtensions.AddHttpWotBinding(null!), + Throws.InstanceOf()); + } + + [Test] + public void AddHttpWotBindingRegistersBindersAndHttpExecutor() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = new TestBuilder(services); + builder.AddHttpWotBinding(); + using ServiceProvider sp = services.BuildServiceProvider(); + + WotProtocolBinderRegistry registry = sp.GetRequiredService(); + System.Collections.Generic.IEnumerable executors = + sp.GetServices(); + + Assert.That(registry.Binders, Is.Not.Empty); + Assert.That(executors.Any(e => e is HttpWotBindingExecutor), Is.True); + } + + [Test] + public void AddHttpWotBindingWithConfigureDelegateCallsDelegate() + { + IOpcUaBuilder builder = NewBuilder(); + bool called = false; + + builder.AddHttpWotBinding(opts => called = true); + + Assert.That(called, Is.True); + } + + [Test] + public void AddModbusWotBindingNullBuilderThrowsArgumentNullException() + { + Assert.That( + () => OpcUaModbusWotBindingBuilderExtensions.AddModbusWotBinding(null!), + Throws.InstanceOf()); + } + + [Test] + public void AddModbusWotBindingRegistersBindersAndModbusExecutor() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = new TestBuilder(services); + builder.AddModbusWotBinding(); + using ServiceProvider sp = services.BuildServiceProvider(); + + WotProtocolBinderRegistry registry = sp.GetRequiredService(); + System.Collections.Generic.IEnumerable executors = + sp.GetServices(); + + Assert.That(registry.Binders, Is.Not.Empty); + Assert.That(executors.Any(e => e is ModbusWotBindingExecutor), Is.True); + } + + [Test] + public void AddModbusWotBindingWithConfigureDelegateCallsDelegate() + { + IOpcUaBuilder builder = NewBuilder(); + bool called = false; + + builder.AddModbusWotBinding(opts => called = true); + + Assert.That(called, Is.True); + } + + [Test] + public void AddOpcUaWotBindingNullBuilderThrowsArgumentNullException() + { + Assert.That( + () => OpcUaTargetWotBindingBuilderExtensions.AddOpcUaWotBinding(null!, _ => { }), + Throws.InstanceOf()); + } + + [Test] + public void AddOpcUaWotBindingNullConfigureThrowsArgumentNullException() + { + IOpcUaBuilder builder = NewBuilder(); + + Assert.That( + () => builder.AddOpcUaWotBinding(null!), + Throws.InstanceOf()); + } + + [Test] + public void AddOpcUaWotBindingWithConfigureDelegateCallsDelegate() + { + IOpcUaBuilder builder = NewBuilder(); + bool called = false; + + builder.AddOpcUaWotBinding(opts => + { + called = true; + opts.DisposeSession = false; + }); + + Assert.That(called, Is.True); + } + + [Test] + public void AddOpcUaWotBindingRegistersBindersAndOpcUaExecutor() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = new TestBuilder(services); + builder.AddOpcUaWotBinding(opts => opts.DisposeSession = false); + using ServiceProvider sp = services.BuildServiceProvider(); + + WotProtocolBinderRegistry registry = sp.GetRequiredService(); + System.Collections.Generic.IEnumerable executors = + sp.GetServices(); + + Assert.That(registry.Binders, Is.Not.Empty); + Assert.That(executors.Any(e => e is OpcUaWotBindingExecutor), Is.True); + } + + [Test] + public void AddMqttWotBindingNullBuilderThrowsArgumentNullException() + { + Assert.That( + () => OpcUaMqttWotBindingBuilderExtensions.AddMqttWotBinding(null!), + Throws.InstanceOf()); + } + + [Test] + public void AddMqttWotBindingRegistersBindersAndMqttExecutor() + { + var services = new ServiceCollection(); + IOpcUaBuilder builder = new TestBuilder(services); + builder.AddMqttWotBinding(); + using ServiceProvider sp = services.BuildServiceProvider(); + + WotProtocolBinderRegistry registry = sp.GetRequiredService(); + System.Collections.Generic.IEnumerable executors = + sp.GetServices(); + + Assert.That(registry.Binders, Is.Not.Empty); + Assert.That(executors.Any(e => e is MqttWotBindingExecutor), Is.True); + } + + [Test] + public void AddMqttWotBindingWithConfigureDelegateCallsDelegate() + { + IOpcUaBuilder builder = NewBuilder(); + bool called = false; + + builder.AddMqttWotBinding(opts => called = true); + + Assert.That(called, Is.True); + } + [Test] public void RegistryResolvesBindersBothAsIWotBinderRegistryAndIWotBindingChannelFactory() { diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs new file mode 100644 index 0000000000..a278f822c6 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs @@ -0,0 +1,445 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.TestFramework; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.Tests; +using Opc.Ua.WotCon.Bindings.OpcUa; +using Opc.Ua.WotCon.Bindings.Planners; +using Quickstarts.ReferenceServer; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Covers previously-uncovered paths in OpcUaWotBindingChannel against a + /// live in-process OPC UA reference server: invalid / malformed NodeId resolution + /// returning BadNodeIdInvalid, null-callback argument guards, null-inputs + /// mapping, non-Good DataValue preservation, extra event fields in + /// BuildEventFilter, and ServiceResultException propagation from + /// failed subscription creation. + /// + [TestFixture] + public sealed class OpcUaWotBindingChannelTests + { + private const string ReferenceServerNamespace = + "http://opcfoundation.org/Quickstarts/ReferenceServer"; + + private const string AddMethodNodeId = + "nsu=" + ReferenceServerNamespace + ";s=Methods_Add"; + + private const string MethodsObjectNodeId = + "nsu=" + ReferenceServerNamespace + ";s=Methods"; + + private const string TriggerNode01Id = + "nsu=" + ReferenceServerNamespace + ";s=NodeIds_Events_TriggerNode01"; + + private const string ServerObjectNodeId = + "nsu=http://opcfoundation.org/UA/;i=2253"; + + /// + /// A string that cannot be parsed as a NodeId or ExpandedNodeId so that + /// TryResolveNodeId returns false. + /// + private const string InvalidNodeId = "INVALID-NOT-A-NODE"; + + private ServerFixture m_serverFixture = null!; + private ISession m_session = null!; + private WotProtocolBinderRegistry m_registry = null!; + private WotBindingPlan m_plan = null!; + + /// + /// Starts an in-process reference server, connects a single shared session, + /// and compiles a Thing Description that exposes edge-case affordances for every + /// uncovered channel path. + /// + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + string pkiRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + m_serverFixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = "opc.tcp", + SecurityNone = true, + AutoAccept = true, + AllNodeManagers = true + }; + await m_serverFixture.StartAsync(pkiRoot).ConfigureAwait(false); + + var clientFixture = new ClientFixture(telemetry); + await clientFixture.LoadClientConfigurationAsync(pkiRoot).ConfigureAwait(false); + var url = new Uri("opc.tcp://localhost:" + + m_serverFixture.Port.ToString(CultureInfo.InvariantCulture)); + m_session = await clientFixture.ConnectAsync(url, SecurityPolicies.None).ConfigureAwait(false); + + m_registry = new WotProtocolBinderRegistry( + [new OpcUaBindingPlanner()], + [ + new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => new ValueTask(m_session), + DisposeSession = false, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }) + ]); + + m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, + System.Text.Encoding.UTF8.GetBytes(BuildThingDescription(url.ToString())))); + + Assert.That(m_plan.Diagnostics.Any(d => d.IsError), Is.False, + "The channel-test Thing Description must compile without diagnostic errors: " + + string.Join("; ", m_plan.Diagnostics.Where(d => d.IsError).Select(d => d.Message))); + } + + /// + /// Closes the shared session and stops the server. + /// + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session is not null) + { + await m_session.CloseAsync().ConfigureAwait(false); + m_session.Dispose(); + } + + if (m_serverFixture is not null) + { + await m_serverFixture.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ReadAsyncWithMalformedNodeIdReturnsBadNodeIdInvalidAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "A malformed NodeId must produce a BadNodeIdInvalid read result."); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + } + + [Test] + public async Task ReadAsyncWithNonExistentNodeIdPreservesServerBadStatusAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "nonexistent" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "Reading a well-formed but non-existent NodeId must return a bad DataValue status."); + Assert.That(StatusCode.IsBad(result.Status), Is.True); + } + } + + [Test] + public async Task WriteAsyncWithMalformedNodeIdReturnsBadNodeIdInvalidAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(42))) + .ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "A malformed NodeId must produce a BadNodeIdInvalid write result."); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + } + + [Test] + public async Task InvokeAsyncWithoutComponentOfMetadataReturnsBadNodeIdInvalidAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "nocomponentof" && + f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + Assert.That(result.Error, Does.Contain("uav:componentOf"), + "The error message must name the missing uav:componentOf field."); + } + } + + [Test] + public async Task InvokeAsyncWithInvalidComponentOfNodeIdReturnsBadNodeIdInvalidAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badcomponentof" && + f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "An unparseable uav:componentOf NodeId must produce BadNodeIdInvalid."); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + } + + [Test] + public async Task InvokeAsyncWithInvalidMethodNodeIdReturnsBadNodeIdInvalidAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badmethodid" && + f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "An unparseable method NodeId must produce BadNodeIdInvalid."); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid)); + } + } + + [Test] + public async Task InvokeAsyncWithNullInputsPassesEmptyArgumentsAndMapsServerErrorAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "nullinputs" && + f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + // null inputs → 'inputs is null ? [] : [.. inputs]' takes the empty-array branch; + // Methods_Add requires 2 arguments so the server rejects the call → bad result. + WotInvokeResult result = await channel.InvokeAsync(null!).ConfigureAwait(false); + + Assert.That(result.Success, Is.False, + "Calling Methods_Add with null (zero) inputs must fail server-side and return a bad result."); + } + } + + [Test] + public async Task ObserveAsyncWithNullCallbackThrowsArgumentNullExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.ObserveAsync(null!).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task ObserveAsyncWithMalformedNodeIdThrowsServiceResultExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.ObserveAsync(_ => { }).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithNullCallbackThrowsArgumentNullExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badevent" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.SubscribeEventAsync(null!).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithMalformedNodeIdThrowsServiceResultExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badevent" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.SubscribeEventAsync(_ => { }).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithExtraEventFieldsIncludesFieldInNotificationAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "extrafields" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .SubscribeEventAsync(received.Enqueue).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + NodeId triggerNodeId = ResolvePortableNodeId(TriggerNode01Id); + var writeValue = new WriteValue + { + NodeId = triggerNodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(77)) + }; + WriteResponse writeResponse = await m_session + .WriteAsync(null, new WriteValue[] { writeValue }, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(StatusCode.IsGood(writeResponse.Results[0]), Is.True, + "Writing the trigger node must succeed to fire a BaseEvent."); + + WotNotification? notification = null; + for (int i = 0; i < 100 && notification is null; i++) + { + if (!received.TryDequeue(out notification)) + { + await Task.Delay(50).ConfigureAwait(false); + } + } + + Assert.That(notification, Is.Not.Null, + "The extra-fields subscription must deliver the triggered event."); + Assert.That(notification!.EventFields.ContainsKey("LocalTime"), Is.True, + "The 'LocalTime' extra uav:eventFields select clause must appear in EventFields."); + } + } + } + + /// + /// Builds a Thing Description JSON that includes edge-case affordances for every + /// uncovered OpcUaWotBindingChannel path. + /// + private static string BuildThingDescription(string endpoint) + { + return + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"title\":\"channel-tests\"," + + "\"properties\":{" + + // badid: malformed NodeId for ReadProperty / WriteProperty / ObserveProperty + "\"badid\":{\"type\":\"integer\",\"observable\":true,\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"readproperty\"]}," + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"writeproperty\"]}," + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"observeproperty\"]}" + + "]}," + + // nonexistent: valid NodeId format but no such node on the server + "\"nonexistent\":{\"type\":\"integer\",\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=99999999\",\"op\":[\"readproperty\"]}" + + "]}}," + + "\"actions\":{" + + // nocomponentof: missing uav:componentOf → InvokeAsync returns BadNodeIdInvalid + "\"nocomponentof\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=2258\",\"op\":[\"invokeaction\"]}" + + "]}," + + // badcomponentof: uav:componentOf is an invalid NodeId string + "\"badcomponentof\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=2258\"," + + "\"uav:componentOf\":\"" + InvalidNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}," + + // badmethodid: valid componentOf but invalid method NodeId + "\"badmethodid\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"uav:componentOf\":\"" + MethodsObjectNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}," + + // nullinputs: real method invoked with null inputs → empty args → server error + "\"nullinputs\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + AddMethodNodeId + "\"," + + "\"uav:componentOf\":\"" + MethodsObjectNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}}," + + "\"events\":{" + + // badevent: malformed NodeId for SubscribeEvent + "\"badevent\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"subscribeevent\"]}" + + "]}," + + // extrafields: real event notifier with uav:eventFields → exercises BuildEventFilter + "\"extrafields\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + ServerObjectNodeId + "\"," + + "\"op\":[\"subscribeevent\"],\"uav:eventFields\":[\"LocalTime\"]}" + + "]}}}"; + } + + /// + /// Resolves a portable nsu= NodeId string against the connected session's + /// namespace table. + /// + private NodeId ResolvePortableNodeId(string value) + { + var expanded = ExpandedNodeId.Parse(value); + return ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs new file mode 100644 index 0000000000..26c9465d23 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs @@ -0,0 +1,384 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.TestFramework; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.Tests; +using Opc.Ua.WotCon.Bindings.OpcUa; +using Opc.Ua.WotCon.Bindings.Planners; +using Quickstarts.ReferenceServer; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// End-to-end tests proving OPC UA-to-OPC UA translation: a WoT Thing + /// Description describing an OPC UA target is compiled and executed + /// against a real in-process OPC UA server (a reference server). The + /// fixture starts the server and connects a single session once, and + /// each test exercises one operation: readproperty, writeproperty with + /// readback, observeproperty (native data-change subscription), + /// invokeaction (a real Method with ordered input/output arguments, + /// resolved through uav:componentOf) and subscribeevent (a real + /// EventNotifier and an emitted event, asserting selected fields). The + /// counter, action and event forms address their NodeIds with the + /// portable nsu= form to prove namespace-table resolution. + /// + [TestFixture] + public sealed class OpcUaWotExecutorTests + { + /// + /// Server_ServerStatus_CurrentTime (readable UtcTime) and + /// Server_ServerStatus_State (a read-only Int32) are standard nodes + /// every OPC UA server exposes; they need no namespace resolution. + /// + private const string CurrentTimeNodeId = "i=2258"; + private const string StateNodeId = "i=2259"; + + /// + /// The ReferenceServer's own namespace; nodes below are addressed with + /// the portable nsu= form so resolution goes through the session's + /// namespace table rather than a guessed namespace index. + /// + private const string ReferenceServerNamespace = "http://opcfoundation.org/Quickstarts/ReferenceServer"; + private const string CounterNodeId = "nsu=" + ReferenceServerNamespace + ";s=Scalar_Static_Int32"; + private const string AddMethodNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods_Add"; + private const string MethodsObjectNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods"; + private const string TriggerNode01Id = "nsu=" + ReferenceServerNamespace + ";s=NodeIds_Events_TriggerNode01"; + + /// + /// The standard Server object (i=2253, ns=0) addressed portably too + /// (nsu= for the base namespace); every ReportEvent call in this + /// stack reports starting at the Server object, so it always receives + /// events regardless of where the event's SourceNode lives. + /// + private const string ServerObjectNodeId = "nsu=http://opcfoundation.org/UA/;i=2253"; + + private ServerFixture m_serverFixture = null!; + private ISession m_session = null!; + private WotProtocolBinderRegistry m_registry = null!; + private WotBindingPlan m_plan = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + string pkiRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + m_serverFixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = "opc.tcp", + SecurityNone = true, + AutoAccept = true, + AllNodeManagers = true + }; + await m_serverFixture.StartAsync(pkiRoot).ConfigureAwait(false); + + var clientFixture = new ClientFixture(telemetry); + await clientFixture.LoadClientConfigurationAsync(pkiRoot).ConfigureAwait(false); + var url = new Uri("opc.tcp://localhost:" + m_serverFixture.Port.ToString(CultureInfo.InvariantCulture)); + m_session = await clientFixture.ConnectAsync(url, SecurityPolicies.None).ConfigureAwait(false); + + m_registry = new WotProtocolBinderRegistry( + [new OpcUaBindingPlanner()], + [ + new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => new ValueTask(m_session), + DisposeSession = false, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }) + ]); + + string ep = url.ToString(); + string td = BuildThingDescription(ep); + m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, System.Text.Encoding.UTF8.GetBytes(td))); + + Assert.That(m_plan.Diagnostics.Any(d => d.IsError), Is.False, + "The Thing Description must compile without diagnostic errors: " + + string.Join("; ", m_plan.Diagnostics.Where(d => d.IsError).Select(d => d.Message))); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session is not null) + { + await m_session.CloseAsync().ConfigureAwait(false); + m_session.Dispose(); + } + if (m_serverFixture is not null) + { + await m_serverFixture.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ReadPropertyReturnsRealServerValueAsync() + { + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "time" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "Reading a real OPC UA node must succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.Not.Null); + } + } + + [Test] + public async Task WritePropertyReadOnlyNodeMapsBadStatusAsync() + { + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "state" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(1))).ConfigureAwait(false); + Assert.That(result.Success, Is.False, + "Writing a read-only OPC UA node must be translated and its bad status mapped."); + } + } + + [Test] + public async Task WritePropertyWithReadbackRoundTripsThroughPortableNodeIdAsync() + { + const int expected = 4242; + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel writeChannel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult writeResult = await writeChannel + .WriteAsync(new DataValue(new Variant(expected))).ConfigureAwait(false); + Assert.That(writeResult.Success, Is.True, + $"Writing the counter property (portable nsu= NodeId) must succeed: {writeResult.Error}"); + } + + IWotBindingChannel readChannel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult readResult = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(readResult.Success, Is.True, + $"Reading back the counter property must succeed: {readResult.Error}"); + Assert.That(readResult.Value.WrappedValue.TryGetValue(out int actual), Is.True); + Assert.That(actual, Is.EqualTo(expected), + "The read-back value must match the value written through the binding."); + } + } + + [Test] + public async Task ObservePropertyDeliversNotificationViaNativeSubscriptionAsync() + { + WotCompiledForm observe = m_plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .ObserveAsync(n => received.Enqueue(n.Value.WrappedValue.AsBoxedObject())).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + bool got = false; + for (int i = 0; i < 100 && !got; i++) + { + got = !received.IsEmpty; + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(got, Is.True, + "The observe channel must deliver a value from the server via a native MonitoredItem."); + } + } + } + + [Test] + public async Task InvokeActionRealMethodReturnsOrderedOutputArgumentsAsync() + { + WotCompiledForm invoke = m_plan.CompiledForms.First( + f => f.AffordanceName == "add" && f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel + .InvokeAsync([new(2.5f), new(3u)]).ConfigureAwait(false); + Assert.That(result.Success, Is.True, + "Invoking the real 'Methods_Add' method (resolved via uav:componentOf) " + + $"must succeed: {result.Error}"); + Assert.That(result.Outputs, Has.Count.EqualTo(1)); + Assert.That(result.Outputs[0].WrappedValue.TryGetValue(out float sum), Is.True); + Assert.That(sum, Is.EqualTo(5.5f).Within(0.0001f), + "The Add method sums its Float and UInt32 arguments in order."); + } + } + + [Test] + public async Task SubscribeEventRealEventNotifierDeliversSelectedFieldsAsync() + { + WotCompiledForm subscribe = m_plan.CompiledForms.First( + f => f.AffordanceName == "trigger" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(subscribe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .SubscribeEventAsync(received.Enqueue).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + NodeId triggerNodeId = ResolvePortableNodeId(TriggerNode01Id); + var write = new WriteValue + { + NodeId = triggerNodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(42)) + }; + WriteResponse writeResponse = await m_session + .WriteAsync(null, new WriteValue[] { write }, CancellationToken.None).ConfigureAwait(false); + Assert.That(StatusCode.IsGood(writeResponse.Results[0]), Is.True, + "Writing the trigger node must succeed and fire a BaseEvent."); + + WotNotification? notification = null; + for (int i = 0; i < 100 && notification is null; i++) + { + if (!received.TryDequeue(out notification)) + { + await Task.Delay(50).ConfigureAwait(false); + } + } + Assert.That(notification, Is.Not.Null, + "The subscribeevent channel must deliver the event triggered by the write."); + + Assert.That(notification!.EventFields.TryGetValue("EventId", out DataValue eventIdValue), Is.True); + Assert.That(eventIdValue.WrappedValue.TryGetValue(out ByteString eventId), Is.True); + Assert.That(eventId.Length, Is.GreaterThan(0), "EventId must be a non-empty identifier."); + + Assert.That( + notification.EventFields.TryGetValue("EventType", out DataValue eventTypeValue), Is.True); + Assert.That(eventTypeValue.WrappedValue.TryGetValue(out NodeId eventType), Is.True); + Assert.That(eventType, Is.EqualTo(Types.ObjectTypeIds.BaseEventType)); + + Assert.That( + notification.EventFields.TryGetValue("SourceNode", out DataValue sourceNodeValue), Is.True); + Assert.That(sourceNodeValue.WrappedValue.TryGetValue(out NodeId sourceNode), Is.True); + Assert.That(sourceNode, Is.EqualTo(triggerNodeId), + "SourceNode must be the trigger variable that raised the event."); + + Assert.That(notification.EventFields.TryGetValue("Severity", out DataValue severityValue), Is.True); + Assert.That(severityValue.WrappedValue.TryGetValue(out ushort severity), Is.True); + Assert.That(severity, Is.EqualTo((ushort)EventSeverity.Medium)); + + Assert.That(notification.EventFields.TryGetValue("Message", out DataValue messageValue), Is.True); + Assert.That(messageValue.WrappedValue.TryGetValue(out LocalizedText message), Is.True); + Assert.That(message.Text, Does.Contain("Trigger event")); + + // The primary DataValue carries the same Message text with a + // Good status and the event's own Time / ReceiveTime. + Assert.That(StatusCode.IsGood(notification.Value.StatusCode), Is.True); + Assert.That(notification.Value.WrappedValue.TryGetValue(out LocalizedText primaryMessage), Is.True); + Assert.That(primaryMessage.Text, Does.Contain("Trigger event")); + } + } + } + + private static string BuildThingDescription(string endpoint) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"title\":\"t\",\"properties\":{" + + "\"time\":{\"type\":\"string\",\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CurrentTimeNodeId + + "\"}]}," + + "\"watch\":{\"type\":\"string\",\"observable\":true,\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CurrentTimeNodeId + + "\",\"op\":[\"observeproperty\"]}]}," + + "\"state\":{\"type\":\"integer\",\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + StateNodeId + + "\"}]}," + + "\"counter\":{\"type\":\"integer\",\"forms\":[" + + "{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CounterNodeId + + "\",\"op\":[\"writeproperty\"]}," + + "{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CounterNodeId + + "\",\"op\":[\"readproperty\"]}" + + "]}}," + + "\"actions\":{\"add\":{\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + AddMethodNodeId + + "\",\"uav:componentOf\":\"" + + MethodsObjectNodeId + + "\",\"op\":[\"invokeaction\"]}]}}," + + "\"events\":{\"trigger\":{\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + ServerObjectNodeId + + "\",\"op\":[\"subscribeevent\"]}]}}}"; + } + + /// + /// Resolves a portable nsu= NodeId string directly against the + /// connected session's namespace table (mirroring the fallback the + /// executor itself uses), so the test can address the trigger + /// variable without hard-coding a namespace index. + /// + private NodeId ResolvePortableNodeId(string value) + { + var expanded = ExpandedNodeId.Parse(value); + return ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs new file mode 100644 index 0000000000..300aedfb61 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs @@ -0,0 +1,298 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Tests.Support +{ + /// + /// A single response returned by the in-process test HTTP server. + /// + public sealed class TestHttpResponse + { + public TestHttpResponse( + int status, string contentType, byte[] body, + IReadOnlyDictionary? headers = null) + { + Status = status; + ContentType = contentType; + Body = body; + Headers = headers; + } + + public int Status { get; } + + public string ContentType { get; } + + public byte[] Body { get; } + + /// + /// Gets optional extra response headers (for example Location). + /// + public IReadOnlyDictionary? Headers { get; } + + public static TestHttpResponse Json(int status, string json) + { + return new TestHttpResponse(status, "application/json", Encoding.UTF8.GetBytes(json)); + } + + /// + /// Creates a redirect response (default 302) carrying a Location header. + /// + public static TestHttpResponse Redirect(string location, int status = 302) + { + return new TestHttpResponse(status, "text/plain", [], + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Location"] = location }); + } + } + + /// + /// A parsed request handed to the richer test-server handler. + /// + public sealed class TestHttpRequest + { + public TestHttpRequest(string method, string path, byte[] body, IReadOnlyDictionary headers) + { + Method = method; + Path = path; + Body = body; + Headers = headers; + } + + public string Method { get; } + + public string Path { get; } + + public byte[] Body { get; } + + public IReadOnlyDictionary Headers { get; } + } + + /// + /// A minimal in-process HTTP/1.1 server built on + /// (avoiding HttpListener URL-ACL requirements). It routes each request + /// to a supplied handler and is used for the HTTP executor end-to-end tests. + /// + public sealed class TestHttpServer : IDisposable + { + public TestHttpServer(Func handler) + : this(request => handler(request.Method, request.Path, request.Body)) + { + } + + public TestHttpServer(Func handler) + { + m_handler = handler; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + BaseUrl = $"http://127.0.0.1:{Port}"; + m_loop = Task.Run(AcceptLoopAsync); + } + + public string BaseUrl { get; } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + _ = Task.Run(() => HandleAsync(client)); + } + } + + private async Task HandleAsync(TcpClient client) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + TestHttpRequest? request = await ReadRequestAsync(stream).ConfigureAwait(false); + if (request is null) + { + return; + } + TestHttpResponse response = m_handler(request); + await WriteResponseAsync(stream, response).ConfigureAwait(false); + } + catch (IOException) + { + // Client disconnected. + } + } + } + + private static async Task ReadRequestAsync(NetworkStream stream) + { + var header = new MemoryStream(); + byte[] one = new byte[1]; + int matched = 0; + byte[] terminator = Encoding.ASCII.GetBytes("\r\n\r\n"); + while (matched < terminator.Length) + { + int read = await stream.ReadAsync(one.AsMemory(0, 1)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + header.WriteByte(one[0]); + matched = one[0] == terminator[matched] ? matched + 1 : (one[0] == terminator[0] ? 1 : 0); + } + + string[] lines = Encoding.ASCII.GetString(header.ToArray()).Split("\r\n"); + string[] requestLine = lines[0].Split(' '); + string method = requestLine.Length > 0 ? requestLine[0] : "GET"; + string path = requestLine.Length > 1 ? requestLine[1] : "/"; + int contentLength = 0; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i]; + int colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon <= 0) + { + continue; + } + string name = line[..colon].Trim(); + string value = line[(colon + 1)..].Trim(); + headers[name] = value; + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) && + !int.TryParse(value, out contentLength)) + { + contentLength = 0; + } + } + + byte[] body = []; + if (contentLength > 0) + { + body = new byte[contentLength]; + int offset = 0; + while (offset < contentLength) + { + int read = await stream + .ReadAsync(body.AsMemory(offset, contentLength - offset)) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + offset += read; + } + } + return new TestHttpRequest(method, path, body, headers); + } + + private static async Task WriteResponseAsync(NetworkStream stream, TestHttpResponse response) + { + byte[] body = response.Body ?? []; + var builder = new StringBuilder(); + builder.Append("HTTP/1.1 ").Append(response.Status).Append(' ') + .Append(Reason(response.Status)).Append("\r\n") + .Append("Content-Type: ").Append(response.ContentType).Append("\r\n") + .Append("Content-Length: ").Append(body.Length).Append("\r\n"); + if (response.Headers is { Count: > 0 }) + { + foreach (KeyValuePair extra in response.Headers) + { + builder.Append(extra.Key).Append(": ").Append(extra.Value).Append("\r\n"); + } + } + builder.Append("Connection: close\r\n\r\n"); + byte[] head = Encoding.ASCII.GetBytes(builder.ToString()); + await stream.WriteAsync(head).ConfigureAwait(false); + if (body.Length > 0) + { + await stream.WriteAsync(body).ConfigureAwait(false); + } + await stream.FlushAsync().ConfigureAwait(false); + } + + private static string Reason(int status) + { + return status switch + { + 200 => "OK", + 204 => "No Content", + 301 => "Moved Permanently", + 302 => "Found", + 303 => "See Other", + 307 => "Temporary Redirect", + 308 => "Permanent Redirect", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status" + }; + } + + private readonly Func m_handler; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new(); + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs new file mode 100644 index 0000000000..48ec7ba8d9 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs @@ -0,0 +1,300 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Tests.Support +{ + /// + /// A minimal in-process Modbus TCP server / simulator supporting the read and + /// write function codes required by the WoT Modbus binding (FC 1/2/3/4/5/6/15/16). + /// + public sealed class TestModbusServer : IDisposable + { + public TestModbusServer() + { + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public ushort[] HoldingRegisters { get; } = new ushort[1024]; + + public ushort[] InputRegisters { get; } = new ushort[1024]; + + public bool[] Coils { get; } = new bool[2048]; + + public bool[] DiscreteInputs { get; } = new bool[2048]; + + public bool RejectConnections + { + get => Volatile.Read(ref m_rejectConnections) != 0; + set => Volatile.Write(ref m_rejectConnections, value ? 1 : 0); + } + + public int AcceptedConnectionCount => Volatile.Read(ref m_acceptedConnectionCount); + + public int LastFunctionCode => Volatile.Read(ref m_lastFunctionCode); + + public void DisconnectClients() + { + foreach (TcpClient client in m_clients.Values) + { + client.Dispose(); + } + } + + public void Dispose() + { + m_cts.Cancel(); + DisconnectClients(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + int connectionId = Interlocked.Increment(ref m_acceptedConnectionCount); + if (RejectConnections) + { + client.Dispose(); + continue; + } + m_clients[connectionId] = client; + _ = ServeAsync(connectionId); + } + } + + private async Task ServeAsync(int connectionId) + { + try + { + if (!m_clients.TryGetValue(connectionId, out TcpClient? client)) + { + return; + } + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!m_cts.IsCancellationRequested) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int length = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + byte[] responsePdu = Process(pdu); + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (System.IO.IOException) + { + // Client disconnected. + } + catch (ObjectDisposedException) + { + // Client disconnected. + } + } + } + finally + { + m_clients.TryRemove(connectionId, out _); + } + } + + private byte[] Process(byte[] pdu) + { + byte function = pdu[0]; + Volatile.Write(ref m_lastFunctionCode, function); + switch (function) + { + case 0x01: + return ReadBits(pdu, Coils, function); + case 0x02: + return ReadBits(pdu, DiscreteInputs, function); + case 0x03: + return ReadRegisters(pdu, HoldingRegisters, function); + case 0x04: + return ReadRegisters(pdu, InputRegisters, function); + case 0x05: + { + int address = (pdu[1] << 8) | pdu[2]; + Coils[address] = pdu[3] == 0xFF; + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x06: + { + int address = (pdu[1] << 8) | pdu[2]; + HoldingRegisters[address] = (ushort)((pdu[3] << 8) | pdu[4]); + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x0F: + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + for (int i = 0; i < quantity; i++) + { + Coils[address + i] = (pdu[6 + (i / 8)] & (1 << (i % 8))) != 0; + } + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x10: + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + for (int i = 0; i < quantity; i++) + { + HoldingRegisters[address + i] = (ushort)((pdu[6 + (i * 2)] << 8) | pdu[7 + (i * 2)]); + } + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + default: + return [(byte)(function | 0x80), 0x01]; + } + } + + private static byte[] ReadRegisters(byte[] pdu, ushort[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + byte[] response = new byte[2 + (quantity * 2)]; + response[0] = function; + response[1] = (byte)(quantity * 2); + for (int i = 0; i < quantity; i++) + { + response[2 + (i * 2)] = (byte)(store[address + i] >> 8); + response[3 + (i * 2)] = (byte)(store[address + i] & 0xFF); + } + return response; + } + + private static byte[] ReadBits(byte[] pdu, bool[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + int byteCount = (quantity + 7) / 8; + byte[] response = new byte[2 + byteCount]; + response[0] = function; + response[1] = (byte)byteCount; + for (int i = 0; i < quantity; i++) + { + if (store[address + i]) + { + response[2 + (i / 8)] |= (byte)(1 << (i % 8)); + } + } + return response; + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new(); + private readonly ConcurrentDictionary m_clients = new(); + private int m_acceptedConnectionCount; + private int m_lastFunctionCode; + private int m_rejectConnections; + } +} From ed6c93d6338d7692b4fe774bcf8cf32b90bab598 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 14:06:04 +0200 Subject: [PATCH 2/9] Add the fluent builder API the generated node managers require The fluent builder generator emits calls to INodeManagerBuilder.VariableFromDataTypeId and to a NodeManagerBuilder constructor overload that carries the data-type lookup, but the runtime side of that API was missing, so every generated node manager failed to compile with CS1729 and CS1061. Adds the VariableFromDataTypeId resolution to the builder interface and implementation, along with NodeStateLookupExtensions.FindByDataType, which is the lookup the builder delegates to and has no other consumer. Resolution reports BadNodeIdInvalid for a null data type, BadNodeIdUnknown when nothing matches, BadBrowseNameDuplicated when the match is ambiguous, and BadTypeMismatch when the resolved node is not a variable. An optional browse name disambiguates a data type that is carried by more than one variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../Fluent/FluentNodeManagerBase.cs | 5 +- .../Fluent/INodeManagerBuilder.cs | 59 +++++++- .../Fluent/NodeManagerBuilder.cs | 95 +++++++++++- .../NodeManager/NodeStateLookupExtensions.cs | 42 +++++- .../Fluent/NodeManagerBuilderTests.cs | 136 ++++++++++++++++++ 5 files changed, 330 insertions(+), 7 deletions(-) diff --git a/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs b/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs index 33d2dbf2cc..1fa33488d9 100644 --- a/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs +++ b/src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs @@ -171,7 +171,7 @@ protected FluentNodeManagerBase( /// .Configure(Configure) /// .Seal(); /// - /// The three root/nodeId/typeId lookups default to scanning the + /// The root/nodeId/typeId/dataTypeId lookups default to scanning the /// manager's /// dictionary, mirroring the resolver wiring that the /// source-generated NodeManagerBase.CreateAddressSpaceAsync @@ -196,7 +196,8 @@ public NodeManagerBuilder CreateFluentBuilder(ushort defaultNamespaceIndex) defaultNamespaceIndex, browseName => PredefinedNodes.Values.FindByBrowseName(browseName)!, nodeId => PredefinedNodes.FindById(nodeId)!, - PredefinedNodes.Values.FindByTypeDefinition); + PredefinedNodes.Values.FindByTypeDefinition, + PredefinedNodes.Values.FindByDataType); AttachToBuilder(builder); return builder; } diff --git a/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs b/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs index 989019f0eb..cb9df45e1c 100644 --- a/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs @@ -154,8 +154,12 @@ INodeBuilder Node(NodeId nodeId) /// /// /// — the id is null. - /// — no instance carries that type definition. - /// — more than one instance matches; supply a disambiguator via the overload. + /// — + /// no instance carries that type definition. + /// — + /// more than one instance matches; supply a + /// disambiguator via the + /// overload. /// /// INodeBuilder NodeFromTypeId(NodeId typeDefinitionId); @@ -247,5 +251,56 @@ INodeBuilder NodeFromTypeId(NodeId typeDefinitionId, QualifiedNa /// CLR type carried by the variable's Value attribute. /// IVariableBuilder VariableFromTypeId(NodeId typeDefinitionId, QualifiedName browseName); + + /// + /// Resolves the unique variable instance whose + /// DataType attribute equals + /// and returns a typed + /// view. Useful for singleton variables whose well-known DataType + /// is more stable than the deployment-specific browse path. + /// + /// + /// CLR type carried by the variable's Value attribute. + /// + /// + /// The DataType id of the variable to locate (typically a + /// generated DataTypeIds.* constant). + /// + /// + /// + /// — the id is null. + /// — + /// no variable carries that DataType. + /// — + /// more than one variable matches; supply a + /// disambiguator via the + /// + /// overload. + /// — + /// the resolved variable's Value is not assignable to + /// . + /// + /// + IVariableBuilder VariableFromDataTypeId(NodeId dataTypeId); + + /// + /// Like but + /// disambiguates among multiple instances by matching + /// against + /// . + /// + /// + /// CLR type carried by the variable's Value attribute. + /// + /// See . + /// + /// Browse name of the instance to pick out. + /// + /// + /// Same conditions as + /// plus when the + /// disambiguator matches no candidate. + /// + IVariableBuilder VariableFromDataTypeId(NodeId dataTypeId, QualifiedName browseName); } } diff --git a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index 7763e9dc7a..3fc9b850ef 100644 --- a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -84,6 +84,13 @@ public sealed class NodeManagerBuilder : INodeManagerBuilder, IFluentDispatcher /// TypeDefinitionId matches the supplied . /// Typically a generated walk over the manager's predefined nodes. /// + /// + /// Delegate that returns every whose + /// DataType matches the supplied . + /// Typically a generated walk over the manager's predefined nodes. + /// When null, DataType lookups always resolve to no + /// candidates (as if no variable declared that DataType). + /// /// /// , , /// , , @@ -95,7 +102,8 @@ public NodeManagerBuilder( ushort defaultNamespaceIndex, Func rootResolver, Func nodeIdResolver, - Func> typeIdResolver) + Func> typeIdResolver, + Func>? dataTypeIdResolver = null) { Context = context ?? throw new ArgumentNullException(nameof(context)); NodeManager = nodeManager ?? throw new ArgumentNullException(nameof(nodeManager)); @@ -103,6 +111,7 @@ public NodeManagerBuilder( m_rootResolver = rootResolver ?? throw new ArgumentNullException(nameof(rootResolver)); m_nodeIdResolver = nodeIdResolver ?? throw new ArgumentNullException(nameof(nodeIdResolver)); m_typeIdResolver = typeIdResolver ?? throw new ArgumentNullException(nameof(typeIdResolver)); + m_dataTypeIdResolver = dataTypeIdResolver ?? (static _ => []); } /// @@ -284,6 +293,27 @@ public IVariableBuilder VariableFromTypeId(NodeId typeDefinition browseName)); } + /// + public IVariableBuilder VariableFromDataTypeId(NodeId dataTypeId) + { + ThrowIfSealed(); + NodeState node = ResolveByDataType(dataTypeId, (QualifiedName)null!); + return ToVariableBuilder(node, FormatNodeId(dataTypeId)); + } + + /// + public IVariableBuilder VariableFromDataTypeId(NodeId dataTypeId, QualifiedName browseName) + { + ThrowIfSealed(); + NodeState node = ResolveByDataType(dataTypeId, browseName); + return ToVariableBuilder( + node, + CoreUtils.Format( + "{0} (browse name '{1}')", + FormatNodeId(dataTypeId), + browseName)); + } + internal VariableBuilder ToVariableBuilder(NodeState node, string lookupHint) { if (node is not BaseVariableState variable) @@ -590,6 +620,68 @@ private NodeState ResolveByTypeDefinition(NodeId typeDefinitionId, QualifiedName return match; } + private NodeState ResolveByDataType(NodeId dataTypeId, QualifiedName browseName) + { + if (dataTypeId.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdInvalid, + "DataTypeId is null or empty."); + } + + ArrayOf candidates = m_dataTypeIdResolver(dataTypeId); + + if (candidates.Count == 0) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdUnknown, + "No predefined variable has DataType '{0}'.", + dataTypeId); + } + + if (browseName.IsNull) + { + if (candidates.Count > 1) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameDuplicated, + "DataType '{0}' is ambiguous: {1} matching variables found. " + + "Pass a QualifiedName disambiguator to VariableFromDataTypeId.", + dataTypeId, + candidates.Count); + } + return candidates[0]; + } + + NodeState? match = null; + for (int i = 0; i < candidates.Count; i++) + { + if (candidates[i].BrowseName == browseName) + { + if (match != null) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameDuplicated, + "DataType '{0}' has multiple variables with browse name '{1}'.", + dataTypeId, + browseName); + } + match = candidates[i]; + } + } + + if (match == null) + { + throw ServiceResultException.Create( + StatusCodes.BadNodeIdUnknown, + "DataType '{0}' has no variable with browse name '{1}'.", + dataTypeId, + browseName); + } + + return match; + } + private void ThrowIfSealed() { if (m_sealed) @@ -621,6 +713,7 @@ private static void ThrowIfDuplicate( private readonly Func m_rootResolver; private readonly Func m_nodeIdResolver; private readonly Func> m_typeIdResolver; + private readonly Func> m_dataTypeIdResolver; private bool m_sealed; private readonly Dictionary m_historyRead = []; private readonly Dictionary m_historyUpdate = []; diff --git a/src/Opc.Ua.Server/NodeManager/NodeStateLookupExtensions.cs b/src/Opc.Ua.Server/NodeManager/NodeStateLookupExtensions.cs index 34a8485995..aaa800293c 100644 --- a/src/Opc.Ua.Server/NodeManager/NodeStateLookupExtensions.cs +++ b/src/Opc.Ua.Server/NodeManager/NodeStateLookupExtensions.cs @@ -34,11 +34,12 @@ namespace Opc.Ua.Server.NodeManager { /// /// Linear-scan + dictionary lookups over a node-manager's predefined - /// node collection. Surfaces the three patterns that node-manager + /// node collection. Surfaces the patterns that node-manager /// subclasses repeatedly hand-roll against /// CustomNodeManager.PredefinedNodes / /// AsyncCustomNodeManager.PredefinedNodes: - /// browse-name root lookup, NodeId lookup, and TypeDefinitionId scan. + /// browse-name root lookup, NodeId lookup, TypeDefinitionId scan, and + /// DataType scan. /// public static class NodeStateLookupExtensions { @@ -135,5 +136,42 @@ public static List FindByTypeDefinition( } return results; } + + /// + /// Returns every in + /// whose + /// equals + /// . The list is empty when no match + /// exists; non-variable nodes are skipped. + /// + /// + /// The set to scan, typically PredefinedNodes.Values. + /// + /// + /// DataType NodeId to match (e.g. a known DataTypeIds + /// constant). + /// + /// + /// is . + /// + public static ArrayOf FindByDataType( + this IEnumerable nodes, + NodeId dataTypeId) + { + if (nodes == null) + { + throw new ArgumentNullException(nameof(nodes)); + } + var results = new List(); + foreach (NodeState node in nodes) + { + if (node is BaseVariableState variable && + variable.DataType == dataTypeId) + { + results.Add(node); + } + } + return new ArrayOf(results.ToArray()); + } } } diff --git a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs index ec4edf7e61..d1a085c545 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs @@ -617,6 +617,32 @@ private static BaseObjectState MakeObject(string name, NodeId typeDefId) }; } + private static NodeManagerBuilder CreateBuilderWithDataTypeIndex( + Dictionary> byDataType) + { + return new NodeManagerBuilder( + CreateContext(), + Mock.Of(), + kNs, + _ => null, + _ => null, + _ => [], + dataTypeId => byDataType.TryGetValue(dataTypeId, out ArrayOf list) + ? list + : []); + } + + private static BaseDataVariableState MakeVariable(string name, NodeId dataTypeId) + { + return new BaseDataVariableState(parent: null) + { + NodeId = new NodeId(name, kNs), + BrowseName = new QualifiedName(name, kNs), + DataType = dataTypeId, + ValueRank = ValueRanks.Scalar + }; + } + [Test] public void NodeFromTypeIdResolvesSingleton() { @@ -865,5 +891,115 @@ public void VariableByBrowseNameThrowsBadTypeMismatchOnNonVariable() () => b.Node(root.NodeId).Variable(m.BrowseName)); Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); } + + [Test] + public void VariableFromDataTypeIdResolvesUniqueVariable() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseDataVariableState only = MakeVariable("ByDataType", dataTypeId); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [only] }); + + IVariableBuilder byDataType = b.VariableFromDataTypeId(dataTypeId); + + Assert.That(byDataType.Node, Is.SameAs(only)); + } + + [Test] + public void VariableFromDataTypeIdNullThrowsBadNodeIdInvalid() + { + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex([]); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(NodeId.Null)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdInvalid)); + } + + [Test] + public void VariableFromDataTypeIdMissingThrowsBadNodeIdUnknown() + { + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex([]); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(DataTypeIds.Int32)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void VariableFromDataTypeIdAmbiguousThrowsBadBrowseNameDuplicated() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseDataVariableState a = MakeVariable("Temp1", dataTypeId); + BaseDataVariableState bn = MakeVariable("Temp2", dataTypeId); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [a, bn] }); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(dataTypeId)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated)); + } + + [Test] + public void VariableFromDataTypeIdWithBrowseNameDisambiguates() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseDataVariableState a = MakeVariable("Temp1", dataTypeId); + BaseDataVariableState bn = MakeVariable("Temp2", dataTypeId); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [a, bn] }); + + IVariableBuilder byName = b.VariableFromDataTypeId(dataTypeId, bn.BrowseName); + + Assert.That(byName.Node, Is.SameAs(bn)); + } + + [Test] + public void VariableFromDataTypeIdWithBrowseNameMissThrowsBadNodeIdUnknown() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseDataVariableState a = MakeVariable("Temp1", dataTypeId); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [a] }); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(dataTypeId, new QualifiedName("Nope", kNs))); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown)); + } + + [Test] + public void VariableFromDataTypeIdWithDuplicateBrowseNameThrowsBadBrowseNameDuplicated() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseDataVariableState first = MakeVariable("Duplicate", dataTypeId); + BaseDataVariableState second = MakeVariable("Duplicate", dataTypeId); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [first, second] }); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(dataTypeId, first.BrowseName)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated)); + } + + /// + /// Mirrors : + /// the DataType resolver contract only guarantees the candidates it is + /// handed are instances at the + /// production resolver level; if a custom resolver (as injected here) + /// returns a non-variable node, + /// must still surface a clear + /// rather than an unhandled cast failure. + /// + [Test] + public void VariableFromDataTypeIdThrowsBadTypeMismatchForNonVariable() + { + NodeId dataTypeId = DataTypeIds.Int32; + BaseObjectState only = MakeObject("NotAVariable", ObjectTypeIds.BaseObjectType); + NodeManagerBuilder b = CreateBuilderWithDataTypeIndex( + new Dictionary> { [dataTypeId] = [only] }); + + ServiceResultException ex = Assert.Throws( + () => b.VariableFromDataTypeId(dataTypeId)); + Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); + } } } From 74b09c4be2157c14468423a6d7c811f0608ca066 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 14:56:55 +0200 Subject: [PATCH 3/9] Wire the data-type lookup into the DI node manager builder DiNodeManager constructed NodeManagerBuilder without the data-type resolver, so VariableFromDataTypeId reported BadNodeIdUnknown ("no predefined variable has DataType") for every DI node manager - a misleading error, since the lookup had simply never been supplied rather than the variable being absent. Delegates to NodeStateLookupExtensions.FindByDataType rather than hand-rolling the scan a fourth time in this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- src/Opc.Ua.Di.Server/DiNodeManager.cs | 4 +++- tests/Opc.Ua.Di.Tests/DeviceBuilderTests.cs | 25 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Opc.Ua.Di.Server/DiNodeManager.cs b/src/Opc.Ua.Di.Server/DiNodeManager.cs index 8bb776d036..730c483dd8 100644 --- a/src/Opc.Ua.Di.Server/DiNodeManager.cs +++ b/src/Opc.Ua.Di.Server/DiNodeManager.cs @@ -33,6 +33,7 @@ using Opc.Ua.Di.Server.Builders; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; +using Opc.Ua.Server.NodeManager; namespace Opc.Ua.Di.Server { @@ -761,7 +762,8 @@ internal NodeManagerBuilder GetOrCreateBuilder() } } return results; - }); + }, + dataTypeId => PredefinedNodes.Values.FindByDataType(dataTypeId)); AttachToBuilder(m_builder); return m_builder; diff --git a/tests/Opc.Ua.Di.Tests/DeviceBuilderTests.cs b/tests/Opc.Ua.Di.Tests/DeviceBuilderTests.cs index a63395660c..2dbb5a0568 100644 --- a/tests/Opc.Ua.Di.Tests/DeviceBuilderTests.cs +++ b/tests/Opc.Ua.Di.Tests/DeviceBuilderTests.cs @@ -31,6 +31,7 @@ using NUnit.Framework; using Opc.Ua.Di.Server; using Opc.Ua.Di.Server.Builders; +using Opc.Ua.Server.Fluent; namespace Opc.Ua.Di.Tests { @@ -134,6 +135,30 @@ public async Task CreateDeviceAsyncRegistersInPredefinedNodes() Assert.That(resolved, Is.SameAs(builder.Device)); } + [Test] + public void VariableFromDataTypeIdResolvesUniquePredefinedVariable() + { + ushort namespaceIndex = m_fixture.Manager.DiNamespaceIndex; + var predefined = new BaseDataVariableState(null) + { + NodeId = new NodeId("UniqueDataTypeVariable", namespaceIndex), + BrowseName = new QualifiedName("UniqueDataTypeVariable", namespaceIndex), + DisplayName = new LocalizedText("UniqueDataTypeVariable"), + DataType = Types.DataTypeIds.String, + ValueRank = ValueRanks.Scalar, + Value = "value" + }; + m_fixture.Manager.AddPlainPredefinedNodeSynchronously(predefined); + NodeManagerBuilder builder = m_fixture.Manager.GetOrCreateBuilder(); + + IVariableBuilder variable = + builder.VariableFromDataTypeId( + Types.DataTypeIds.String, + predefined.BrowseName); + + Assert.That(variable.Node, Is.SameAs(predefined)); + } + [Test] public async Task CreateDeviceAsyncFailsOnDuplicateBrowseName() { From 47dd8ba6d6c32c827dee1bb87c489f4bd8256081 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 18:51:22 +0200 Subject: [PATCH 4/9] Apply an endpoint policy before opening a WoT binding channel Thing Descriptions are remote-supplied, so the host in a form's href was an unvalidated outbound request target: the executors would connect to loopback, link-local and private-range addresses, including the cloud instance metadata service, and return the response body to the caller as a readable value. Adds WotEndpointPolicy and WotEndpointValidator and enforces them in WotProtocolBinderRegistry.OpenChannelAsync, the single point through which every executor opens a channel. Loopback and private ranges are denied by default and can be re-enabled per deployment. As with the asset endpoint validator, DNS is deliberately not resolved during validation, because resolving at validation time and again at connect time is itself a request-forgery vector. Also rejects control characters in a form's declared content type, which could otherwise be injected verbatim into outbound request headers, and MQTT topic wildcards, which would subscribe the server to an entire broker namespace under its own identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../IWotBindingExecutor.cs | 9 +- .../IWotProtocolBinder.cs | 38 +++- .../OpcUaWotBindingBuilderExtensions.cs | 23 ++- .../Planners/BacnetBindingPlanner.cs | 5 +- .../Planners/CoapBindingPlanner.cs | 5 +- .../Planners/HttpBindingPlanner.cs | 5 +- .../Planners/LoRaWanBindingPlanner.cs | 5 +- .../Planners/ModbusBindingPlanner.cs | 9 +- .../Planners/MqttBindingPlanner.cs | 18 +- .../Planners/OpcUaBindingPlanner.cs | 5 +- .../Planners/ProfinetBindingPlanner.cs | 5 +- .../WotBindingBounds.cs | 5 + .../WotEndpointPolicy.cs | 102 ++++++++++ .../WotEndpointValidator.cs | 183 ++++++++++++++++++ .../WotProtocolBinderRegistry.cs | 30 ++- .../BindingPlannerTests.cs | 61 ++++++ .../WotEndpointValidatorTests.cs | 131 +++++++++++++ .../WotProtocolBinderRegistryTests.cs | 136 +++++++++++++ 18 files changed, 759 insertions(+), 16 deletions(-) create mode 100644 src/Opc.Ua.WotCon.Bindings/WotEndpointPolicy.cs create mode 100644 src/Opc.Ua.WotCon.Bindings/WotEndpointValidator.cs create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/WotEndpointValidatorTests.cs diff --git a/src/Opc.Ua.WotCon.Bindings/IWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/IWotBindingExecutor.cs index 1e8def508c..92f6c614f2 100644 --- a/src/Opc.Ua.WotCon.Bindings/IWotBindingExecutor.cs +++ b/src/Opc.Ua.WotCon.Bindings/IWotBindingExecutor.cs @@ -48,11 +48,13 @@ public sealed class WotExecutorContext public WotExecutorContext( IWotCredentialProvider? credentials = null, IWotCodecRegistry? codecs = null, - WotBindingBounds? bounds = null) + WotBindingBounds? bounds = null, + WotEndpointPolicy? endpointPolicy = null) { Credentials = credentials ?? NullWotCredentialProvider.Instance; Codecs = codecs ?? WotPayloadCodecRegistry.Default; Bounds = bounds ?? WotBindingBounds.Default; + EndpointPolicy = endpointPolicy ?? WotEndpointPolicy.Default; } /// @@ -69,6 +71,11 @@ public WotExecutorContext( /// Gets the enforced safety bounds. /// public WotBindingBounds Bounds { get; } + + /// + /// Gets the endpoint policy enforced before opening a live channel. + /// + public WotEndpointPolicy EndpointPolicy { get; } } /// diff --git a/src/Opc.Ua.WotCon.Bindings/IWotProtocolBinder.cs b/src/Opc.Ua.WotCon.Bindings/IWotProtocolBinder.cs index a0c167f4eb..e533e0a1cc 100644 --- a/src/Opc.Ua.WotCon.Bindings/IWotProtocolBinder.cs +++ b/src/Opc.Ua.WotCon.Bindings/IWotProtocolBinder.cs @@ -308,13 +308,45 @@ protected bool RequireHref( /// /// Selects a codec for a content type, reporting when none is available. /// - protected string ResolveCodec( - WotAffordanceForm form, WotBindingPlanContext context, out WotPayloadDescriptor payload) + protected bool ResolveCodec( + WotAffordanceForm form, + WotBindingPlanContext context, + ICollection diagnostics, + out WotPayloadDescriptor payload) { string contentType = string.IsNullOrEmpty(form.ContentType) ? "application/json" : form.ContentType!; + if (!ValidateContentType(form, contentType, diagnostics)) + { + payload = new WotPayloadDescriptor(contentType, string.Empty); + return false; + } context.Codecs.TrySelect(form.ContentType, out IWotPayloadCodec codec); payload = new WotPayloadDescriptor(contentType, codec.Id); - return codec.Id; + return true; + } + + /// + /// Validates a WoT contentType value before it reaches protocol sinks. + /// + protected static bool ValidateContentType( + WotAffordanceForm form, + string contentType, + ICollection diagnostics) + { + for (int i = 0; i < contentType.Length; i++) + { + char ch = contentType[i]; + if (ch is '\r' or '\n' or '\0' || ch > 0x7F) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "The contentType contains characters that are not permitted in an HTTP header value.", + form.Pointer("contentType"), + "contentType")); + return false; + } + } + return true; } /// diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUaWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/OpcUaWotBindingBuilderExtensions.cs index c8029c0cdb..e93ca76340 100644 --- a/src/Opc.Ua.WotCon.Bindings/OpcUaWotBindingBuilderExtensions.cs +++ b/src/Opc.Ua.WotCon.Bindings/OpcUaWotBindingBuilderExtensions.cs @@ -128,6 +128,25 @@ public static IOpcUaBuilder AddWotCredentialProvider( return builder; } + /// + /// Registers the endpoint policy enforced before a WoT binding executor opens an outbound channel. + /// + /// + public static IOpcUaBuilder AddWotEndpointPolicy( + this IOpcUaBuilder builder, WotEndpointPolicy policy) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (policy is null) + { + throw new ArgumentNullException(nameof(policy)); + } + builder.Services.AddSingleton(policy); + return builder; + } + private static void EnsureRegistry(IServiceCollection services) { services.EnsureWotBinderRegistry(); @@ -165,7 +184,9 @@ public static IServiceCollection EnsureWotBinderRegistry(this IServiceCollection sp.GetServices(), sp.GetServices(), sp.GetService(), - sp.GetService())); + sp.GetService(), + sp.GetService(), + sp.GetService())); services.TryAddSingleton( sp => sp.GetRequiredService()); services.TryAddSingleton( diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/BacnetBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/BacnetBindingPlanner.cs index 511bc593f8..850086f847 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/BacnetBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/BacnetBindingPlanner.cs @@ -123,7 +123,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding var addressing = new WotAddressingDescriptor( $"{objectType}:{instanceNumber.ToString(CultureInfo.InvariantCulture)}:{propertyId}", metadata); WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "bacnet"); - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/CoapBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/CoapBindingPlanner.cs index 960a295f99..75c5ab6944 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/CoapBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/CoapBindingPlanner.cs @@ -123,7 +123,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding form.Pointer("cov:contentFormat"), "cov:contentFormat")); } - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } WotEndpointDescriptor endpoint = MakeEndpoint(uri); var addressing = new WotAddressingDescriptor(uri.AbsoluteUri); ImmutableArray security = diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/HttpBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/HttpBindingPlanner.cs index a41ac5a66c..4b500fb3cc 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/HttpBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/HttpBindingPlanner.cs @@ -127,7 +127,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding form.Pointer("subprotocol"), "subprotocol")); } - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } WotEndpointDescriptor endpoint = MakeEndpoint(uri); var addressing = new WotAddressingDescriptor(uri.AbsoluteUri); ImmutableArray security = diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/LoRaWanBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/LoRaWanBindingPlanner.cs index dee60fc3b4..68ae983890 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/LoRaWanBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/LoRaWanBindingPlanner.cs @@ -116,7 +116,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding var addressing = new WotAddressingDescriptor( $"{devEui}/{fPort.ToString(CultureInfo.InvariantCulture)}", metadata); WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "lorawan"); - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/ModbusBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/ModbusBindingPlanner.cs index 3232f80e23..1aa6984871 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/ModbusBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/ModbusBindingPlanner.cs @@ -286,13 +286,18 @@ function is not null && .Add("functionCode", function.Value.Code.ToString(CultureInfo.InvariantCulture)); } + string contentType = string.IsNullOrEmpty(form.ContentType) ? "application/octet-stream" : form.ContentType!; + if (!ValidateContentType(form, contentType, diagnostics)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } + ImmutableDictionary payloadMetadata = ImmutableDictionary.Empty .Add("type", dataType) .Add("mostSignificantByte", msbFirst ? "true" : "false") .Add("mostSignificantWord", mswFirst ? "true" : "false"); var payload = new WotPayloadDescriptor( - string.IsNullOrEmpty(form.ContentType) ? "application/octet-stream" : form.ContentType!, - OctetStreamWotPayloadCodec.Instance.Id, payloadMetadata); + contentType, OctetStreamWotPayloadCodec.Instance.Id, payloadMetadata); WotEndpointDescriptor endpoint = MakeEndpoint(uri); var addressing = new WotAddressingDescriptor( diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/MqttBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/MqttBindingPlanner.cs index af0beb060e..87e8fd9584 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/MqttBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/MqttBindingPlanner.cs @@ -121,6 +121,14 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding form.Pointer("mqv:topic"), "mqv:topic")); return WotBindingCompilation.Unsupported([.. diagnostics]); } + if (!context.Bounds.AllowMqttWildcardTopics && ContainsMqttWildcard(topic)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "MQTT topic wildcards are not permitted for a WoT affordance unless explicitly enabled.", + form.Pointer("mqv:topic"), "mqv:topic")); + return WotBindingCompilation.Unsupported([.. diagnostics]); + } int qos = 0; if (form.TryGetInt32("mqv:qos", out int parsedQos)) @@ -150,7 +158,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding controlPacket = packet; } - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } WotEndpointDescriptor endpoint = MakeEndpoint(uri); var addressing = new WotAddressingDescriptor(topic, ImmutableDictionary.Empty @@ -185,5 +196,10 @@ private static string DefaultControlPacket(WoTBindingCapabilityEnum operation) _ => "subscribe" }; } + + private static bool ContainsMqttWildcard(string topic) + { + return topic.Contains('#', StringComparison.Ordinal) || topic.Contains('+', StringComparison.Ordinal); + } } } diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/OpcUaBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/OpcUaBindingPlanner.cs index c1e4783331..1ce022232a 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/OpcUaBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/OpcUaBindingPlanner.cs @@ -139,7 +139,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding metadata = metadata.Add("eventFields", string.Join("|", eventFields)); } - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } var addressing = new WotAddressingDescriptor(nodeId!, metadata); ImmutableArray security = ResolveSecurity(form, context, authority, diagnostics); diff --git a/src/Opc.Ua.WotCon.Bindings/Planners/ProfinetBindingPlanner.cs b/src/Opc.Ua.WotCon.Bindings/Planners/ProfinetBindingPlanner.cs index 5e2f57a6fc..127ccf03d9 100644 --- a/src/Opc.Ua.WotCon.Bindings/Planners/ProfinetBindingPlanner.cs +++ b/src/Opc.Ua.WotCon.Bindings/Planners/ProfinetBindingPlanner.cs @@ -100,7 +100,10 @@ public override WotBindingCompilation Compile(WotAffordanceForm form, WotBinding $"{subslot.ToString(CultureInfo.InvariantCulture)}/index:" + index.ToString(CultureInfo.InvariantCulture), metadata); WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "profinet"); - ResolveCodec(form, context, out WotPayloadDescriptor payload); + if (!ResolveCodec(form, context, diagnostics, out WotPayloadDescriptor payload)) + { + return WotBindingCompilation.Unsupported([.. diagnostics]); + } ImmutableArray.Builder entries = ImmutableArray.CreateBuilder(); foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) diff --git a/src/Opc.Ua.WotCon.Bindings/WotBindingBounds.cs b/src/Opc.Ua.WotCon.Bindings/WotBindingBounds.cs index f28d4c8d58..451e3e2159 100644 --- a/src/Opc.Ua.WotCon.Bindings/WotBindingBounds.cs +++ b/src/Opc.Ua.WotCon.Bindings/WotBindingBounds.cs @@ -53,6 +53,11 @@ public sealed class WotBindingBounds /// public int MaxTopicLength { get; set; } = 65535; + /// + /// Gets or sets whether MQTT subscribe topics may contain the # or + wildcard characters. + /// + public bool AllowMqttWildcardTopics { get; set; } + /// /// Gets or sets the maximum accepted request / response payload size (bytes). /// diff --git a/src/Opc.Ua.WotCon.Bindings/WotEndpointPolicy.cs b/src/Opc.Ua.WotCon.Bindings/WotEndpointPolicy.cs new file mode 100644 index 0000000000..89227cedd8 --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/WotEndpointPolicy.cs @@ -0,0 +1,102 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; + +namespace Opc.Ua.WotCon.Bindings +{ + /// + /// Allow-list policy controlling which endpoint URIs WoT binding executors are permitted to reach. + /// + /// + /// Safe defaults: + /// * Schemes restricted to the executable bindings shipped by this assembly set: + /// http, https, modbus+tcp, modbus, + /// mqtt, mqtts, opc.tcp, opc.https and opc.wss. + /// * Loopback (127.0.0.0/8, ::1) blocked. + /// * Private ranges blocked: RFC1918 (10/8, 172.16/12, 192.168/16), + /// CGNAT (100.64/10), IPv4 link-local (169.254/16 — including the AWS / Azure IMDS + /// address 169.254.169.254), IPv6 ULA (fc00::/7), and IPv6 link-local (fe80::/10). + /// + public sealed class WotEndpointPolicy + { + /// + /// Gets the shared default endpoint policy. + /// + public static WotEndpointPolicy Default { get; } = new WotEndpointPolicy(); + + /// + /// The set of schemes the validator accepts. Case-insensitive per RFC 3986 §3.1. + /// + public ISet AllowedSchemes { get; } + = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "http", + "https", + "modbus+tcp", + "modbus", + "mqtt", + "mqtts", + "opc.tcp", + "opc.https", + "opc.wss" + }; + + /// + /// When true the validator permits loopback addresses (127.0.0.0/8 / ::1) and the + /// literal host names localhost, ip6-localhost, ip6-loopback. Default + /// false — operators must opt in explicitly to expose the server's own listeners to + /// a remote caller. + /// + public bool AllowLoopback { get; set; } + + /// + /// When true the validator permits private-range IP literals (RFC1918, RFC6598 + /// CGNAT, RFC4193 ULA, IPv4 / IPv6 link-local). Default false so the IMDS attack + /// surface (e.g. 169.254.169.254) is closed by default. + /// + public bool AllowPrivateAddresses { get; set; } + + /// + /// Exclusive allow-list of host names. When non-empty, only hosts that appear in this set + /// (case-insensitive comparison) pass the validator regardless of the scheme / loopback / + /// private-range gates. Use this to pin the server to a known set of devices. + /// + public ISet AllowedHosts { get; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Hosts that are always denied even when the rest of the policy would accept them. Evaluated after + /// so an operator can layer an explicit deny on top of a broad allow. + /// + public ISet BlockedHosts { get; } + = new HashSet(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/WotEndpointValidator.cs b/src/Opc.Ua.WotCon.Bindings/WotEndpointValidator.cs new file mode 100644 index 0000000000..78d9b91c7c --- /dev/null +++ b/src/Opc.Ua.WotCon.Bindings/WotEndpointValidator.cs @@ -0,0 +1,183 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Net; +using System.Net.Sockets; + +namespace Opc.Ua.WotCon.Bindings +{ + /// + /// Validates a remote-supplied endpoint string against a . + /// + /// + /// DNS resolution is intentionally **not** performed during validation. Resolving the host name + /// to an IP at validation time and then re-resolving it at connect time is itself a TOCTOU SSRF + /// vector — a hostile DNS could return a public IP to the validator and a private IP to the + /// connector. Operators who need IP-range enforcement must either pin + /// to IP literals or accept that the IP-range gates + /// only fire when the host portion of the URI itself is an IP literal. + /// + public static class WotEndpointValidator + { + /// + /// Validates , returning the normalized in + /// on success. + /// + /// + public static ServiceResult Validate( + string? endpoint, + WotEndpointPolicy policy, + out Uri? normalized) + { + normalized = null; + + if (policy is null) + { + throw new ArgumentNullException(nameof(policy)); + } + if (string.IsNullOrWhiteSpace(endpoint)) + { + return ServiceResult.Create(StatusCodes.BadInvalidArgument, "Endpoint is required."); + } + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri)) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "Endpoint is not a syntactically valid absolute URI."); + } + if (!policy.AllowedSchemes.Contains(uri.Scheme)) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint scheme '{0}' is not in the policy's AllowedSchemes set.", + uri.Scheme); + } + + string host = uri.Host ?? string.Empty; + if (policy.AllowedHosts.Count > 0 && !policy.AllowedHosts.Contains(host)) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint host '{0}' is not in the policy's AllowedHosts set.", + host); + } + if (policy.BlockedHosts.Contains(host)) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint host '{0}' is in the policy's BlockedHosts set.", + host); + } + + if (IPAddress.TryParse(host, out IPAddress? parsedIp)) + { + IPAddress ip = parsedIp.IsIPv4MappedToIPv6 ? parsedIp.MapToIPv4() : parsedIp; + if (!policy.AllowLoopback && IPAddress.IsLoopback(ip)) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint host '{0}' is a loopback address; set WotEndpointPolicy.AllowLoopback = true to permit.", + host); + } + if (!policy.AllowPrivateAddresses && IsPrivateAddress(ip)) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint host '{0}' is in a private / link-local range; " + + "set WotEndpointPolicy.AllowPrivateAddresses = true to permit.", + host); + } + } + else if (IsLocalHostName(host) && !policy.AllowLoopback) + { + return ServiceResult.Create( + StatusCodes.BadSecurityChecksFailed, + "Endpoint host '{0}' is a localhost alias; set WotEndpointPolicy.AllowLoopback = true to permit.", + host); + } + + normalized = uri; + return ServiceResult.Good; + } + + /// + /// Returns true for IPv4 RFC1918 / RFC6598 CGNAT / RFC3927 link-local, IPv6 + /// RFC4193 ULA, and IPv6 RFC4291 link-local addresses. + /// + private static bool IsPrivateAddress(IPAddress ip) + { + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = ip.GetAddressBytes(); + if (bytes[0] == 10) + { + return true; + } + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) + { + return true; + } + if (bytes[0] == 192 && bytes[1] == 168) + { + return true; + } + if (bytes[0] == 169 && bytes[1] == 254) + { + return true; + } + if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127) + { + return true; + } + return false; + } + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + byte[] bytes = ip.GetAddressBytes(); + if ((bytes[0] & 0xFE) == 0xFC) + { + return true; + } + if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80) + { + return true; + } + } + return false; + } + + private static bool IsLocalHostName(string host) + { + return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "ip6-localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "ip6-loopback", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Opc.Ua.WotCon.Bindings/WotProtocolBinderRegistry.cs b/src/Opc.Ua.WotCon.Bindings/WotProtocolBinderRegistry.cs index 7437d1df86..05bcfd9828 100644 --- a/src/Opc.Ua.WotCon.Bindings/WotProtocolBinderRegistry.cs +++ b/src/Opc.Ua.WotCon.Bindings/WotProtocolBinderRegistry.cs @@ -55,12 +55,14 @@ public sealed class WotProtocolBinderRegistry : IWotBinderRegistry, IWotBindingC /// The credential provider used at activation time. /// The codec registry used to select payload codecs. /// The safety bounds enforced during planning. + /// The endpoint policy enforced before opening live channels. public WotProtocolBinderRegistry( IEnumerable binders, IEnumerable? executors = null, IWotCredentialProvider? credentials = null, IWotCodecRegistry? codecs = null, - WotBindingBounds? bounds = null) + WotBindingBounds? bounds = null, + WotEndpointPolicy? endpointPolicy = null) { if (binders is null) { @@ -69,6 +71,7 @@ public WotProtocolBinderRegistry( m_credentials = credentials ?? NullWotCredentialProvider.Instance; m_codecs = codecs ?? WotPayloadCodecRegistry.Default; m_bounds = bounds ?? WotBindingBounds.Default; + m_endpointPolicy = endpointPolicy ?? WotEndpointPolicy.Default; var seenBinderKeys = new HashSet(StringComparer.Ordinal); foreach (IWotProtocolBinder binder in binders) @@ -277,10 +280,32 @@ public ValueTask OpenChannelAsync( throw new InvalidOperationException( $"No executor is registered for binding '{form.Binding.Key}'."); } - var context = new WotExecutorContext(m_credentials, m_codecs, m_bounds); + string endpoint = GetExecutableEndpoint(form); + ServiceResult validation = WotEndpointValidator.Validate(endpoint, m_endpointPolicy, out _); + if (ServiceResult.IsBad(validation)) + { + throw new ServiceResultException(validation); + } + + var context = new WotExecutorContext(m_credentials, m_codecs, m_bounds, m_endpointPolicy); return executor.ActivateAsync(form, context, cancellationToken); } + private static string GetExecutableEndpoint(WotCompiledForm form) + { + if (string.Equals(form.Binding.Id, "w3c.http", StringComparison.Ordinal)) + { + return form.Addressing.Target; + } + if (string.Equals(form.Binding.Id, "w3c.modbus", StringComparison.Ordinal) || + string.Equals(form.Binding.Id, "w3c.mqtt", StringComparison.Ordinal) || + string.Equals(form.Binding.Id, "opc.opcua", StringComparison.Ordinal)) + { + return form.Endpoint.BaseUri; + } + return form.Endpoint.BaseUri; + } + private IWotProtocolBinder? Select(WotAffordanceForm form, WotBindingSelectionContext selection) { IWotProtocolBinder? best = null; @@ -430,6 +455,7 @@ private static bool ValidateStringTerm( private readonly IWotCredentialProvider m_credentials; private readonly IWotCodecRegistry m_codecs; private readonly WotBindingBounds m_bounds; + private readonly WotEndpointPolicy m_endpointPolicy; private readonly Dictionary m_binders = new(StringComparer.Ordinal); diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/BindingPlannerTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/BindingPlannerTests.cs index ae53e1238b..5d247a5262 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/BindingPlannerTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/BindingPlannerTests.cs @@ -260,6 +260,38 @@ public void MqttPlannerRejectsTopicExceedingMaxLength() d.Code == WotBindingDiagnosticCode.BoundsExceeded), Is.True); } + [TestCase("#")] + [TestCase("tenant/+/temperature")] + public void MqttPlannerRejectsWildcardTopicByDefault(string topic) + { + var planner = new MqttBindingPlanner(); + WotAffordanceForm form = MakePropertyForm( + "{\"href\":\"mqtt://broker.example.com:1883\",\"mqv:topic\":\"" + topic + + "\",\"op\":\"observeproperty\"}", + ops: ["observeproperty"]); + + WotBindingCompilation result = planner.Compile(form, DefaultContext()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => + d.Code == WotBindingDiagnosticCode.InvalidFieldValue), Is.True); + } + + [Test] + public void MqttPlannerAllowsWildcardTopicWhenPolicyOptsIn() + { + var planner = new MqttBindingPlanner(); + WotAffordanceForm form = MakePropertyForm( + """{"href":"mqtt://broker.example.com:1883","mqv:topic":"tenant/+","op":"observeproperty"}""", + ops: ["observeproperty"]); + var context = new WotBindingPlanContext(bounds: new WotBindingBounds { AllowMqttWildcardTopics = true }); + + WotBindingCompilation result = planner.Compile(form, context); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries[0].Addressing.Target, Is.EqualTo("tenant/+")); + } + [Test] public void MqttPlannerWarnsOnUnknownControlPacket() { @@ -313,6 +345,35 @@ public void MqttPlannerIdentity() Assert.That(planner.Identity.Id, Is.EqualTo("w3c.mqtt")); } + [Test] + public void HttpPlannerRejectsUnsafeContentType() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = MakePropertyForm( + """{"href":"http://example.com/p","contentType":"application/json\r\nX-Injected: pwned"}""", + ops: ["writeproperty"]); + + WotBindingCompilation result = planner.Compile(form, DefaultContext()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => + d.Code == WotBindingDiagnosticCode.InvalidFieldValue), Is.True); + } + + [Test] + public void HttpPlannerAcceptsSafeContentType() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = MakePropertyForm( + """{"href":"http://example.com/p","contentType":"application/json; charset=utf-8"}""", + ops: ["writeproperty"]); + + WotBindingCompilation result = planner.Compile(form, DefaultContext()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries[0].Payload.ContentType, Is.EqualTo("application/json; charset=utf-8")); + } + [Test] public void CoapPlannerCompilesValidPropertyForm() { diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/WotEndpointValidatorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/WotEndpointValidatorTests.cs new file mode 100644 index 0000000000..d4064d6119 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/WotEndpointValidatorTests.cs @@ -0,0 +1,131 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for . + /// + [TestFixture] + public sealed class WotEndpointValidatorTests + { + [TestCase("http://169.254.169.254/latest/meta-data/")] + [TestCase("http://[::ffff:169.254.169.254]/latest/meta-data/")] + [TestCase("http://127.0.0.1/admin")] + [TestCase("http://[::1]/admin")] + [TestCase("http://10.0.0.1/device")] + [TestCase("http://192.168.1.1/device")] + [TestCase("http://172.16.0.1/device")] + [TestCase("http://100.64.0.1/device")] + [TestCase("http://[fc00::1]/device")] + [TestCase("http://[fe80::1]/device")] + public void ValidateRejectsLoopbackAndPrivateIpLiterals(string endpoint) + { + ServiceResult result = WotEndpointValidator.Validate(endpoint, new WotEndpointPolicy(), out Uri? normalized); + + Assert.That(ServiceResult.IsBad(result), Is.True); + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That(normalized, Is.Null); + } + + [Test] + public void ValidateAcceptsPublicHost() + { + ServiceResult result = WotEndpointValidator.Validate( + "https://example.com/api", new WotEndpointPolicy(), out Uri? normalized); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(normalized, Is.Not.Null); + Assert.That(normalized!.Host, Is.EqualTo("example.com")); + } + + [Test] + public void ValidateAllowsLoopbackWhenPolicyOptsIn() + { + var policy = new WotEndpointPolicy { AllowLoopback = true }; + + ServiceResult result = WotEndpointValidator.Validate("http://127.0.0.1/admin", policy, out Uri? normalized); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(normalized, Is.Not.Null); + } + + [Test] + public void ValidateAllowsPrivateAddressWhenPolicyOptsIn() + { + var policy = new WotEndpointPolicy { AllowPrivateAddresses = true }; + + ServiceResult result = WotEndpointValidator.Validate("http://10.0.0.1/device", policy, out Uri? normalized); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(normalized, Is.Not.Null); + } + + [Test] + public void ValidateAllowedHostsIsExclusive() + { + var policy = new WotEndpointPolicy(); + policy.AllowedHosts.Add("allowed.example.com"); + + ServiceResult accepted = WotEndpointValidator.Validate( + "https://allowed.example.com/api", policy, out Uri? normalized); + ServiceResult rejected = WotEndpointValidator.Validate( + "https://other.example.com/api", policy, out _); + + Assert.That(ServiceResult.IsGood(accepted), Is.True); + Assert.That(normalized, Is.Not.Null); + Assert.That(rejected.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void ValidateBlockedHostsDenyHost() + { + var policy = new WotEndpointPolicy(); + policy.BlockedHosts.Add("blocked.example.com"); + + ServiceResult result = WotEndpointValidator.Validate("https://blocked.example.com/api", policy, out _); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [TestCase("relative/path")] + [TestCase("://missing-scheme")] + [TestCase("")] + public void ValidateMalformedOrRelativeUriFailsClosed(string endpoint) + { + ServiceResult result = WotEndpointValidator.Validate(endpoint, new WotEndpointPolicy(), out Uri? normalized); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(normalized, Is.Null); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/WotProtocolBinderRegistryTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/WotProtocolBinderRegistryTests.cs index a67b3c4fe8..3eadcd7406 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/WotProtocolBinderRegistryTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/WotProtocolBinderRegistryTests.cs @@ -28,8 +28,10 @@ * ======================================================================*/ using System; +using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.WotCon.Bindings.Planners; @@ -246,6 +248,57 @@ public void OpenChannelAsyncWithNullFormThrows() async () => await registry.OpenChannelAsync(null!).ConfigureAwait(false)); } + [Test] + public void OpenChannelAsyncRejectsEndpointBeforeExecutorActivation() + { + var executor = new TestExecutor(new HttpBindingPlanner().Identity); + var registry = new WotProtocolBinderRegistry([new HttpBindingPlanner()], [executor]); + var form = new WotCompiledForm( + new HttpBindingPlanner().Identity, + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + new WotEndpointDescriptor("http", "169.254.169.254", 80, "http://169.254.169.254"), + new WotAddressingDescriptor("http://169.254.169.254/latest/meta-data/"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], + isExecutable: true); + + ServiceResultException exception = Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(form).ConfigureAwait(false))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That(executor.Activations, Is.Zero); + } + + [Test] + public async Task OpenChannelAsyncAcceptsPublicEndpoint() + { + var executor = new TestExecutor(new HttpBindingPlanner().Identity); + var registry = new WotProtocolBinderRegistry([new HttpBindingPlanner()], [executor]); + var form = new WotCompiledForm( + new HttpBindingPlanner().Identity, + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + new WotEndpointDescriptor("http", "example.com", 80, "http://example.com"), + new WotAddressingDescriptor("http://example.com/p"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + [], + isExecutable: true); + + await using IWotBindingChannel channel = await registry.OpenChannelAsync(form).ConfigureAwait(false); + + Assert.That(channel.Form, Is.SameAs(form)); + Assert.That(executor.Activations, Is.EqualTo(1)); + } + [Test] public void RegistryConstructorIgnoresNullBinders() { @@ -327,5 +380,88 @@ public void PrepareTargetMappingPresentOnEventAffordanceIsRejected() Assert.That(plan.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.TargetMappingNotOnProperty), Is.True); } + + private sealed class TestExecutor : IWotBindingExecutor + { + public TestExecutor(WotBindingIdentity identity) + { + Identity = identity; + } + + public WotBindingIdentity Identity { get; } + + public int Activations { get; private set; } + + public bool CanExecute(WotCompiledForm form) + { + return string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + } + + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + Activations++; + return new ValueTask(new TestChannel(form)); + } + } + + private sealed class TestChannel : IWotBindingChannel + { + public TestChannel(WotCompiledForm form) + { + Form = form; + } + + public WotCompiledForm Form { get; } + + public ValueTask DisposeAsync() + { + return default; + } + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + return new ValueTask( + new WotReadResult(StatusCodes.Good, new DataValue(Variant.Null))); + } + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + return new ValueTask(new WotInvokeResult(StatusCodes.Good)); + } + + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + return new ValueTask(new TestSubscription(Form)); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + return new ValueTask(new TestSubscription(Form)); + } + } + + private sealed class TestSubscription : IWotSubscription + { + public TestSubscription(WotCompiledForm form) + { + Form = form; + } + + public WotCompiledForm Form { get; } + + public ValueTask DisposeAsync() + { + return default; + } + } } } From b48558b01f3577f801620dc7b691bcbe5a393778 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 19:04:17 +0200 Subject: [PATCH 5/9] Harden WoT binding executor sinks Validate HTTP content types with MediaTypeHeaderValue before assigning request content, and add parser-backed validation for configured default headers and credential headers so CRLF-injected values are rejected instead of written to the wire. Reject MQTT wildcard publish topics at the executor sink even when planning has explicitly opted into wildcard subscribe topics, and add the missing Modbus multiple-register write null and range guards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../MqttWotBindingChannel.cs | 17 ++ .../Http/HttpWotBindingChannel.cs | 56 ++++++- .../Modbus/ModbusTcpClient.cs | 24 +++ .../ExecutorUnitTests.cs | 147 +++++++++++++++++- .../HttpCredentialResolutionTests.cs | 6 +- .../HttpRedirectSecurityTests.cs | 18 ++- .../HttpWotBindingChannelTests.cs | 3 +- .../HttpWotExecutorTests.cs | 3 +- .../ModbusWotBindingChannelTests.cs | 9 +- .../ModbusWotExecutorHardeningTests.cs | 3 +- .../ModbusWotExecutorTests.cs | 3 +- .../MqttWotBindingChannelTests.cs | 40 ++++- .../MqttWotExecutorTests.cs | 3 +- .../OpcUaWotBindingChannelTests.cs | 3 +- .../OpcUaWotExecutorTests.cs | 3 +- 15 files changed, 310 insertions(+), 28 deletions(-) diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs index d509396fe9..e285798be5 100644 --- a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs @@ -118,6 +118,10 @@ public async ValueTask WriteAsync( { return new WotWriteResult(StatusCodes.BadTimeout, "The MQTT publish timed out."); } + catch (ArgumentException ex) + { + return new WotWriteResult(StatusCodes.BadInvalidArgument, ex.Message); + } catch (MqttCommunicationException ex) { return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); @@ -146,6 +150,10 @@ public async ValueTask InvokeAsync( { return new WotInvokeResult(StatusCodes.BadCommunicationError, null, ex.Message); } + catch (ArgumentException ex) + { + return new WotInvokeResult(StatusCodes.BadInvalidArgument, null, ex.Message); + } } [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -241,6 +249,10 @@ private async Task TryUnsubscribeAsync() private async Task PublishAsync(byte[] payload, CancellationToken cancellationToken) { + if (ContainsMqttWildcard(m_topic)) + { + throw new ArgumentException("MQTT publish topics must not contain wildcard characters."); + } MqttApplicationMessage message = new MqttApplicationMessageBuilder() .WithTopic(m_topic) .WithPayload(payload) @@ -264,6 +276,11 @@ private static byte[] ToArray(ReadOnlySequence payload) return payload.IsEmpty ? [] : payload.ToArray(); } + private static bool ContainsMqttWildcard(string topic) + { + return topic.Contains('#', StringComparison.Ordinal) || topic.Contains('+', StringComparison.Ordinal); + } + private static MqttQualityOfServiceLevel ParseQos( System.Collections.Immutable.ImmutableDictionary metadata) { diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs index 7a87bcb0a3..cba81d5234 100644 --- a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs @@ -32,6 +32,7 @@ using System.Collections.Immutable; using System.IO; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -304,13 +305,26 @@ private async Task SendOnceAsync( ReadOnlyMemory? content, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(method, requestUri); - ApplyHeaders(request, sameOrigin); + string? headerError = ApplyHeaders(request, sameOrigin); + if (headerError is not null) + { + return HopResult.Terminal(StatusCodes.BadSecurityChecksFailed, [], headerError); + } if (content is { } body && method != HttpMethod.Get && method != HttpMethod.Head) { + MediaTypeHeaderValue? mediaType = null; + if (!string.IsNullOrEmpty(Form.Payload.ContentType) && + !MediaTypeHeaderValue.TryParse(Form.Payload.ContentType, out mediaType)) + { + return HopResult.Terminal( + StatusCodes.BadInvalidArgument, + [], + "The form contentType is not a valid media type."); + } var byteContent = new ByteArrayContent(body.ToArray()); - if (!string.IsNullOrEmpty(Form.Payload.ContentType)) + if (mediaType is not null) { - byteContent.Headers.TryAddWithoutValidation("Content-Type", Form.Payload.ContentType); + byteContent.Headers.ContentType = mediaType; } request.Content = byteContent; } @@ -446,7 +460,28 @@ private async Task ResolveCredentialAsync(CancellationToken cancellationToken) m_credential = credential; } - private void ApplyHeaders(HttpRequestMessage request, bool includeCredentials) + private static bool TryAddHeader(HttpRequestMessage request, KeyValuePair header) + { + try + { + request.Headers.Add(header.Key, header.Value); + return true; + } + catch (FormatException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + } + + private string? ApplyHeaders(HttpRequestMessage request, bool includeCredentials) { // A cross-origin redirect must not carry any custom (potentially // credential-bearing) header, so both the caller's default headers and @@ -454,19 +489,26 @@ private void ApplyHeaders(HttpRequestMessage request, bool includeCredentials) // origin. if (!includeCredentials) { - return; + return null; } foreach (KeyValuePair header in m_defaultHeaders) { - request.Headers.TryAddWithoutValidation(header.Key, header.Value); + if (!TryAddHeader(request, header)) + { + return "A configured default HTTP header is not valid."; + } } if (m_credential is { } credential) { foreach (KeyValuePair header in credential.Headers) { - request.Headers.TryAddWithoutValidation(header.Key, header.Value); + if (!TryAddHeader(request, header)) + { + return "A resolved credential HTTP header is not valid."; + } } } + return null; } private Uri AppendCredentialQuery(Uri target) diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs index 4982a34c94..eb8aaa6e5b 100644 --- a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs @@ -126,7 +126,12 @@ public async ValueTask WriteSingleRegisterAsync( public async ValueTask WriteMultipleRegistersAsync( byte unitId, ushort address, ushort[] values, CancellationToken cancellationToken) { + if (values is null) + { + throw new ArgumentNullException(nameof(values)); + } int count = values.Length; + ValidateRegisterRange(address, count, ModbusProtocolLimits.MaxWriteRegisters, nameof(values)); byte byteCount = (byte)(count * 2); byte[] pdu = new byte[6 + byteCount]; pdu[0] = 0x10; @@ -230,6 +235,25 @@ private static void ValidateBitRange(ushort address, int quantity, int maximum, } } + private static void ValidateRegisterRange(ushort address, int quantity, int maximum, string parameterName) + { + if (quantity is < 1 || quantity > maximum) + { + throw new ArgumentOutOfRangeException( + parameterName, + quantity, + $"The Modbus register quantity must be between 1 and {maximum}."); + } + if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress) + { + throw new ArgumentOutOfRangeException( + parameterName, + quantity, + $"The Modbus range starting at {address} for {quantity} registers exceeds the maximum " + + $"address {ModbusProtocolLimits.MaxAddress}."); + } + } + /// public void Dispose() { diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs index 958f8060fe..8fb5f49a38 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs @@ -27,9 +27,11 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System.Collections.Immutable; using System.Linq; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.WotCon.Bindings.Http; @@ -58,6 +60,11 @@ private static WotCompiledForm Compiled(string bindingId, string scheme) [], isExecutable: true); } + private static WotEndpointPolicy AllowLoopbackPolicy() + { + return new WotEndpointPolicy { AllowLoopback = true }; + } + [Test] public void CanExecuteMatchesOwnBindingOnly() { @@ -95,7 +102,8 @@ public async Task HttpErrorStatusMapping() var registry = new WotProtocolBinderRegistry( [new HttpBindingPlanner()], - [new HttpWotBindingExecutor()]); + [new HttpWotBindingExecutor()], + endpointPolicy: AllowLoopbackPolicy()); string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + server.BaseUrl + @@ -113,5 +121,142 @@ [new HttpBindingPlanner()], } } } + + [Test] + public async Task HttpChannelRejectsInvalidContentTypeAtSink() + { + int requests = 0; + using var server = new TestHttpServer(request => + { + requests++; + return TestHttpResponse.Json(200, "\"ok\""); + }); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor()], + endpointPolicy: AllowLoopbackPolicy()); + var form = new WotCompiledForm( + new HttpBindingPlanner().Identity, + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.WriteProperty, + "writeproperty", + new WotEndpointDescriptor("http", "127.0.0.1", server.Port, server.BaseUrl), + new WotAddressingDescriptor(server.BaseUrl + "/p"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.WriteProperty, "writeproperty", "PUT"), + new WotPayloadDescriptor("application/json\r\nX-Injected: pwned", "json"), + [], + isExecutable: true); + + await using IWotBindingChannel channel = await registry.OpenChannelAsync(form).ConfigureAwait(false); + WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(42L))).ConfigureAwait(false); + + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(requests, Is.Zero); + } + + [Test] + public async Task HttpChannelRejectsInvalidDefaultHeader() + { + using var server = new TestHttpServer(_ => TestHttpResponse.Json(200, "1")); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(new HttpWotBindingOptions + { + DefaultHeaders = new System.Collections.Generic.Dictionary + { + ["X-Test"] = "ok\r\nX-Injected: pwned" + } + })], + endpointPolicy: AllowLoopbackPolicy()); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + server.BaseUrl + + "/p\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + await using IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task HttpChannelRejectsInvalidCredentialHeader() + { + using var server = new TestHttpServer(_ => TestHttpResponse.Json(200, "1")); + var credential = new WotCredential( + WotSecurityScheme.Bearer, + ImmutableDictionary.Empty.Add("Authorization", "Bearer token\r\nMetadata: true")); + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor()], + credentials: new StaticCredentialProvider(credential), + endpointPolicy: AllowLoopbackPolicy()); + var security = ImmutableArray.Create(new WotCredentialReference( + "bearer_sc", + WotSecurityScheme.Bearer, + HttpBindingPlanner.BindingUri, + server.BaseUrl)); + var form = new WotCompiledForm( + new HttpBindingPlanner().Identity, + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + new WotEndpointDescriptor("http", "127.0.0.1", server.Port, server.BaseUrl), + new WotAddressingDescriptor(server.BaseUrl + "/p"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + security, + isExecutable: true); + + await using IWotBindingChannel channel = await registry.OpenChannelAsync(form).ConfigureAwait(false); + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void ModbusWriteMultipleRegistersRejectsNullValues() + { + using var client = new ModbusTcpClient("127.0.0.1", 502, System.TimeSpan.FromSeconds(1)); + + Assert.ThrowsAsync( + async () => await client.WriteMultipleRegistersAsync( + 1, 0, null!, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public void ModbusWriteMultipleRegistersRejectsOutOfRangeValues() + { + using var client = new ModbusTcpClient("127.0.0.1", 502, System.TimeSpan.FromSeconds(1)); + ushort[] values = new ushort[ModbusProtocolLimits.MaxWriteRegisters + 1]; + + Assert.ThrowsAsync( + async () => await client.WriteMultipleRegistersAsync( + 1, 0, values, CancellationToken.None).ConfigureAwait(false)); + } + + private sealed class StaticCredentialProvider : IWotCredentialProvider + { + public StaticCredentialProvider(WotCredential credential) + { + m_credential = credential; + } + + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + return new ValueTask(m_credential); + } + + private readonly WotCredential m_credential; + } } } diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs index 3751375891..ef08490c15 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs @@ -83,7 +83,8 @@ [new HttpBindingPlanner()], [ new HttpWotBindingExecutor() ], - credentials: credentials); + credentials: credentials, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) .ConfigureAwait(false); @@ -112,7 +113,8 @@ [new HttpBindingPlanner()], [ new HttpWotBindingExecutor() ], - credentials: credentials); + credentials: credentials, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) .ConfigureAwait(false); diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs index 917907f76f..cab2b7fa0f 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs @@ -66,7 +66,8 @@ private static WotProtocolBinderRegistry OwnedRegistry( return new WotProtocolBinderRegistry( [new HttpBindingPlanner()], [new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions())], - credentials: credentials); + credentials: credentials, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string href) @@ -477,7 +478,8 @@ [new HttpBindingPlanner()], [ new HttpWotBindingExecutor(new HttpWotBindingOptions { ClientFactory = () => client }) ], - credentials: new HeaderQueryCredentialProvider()); + credentials: new HeaderQueryCredentialProvider(), + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotCompiledForm read = ReadForm(registry, server.BaseUrl + "/p"); Assert.ThrowsAsync( @@ -512,7 +514,8 @@ [new HttpBindingPlanner()], ClientFactory = () => client, DefaultHeaders = DefaultHeaderOptions().DefaultHeaders }) - ]); + ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p"); InvalidOperationException? exception = Assert.ThrowsAsync( @@ -560,7 +563,8 @@ public void CallerSuppliedClientWithoutHeadersFailsClosed(bool configureEmptyHea } var registry = new WotProtocolBinderRegistry( [new HttpBindingPlanner()], - [new HttpWotBindingExecutor(options)]); + [new HttpWotBindingExecutor(options)], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotCompiledForm read = ReadFormNoSecurity(registry, server.BaseUrl + "/p"); InvalidOperationException? exception = Assert.ThrowsAsync( @@ -610,7 +614,8 @@ [new HttpBindingPlanner()], return client; } }) - ]); + ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p"); InvalidOperationException? exception = Assert.ThrowsAsync( @@ -665,7 +670,8 @@ public async Task ConfirmedSafeCallerClientIsNotMutatedOrDisposed() }; var registry = new WotProtocolBinderRegistry( [new HttpBindingPlanner()], - [new HttpWotBindingExecutor(options)]); + [new HttpWotBindingExecutor(options)], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); IWotBindingChannel channel = await registry.OpenChannelAsync( ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs index 3472f804ad..a3a5ca5b43 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs @@ -61,7 +61,8 @@ [new HttpBindingPlanner()], ClientFactory = () => new HttpClient(), CallerClientHandlesRedirectSafety = true })], - bounds: bounds); + bounds: bounds, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs index 1aa7721cf8..fd093a5127 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs @@ -52,7 +52,8 @@ private static WotProtocolBinderRegistry Registry(HttpWotBindingOptions? options return new WotProtocolBinderRegistry( [new HttpBindingPlanner()], [ new HttpWotBindingExecutor(options ?? - new HttpWotBindingOptions()) ]); + new HttpWotBindingOptions()) ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs index 4637dc6d95..12338609f4 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs @@ -61,7 +61,8 @@ [new ModbusBindingPlanner()], [new ModbusWotBindingExecutor(new ModbusWotBindingOptions { ObserveInterval = TimeSpan.FromMilliseconds(100) - })]); + })], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) @@ -340,7 +341,8 @@ public async Task ModbusChannelReadMapsTimeoutToBadTimeout() var registry = new WotProtocolBinderRegistry( [new ModbusBindingPlanner()], [new ModbusWotBindingExecutor(options)], - bounds: smallBounds); + bounds: smallBounds, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); WotCompiledForm read = plan.CompiledForms.First( @@ -394,7 +396,8 @@ public async Task ModbusChannelWriteMapsTimeoutToBadTimeout() var registry = new WotProtocolBinderRegistry( [new ModbusBindingPlanner()], [new ModbusWotBindingExecutor(options)], - bounds: smallBounds); + bounds: smallBounds, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16")); WotCompiledForm write = plan.CompiledForms.First( diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs index 34f921f9eb..a22fd7f77d 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs @@ -53,7 +53,8 @@ private static WotProtocolBinderRegistry Registry() { return new WotProtocolBinderRegistry( [new ModbusBindingPlanner()], - [new ModbusWotBindingExecutor()]); + [new ModbusWotBindingExecutor()], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } [Test] diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs index bee8f21add..3c398d3e37 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs @@ -47,7 +47,8 @@ private static WotProtocolBinderRegistry Registry() { return new WotProtocolBinderRegistry( [new ModbusBindingPlanner()], - [new ModbusWotBindingExecutor()]); + [new ModbusWotBindingExecutor()], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs index 3e662f1155..7290f9e69e 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs @@ -73,12 +73,15 @@ private static async Task StartBrokerAsync(int port) return broker; } - private static WotProtocolBinderRegistry Registry(TimeSpan? readTimeout = null) + private static WotProtocolBinderRegistry Registry( + TimeSpan? readTimeout = null, WotBindingBounds? bounds = null) { return new WotProtocolBinderRegistry( [new MqttBindingPlanner()], [new MqttWotBindingExecutor( - new MqttWotBindingOptions { ReadTimeout = readTimeout ?? TimeSpan.FromSeconds(5) })]); + new MqttWotBindingOptions { ReadTimeout = readTimeout ?? TimeSpan.FromSeconds(5) })], + bounds: bounds, + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) @@ -174,6 +177,39 @@ public async Task MqttChannelInvokeAsyncPublishesWithInputValue() } } + [Test] + public async Task MqttChannelRejectsWildcardPublishTopic() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry( + bounds: new WotBindingBounds { AllowMqttWildcardTopics = true }); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{" + + "\"href\":\"mqtt://127.0.0.1:" + + port.ToString(System.Globalization.CultureInfo.InvariantCulture) + + "\",\"mqv:topic\":\"#\",\"op\":[\"writeproperty\"]}]}}}"; + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync( + new DataValue(new Variant(42L))).ConfigureAwait(false); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + [Test] public async Task MqttChannelSubscribeEventAsyncReceivesNotifications() { diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs index 2bdc87e980..6bea0f606a 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs @@ -61,7 +61,8 @@ private static WotProtocolBinderRegistry Registry() return new WotProtocolBinderRegistry( [new MqttBindingPlanner()], [ new MqttWotBindingExecutor( - new MqttWotBindingOptions { ReadTimeout = TimeSpan.FromSeconds(5) }) ]); + new MqttWotBindingOptions { ReadTimeout = TimeSpan.FromSeconds(5) }) ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); } private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs index a278f822c6..9bfd744146 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs @@ -117,7 +117,8 @@ [new OpcUaBindingPlanner()], DisposeSession = false, ObserveInterval = TimeSpan.FromMilliseconds(100) }) - ]); + ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument( "xid", WoTDocumentKindEnum.ThingDescription, diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs index 26c9465d23..bd842a3779 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs @@ -122,7 +122,8 @@ [new OpcUaBindingPlanner()], DisposeSession = false, ObserveInterval = TimeSpan.FromMilliseconds(100) }) - ]); + ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); string ep = url.ToString(); string td = BuildThingDescription(ep); From 2ac7f7eec1516c902f7c27991f07032900bfc7c4 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 19:20:53 +0200 Subject: [PATCH 6/9] Re-apply the endpoint policy to every HTTP redirect hop The endpoint policy was applied to a form's own target before the channel opened, but a redirect selects a new target after that check. A permitted origin could therefore bounce the request to a loopback or link-local address - including the cloud instance metadata service - that the initial validation would have refused. Redirect resolution now re-validates each hop against the same policy, so the scheme, downgrade, loop and endpoint gates all apply for the whole chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../Http/HttpWotBindingChannel.cs | 14 +++++ .../HttpRedirectSecurityTests.cs | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs index cba81d5234..c9dd63686b 100644 --- a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs @@ -382,6 +382,20 @@ System.Net.HttpStatusCode.TemporaryRedirect or error = "The HTTP redirect downgrades https to http, which is refused."; return null; } + + // The endpoint policy is applied to the form's own target before the channel + // opens, but a redirect chooses a new target after that check. Re-apply the + // policy to every hop, otherwise a permitted host can bounce the request to a + // loopback or link-local address that the initial validation would have refused. + ServiceResult validation = WotEndpointValidator.Validate( + location.AbsoluteUri, + m_context.EndpointPolicy, + out _); + if (ServiceResult.IsBad(validation)) + { + error = $"The HTTP redirect targets an endpoint refused by policy: {validation.StatusCode}."; + return null; + } return location; } diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs index cab2b7fa0f..79f5baa803 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs @@ -337,6 +337,59 @@ public async Task RedirectToDisallowedSchemeIsRejected() } } + [Test] + [TestCase("http://169.254.169.254/latest/meta-data/")] + [TestCase("http://10.0.0.1/admin")] + [TestCase("http://192.168.1.1/admin")] + [TestCase("http://[fc00::1]/admin")] + public async Task RedirectToPolicyBlockedEndpointIsRejectedAsync(string blockedTarget) + { + // The endpoint policy is applied to the form's own target before the channel + // opens. A permitted origin that redirects to a link-local or private address + // would otherwise reach it, so every hop must be re-validated. + using var server = new TestHttpServer(_ => TestHttpResponse.Redirect(blockedTarget)); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + } + + [Test] + public async Task RedirectToPrivateEndpointIsAllowedWhenPolicyOptsInAsync() + { + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "7"); + }); + using var originServer = new TestHttpServer( + _ => TestHttpResponse.Redirect(targetServer.BaseUrl + "/p")); + + var registry = new WotProtocolBinderRegistry( + [new HttpBindingPlanner()], + [new HttpWotBindingExecutor(new HttpWotBindingOptions())], + endpointPolicy: new WotEndpointPolicy + { + AllowLoopback = true, + AllowPrivateAddresses = true + }); + + IWotBindingChannel channel = await registry.OpenChannelAsync( + ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "An operator opt-in must still permit the redirect."); + } + } + [Test] public async Task RedirectLimitIsEnforced() { From 81d77a76d430dd97e13114ba628baed9e8f08873 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 09:05:58 +0200 Subject: [PATCH 7/9] Register the MQTT binding project in the solution Opc.Ua.WotCon.Bindings.Mqtt was added without being listed in UA.slnx, so the solution build never compiled it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- UA.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/UA.slnx b/UA.slnx index c74282e00e..5e4545b3c1 100644 --- a/UA.slnx +++ b/UA.slnx @@ -94,6 +94,7 @@ + From 5f19e8dd102383f14f0fe6e3b13c9ba5d8d4f514 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 19:17:20 +0200 Subject: [PATCH 8/9] Address WoT executor review feedback Validate hand-built MQTT subscribe topics, reject concurrent MQTT reads, preserve synthetic OPC UA endpoint ports, and remove blocking waits from test helper teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../MqttWotBindingChannel.cs | 50 ++++++- .../OpcUa/OpcUaWotBindingExecutor.cs | 45 ++++++- .../MqttWotBindingChannelTests.cs | 124 ++++++++++++++++++ .../OpcUaWotBindingExecutorUnitTests.cs | 108 +++++++++++++++ .../Support/TestHttpServer.cs | 27 ++-- .../Support/TestModbusServer.cs | 29 ++-- 6 files changed, 353 insertions(+), 30 deletions(-) create mode 100644 tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs index e285798be5..cbb234e9e8 100644 --- a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs @@ -55,6 +55,7 @@ public MqttWotBindingChannel( m_client = client; Form = form; m_options = options; + m_bounds = context.Bounds; m_topic = form.Addressing.Target; m_qos = ParseQos(form.Addressing.Metadata); m_retain = ParseBool(form.Addressing.Metadata, "retain"); @@ -67,7 +68,14 @@ public MqttWotBindingChannel( public async ValueTask ReadAsync(CancellationToken cancellationToken = default) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Interlocked.Exchange(ref m_pendingRead, completion); + if (Interlocked.CompareExchange(ref m_pendingRead, completion, null) is not null) + { + var exception = new ServiceResultException( + StatusCodes.BadInvalidState, + "An MQTT read is already pending on this channel."); + completion.SetCanceled(CancellationToken.None); + throw exception; + } using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(m_options.ReadTimeout); try @@ -87,10 +95,21 @@ public async ValueTask ReadAsync(CancellationToken cancellationTo } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { + completion.TrySetCanceled(timeout.Token); return new WotReadResult( StatusCodes.BadTimeout, DataValue.FromStatusCode(StatusCodes.BadTimeout), "Timed out waiting for an MQTT message."); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + completion.TrySetCanceled(cancellationToken); + throw; + } + catch + { + completion.TrySetCanceled(CancellationToken.None); + throw; + } finally { Interlocked.CompareExchange(ref m_pendingRead, null, completion); @@ -171,7 +190,15 @@ public async ValueTask ObserveAsync( m_handlers.Add(onNotification); m_observing = true; } - await SubscribeAsync(cancellationToken).ConfigureAwait(false); + try + { + await SubscribeAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + RemoveHandler(onNotification); + throw; + } return new HandlerSubscription(this, onNotification); } @@ -226,6 +253,7 @@ private Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args) private async Task SubscribeAsync(CancellationToken cancellationToken) { + ValidateSubscribeTopic(); MqttClientSubscribeOptions options = new MqttClientSubscribeOptionsBuilder() .WithTopicFilter(m_topic, m_qos) .Build(); @@ -262,6 +290,23 @@ private async Task PublishAsync(byte[] payload, CancellationToken cancellationTo await m_client.PublishAsync(message, cancellationToken).ConfigureAwait(false); } + private void ValidateSubscribeTopic() + { + WotBindingBounds.EnsurePositive(m_bounds.MaxTopicLength, nameof(WotBindingBounds.MaxTopicLength)); + if (m_topic.Length > m_bounds.MaxTopicLength) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + $"The MQTT subscribe topic exceeds the maximum length of {m_bounds.MaxTopicLength}."); + } + if (!m_bounds.AllowMqttWildcardTopics && ContainsMqttWildcard(m_topic)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, + "MQTT subscribe topics must not contain wildcard characters unless explicitly allowed."); + } + } + private void RemoveHandler(Action handler) { lock (m_lock) @@ -328,6 +373,7 @@ public async ValueTask DisposeAsync() private readonly IMqttClient m_client; private readonly MqttWotBindingOptions m_options; + private readonly WotBindingBounds m_bounds; private readonly string m_topic; private readonly MqttQualityOfServiceLevel m_qos; private readonly bool m_retain; diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs index a556fa5e1a..371371aa75 100644 --- a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs +++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using Opc.Ua.Client; @@ -77,13 +78,51 @@ public async ValueTask ActivateAsync( throw new InvalidOperationException( "No OPC UA session factory is configured on the executor options."); } - string endpoint = string.IsNullOrEmpty(form.Endpoint.BaseUri) - ? form.Endpoint.Scheme + "://" + (form.Endpoint.Host ?? string.Empty) - : form.Endpoint.BaseUri; + string endpoint = BuildEndpoint(form.Endpoint); ISession session = await m_options.SessionFactory(endpoint, cancellationToken).ConfigureAwait(false); return new OpcUaWotBindingChannel(session, m_options.DisposeSession, form, context, m_options); } + private static string BuildEndpoint(WotEndpointDescriptor endpoint) + { + if (!string.IsNullOrEmpty(endpoint.BaseUri)) + { + return endpoint.BaseUri; + } + string authority = FormatHost(endpoint.Host); + int defaultPort = GetDefaultPort(endpoint.Scheme); + if (endpoint.Port >= 0 && endpoint.Port != defaultPort) + { + authority += ":" + endpoint.Port.ToString(CultureInfo.InvariantCulture); + } + return endpoint.Scheme + "://" + authority; + } + + private static string FormatHost(string? host) + { + if (string.IsNullOrEmpty(host) || + host[0] == '[' || + !host.Contains(':', StringComparison.Ordinal)) + { + return host ?? string.Empty; + } + return "[" + host + "]"; + } + + private static int GetDefaultPort(string scheme) + { + if (string.Equals(scheme, "opc.tcp", StringComparison.OrdinalIgnoreCase)) + { + return 4840; + } + if (string.Equals(scheme, "opc.https", StringComparison.OrdinalIgnoreCase) || + string.Equals(scheme, "opc.wss", StringComparison.OrdinalIgnoreCase)) + { + return 443; + } + return -1; + } + private readonly OpcUaWotBindingOptions m_options; } } diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs index 7290f9e69e..96768bb6d5 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs @@ -29,6 +29,8 @@ using System; using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Sockets; @@ -109,6 +111,32 @@ private static string ActionTd(int port, string topic) "/" + topic + "\",\"mqv:qos\":0}]}}}"; } + private static WotCompiledForm BuildRawForm( + int port, string topic, WoTBindingCapabilityEnum capability, string opToken) + { + var endpoint = new WotEndpointDescriptor( + "mqtt", + "127.0.0.1", + port, + "mqtt://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture)); + var addressing = new WotAddressingDescriptor(topic); + var operation = new WotOperationDescriptor(capability, opToken, "subscribe"); + var payload = new WotPayloadDescriptor("application/json", "json"); + return new WotCompiledForm( + new WotBindingIdentity("w3c.mqtt", "1.0-ed", MqttBindingPlanner.BindingUri), + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + capability, + opToken, + endpoint, + addressing, + operation, + payload, + ImmutableArray.Empty, + isExecutable: true); + } + private static async Task WaitForAsync(ConcurrentQueue queue, int maxAttempts = 80) { for (int i = 0; i < maxAttempts; i++) @@ -210,6 +238,102 @@ public async Task MqttChannelRejectsWildcardPublishTopic() } } + [Test] + public async Task MqttChannelReadAsyncRejectsConcurrentReadWithoutAbandoningFirstAsync() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromSeconds(5)); + WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/concurrent")); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Task firstRead = channel.ReadAsync().AsTask(); + + Assert.That( + async () => await channel.ReadAsync().ConfigureAwait(false), + Throws.InstanceOf()); + + IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (publisher.ConfigureAwait(false)) + { + WotWriteResult writeResult = await publisher + .WriteAsync(new DataValue(new Variant(123L))) + .ConfigureAwait(false); + Assert.That(writeResult.Success, Is.True); + } + + WotReadResult firstResult = await firstRead.WaitAsync(TimeSpan.FromSeconds(5)) + .ConfigureAwait(false); + Assert.That(firstResult.Success, Is.True); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelReadAsyncRejectsHandBuiltTopicExceedingBoundsAsync() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(bounds: new WotBindingBounds { MaxTopicLength = 5 }); + WotCompiledForm read = BuildRawForm( + port, "things/too-long", WoTBindingCapabilityEnum.ReadProperty, "readproperty"); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + ServiceResultException exception = Assert.ThrowsAsync( + async () => await channel.ReadAsync().ConfigureAwait(false))!; + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadOutOfRange)); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + + [Test] + public async Task MqttChannelObserveAsyncRejectsHandBuiltWildcardTopicWhenDisallowedAsync() + { + int port = FreePort(); + MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false); + try + { + WotProtocolBinderRegistry registry = Registry(); + WotCompiledForm observe = BuildRawForm( + port, "things/+/value", WoTBindingCapabilityEnum.ObserveProperty, "observeproperty"); + + IWotBindingChannel channel = await registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + ServiceResultException exception = Assert.ThrowsAsync( + async () => await channel.ObserveAsync(_ => { }).ConfigureAwait(false))!; + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + } + finally + { + await broker.StopAsync().ConfigureAwait(false); + broker.Dispose(); + } + } + [Test] public async Task MqttChannelSubscribeEventAsyncReceivesNotifications() { diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs new file mode 100644 index 0000000000..24ae731bde --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs @@ -0,0 +1,108 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.WotCon.Bindings.OpcUa; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for endpoint construction in . + /// + [TestFixture] + public sealed class OpcUaWotBindingExecutorUnitTests + { + [TestCase("opc.tcp", 4841, "opc.tcp://example.test:4841")] + [TestCase("opc.tcp", 4840, "opc.tcp://example.test")] + [TestCase("opc.https", 443, "opc.https://example.test")] + public async Task ActivateAsyncBuildsEndpointFromHostAndPortWhenBaseUriIsEmptyAsync( + string scheme, int port, string expected) + { + string? capturedEndpoint = null; + var executor = new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => + { + capturedEndpoint = endpoint; + return ValueTask.FromException(new InvalidOperationException("stop")); + } + }); + + WotCompiledForm form = BuildForm(scheme, port, baseUri: string.Empty); + + Assert.That( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(capturedEndpoint, Is.EqualTo(expected)); + } + + [Test] + public async Task ActivateAsyncPreservesExplicitBaseUriAsync() + { + string? capturedEndpoint = null; + var executor = new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => + { + capturedEndpoint = endpoint; + return ValueTask.FromException(new InvalidOperationException("stop")); + } + }); + + WotCompiledForm form = BuildForm("opc.tcp", 4841, "opc.tcp://actual.example:1111"); + + Assert.That( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(capturedEndpoint, Is.EqualTo("opc.tcp://actual.example:1111")); + } + + private static WotCompiledForm BuildForm(string scheme, int port, string baseUri) + { + return new WotCompiledForm( + new WotBindingIdentity("opc.opcua", "10101", OpcUaBindingPlanner.BindingUri), + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + new WotEndpointDescriptor(scheme, "example.test", port, baseUri), + new WotAddressingDescriptor("i=2258"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "Read"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, + isExecutable: true); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs index 300aedfb61..4cdf72c16d 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs @@ -121,6 +121,7 @@ public TestHttpServer(Func handler) Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; BaseUrl = $"http://127.0.0.1:{Port}"; m_loop = Task.Run(AcceptLoopAsync); + ObserveLoopFaults(m_loop); } public string BaseUrl { get; } @@ -129,23 +130,14 @@ public TestHttpServer(Func handler) public void Dispose() { - m_cts.Cancel(); + Volatile.Write(ref m_stopped, 1); m_listener.Stop(); m_listener.Dispose(); - try - { - m_loop.Wait(2000); - } - catch (AggregateException) - { - // Ignore teardown faults. - } - m_cts.Dispose(); } private async Task AcceptLoopAsync() { - while (!m_cts.IsCancellationRequested) + while (!IsStopped) { TcpClient client; try @@ -164,6 +156,17 @@ private async Task AcceptLoopAsync() } } + private bool IsStopped => Volatile.Read(ref m_stopped) != 0; + + private static void ObserveLoopFaults(Task loop) + { + _ = loop.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + private async Task HandleAsync(TcpClient client) { using (client) @@ -293,6 +296,6 @@ private static string Reason(int status) private readonly Func m_handler; private readonly TcpListener m_listener; private readonly Task m_loop; - private readonly CancellationTokenSource m_cts = new(); + private int m_stopped; } } diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs index 48ec7ba8d9..0aabb456ec 100644 --- a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs @@ -48,6 +48,7 @@ public TestModbusServer() m_listener.Start(); Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; m_loop = Task.Run(AcceptLoopAsync); + ObserveLoopFaults(m_loop); } public int Port { get; } @@ -80,24 +81,15 @@ public void DisconnectClients() public void Dispose() { - m_cts.Cancel(); + Volatile.Write(ref m_stopped, 1); DisconnectClients(); m_listener.Stop(); m_listener.Dispose(); - try - { - m_loop.Wait(2000); - } - catch (AggregateException) - { - // Ignore teardown faults. - } - m_cts.Dispose(); } private async Task AcceptLoopAsync() { - while (!m_cts.IsCancellationRequested) + while (!IsStopped) { TcpClient client; try @@ -136,7 +128,7 @@ private async Task ServeAsync(int connectionId) { try { - while (!m_cts.IsCancellationRequested) + while (!IsStopped) { byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); if (header is null) @@ -289,12 +281,23 @@ private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) return buffer; } + private bool IsStopped => Volatile.Read(ref m_stopped) != 0; + + private static void ObserveLoopFaults(Task loop) + { + _ = loop.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + private readonly TcpListener m_listener; private readonly Task m_loop; - private readonly CancellationTokenSource m_cts = new(); private readonly ConcurrentDictionary m_clients = new(); private int m_acceptedConnectionCount; private int m_lastFunctionCode; private int m_rejectConnections; + private int m_stopped; } } From c3ee6775ec60dd7f1e2d9fb80fe81489708634af Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 19:40:42 +0200 Subject: [PATCH 9/9] Pass the executor telemetry context into polling subscriptions The HTTP and Modbus channels are the only production callers that create a PollingWotSubscription, so without forwarding the runtime context the disposal diagnostic could never reach a real logger. Both channels already receive a WotExecutorContext, so hand its telemetry to the subscription; the Modbus channel now retains that context the same way the HTTP channel does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs | 3 ++- src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs index c9dd63686b..58770d9d86 100644 --- a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs @@ -162,7 +162,8 @@ public ValueTask ObserveAsync( // so consumers observe the fault without the poll loop faulting. onError: _ => onNotification(new WotNotification( DataValue.FromStatusCode(StatusCodes.BadCommunicationError))), - retryPolicy: m_options.RetryPolicy); + retryPolicy: m_options.RetryPolicy, + telemetry: m_context.Telemetry); return new ValueTask(subscription); } diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs index 51e6131f21..e9a9fea8f4 100644 --- a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs +++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs @@ -51,6 +51,7 @@ public ModbusWotBindingChannel( { m_client = client; Form = form; + m_context = context; m_options = options; m_operation = addressing.Operation; @@ -283,7 +284,8 @@ public ValueTask ObserveAsync( // so consumers observe the fault without the poll loop faulting. onError: _ => onNotification(new WotNotification( DataValue.FromStatusCode(StatusCodes.BadCommunicationError))), - retryPolicy: m_options.RetryPolicy); + retryPolicy: m_options.RetryPolicy, + telemetry: m_context.Telemetry); return new ValueTask(subscription); } @@ -307,6 +309,7 @@ private Variant ToBitVariant(bool[] bits) } private readonly ModbusTcpClient m_client; + private readonly WotExecutorContext m_context; private readonly ModbusWotBindingOptions m_options; private readonly ModbusOperation m_operation; private readonly ushort m_address;