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)); + } + } +}