From aef86ba9dc3e0efe711ca84667cdbd03bc06be31 Mon Sep 17 00:00:00 2001 From: romanett Date: Fri, 14 Aug 2026 17:24:34 +0200 Subject: [PATCH] Add SelectionListType write validation (#4254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds write-validation for the standard OPC UA `SelectionListType` VariableType via the handwritten `SelectionListState` partial, plus unit tests. Split out of #4220 into its own PR against `master`. ## The feature `SelectionListState` overrides `WriteValueAttribute` to enforce **OPC UA Part 5 section 7.18**: when the optional `RestrictToList` property is present and `true`, a written value must be one of the entries in the mandatory `Selections` property. Behavior contract: - Validation **gates on `RestrictToList`** — only enforced when the property is present and true. Otherwise the base-class behavior applies unchanged. - **Empty `Selections` rejects all** values (nothing is a valid member). - Membership is **data-type independent** — determined via `Variant` equality rather than any specific built-in type. - **Mismatched built-in types defer to the base class** for its normal type validation. The generated `SelectionListState` partial (base `BaseDataVariableState`, typed `Selections`/`SelectionDescriptions`/`RestrictToList` properties) is produced by the source generator from the core address space; this handwritten partial supplies only the write-validation override, using a single clean generic helper. ## Validation - Build `Opc.Ua.Core.Types` (net10.0, Release): **0 warnings, 0 errors**. - `SelectionListStateTests` (net10.0, Release): **14 passed, 0 failed**. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../State/SelectionListState.cs | 112 ++++++ .../Stack/State/SelectionListStateTests.cs | 351 ++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 src/Opc.Ua.Core.Types/State/SelectionListState.cs create mode 100644 tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs diff --git a/src/Opc.Ua.Core.Types/State/SelectionListState.cs b/src/Opc.Ua.Core.Types/State/SelectionListState.cs new file mode 100644 index 0000000000..f1749a44b6 --- /dev/null +++ b/src/Opc.Ua.Core.Types/State/SelectionListState.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 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 +{ + /// + /// Enforces the SelectionListType write contract from OPC UA Part 5 section 7.18: + /// when the optional RestrictToList property is present and true, a written value + /// must be one of the entries in the mandatory Selections property. Access level, + /// data type and index range validation continue to be handled by the base class. + /// + public partial class SelectionListState + { + /// + protected override ServiceResult WriteValueAttribute( + ISystemContext context, + NumericRange indexRange, + Variant value, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + // The Selections restriction only applies to a value that the base class would + // otherwise accept. Checking index range, access level, user access and data + // type up front keeps the base error codes (BadNotWritable, BadUserAccessDenied, + // BadTypeMismatch, BadIndexRangeInvalid) in precedence over BadOutOfRange. + if (indexRange.IsNull && + IsRestrictedToList(context) && + WouldBaseAcceptWrite(context, value) && + !IsMemberOfSelections(context, value)) + { + return StatusCodes.BadOutOfRange; + } + + return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp); + } + + /// + /// Returns true when the RestrictToList property exists and is set to true. + /// + private bool IsRestrictedToList(ISystemContext context) + { + return FindChild(context, new QualifiedName(BrowseNames.RestrictToList)) is BaseVariableState restrictToList && + restrictToList.WrappedValue.TryGetValue(out bool enabled) && + enabled; + } + + /// + /// Mirrors the base class access level, user access and data type checks so that + /// their error codes keep precedence over the Selections membership restriction. + /// The membership restriction is only applied to a value the base class accepts. + /// + private bool WouldBaseAcceptWrite(ISystemContext context, Variant value) + { + if ((AccessLevel & AccessLevels.CurrentWrite) == 0 || + (UserAccessLevel & AccessLevels.CurrentWrite) == 0) + { + return false; + } + + return !TypeInfo.IsInstanceOfDataType( + value, + DataType, + ValueRank, + context.NamespaceUris, + context.TypeTable).IsUnknown; + } + + /// + /// Determines whether is one of the entries in the + /// mandatory Selections property. Selections is mandatory: a missing or non-array + /// value is a malformed node and rejects the write, and an empty array permits no + /// value. Expand lifts each entry into a Variant, so membership is a data-type + /// independent Variant equality check across every OPC UA built-in type. + /// + private bool IsMemberOfSelections(ISystemContext context, Variant value) + { + Variant selections = FindChild(context, new QualifiedName(BrowseNames.Selections)) is BaseVariableState property + ? property.WrappedValue + : Variant.Null; + + return selections.TypeInfo.IsArray && selections.Expand().Contains(value); + } + } +} diff --git a/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs b/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs new file mode 100644 index 0000000000..8b3822c500 --- /dev/null +++ b/tests/Opc.Ua.Core.Tests/Stack/State/SelectionListStateTests.cs @@ -0,0 +1,351 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 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 NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Core.Tests.Stack.State +{ + /// + /// Tests for the handwritten SelectionListState write validation which + /// rejects values that are not contained in the node's Selections property + /// when RestrictToList is true. + /// + [TestFixture] + [Category("NodeState")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class SelectionListStateTests + { + private static readonly string[] s_colors = ["Red", "Green", "Blue"]; + private static readonly Variant[] s_colorVariants = + [Variant.From("Red"), Variant.From("Green"), Variant.From("Blue")]; + private static readonly int[] s_numbers = [1, 2, 3]; + + private SystemContext m_context; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + var messageContext = ServiceMessageContext.CreateEmpty(telemetry); + m_context = new SystemContext(telemetry) + { + NamespaceUris = messageContext.NamespaceUris, + ServerUris = messageContext.ServerUris, + EncodeableFactory = messageContext.Factory, + TypeTable = new TypeTable(messageContext.NamespaceUris) + }; + } + + [Test] + public void WriteMemberOfStringSelectionsSucceeds() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf())); + + ServiceResult result = WriteValue(node, "Green"); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True); + Assert.That(written, Is.EqualTo("Green")); + } + + [Test] + public void WriteNonMemberOfStringSelectionsReturnsBadOutOfRange() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf())); + + ServiceResult result = WriteValue(node, "Purple"); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange)); + } + + [Test] + public void WriteWithoutSelectionsChildFallsThroughToBase() + { + var node = new SelectionListState(null) + { + NodeId = new NodeId(1000), + BrowseName = new QualifiedName("Colors"), + DataType = DataTypeIds.String, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite, + Value = Variant.From("Red") + }; + + ServiceResult result = WriteValue(node, "AnythingGoes"); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True); + Assert.That(written, Is.EqualTo("AnythingGoes")); + } + + [Test] + public void WriteWithEmptyStringSelectionsAndRestrictToListReturnsBadOutOfRange() + { + SelectionListState node = CreateSelectionList( + Variant.From(ArrayOf.Empty)); + + ServiceResult result = WriteValue(node, "AnythingGoes"); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange)); + } + + [Test] + public void WriteNonMemberWhenRestrictToListFalseSucceeds() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf()), + addRestrictToList: true, + restrictToList: false); + + ServiceResult result = WriteValue(node, "Purple"); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True); + Assert.That(written, Is.EqualTo("Purple")); + } + + [Test] + public void WriteNonMemberWhenRestrictToListAbsentSucceeds() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf()), + addRestrictToList: false); + + ServiceResult result = WriteValue(node, "Purple"); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out string written), Is.True); + Assert.That(written, Is.EqualTo("Purple")); + } + + [Test] + public void WriteNonStringValueWhenStringSelectionsFallsThroughToBaseTypeCheck() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf())); + + ServiceResult result = node.WriteAttribute( + m_context, + Attributes.Value, + NumericRange.Null, + new DataValue(Variant.From(42))); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void WriteMemberOfVariantSelectionsSucceeds() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colorVariants.ToArrayOf())); + + ServiceResult result = WriteValue(node, "Blue"); + + Assert.That(ServiceResult.IsGood(result), Is.True); + } + + [Test] + public void WriteIncompatibleTypeWhenVariantSelectionsFallsThroughToBaseTypeCheck() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colorVariants.ToArrayOf())); + + ServiceResult result = node.WriteAttribute( + m_context, + Attributes.Value, + NumericRange.Null, + new DataValue(Variant.From(42))); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public void WriteMemberOfInt32SelectionsSucceeds() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_numbers.ToArrayOf()), + DataTypeIds.Int32, + Variant.From(1)); + + ServiceResult result = WriteValue(node, Variant.From(2)); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out int written), Is.True); + Assert.That(written, Is.EqualTo(2)); + } + + [Test] + public void WriteNonMemberOfInt32SelectionsReturnsBadOutOfRange() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_numbers.ToArrayOf()), + DataTypeIds.Int32, + Variant.From(1)); + + ServiceResult result = WriteValue(node, Variant.From(99)); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange)); + } + + [Test] + public void WriteMemberOfDoubleSelectionsSucceeds() + { + double[] doubles = [1.5, 2.5, 3.5]; + SelectionListState node = CreateSelectionList( + Variant.From(doubles.ToArrayOf()), + DataTypeIds.Double, + Variant.From(1.5)); + + ServiceResult result = WriteValue(node, Variant.From(2.5)); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out double written), Is.True); + Assert.That(written, Is.EqualTo(2.5)); + } + + [Test] + public void WriteMemberOfNodeIdSelectionsSucceeds() + { + NodeId[] nodeIds = [new NodeId(1), new NodeId(2), new NodeId(3)]; + SelectionListState node = CreateSelectionList( + Variant.From(nodeIds.ToArrayOf()), + DataTypeIds.NodeId, + Variant.From(new NodeId(1))); + + ServiceResult result = WriteValue(node, Variant.From(new NodeId(2))); + + Assert.That(ServiceResult.IsGood(result), Is.True); + Assert.That(node.WrappedValue.TryGetValue(out NodeId written), Is.True); + Assert.That(written, Is.EqualTo(new NodeId(2))); + } + + [Test] + public void WriteNonMemberOfVariantSelectionsReturnsBadOutOfRange() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colorVariants.ToArrayOf())); + + ServiceResult result = WriteValue(node, "Purple"); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadOutOfRange)); + } + + [Test] + public void WriteMemberButNotWritableStillReturnsBadNotWritable() + { + SelectionListState node = CreateSelectionList( + Variant.From(s_colors.ToArrayOf())); + node.AccessLevel = AccessLevels.CurrentRead; + node.UserAccessLevel = AccessLevels.CurrentRead; + + ServiceResult result = WriteValue(node, "Green"); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadNotWritable)); + } + + private SelectionListState CreateSelectionList( + Variant selectionsValue, + bool addRestrictToList = true, + bool restrictToList = true) + { + return CreateSelectionList( + selectionsValue, + DataTypeIds.String, + Variant.From("Red"), + addRestrictToList, + restrictToList); + } + + private SelectionListState CreateSelectionList( + Variant selectionsValue, + NodeId dataType, + Variant initialValue, + bool addRestrictToList = true, + bool restrictToList = true) + { + var node = new SelectionListState(null) + { + NodeId = new NodeId(1000), + BrowseName = new QualifiedName("Colors"), + DataType = dataType, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentReadOrWrite, + UserAccessLevel = AccessLevels.CurrentReadOrWrite, + Value = initialValue + }; + + var selections = new BaseDataVariableState(node) + { + NodeId = new NodeId(1001), + BrowseName = new QualifiedName(BrowseNames.Selections), + DataType = dataType, + ValueRank = ValueRanks.OneDimension, + Value = selectionsValue + }; + + node.AddChild(selections); + + if (addRestrictToList) + { + var restrictToListNode = new BaseDataVariableState(node) + { + NodeId = new NodeId(1002), + BrowseName = new QualifiedName("RestrictToList"), + DataType = DataTypeIds.Boolean, + ValueRank = ValueRanks.Scalar, + Value = Variant.From(restrictToList) + }; + + node.AddChild(restrictToListNode); + } + + return node; + } + + private ServiceResult WriteValue(SelectionListState node, string value) + { + return WriteValue(node, Variant.From(value)); + } + + private ServiceResult WriteValue(SelectionListState node, Variant value) + { + return node.WriteAttribute( + m_context, + Attributes.Value, + NumericRange.Null, + new DataValue(value)); + } + } +}