Add SelectionListType write validation (#4254)

## 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>
This commit is contained in:
romanett
2026-08-14 17:24:34 +02:00
committed by GitHub
parent 6e0ed89d3f
commit aef86ba9dc
2 changed files with 463 additions and 0 deletions
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public partial class SelectionListState
{
/// <inheritdoc/>
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);
}
/// <summary>
/// Returns true when the RestrictToList property exists and is set to true.
/// </summary>
private bool IsRestrictedToList(ISystemContext context)
{
return FindChild(context, new QualifiedName(BrowseNames.RestrictToList)) is BaseVariableState restrictToList &&
restrictToList.WrappedValue.TryGetValue(out bool enabled) &&
enabled;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Determines whether <paramref name="value"/> 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.
/// </summary>
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);
}
}
}
@@ -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
{
/// <summary>
/// Tests for the handwritten SelectionListState write validation which
/// rejects values that are not contained in the node's Selections property
/// when RestrictToList is true.
/// </summary>
[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<string>.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));
}
}
}