mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 01:26:20 +08:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user