diff --git a/docs/NodeIdAssignment.md b/docs/NodeIdAssignment.md new file mode 100644 index 0000000000..68a875b0db --- /dev/null +++ b/docs/NodeIdAssignment.md @@ -0,0 +1,379 @@ +# NodeId Assignment + +Every node a server creates at runtime needs a NodeId. This document +describes the mechanisms the stack uses to produce one: the factory +contract, the identifier formats, how type declarations differ from +instances, what the source generator emits, and what each NodeManager base +class does. + +## Contents + +- [The contract](#the-contract) +- [DefaultNodeIdFactory](#defaultnodeidfactory) +- [Types and instances](#types-and-instances) +- [What the source generator emits](#what-the-source-generator-emits) +- [NodeManager behaviour](#nodemanager-behaviour) +- [Configuring the factory](#configuring-the-factory) +- [Who forces a fresh identifier](#who-forces-a-fresh-identifier) + +## The contract + +`INodeIdFactory` (`Opc.Ua.Types/State/ISystemContext.cs`) has one member: + +```csharp +NodeId New(ISystemContext context, NodeState node); +``` + +It reaches node code through `ISystemContext.NodeIdFactory`. A NodeManager +publishes itself as that factory in its constructor +(`SystemContext.NodeIdFactory = this`), so any node-authoring code holding +the manager's `SystemContext` mints identifiers through the manager. This +points at the *NodeManager*, not at its factory, so that a subclass +overriding `New` is still the one node-level code reaches — which is why +`AsyncCustomNodeManager` and `CustomNodeManager2` do it once for every +manager that derives from them, and a subclass never repeats it. + +`IRebasableNodeIdFactory` (`Opc.Ua.Server/NodeManager/IRebasableNodeIdFactory.cs`) +extends that with what a NodeManager needs and a bare `INodeIdFactory` +cannot express: which namespace to mint into, which identifier style to +mint, and an identifier for a node that does not exist yet. + +```csharp +NodeIdAssignmentMode Mode { get; } +ushort DefaultNamespaceIndex { get; } +IRebasableNodeIdFactory WithDefaultNamespaceIndex(ushort defaultNamespaceIndex); +IRebasableNodeIdFactory WithMode(NodeIdAssignmentMode mode); +NodeId NextCounterNodeId(); +NodeId CreateChildNodeId(NodeId parentNodeId, QualifiedName browseName, ushort namespaceIndex, NamespaceTable namespaceUris); +``` + +The namespace is why this is a contract rather than constructor +configuration: a namespace belongs to the NodeManager, not to the node, and +a manager learns its own namespace index only after the server's namespace +table has been extended — which is later than a factory registered in +dependency injection was built. Implementations are therefore immutable and +the `With…` methods return a view, so one registered instance serves +managers that own different namespaces. + +`AsyncCustomNodeManager.NodeIdFactory` is typed as this interface, so a +caller can put its own rule in front of `DefaultNodeIdFactory` by +decorating it — claim one subtree, delegate the rest — instead of +overriding `New` on the NodeManager and scattering the identifier rule +across it. + +The property is nullable, and null means *do not assign*. A node copy +deliberately hides the factory from the children it materialises, so code +that cannot work without one calls +`ISystemContext.RequireNodeIdFactory()` and fails with a diagnosable +error rather than a `NullReferenceException`. + +`New` may be called more than once for the same node — `AssignNodeIds` +walks a subtree on every create pass — so an implementation has to be +idempotent or the address space ends up with orphaned references. + +## DefaultNodeIdFactory + +`Opc.Ua.Server/NodeManager/DefaultNodeIdFactory.cs` is the stack's +implementation. It is immutable and safe to share. + +### The canonical path + +Every mode except `Counter` derives from one canonical browse path: a +length-prefixed encoding of the parent identifier and the child browse +name. + +``` +v1:10:l:6:s=Root:10:l:6:Group1 +``` + +Each segment carries its own length, so no combination of parent and +browse name can produce the same path as a different combination. The +classic `{parentIdentifier}_{browseName}` convention could: `A_B` plus `C` +and `A` plus `B_C` both gave `A_B_C`. The parent segment keeps the +parent's identifier type prefix (`s=`, `i=`, `g=`, `b=`), so a string +parent `"42"` never reads as a numeric parent `42`. + +Segments are qualified by namespace: + +| Prefix | Meaning | +|---|---| +| `l:` | in the namespace being minted into | +| `z:` | in namespace 0 | +| `u::` | in another namespace, named by URI | +| `x::` | in another namespace that has no URI registered | + +The URI form is what makes an identifier independent of namespace-table +ordering. The index form is a fallback for an unregistered namespace; it +is deliberately distinct so identifiers minted before the namespace was +registered cannot collide with the ones minted after. + +The path is scratch. In every mode but `String` it is hashed and thrown +away, so it is built into a stack buffer — or, when it does not fit, one +rented from `ArrayPool` — and never becomes a string at all. The +length is measured and written by one routine, so the length reserved for +a segment cannot drift from the length written into it. + +### Modes + +`NodeIdAssignmentMode` selects how the path becomes an identifier. + +| Mode | Identifier | Stable across restarts | Collides when browse paths repeat | +|---|---|---|---| +| `None` | none — raises `BadConfigurationError` | — | — | +| `Numeric` | first 32 bits of the path's SHA-256 | yes | yes, plus hash collisions | +| `String` | the canonical path verbatim | yes | yes | +| `Guid` | first 128 bits of the path's SHA-256 | yes | yes | +| `Opaque` | first 128 bits of the path's SHA-256 | yes | yes | +| `Counter` | sequential number | no | no | + +`Numeric` is the default: it is the most compact form on the wire and the +most readable in a client UI. + +`String` is the only mode that cannot collide for distinct browse paths, +because it keeps the canonical path whole and two different paths are +never the same text. The others project the path through SHA-256 and +inherit that hash's collision probability — negligible for `Guid` and +`Opaque` at 128 bits, but real for `Numeric` at 32. + +### Collision detection + +A 32 bit identifier is a birthday problem: distinct browse paths are +expected to land on one after roughly 2^16 of them. Left alone that is +silent damage, because the predefined-node index takes the last writer — +one node would simply replace the other and the address space would be +quietly wrong. + +So the factory records what it mints and raises +`BadConfigurationError` when a second browse path lands on an identifier +that a different one already has. The error names the path that was +refused and the modes that do not have the problem. + +Minting the same path twice is normal — `AssignNodeIds` walks a subtree +on every create pass — so the record keeps a witness of the path +alongside the identifier, and only a *differing* witness is a collision. +The witness is the tail of the same hash, so two paths would have to +agree on the identifier and on a further 64 bits before a real collision +could pass as a re-mint. + +Three consequences worth knowing: + +- The record is kept per namespace, because identifiers in different + namespaces cannot collide, and is shared by every view derived from one + factory — so two NodeManagers that rebase the registered factory onto + the same namespace are checked against each other, and draw sequential + identifiers from one counter rather than from two seeded moments apart. + Two NodeManagers that each construct a factory of their own instead of + taking the registered one share nothing, and have no way to. +- It is never pruned. An identifier handed to a client stays spoken for + even after the node goes away, so re-minting it for a different path is + exactly the collision this catches. It costs roughly 50 bytes per + distinct path minted. +- It covers what the factory mints, not identifiers a caller assigned + itself. `String` cannot collide and keeps no record. `Counter` cannot + collide with itself either, but it mints into the same numeric space a + hash does, so when checking is asked for it reserves what it hands out — + otherwise a namespace minting in both modes could issue one identifier + twice. It steps over a taken value rather than reporting it, because the + counter is the side free to move. + +`Counter` is for nodes whose browse paths repeat over time — per-session +diagnostics objects, inference jobs, rediscovered assets. Its counter +starts above `0x40000000`, out of reach of any authored model, so a +long-running server cannot walk a runtime instance onto a type node. +(The predefined-node index takes the last writer, so such a collision +silently replaces the type rather than failing.) + +### Which namespace + +Every identifier is minted into the factory's `DefaultNamespaceIndex` — +the NodeManager's own namespace. Neither the parent's namespace nor the +browse name's is consulted: + +- a parent can belong to a companion-specification model whose NodeIds + are fixed by its NodeSet (`DeviceSet` in the DI namespace, for + instance). A server may not mint identifiers there. +- a browse name only names the type that declared the child. + +A NodeManager whose instance namespace is not its first one rebases the +factory with `WithDefaultNamespaceIndex`. + +### When an existing NodeId is kept + +`New` keeps a node's current NodeId when the caller can plausibly have +chosen it, and mints a fresh one otherwise. Two cases qualify: + +- the identifier is **already in this NodeManager's namespace**, which no + other model's NodeIds ever are; +- the node **stands on its own** rather than hanging off a parent. + `NodeState.Create` hands a root its NodeId before running the assignment + pass, so a caller naming a node explicitly arrives this way. + +Everything else is re-minted, because it reached `New` through +`AssignNodeIds` walking a subtree copied from a type declaration and still +carrying that declaration's identifiers. See +[Who forces a fresh identifier](#who-forces-a-fresh-identifier). + +### Nodes with no derivable path + +A node with no browse name, or one hanging off a transient parent that +has no NodeId (an event instance and its fields), has no stable path. +Those fall back to the counter whatever mode is selected, so a NodeManager +never has to special-case them. + +## Types and instances + +An information model ships **type declarations**: `PumpType` and every +child below it, each with a NodeId fixed by the NodeSet. When a server +creates an instance, `NodeState.CreateInstance` copies that subtree — and +the copy carries the declaration's NodeIds. + +Rebasing the copy onto per-instance identifiers is what +`NodeState.AssignNodeIds` does: it walks the subtree calling +`INodeIdFactory.New` on each node, collects an old-to-new mapping table, +and hands it to `UpdateReferenceTargets` so references inside the subtree +follow. + +If `New` returns the declaration NodeId unchanged, every instance of the +type aliases onto the type's own nodes. The failure is silent: the +predefined-node index replaces rather than rejects, so the ObjectType +quietly becomes an instance node. Guarding against that is the reason +`DefaultNodeIdFactory` re-mints anything it did not mint itself, and the +reason the generator's helpers check the declaration constant before +rebasing. + +Type nodes themselves are never passed through `AssignNodeIds`; they are +loaded from the NodeSet and registered as predefined nodes. + +## What the source generator emits + +For each child of a generated type the generator emits helpers that +materialise it, each with its own NodeId policy. + +`Create{Type}` / `CreateInstanceOf{Type}Type(parent, browseName)` — builds +an instance and rebases the subtree through the context's factory. + +`Add{Child}(context, nodeId = default)`: + +| Argument | Behaviour | +|---|---| +| `nodeId` non-null | the child takes that NodeId, then its descendants are rebased around it | +| `nodeId` null | rebased through the factory, but **only if** the child still carries its declaration constant | + +`CreateOrReplace{Child}(context, replacement, assignInstanceNodeIds)`: + +| Argument | Behaviour | +|---|---| +| `assignInstanceNodeIds` false | the child keeps whatever NodeId it has | +| `assignInstanceNodeIds` true | rebased, but only if the NodeId is null or still the declaration constant | + +The declaration-constant guard (`nodeState.NodeId.Equals(TypeNodeIdConstant)`) +is the generator's compile-time way of asking "is this still a +declaration identifier?". It also means a caller who assigned an +identifier explicitly keeps it. + +## NodeManager behaviour + +### CustomNodeManager2 + +Unchanged, for backwards compatibility: + +```csharp +public virtual NodeId New(ISystemContext context, NodeState node) +{ + return node.NodeId; +} +``` + +It mints nothing. A node keeps whatever NodeId it already has, and a node +with none keeps `NodeId.Null`. Subclasses that need identifiers override +`New` themselves. Existing servers built on this base see no change in +behaviour. + +### AsyncCustomNodeManager + +`New` delegates to the `NodeIdFactory` property and does nothing else. The +manager holds no counter of its own. A subclass selects its identifier +style by assigning that factory in its constructor rather than by +overriding `New`: + +```csharp +NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter); +``` + +`WithMode` and `WithDefaultNamespaceIndex` both return a copy, so a +factory shared through dependency injection is never mutated. Assigning a +factory that names no namespace (a bare `new DefaultNodeIdFactory(mode)`) +adopts the manager's own namespace; one that names a namespace is left +alone. + +The default is `NodeIdAssignmentMode.Numeric`, so a manager that overrides +nothing gets compact deterministic browse-path identifiers, checked for +collisions. + +### FluentNodeManagerBase and the fluent builders + +`FluentNodeManagerBase` derives from `AsyncCustomNodeManager` and inherits +its behaviour. The fluent builders — `AddObject`, `WithProperty`, +`CreateInstance`, and the alarm and state-machine helpers — mint through +`FluentNodeRegistration.AssignNodeId`, which routes to the owning +manager's `New`. They no longer each spell out a +`{parentIdentifier}_{browseName}` concatenation. + +Inside a `Configure` delegate the mode can be selected for everything +created after it: + +```csharp +builder.WithNodeIdAssignment(NodeIdAssignmentMode.Guid); +``` + +Every builder mints through the owning NodeManager's `New`, so there is no +second identifier shape to fall back to. `WithNodeIdAssignment` needs the +factory itself, which only an `AsyncCustomNodeManager` carries, so on any +other NodeManager it raises `BadConfigurationError` rather than quietly +doing nothing. + +## Configuring the factory + +Through dependency injection, once for the whole server: + +```csharp +builder.AddNodeIdFactory(NodeIdAssignmentMode.Guid); +``` + +`DependencyInjectionStandardServer` resolves the registered +`IRebasableNodeIdFactory`, `StandardServer` threads it into +`ServerInternalData`, and every `AsyncCustomNodeManager` picks it up +through `INodeIdFactoryProvider`, rebased onto its own namespace. + +The registration is keyed on the interface, so an implementation of your +own — typically a decorator holding a `DefaultNodeIdFactory` — can be +registered in its place: + +```csharp +builder.AddNodeIdFactory(new ReservingNodeIdFactory(new DefaultNodeIdFactory())); +``` + +## Who forces a fresh identifier + +Two things can be true of a node arriving at `New` with a NodeId already +set: the caller chose that identifier, or it is a type declaration's +identifier that `NodeState.CreateInstance` copied onto an instance. The +factory cannot tell them apart, so the rule is positional rather than +inferred: + +- a node that **stands on its own** keeps its NodeId; +- a node **hanging off a parent** is re-minted, because it reached `New` + through `AssignNodeIds` walking a copied subtree. + +A root that must also shed a declaration identifier says so by naming the +identifier it wants. `ISystemContext.CreateInstance` is the short way to +do that: it builds the subtree and then rebases it through +`AssignInstanceNodeId`, which forces a fresh identifier by clearing the +NodeId and asking again. That is the same path the generated +`CreateInstanceOf{Type}` helpers take, so hand-written and generated +authoring agree. + +Reaching for `NodeState.Create(..., NodeId.Null, ..., assignNodeIds: true)` +on a generated state object is the trap: the object is born carrying its +own type's NodeId, `Create` only replaces that when handed an identifier, +and the result is an instance sitting on the type's node. diff --git a/docs/WhatsNewIn2.0.md b/docs/WhatsNewIn2.0.md index 450f35b4b2..3118ae0ce5 100644 --- a/docs/WhatsNewIn2.0.md +++ b/docs/WhatsNewIn2.0.md @@ -520,6 +520,9 @@ coverage service; see server/client/network redundancy and the opt-in distributed HA building blocks; [Kubernetes High Availability Deployment](Kubernetes.md) — the Kubernetes deployment guide for the `Opc.Ua.Redundancy.Kubernetes` package. +- [NodeId Assignment](NodeIdAssignment.md) — how runtime NodeIds are + minted: the factory contract, identifier formats, types vs instances, + the generated helpers, and per-NodeManager behaviour. - [Dependency Injection](DependencyInjection.md), [Native AOT](NativeAoT.md), [Diagnostics](Diagnostics.md), diff --git a/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs b/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs index 708560047a..6bd91e96e4 100644 --- a/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs +++ b/samples/DI/PumpDeviceIntegrationServer/PumpNodeManager.cs @@ -29,6 +29,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -100,9 +101,6 @@ namespace Pumps Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { - // Base class constructor sets SystemContext.NodeIdFactory to - // itself; our New() override takes over. - SystemContext.NodeIdFactory = this; m_options = options?.Value ?? new PumpDeviceIntegrationOptions(); if (m_options.PumpCount < 1 || m_options.PumpCount > 100) { @@ -138,21 +136,6 @@ namespace Pumps internal TimeSpan SimulationInterval => m_options.SimulationInterval; - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && - instance.Parent != null) - { - string parentId = instance.Parent.NodeId.IdentifierAsString; - return new NodeId( - $"{parentId}_{instance.SymbolicName}", - InstanceNamespaceIndex); - } - - return node.NodeId; - } - /// /// Creates and registers a generated /// instance organized by the DI DeviceSet. @@ -307,10 +290,15 @@ namespace Pumps "The DI DeviceSet is not available."); } - var pumpNodeId = new NodeId( - $"{deviceSet.NodeId.IdentifierAsString}_{pumpBrowseName.Name}", - InstanceNamespaceIndex); - if (PredefinedNodes.ContainsKey(pumpNodeId)) + // The duplicate is looked up by browse name rather than by + // predicting the identifier the factory would mint. A prediction + // only holds while the factory derives identifiers from the browse + // path: under Counter mode minting one consumes a counter value + // and returns an identifier no node can already have, so the check + // would pass and let a second pump of the same name through. + var existingDevices = new List(); + deviceSet.GetChildren(SystemContext, existingDevices); + if (existingDevices.Any(device => device.BrowseName == pumpBrowseName)) { m_logger.DeviceSetAlreadyContains(pumpBrowseName.Name); throw ServiceResultException.Create( diff --git a/samples/OpenUsd/GeneratorServer/GeneratorNodeManager.cs b/samples/OpenUsd/GeneratorServer/GeneratorNodeManager.cs index 701df8c256..89e025a55c 100644 --- a/samples/OpenUsd/GeneratorServer/GeneratorNodeManager.cs +++ b/samples/OpenUsd/GeneratorServer/GeneratorNodeManager.cs @@ -144,10 +144,6 @@ namespace Generators Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { - // The base constructor points SystemContext.NodeIdFactory at itself; - // the New() override below takes over so every instance child gets a - // NodeId derived from its parent rather than the type-level one. - SystemContext.NodeIdFactory = this; m_options = options?.Value ?? new GeneratorDeviceIntegrationOptions(); if (m_options.GeneratorCount is < 1 or > 100) { @@ -191,19 +187,6 @@ namespace Generators /// internal bool InjectFaults => m_options.InjectFaults; - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState { Parent: not null } instance) - { - string parentId = instance.Parent.NodeId.IdentifierAsString; - return new NodeId( - $"{parentId}_{instance.SymbolicName}", - InstanceNamespaceIndex); - } - return node.NodeId; - } - /// /// Creates and registers a generator set organised by the DI /// DeviceSet, wired into the running simulation. diff --git a/samples/OpenUsd/SiteCompositionServer/SiteNodeManager.cs b/samples/OpenUsd/SiteCompositionServer/SiteNodeManager.cs index 7f2dd359ac..69a3a322fb 100644 --- a/samples/OpenUsd/SiteCompositionServer/SiteNodeManager.cs +++ b/samples/OpenUsd/SiteCompositionServer/SiteNodeManager.cs @@ -110,7 +110,6 @@ namespace SiteComposition IOptions? options = null) : base(server, configuration, Namespaces.Site, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { - SystemContext.NodeIdFactory = this; m_options = options?.Value ?? new SiteCompositionOptions(); m_log = server.Telemetry.CreateLogger(); } @@ -120,17 +119,6 @@ namespace SiteComposition /// private ushort SiteNamespaceIndex => NamespaceIndexes[0]; - /// - public override NodeId New(ISystemContext context, NodeState node) { - if (node is BaseInstanceState { Parent: not null } instance) - { - return new NodeId( - $"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}", - SiteNamespaceIndex); - } - return node.NodeId; - } - /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, diff --git a/samples/Quickstarts.Servers/Alarms/AlarmNodeManager.cs b/samples/Quickstarts.Servers/Alarms/AlarmNodeManager.cs index b62d3c649e..da38711fa8 100644 --- a/samples/Quickstarts.Servers/Alarms/AlarmNodeManager.cs +++ b/samples/Quickstarts.Servers/Alarms/AlarmNodeManager.cs @@ -108,23 +108,6 @@ namespace Alarms base.Dispose(disposing); } - /// - /// Creates the NodeId for the specified node. - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && - instance.Parent != null && - instance.Parent.NodeId.TryGetValue(out string id)) - { - return new NodeId( - id + "_" + instance.SymbolicName, - instance.Parent.NodeId.NamespaceIndex); - } - - return node.NodeId; - } - /// /// Does any initialization required before the address space can be used. /// diff --git a/samples/Quickstarts.Servers/Boiler/BoilerNodeManager.cs b/samples/Quickstarts.Servers/Boiler/BoilerNodeManager.cs index 106da9afae..0dbd248616 100644 --- a/samples/Quickstarts.Servers/Boiler/BoilerNodeManager.cs +++ b/samples/Quickstarts.Servers/Boiler/BoilerNodeManager.cs @@ -71,22 +71,14 @@ namespace Boiler Server.NamespaceUris.GetIndexOrAppend(namespaceUris[0]); m_namespaceIndex = Server.NamespaceUris.GetIndexOrAppend(namespaceUris[1]); - m_lastUsedId = 0; + // counter identifiers in the boiler namespace, which is the + // second one this manager owns. + NodeIdFactory = NodeIdFactory + .WithMode(NodeIdAssignmentMode.Counter) + .WithDefaultNamespaceIndex(m_namespaceIndex); m_boilers = []; } - /// - /// Creates the NodeId for the specified node. - /// - /// The context. - /// The node. - /// The new NodeId. - public override NodeId New(ISystemContext context, NodeState node) - { - uint id = Utils.IncrementIdentifier(ref m_lastUsedId); - return new NodeId(id, m_namespaceIndex); - } - /// /// Does any initialization required before the address space can be used. /// @@ -116,7 +108,16 @@ namespace Boiler string name = Utils.Format("Boiler #{0}", unitNumber); - boiler.Create(context, default, new QualifiedName(name, m_namespaceIndex), default, true); + // A generated state object is born carrying its own type's NodeId, + // and Create only replaces that when handed one. Passing the null + // NodeId would leave this boiler sitting on BoilerType, so the + // second boiler would overwrite the ObjectType in the index. + // CreateInstance builds the subtree and then rebases the root and + // its children onto fresh instance identifiers. + context.CreateInstance( + boiler, + new QualifiedName(name, m_namespaceIndex), + new LocalizedText(name)); NodeState folder = FindPredefinedNode( ExpandedNodeId.ToNodeId(ObjectIds.Boilers, Server.NamespaceUris)!)!; @@ -272,7 +273,6 @@ namespace Boiler } private readonly ushort m_namespaceIndex; - private uint m_lastUsedId; private readonly List m_boilers; } } diff --git a/samples/Quickstarts.Servers/MemoryBuffer/MemoryBufferNodeManager.cs b/samples/Quickstarts.Servers/MemoryBuffer/MemoryBufferNodeManager.cs index afc736f779..1c91926917 100644 --- a/samples/Quickstarts.Servers/MemoryBuffer/MemoryBufferNodeManager.cs +++ b/samples/Quickstarts.Servers/MemoryBuffer/MemoryBufferNodeManager.cs @@ -104,6 +104,11 @@ namespace MemoryBuffer namespaceIndex = Server.NamespaceUris .GetIndexOrAppend(Namespaces.MemoryBuffer + "/Instance"); + // the buffers live in the instance namespace, so their + // children are minted there too rather than in the model + // namespace the base class would otherwise have adopted. + NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex(namespaceIndex); + if (m_configuration != null && !m_configuration.Buffers.IsNull) { for (int ii = 0; ii < m_configuration.Buffers.Count; ii++) @@ -127,6 +132,14 @@ namespace MemoryBuffer // link to root. root.AddChild(bufferNode); + // Register the buffer subtree. Its own properties - + // StartAddress and SizeInBytes - are real nodes with + // real identifiers, and a client that browses the + // buffer is handed them; without this they resolve to + // nothing. The virtual tags below are still ephemeral + // and are not registered. + AddPredefinedNode(SystemContext, bufferNode); + // save the buffers for easy look up later. m_buffers[bufferNode.SymbolicName] = bufferNode; } @@ -182,59 +195,79 @@ namespace MemoryBuffer return buffer; } - // tag ids have the syntax [
] - if (id![^1] != ']') + // Tag ids have the syntax [
]. An id + // in any other shape is an ordinary registered node - the + // buffer's own properties carry identifiers minted by the + // manager's factory - so it falls through to the base + // lookup rather than being reported as unknown. + MemoryTagState? tag = ResolveVirtualTag(id!); + + if (tag != null) { - return null; + return tag; } - - int index = id.IndexOf('[', StringComparison.Ordinal); - - if (index == -1) - { - return null; - } - - string bufferName = id[..index]; - - // verify the buffer. - if (!m_buffers.TryGetValue(bufferName, out buffer)) - { - return null; - } - - // validate the address. - string offsetText = id.Substring(index + 1, id.Length - index - 2); - - for (int ii = 0; ii < offsetText.Length; ii++) - { - if (!char.IsDigit(offsetText[ii])) - { - return null; - } - } - - // check range on offset. - uint offset = Convert.ToUInt32(offsetText, CultureInfo.InvariantCulture); - - if (offset >= buffer.SizeInBytes!.Value) - { - return null; - } - - // the tags contain all of the metadata required to support the UA - // operations and pointers to functions in the buffer object that - // allow the value to be accessed. These tags are ephemeral and are - // discarded after the operation completes. This design pattern allows - // the server to expose potentially millions of UA nodes without - // creating millions of objects that reside in memory. - return new MemoryTagState(buffer, offset); } return base.GetManagerHandle(context, nodeId, cache); } } + /// + /// Resolves an identifier of the form <bufferName>[<address>] + /// to a tag inside that buffer. + /// + /// + /// The tags carry all the metadata the UA operations need plus + /// pointers into the buffer object, and are discarded once the + /// operation completes. That is what lets the server expose millions + /// of nodes without holding millions of objects in memory. + /// + /// The string identifier to parse. + /// The tag, or null when the id is not a tag id. + private MemoryTagState? ResolveVirtualTag(string id) + { + if (id.Length == 0 || id[^1] != ']') + { + return null; + } + + int index = id.IndexOf('[', StringComparison.Ordinal); + + if (index == -1) + { + return null; + } + + if (!m_buffers.TryGetValue(id[..index], out MemoryBufferState? buffer)) + { + return null; + } + + string offsetText = id.Substring(index + 1, id.Length - index - 2); + + for (int ii = 0; ii < offsetText.Length; ii++) + { + if (!char.IsDigit(offsetText[ii])) + { + return null; + } + } + + if (offsetText.Length == 0) + { + return null; + } + + uint offset = Convert.ToUInt32(offsetText, CultureInfo.InvariantCulture); + + if (offset >= buffer.SizeInBytes!.Value) + { + return null; + } + + return new MemoryTagState(buffer, offset); + } + /// /// Creates a new set of monitored items for a set of variables. /// diff --git a/samples/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs b/samples/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs index a60bb91ad4..c10ee38f1e 100644 --- a/samples/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs +++ b/samples/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs @@ -67,8 +67,6 @@ namespace Quickstarts.ReferenceServer server.Telemetry.CreateLogger(), Namespaces.ReferenceServer) { - SystemContext.NodeIdFactory = this; - // use suitable defaults if no configuration exists. } @@ -114,23 +112,6 @@ namespace Quickstarts.ReferenceServer } } - /// - /// Creates the NodeId for the specified node. - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && - instance.Parent != null && - instance.Parent.NodeId.TryGetValue(out string id)) - { - return new NodeId( - id + "_" + instance.SymbolicName, - instance.Parent.NodeId.NamespaceIndex); - } - - return node.NodeId; - } - /// /// /// Enables the OPC UA NodeManagement service set (AddNodes / diff --git a/samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs b/samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs index 5d2083a457..9aa7541422 100644 --- a/samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs +++ b/samples/Quickstarts.Servers/SampleNodeManager/SampleNodeManager.cs @@ -90,6 +90,18 @@ namespace Opc.Ua.Sample } } + /// + /// Mints NodeIds for nodes this NodeManager creates at runtime. + /// + /// + /// This sample base predates and + /// so carries the factory itself. A subclass selects its identifier + /// style by assigning this rather than by overriding + /// . + /// + public IRebasableNodeIdFactory NodeIdFactory { get; set; } + = new DefaultNodeIdFactory(NodeIdAssignmentMode.String); + /// /// Creates the NodeId for the specified node. /// @@ -98,7 +110,7 @@ namespace Opc.Ua.Sample /// The new NodeId. public virtual NodeId New(ISystemContext context, NodeState node) { - return node.NodeId; + return NodeIdFactory.New(context, node); } /// @@ -308,6 +320,18 @@ namespace Opc.Ua.Sample .GetIndexOrAppend(m_namespaceUris[ii]); } + // A factory still pointing at namespace 0 adopts the first + // namespace this manager owns. Namespace 0 is the OPC UA + // namespace, so minting there would hand out identifiers in + // a namespace no NodeManager owns. A subclass that wants a + // different one of its namespaces rebases the factory itself, + // as BoilerNodeManager and MemoryBufferNodeManager do. + if (NodeIdFactory.DefaultNamespaceIndex == 0 && m_namespaceIndexes.Length > 0) + { + NodeIdFactory = NodeIdFactory + .WithDefaultNamespaceIndex(m_namespaceIndexes[0]); + } + LoadPredefinedNodes(SystemContext, externalReferences); } } diff --git a/samples/Quickstarts.Servers/TestData/TestDataNodeManager.cs b/samples/Quickstarts.Servers/TestData/TestDataNodeManager.cs index 085d8a403f..70a74a0933 100644 --- a/samples/Quickstarts.Servers/TestData/TestDataNodeManager.cs +++ b/samples/Quickstarts.Servers/TestData/TestDataNodeManager.cs @@ -87,8 +87,6 @@ namespace TestData // use suitable defaults if no configuration exists. - m_lastUsedId = m_configuration.NextUnusedId - 1; - // create the object used to access the test system. m_system = new TestDataSystem( this, @@ -166,18 +164,6 @@ namespace TestData } } - /// - /// Creates the NodeId for the specified node. - /// - /// The context. - /// The node. - /// The new NodeId. - public override NodeId New(ISystemContext context, NodeState node) - { - uint id = Opc.Ua.Utils.IncrementIdentifier(ref m_lastUsedId); - return new NodeId(id, m_namespaceIndex); - } - /// /// Does any initialization required before the address space can be used. /// @@ -195,6 +181,13 @@ namespace TestData m_namespaceIndex = Server.NamespaceUris .GetIndexOrAppend(Namespaces.TestData + "Instance"); + // counter identifiers in the instance namespace. The factory's + // counter starts far above any authored identifier, which is + // what NextUnusedId used to be configured for. + NodeIdFactory = NodeIdFactory + .WithMode(NodeIdAssignmentMode.Counter) + .WithDefaultNamespaceIndex(m_namespaceIndex); + await base.CreateAddressSpaceAsync(externalReferences, cancellationToken).ConfigureAwait(false); #if CONDITION_SAMPLES @@ -717,7 +710,6 @@ namespace TestData private ushort m_namespaceIndex; private ushort m_typeNamespaceIndex; private readonly TestDataSystem m_system; - private uint m_lastUsedId; #if CONDITION_SAMPLES private Timer m_systemStatusTimer; private TestSystemConditionState m_systemStatusCondition; diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.AddressSpace.cs b/src/Opc.Ua.AI.Server/AiNodeManager.AddressSpace.cs index f06ed21df2..5cc27055a5 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.AddressSpace.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.AddressSpace.cs @@ -187,13 +187,15 @@ namespace Opc.Ua.AI.Server // does so when the parent actually changes - so a node handed its parent // in the constructor is indexed by the Server and invisible to a client, // which is a great deal harder to notice than an outright failure. + // The state object starts out carrying ModelType's own NodeId, so + // the instance is given one explicitly. Left to default, the root + // would keep the type's identifier and the predefined-node index + // would silently replace ModelType with this instance. var model = new ModelState(null); - model.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + model, new QualifiedName(browseName, NamespaceIndex), - new LocalizedText(source.Name), - true); + new LocalizedText(source.Name)); Child>(model, BrowseNames.ModelId).Value = FormattableString.Invariant( @@ -289,12 +291,10 @@ namespace Opc.Ua.AI.Server { InferenceLocationEnum site = MapSite(backend.Site); var deployment = new DeploymentState(null); - deployment.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + deployment, new QualifiedName(browseName, NamespaceIndex), - new LocalizedText(deploymentId), - true); + new LocalizedText(deploymentId)); Child>(deployment, BrowseNames.DeploymentId).Value = deploymentId; diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.Catalogue.cs b/src/Opc.Ua.AI.Server/AiNodeManager.Catalogue.cs index 1eed7e4dbe..4173a32fb5 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.Catalogue.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.Catalogue.cs @@ -67,12 +67,10 @@ namespace Opc.Ua.AI.Server private void BuildCatalogue() { m_source = new ModelSourceState(null); - m_source.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + m_source, new QualifiedName("ModelSource", NamespaceIndex), - new LocalizedText(m_backendOptions.EndpointUri), - true); + new LocalizedText(m_backendOptions.EndpointUri)); Child>(m_source, BrowseNames.SourceId).Value = m_options.SourceId; diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.Jobs.cs b/src/Opc.Ua.AI.Server/AiNodeManager.Jobs.cs index bda374ede5..d2f885ccf6 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.Jobs.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.Jobs.cs @@ -107,12 +107,10 @@ namespace Opc.Ua.AI.Server string jobId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); job = new InferenceJobState(null); - job.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + job, new QualifiedName("Job_" + jobId, NamespaceIndex), - new LocalizedText("Job " + jobId), - true); + new LocalizedText("Job " + jobId)); Child>(job, BrowseNames.JobId).Value = jobId; Child>(job, BrowseNames.Deployment).Value = diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.Learning.cs b/src/Opc.Ua.AI.Server/AiNodeManager.Learning.cs index 666accf092..e5162c8d83 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.Learning.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.Learning.cs @@ -114,12 +114,10 @@ namespace Opc.Ua.AI.Server private void BuildLearningJob() { m_learningJob = new LearningJobState(null); - m_learningJob.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + m_learningJob, new QualifiedName("LearningSamples", NamespaceIndex), - new LocalizedText("Learning samples"), - true); + new LocalizedText("Learning samples")); Child>(m_learningJob, BrowseNames.JobId).Value = "learning-samples"; diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.Transfer.cs b/src/Opc.Ua.AI.Server/AiNodeManager.Transfer.cs index 8596bd0eaa..017e2b5b19 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.Transfer.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.Transfer.cs @@ -112,12 +112,10 @@ namespace Opc.Ua.AI.Server string transferId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); node = new InferenceTransferState(null); - node.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + node, new QualifiedName("Transfer_" + transferId, NamespaceIndex), - new LocalizedText("Transfer " + transferId), - true); + new LocalizedText("Transfer " + transferId)); Child>(node, BrowseNames.TransferId).Value = transferId; Child>(node, BrowseNames.State).Value = diff --git a/src/Opc.Ua.AI.Server/AiNodeManager.cs b/src/Opc.Ua.AI.Server/AiNodeManager.cs index d842df7915..d2e0a5824a 100644 --- a/src/Opc.Ua.AI.Server/AiNodeManager.cs +++ b/src/Opc.Ua.AI.Server/AiNodeManager.cs @@ -74,7 +74,6 @@ namespace Opc.Ua.AI.Server private readonly HashSet m_learningSampleIds = new(StringComparer.Ordinal); private readonly Queue m_learningSampleOrder = new(); private readonly StreamFileManager m_files; - private int m_nextId; private AiRootState? m_root; private ModelState? m_primaryModel; @@ -117,7 +116,11 @@ namespace Opc.Ua.AI.Server m_fallbackBackendOptions = fallbackBackendOptions ?? m_backendOptions; m_logger = logger ?? (ILogger)NullLogger.Instance; m_files = new StreamFileManager(m_options.MaxTransferSize); - SystemContext.NodeIdFactory = this; + + // counter identifiers: inference jobs and transfers come and go + // under repeating browse names, so a browse-path identifier + // would collide between one job and its successor. + NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter); } /// @@ -161,24 +164,6 @@ namespace Opc.Ua.AI.Server /// public NodeId LearningJobId => m_learningJob?.NodeId ?? NodeId.Null; - /// - /// - /// String identifiers, deliberately. Numeric ones would be drawn from the - /// same namespace the loaded NodeSet occupies, and this model runs to - /// ns=2;i=7001 - so a counter starting at 1 walks into the type nodes, and - /// the predefined-node index overwrites rather than rejects. A Server that - /// had served a few hundred transfers would quietly have replaced - /// AiRootType with an inference job's FinishedAt property. - /// A string identifier cannot collide with a numeric one at all, which is a - /// stronger guarantee than any seed value. - /// - public override NodeId New(ISystemContext context, NodeState node) - { - return new NodeId( - FormattableString.Invariant($"n{Interlocked.Increment(ref m_nextId)}"), - NamespaceIndex); - } - /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, @@ -362,12 +347,10 @@ namespace Opc.Ua.AI.Server // without one is indexed, readable by NodeId and callable, but no client // can navigate to it - so the whole optional half of the model simply // is not there, without anything failing. - typed.Create( - SystemContext, - NodeId.Null, + SystemContext.CreateInstance( + typed, qualifiedName, - new LocalizedText(browseName), - true); + new LocalizedText(browseName)); typed.ReferenceTypeId = typed is PropertyState ? Opc.Ua.ReferenceTypeIds.HasProperty diff --git a/src/Opc.Ua.Di.Server/DiNodeManager.cs b/src/Opc.Ua.Di.Server/DiNodeManager.cs index bbb655a76b..e689799fb6 100644 --- a/src/Opc.Ua.Di.Server/DiNodeManager.cs +++ b/src/Opc.Ua.Di.Server/DiNodeManager.cs @@ -119,7 +119,13 @@ namespace Opc.Ua.Di.Server CombineNamespaces(configuration)) { m_instanceNamespaceUri = GetInstanceNamespaceUri(configuration); - SystemContext.NodeIdFactory = this; + + // devices live in the server's own instance namespace. Their + // parents - DeviceSet and the type declarations below it - come + // from the DI model, and a subclass registers its own model + // namespaces ahead of the instance one, so neither the first + // namespace nor the parent's is the right place to mint into. + NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex(InstanceNamespaceIndex); PostSetupRunner = postSetupRunner; } @@ -161,7 +167,13 @@ namespace Opc.Ua.Di.Server CombineNamespaces(configuration, additionalNamespaceUris)) { m_instanceNamespaceUri = GetInstanceNamespaceUri(configuration); - SystemContext.NodeIdFactory = this; + + // devices live in the server's own instance namespace. Their + // parents - DeviceSet and the type declarations below it - come + // from the DI model, and a subclass registers its own model + // namespaces ahead of the instance one, so neither the first + // namespace nor the parent's is the right place to mint into. + NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex(InstanceNamespaceIndex); PostSetupRunner = postSetupRunner; } @@ -240,22 +252,6 @@ namespace Opc.Ua.Di.Server /// public ArrayOf ServerProfiles => BuildServerProfiles(); - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && - instance.Parent != null) - { - string parentId = instance.Parent.NodeId.IdentifierAsString; - - return new NodeId( - $"{parentId}_{instance.SymbolicName}", - InstanceNamespaceIndex); - } - - return node.NodeId; - } - /// /// References an instance node from the Machinery Machines folder /// if it is present in the address space. Returns diff --git a/src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs b/src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs index b70ae0d483..08e043b048 100644 --- a/src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs +++ b/src/Opc.Ua.Gds.Server/ApplicationsNodeManager.cs @@ -74,7 +74,9 @@ namespace Opc.Ua.Gds.Server { NamespaceUris = ["http://opcfoundation.org/UA/GDS/applications/", Namespaces.OpcUaGds]; - SystemContext.NodeIdFactory = this; + // counter identifiers: applications, certificate groups and trust + // lists are registered and unregistered under repeating names. + NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter); m_configuration = configuration; // get the configuration for the node manager. @@ -147,20 +149,6 @@ namespace Opc.Ua.Gds.Server } } - /// - /// Creates the NodeId for the specified node. - /// - public override NodeId New(ISystemContext context, NodeState node) - { - // generate a numeric node id if the node has a parent and no node id assigned. - if (node is BaseInstanceState instance && instance.Parent != null) - { - return GenerateNodeId(); - } - - return node.NodeId; - } - private NodeId GetTrustListId(NodeId certificateGroupId) { if (certificateGroupId.IsNull) @@ -2209,14 +2197,6 @@ namespace Opc.Ua.Gds.Server return handle.Node; } - /// - /// Generates a new node id. - /// - private NodeId GenerateNodeId() - { - return new NodeId(++m_nextNodeId, NamespaceIndex); - } - protected async ValueTask SetCertificateGroupNodesAsync(ICertificateGroup certificateGroup) { certificateGroup.DefaultTrustList = null!; @@ -2891,7 +2871,6 @@ namespace Opc.Ua.Gds.Server } private readonly bool m_autoApprove; - private uint m_nextNodeId; private readonly ApplicationConfiguration m_configuration; private readonly GlobalDiscoveryServerConfiguration m_globalDiscoveryServerConfiguration; private readonly IApplicationsDatabase m_database; diff --git a/src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs b/src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs index 4082b6156a..ed808841dc 100644 --- a/src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs +++ b/src/Opc.Ua.ISA95.Server/Isa95NodeManager.cs @@ -69,7 +69,6 @@ namespace Opc.Ua.ISA95.Server m_configurators = configurators ?? []; m_options.Validate(); RegisterEncodeables(server.Factory); - SystemContext.NodeIdFactory = this; } public FolderState? Root { get; private set; } @@ -77,23 +76,6 @@ namespace Opc.Ua.ISA95.Server public ushort InstanceNamespaceIndex => (ushort)Server.NamespaceUris.GetIndex(m_options.InstanceNamespaceUri); - public override NodeId New(ISystemContext context, NodeState node) - { - if (!node.NodeId.IsNull) - { - return node.NodeId; - } - if (node is BaseInstanceState instance && instance.Parent != null) - { - string parent = instance.Parent.NodeId.IdentifierAsString; - string name = instance.BrowseName.Name ?? instance.SymbolicName ?? "Node"; - return new NodeId($"{parent}_{name}", InstanceNamespaceIndex); - } - return new NodeId( - $"ISA95_{Interlocked.Increment(ref m_nextNodeId)}", - InstanceNamespaceIndex); - } - protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) @@ -1306,7 +1288,6 @@ namespace Opc.Ua.ISA95.Server private int m_catalogChangesDisposed; private long m_jobOrderAppliedGeneration; private long m_jobOrderRefreshGeneration; - private long m_nextNodeId; } internal static partial class Isa95NodeManagerLog diff --git a/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs b/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs index 19643255db..e6dcac49b7 100644 --- a/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs +++ b/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs @@ -71,20 +71,6 @@ namespace Opc.Ua.Positioning.Server Gpos.Namespaces.GPOS) { m_runner = runner; - SystemContext.NodeIdFactory = this; - } - - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && instance.Parent != null) - { - return new NodeId( - $"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}", - instance.Parent.NodeId.NamespaceIndex); - } - - return node.NodeId; } /// diff --git a/src/Opc.Ua.Robotics.Server/RobotIntentNodeManager.cs b/src/Opc.Ua.Robotics.Server/RobotIntentNodeManager.cs index dc1cc918bc..c5e1967dbc 100644 --- a/src/Opc.Ua.Robotics.Server/RobotIntentNodeManager.cs +++ b/src/Opc.Ua.Robotics.Server/RobotIntentNodeManager.cs @@ -84,7 +84,12 @@ namespace Opc.Ua.Robotics.Server m_options.Validate(); m_runner = runner; m_services = services; - SystemContext.NodeIdFactory = this; + + // mint into the server's own instance namespace. It is listed + // after the model namespaces, which belong to the loaded + // NodeSets and whose identifiers are not ours to hand out. + NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex( + GetInstanceNamespaceIndex(SystemContext)); RegisterEncodeables(SystemContext); } @@ -118,22 +123,6 @@ namespace Opc.Ua.Robotics.Server internal bool BaseDisposeStarted => Volatile.Read(ref m_baseDisposeStarted) != 0; - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (node is BaseInstanceState instance && instance.Parent != null) - { - return new NodeId( - $"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}", - GetInstanceNamespaceIndex(context)); - } - if (node.NodeId.IsNull) - { - return new NodeId(Guid.NewGuid(), GetInstanceNamespaceIndex(context)); - } - return node.NodeId; - } - /// /// Creates a direct build context for non-DI configuration. /// @@ -404,7 +393,7 @@ namespace Opc.Ua.Robotics.Server return root; } - private ushort GetInstanceNamespaceIndex(ISystemContext context) + private ushort GetInstanceNamespaceIndex(ServerSystemContext context) { return (ushort)context.NamespaceUris.GetIndex(m_options.InstanceNamespaceUri); } diff --git a/src/Opc.Ua.Server/AliasNames/AliasNameNodeManager.cs b/src/Opc.Ua.Server/AliasNames/AliasNameNodeManager.cs index c8d09c9974..3c55a60145 100644 --- a/src/Opc.Ua.Server/AliasNames/AliasNameNodeManager.cs +++ b/src/Opc.Ua.Server/AliasNames/AliasNameNodeManager.cs @@ -112,6 +112,10 @@ namespace Opc.Ua.Server.AliasNames m_localCategoryDispatcher, AuthorizeMutation, m_aliasLogger); + + // counter identifiers: alias categories are rematerialized from + // the store as it changes, so browse names repeat over time. + NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter); } /// @@ -124,20 +128,6 @@ namespace Opc.Ua.Server.AliasNames /// public AliasNameNodeManagerOptions Options { get; } - /// - public override NodeId New(ISystemContext context, NodeState node) - { - // Preserve any caller-assigned NodeId that already lives in our - // namespace; otherwise mint a sequential numeric id. - if (!node.NodeId.IsNull && - node.NodeId.NamespaceIndex == NamespaceIndex) - { - return node.NodeId; - } - uint id = Utils.IncrementIdentifier(ref m_nextNodeId); - return new NodeId(id, NamespaceIndex); - } - /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, @@ -314,7 +304,6 @@ namespace Opc.Ua.Server.AliasNames /// private readonly AliasNameStoreRegistry m_localCategoryDispatcher; private bool m_registeredWithServer; - private uint m_nextNodeId; private readonly Lock m_lock = new(); } diff --git a/src/Opc.Ua.Server/AliasNames/AliasNameNodeMaterializer.cs b/src/Opc.Ua.Server/AliasNames/AliasNameNodeMaterializer.cs index 4b2da93f70..9111828014 100644 --- a/src/Opc.Ua.Server/AliasNames/AliasNameNodeMaterializer.cs +++ b/src/Opc.Ua.Server/AliasNames/AliasNameNodeMaterializer.cs @@ -347,10 +347,11 @@ namespace Opc.Ua.Server.AliasNames category.TypeDefinitionId = ObjectTypeIds.AliasNameCategoryType; category.ReferenceTypeId = ReferenceTypeIds.Organizes; - // Mint ids for the mandatory children first — the host's - // id factory does not preserve a caller-assigned id — then - // pin the category to the descriptor's NodeId, which is - // the key the store and the registry dispatch on. + // Mint ids for the mandatory children first, then pin the + // category to the descriptor's NodeId, which is the key the + // store and the registry dispatch on. Assigning it before the + // children would derive their ids from it, so the order is + // deliberate rather than incidental. category.AssignNodeIds(m_host.SystemContext, []); category.NodeId = descriptor.NodeId; } @@ -758,9 +759,20 @@ namespace Opc.Ua.Server.AliasNames AliasNameState aliasNode = m_host.SystemContext.CreateInstanceOfAliasNameType(); - aliasNode.NodeId = mintedId.IsNull - ? m_host.MintNodeId(aliasNode) - : mintedId; + if (mintedId.IsNull) + { + // The no-argument helper does no instance assignment, so the + // node still carries the standard AliasNameType NodeId. It has + // no parent either, and a factory keeps an identifier on a node + // that stands on its own - so without clearing it first the + // alias would advertise the type's own NodeId. + aliasNode.NodeId = NodeId.Null; + aliasNode.NodeId = m_host.MintNodeId(aliasNode); + } + else + { + aliasNode.NodeId = mintedId; + } aliasNode.BrowseName = RehomeServerDefinedName(aliasName); aliasNode.DisplayName = new LocalizedText(string.Empty, name); aliasNode.SymbolicName = name; diff --git a/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs b/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs index ae57e1cc45..3436637feb 100644 --- a/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs +++ b/src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs @@ -100,7 +100,14 @@ namespace Opc.Ua.Server SetNamespaces(namespaceUris); m_namespaceIndex = Server.NamespaceUris.GetIndexOrAppend(namespaceUris[1]); - m_lastUsedId = (uint)DateTime.UtcNow.Ticks & 0x7FFFFFFF; + + // counter identifiers in the diagnostics namespace rather than + // the first one, which is the OPC UA namespace this manager only + // reads. Session and subscription diagnostics come and go under + // repeating browse names, so browse paths would collide. + NodeIdFactory = NodeIdFactory + .WithMode(NodeIdAssignmentMode.Counter) + .WithDefaultNamespaceIndex(m_namespaceIndex); m_sessions = []; m_subscriptions = []; DiagnosticsEnabled = true; @@ -146,18 +153,6 @@ namespace Opc.Ua.Server base.Dispose(disposing); } - /// - /// Creates the NodeId for the specified node. - /// - /// The context. - /// The node. - /// The new NodeId. - public override NodeId New(ISystemContext context, NodeState node) - { - uint id = Utils.IncrementIdentifier(ref m_lastUsedId); - return new NodeId(id, m_namespaceIndex); - } - /// /// Does any initialization required before the address space can be used. /// @@ -2336,7 +2331,6 @@ namespace Opc.Ua.Server private readonly Lock m_diagnosticsLock = new(); private readonly TimeProvider m_timeProvider; private readonly ushort m_namespaceIndex; - private uint m_lastUsedId; private ITimer? m_diagnosticsScanTimer; private int m_diagnosticsMonitoringCount; private bool m_doScanBusy; diff --git a/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs b/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs index ec473a0ea3..f3d862b011 100644 --- a/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs +++ b/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs @@ -76,7 +76,6 @@ namespace Opc.Ua.Server.FileSystem { Provider = provider ?? throw new ArgumentNullException(nameof(provider)); SystemContext.SystemHandle = this; - SystemContext.NodeIdFactory = this; NamespaceUris = [BuildNamespaceUri(provider)]; NamespaceIndex = base.NamespaceIndex; diff --git a/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs index 4600a9d5c1..b529e25b7b 100644 --- a/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/AlarmBuilderExtensions.cs @@ -217,10 +217,7 @@ namespace Opc.Ua.Server.Fluent alarm.BrowseName = browseName; alarm.DisplayName = new LocalizedText(symbolicName); - string parentIdentifier = parent.Node.NodeId.IdentifierAsString; - alarm.NodeId = new NodeId( - $"{parentIdentifier}_{symbolicName}", - parent.Node.NodeId.NamespaceIndex); + FluentNodeRegistration.AssignNodeId(parent.Builder, alarm); // Initialize standard alarm state surface so the alarm is // immediately addressable as a valid OPC UA condition. diff --git a/src/Opc.Ua.Server/Fluent/FluentNodeRegistration.cs b/src/Opc.Ua.Server/Fluent/FluentNodeRegistration.cs index da43ab82d4..1c39938275 100644 --- a/src/Opc.Ua.Server/Fluent/FluentNodeRegistration.cs +++ b/src/Opc.Ua.Server/Fluent/FluentNodeRegistration.cs @@ -31,14 +31,33 @@ namespace Opc.Ua.Server.Fluent { internal static class FluentNodeRegistration { + /// + /// Mints the NodeId for a node the fluent surface just created. + /// + /// + /// Routed through the owning NodeManager so that a fluent graph + /// obeys the same as the rest of + /// the manager, instead of the ambiguous + /// {parentIdentifier}_{browseName} concatenation the fluent + /// builders used to each spell out for themselves. The node is + /// created here and there is nothing to preserve, so the NodeId is + /// cleared first to say so. + /// + /// The builder that owns the node. + /// The freshly created node. + internal static void AssignNodeId( + INodeManagerBuilder builder, + NodeState node) + { + node.NodeId = NodeId.Null; + node.NodeId = builder.NodeManager.New(builder.Context, node); + } + internal static void RegisterCreatedNode( INodeManagerBuilder builder, NodeState node) { - if (builder.NodeManager is AsyncCustomNodeManager manager) - { - manager.AddPredefinedNodeSynchronously(node); - } + builder.NodeManager.AddNode(node); } internal static void RegisterAlarmEventSource( @@ -57,10 +76,9 @@ namespace Opc.Ua.Server.Fluent current = current is BaseInstanceState instance ? instance.Parent : null; } - if (firstSource != null && - builder.NodeManager is AsyncCustomNodeManager manager) + if (firstSource != null) { - manager.AddRootNotifierSynchronously(firstSource); + builder.NodeManager.AddRootNotifier(firstSource); } } } diff --git a/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs b/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs index 1d259dc0a0..4a9587bb6f 100644 --- a/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/INodeManagerBuilder.cs @@ -85,6 +85,17 @@ namespace Opc.Ua.Server.Fluent /// IAsyncNodeManager NodeManager { get; } + /// + /// The namespace index a browse name given as a plain string is + /// qualified with. + /// + /// + /// Exposed so that the child helpers qualify a string browse name the + /// same way does, + /// rather than each inventing a namespace of its own. + /// + ushort DefaultNamespaceIndex { get; } + /// /// Manager-level dispatch surface populated by the On* /// methods on the per-node builders. The owning node manager diff --git a/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs index b01a6f3623..0ad2c899d9 100644 --- a/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs @@ -131,10 +131,7 @@ namespace Opc.Ua.Server.Fluent instance.DisplayName = new LocalizedText(symbolicName); instance.ReferenceTypeId = ReferenceTypeIds.HasComponent; - string parentIdentifier = parent.Node.NodeId.IdentifierAsString; - instance.NodeId = new NodeId( - $"{parentIdentifier}_{symbolicName}", - parent.Node.NodeId.NamespaceIndex); + FluentNodeRegistration.AssignNodeId(parent.Builder, instance); if (!typeDefinitionId.IsNull) diff --git a/src/Opc.Ua.Server/Fluent/NodeIdAssignmentBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/NodeIdAssignmentBuilderExtensions.cs new file mode 100644 index 0000000000..69b4fd2a61 --- /dev/null +++ b/src/Opc.Ua.Server/Fluent/NodeIdAssignmentBuilderExtensions.cs @@ -0,0 +1,85 @@ +/* ======================================================================== + * 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.Server.Fluent +{ + /// + /// Selects how the NodeManager behind a fluent builder mints NodeIds. + /// + public static class NodeIdAssignmentBuilderExtensions + { + /// + /// Selects the identifier type that the owning NodeManager mints + /// NodeIds with, for this and every node created after it. + /// + /// + /// Call this before the nodes it should apply to - at the top of a + /// Configure delegate, typically. Nodes created earlier keep + /// the identifiers they were already given, so switching mode + /// midway leaves a graph with two identifier styles. + /// + /// + /// The builder type, so the call composes in a chain that continues + /// with either or + /// members. + /// + /// The fluent node-manager builder. + /// The identifier type to mint. + /// The same . + /// + /// is null. + /// + /// + /// Raised when the builder's NodeManager does not mint its own + /// NodeIds, so there is no mode to select. + /// + public static TBuilder WithNodeIdAssignment( + this TBuilder builder, + NodeIdAssignmentMode mode) + where TBuilder : INodeManagerBuilder + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (builder.NodeManager is not AsyncCustomNodeManager manager) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "NodeId assignment can only be selected on a NodeManager " + + "deriving from AsyncCustomNodeManager."); + } + + manager.NodeIdFactory = manager.NodeIdFactory.WithMode(mode); + return builder; + } + } +} diff --git a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs index c82f31c9de..6fbe23fb08 100644 --- a/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs +++ b/src/Opc.Ua.Server/Fluent/NodeManagerBuilder.cs @@ -119,6 +119,9 @@ namespace Opc.Ua.Server.Fluent m_dataTypeIdResolver = dataTypeIdResolver ?? (static _ => []); } + /// + public ushort DefaultNamespaceIndex => m_defaultNamespaceIndex; + /// public ISystemContext Context { get; } diff --git a/src/Opc.Ua.Server/Fluent/PropertyInitBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/PropertyInitBuilderExtensions.cs index c260520d7d..020ee6d16a 100644 --- a/src/Opc.Ua.Server/Fluent/PropertyInitBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/PropertyInitBuilderExtensions.cs @@ -420,12 +420,11 @@ namespace Opc.Ua.Server.Fluent AccessLevel = AccessLevels.CurrentRead, UserAccessLevel = AccessLevels.CurrentRead, MinimumSamplingInterval = MinimumSamplingIntervals.Indeterminate, - Historizing = false, - NodeId = new NodeId( - string.Concat(parent.NodeId.IdentifierAsString, "_", name), - parent.NodeId.NamespaceIndex) + Historizing = false }; + FluentNodeRegistration.AssignNodeId(builder.Builder, property); + property.WrappedValue = value; parent.AddChild(property); diff --git a/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs index 21cb512d01..5c8729096c 100644 --- a/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs @@ -222,6 +222,53 @@ namespace Opc.Ua.Server.Fluent /// ). /// /// is null. + public static INodeBuilder AddObject( + this INodeBuilder parent, + string browseName, + NodeId typeDefinitionId = default) + { + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } + if (string.IsNullOrEmpty(browseName)) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameInvalid, + "Browse name is null or empty."); + } + + return parent.AddObject( + new QualifiedName(browseName, parent.Builder.DefaultNamespaceIndex), + typeDefinitionId); + } + + /// + /// Creates a child object under the parent using an explicitly + /// namespaced browse name. + /// + /// + /// Namespace 0 is the OPC UA namespace, which no NodeManager owns, so + /// a browse name there is refused rather than quietly authored into + /// someone else's namespace. Use the string overload for the + /// builder's own namespace. + /// + /// The parent to create the child under. + /// + /// Browse name carrying a nonzero namespace index. + /// + /// + /// Type definition to apply; defaults to + /// . + /// + /// + /// A typed for the + /// newly created child. + /// + /// is null. + /// + /// The browse name is null, or is in namespace 0. + /// public static INodeBuilder AddObject( this INodeBuilder parent, QualifiedName browseName, @@ -231,9 +278,18 @@ namespace Opc.Ua.Server.Fluent { throw new ArgumentNullException(nameof(parent)); } - if (browseName.IsNull) + if (browseName.IsNull || string.IsNullOrEmpty(browseName.Name)) { - throw new ArgumentNullException(nameof(browseName)); + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameInvalid, + "Browse name is null or empty."); + } + if (browseName.NamespaceIndex == 0) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameInvalid, + "A QualifiedName browse name must specify a nonzero namespace index. " + + "Use the string overload for the manager's default namespace."); } NodeId typeDef = typeDefinitionId.IsNull ? ObjectTypeIds.BaseObjectType : typeDefinitionId; @@ -247,13 +303,7 @@ namespace Opc.Ua.Server.Fluent TypeDefinitionId = typeDef }; - // Generate a NodeId that mirrors the parent's identifier scope. - // Pattern matches PumpDeviceIntegrationServer's NodeIdFactory: - // "{parentIdentifier}_{childBrowseName}" in the parent's namespace. - string parentIdentifier = parent.Node.NodeId.IdentifierAsString; - child.NodeId = new NodeId( - $"{parentIdentifier}_{symbolicName}", - parent.Node.NodeId.NamespaceIndex); + FluentNodeRegistration.AssignNodeId(parent.Builder, child); parent.Node.AddChild(child); FluentNodeRegistration.RegisterCreatedNode(parent.Builder, child); diff --git a/src/Opc.Ua.Server/Fluent/StateMachineBuilderExtensions.cs b/src/Opc.Ua.Server/Fluent/StateMachineBuilderExtensions.cs index 0ccf03dcb5..ae93801810 100644 --- a/src/Opc.Ua.Server/Fluent/StateMachineBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Fluent/StateMachineBuilderExtensions.cs @@ -155,10 +155,7 @@ namespace Opc.Ua.Server.Fluent machine.BrowseName = browseName; machine.DisplayName = new LocalizedText(symbolicName); - string parentIdentifier = parent.Node.NodeId.IdentifierAsString; - machine.NodeId = new NodeId( - $"{parentIdentifier}_{symbolicName}", - parent.Node.NodeId.NamespaceIndex); + FluentNodeRegistration.AssignNodeId(parent.Builder, machine); machine.Create( parent.Builder.Context, diff --git a/src/Opc.Ua.Server/Hosting/DependencyInjectionStandardServer.cs b/src/Opc.Ua.Server/Hosting/DependencyInjectionStandardServer.cs index c292833f57..b4fa3755cc 100644 --- a/src/Opc.Ua.Server/Hosting/DependencyInjectionStandardServer.cs +++ b/src/Opc.Ua.Server/Hosting/DependencyInjectionStandardServer.cs @@ -54,6 +54,8 @@ namespace Opc.Ua.Server.Hosting { m_services = services ?? throw new ArgumentNullException(nameof(services)); SecurityPolicyRegistry = m_services.GetService(); + NodeIdFactory = m_services.GetService(); + DetectNodeIdCollisions = m_services.GetService()?.Enabled; OpcUaServerRegistrationStaging.Apply(this, m_services); } diff --git a/src/Opc.Ua.Server/Hosting/NodeIdCollisionDetection.cs b/src/Opc.Ua.Server/Hosting/NodeIdCollisionDetection.cs new file mode 100644 index 0000000000..7115247c41 --- /dev/null +++ b/src/Opc.Ua.Server/Hosting/NodeIdCollisionDetection.cs @@ -0,0 +1,45 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.Server +{ + /// + /// The registered answer to whether a hosted server's NodeManagers watch + /// for NodeId collisions. + /// + /// + /// A type of its own rather than a bare bool so that dependency + /// injection can tell "the server was configured to leave it off" from + /// "the server was not configured", which is the difference between + /// overriding + /// and deferring to it. + /// + /// Whether NodeManagers watch for collisions. + public sealed record NodeIdCollisionDetection(bool Enabled); +} diff --git a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs index a8d0bb8645..261b663e25 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs @@ -484,6 +484,91 @@ namespace Microsoft.Extensions.DependencyInjection return builder; } + /// + /// Registers the NodeId factory that every NodeManager the hosted + /// server owns mints runtime NodeIds with. + /// + /// + /// Each NodeManager rebases the registered factory onto its own + /// namespace, so one registration serves the whole server. Without + /// this call NodeManagers default to + /// . + /// + /// The server builder. + /// The identifier type to mint. + /// The same for chaining. + /// + /// is null. + public static IOpcUaServerBuilder AddNodeIdFactory( + this IOpcUaServerBuilder builder, + NodeIdAssignmentMode mode) + { + return builder.AddNodeIdFactory(new DefaultNodeIdFactory(mode)); + } + + /// + /// Registers the NodeId factory that every NodeManager the hosted + /// server owns mints runtime NodeIds with. + /// + /// + /// Each NodeManager rebases the registered factory onto its own + /// namespace, so one registration serves the whole server. The + /// factory is registered as , so + /// a decorator around can be + /// registered in its place. + /// + /// The server builder. + /// The factory to register. + /// The same for chaining. + /// or + /// is null. + public static IOpcUaServerBuilder AddNodeIdFactory( + this IOpcUaServerBuilder builder, + IRebasableNodeIdFactory nodeIdFactory) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (nodeIdFactory is null) + { + throw new ArgumentNullException(nameof(nodeIdFactory)); + } + + builder.Services.Replace(ServiceDescriptor.Singleton(nodeIdFactory)); + return builder; + } + + /// + /// Decides, for the whole hosted server, whether NodeManagers refuse + /// to mint a NodeId they already gave a different browse path. + /// + /// + /// Without this call each factory keeps + /// , which + /// is on in a debug build and off otherwise. Watching costs memory + /// that grows with the address space, which is why the decision is + /// server-wide rather than per NodeManager. + /// + /// The server builder. + /// Whether to watch. + /// The same for chaining. + /// + /// is null. + public static IOpcUaServerBuilder DetectNodeIdCollisions( + this IOpcUaServerBuilder builder, + bool detectCollisions = true) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.Replace( + ServiceDescriptor.Singleton(new NodeIdCollisionDetection(detectCollisions))); + return builder; + } + /// /// Registers a role manager that is installed on the hosted server at startup. /// diff --git a/src/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs b/src/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs index 13790f9d87..04f0bf4cd9 100644 --- a/src/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs +++ b/src/Opc.Ua.Server/NodeManager/Adapters/AsyncNodeManagerAdapter.cs @@ -79,6 +79,38 @@ namespace Opc.Ua.Server /// public IEnumerable NamespaceUris => SyncNodeManager.NamespaceUris; + /// + /// + /// Delegates to the wrapped NodeManager when it mints NodeIds of its + /// own. A plain does not, and keeping the + /// node's own NodeId is what CustomNodeManager2 has always + /// done, so that is the answer here too. + /// + public NodeId New(ISystemContext context, NodeState node) + { + return SyncNodeManager is INodeIdFactory factory + ? factory.New(context, node) + : node.NodeId; + } + + /// + /// + /// A synchronous NodeManager exposes no registration entry point, so + /// a graph built against this adapter is registered by whatever + /// created it. + /// + public void AddNode(NodeState node) + { + } + + /// + /// + /// See . + /// + public void AddRootNotifier(NodeState notifier) + { + } + /// public INodeManager SyncNodeManager { get; } diff --git a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs index c3f0191148..69b375aa58 100644 --- a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs @@ -151,8 +151,10 @@ namespace Opc.Ua.Server // the node id factory assigns new node ids to new nodes. // the strategy used by a NodeManager depends on what kind of information it provides. + // this points at the NodeManager rather than at its factory so that a sub-class + // overriding New() is still the one node level code reaches. A sub-class therefore + // never has to repeat this assignment. SystemContext.NodeIdFactory = this; - m_lastUsedNodeId = (uint)DateTime.UtcNow.Ticks & 0x7FFFFFFF; // add the uris to the server's namespace table and cache the indexes. ushort[] namespaceIndexes = []; @@ -170,6 +172,10 @@ namespace Opc.Ua.Server m_namespaceUris = namespaceUris; m_namespaceIndexes = namespaceIndexes; + // nodes created at runtime get a deterministic NodeId derived + // from their browse path unless a sub-class picks another rule. + m_nodeIdFactory = ResolveNodeIdFactory(server, DefaultNamespaceIndex); + m_syncNodeManager = (INodeManager3)this.ToSyncNodeManager(); // create a monitored item manager that owns sampling groups / monitoredNodes @@ -233,23 +239,141 @@ namespace Opc.Ua.Server } } + /// + /// Mints NodeIds for nodes that this NodeManager creates at runtime. + /// + /// + /// Resolved from dependency injection when the server was composed + /// that way, otherwise a + /// factory, which derives a deterministic identifier from the node's + /// browse path. Assign a factory to mint a different identifier + /// type, or a factory + /// together with a override to assign NodeIds by + /// another rule entirely. The assigned factory is rebased onto this + /// NodeManager's own namespace. + /// + /// + /// Thrown when the factory is set to null. + /// + public IRebasableNodeIdFactory NodeIdFactory + { + get => m_nodeIdFactory; + set + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + // A factory that names no namespace adopts this NodeManager's + // own, which is what a bare "new DefaultNodeIdFactory(mode)" + // means. One that names a namespace is left alone: namespace 0 + // is the OPC UA namespace and never a place a NodeManager + // mints into, so it is unambiguous as "not specified", and + // forcing the namespace back here would silently undo the + // rebase of a NodeManager whose instance namespace is not its + // first one. + IRebasableNodeIdFactory rebased = value.DefaultNamespaceIndex == 0 + ? value.WithDefaultNamespaceIndex(DefaultNamespaceIndex) + : value; + + // whether to watch for collisions is the server's decision, + // so it is reapplied to whatever factory a NodeManager + // assigns rather than left to the factory that was handed in. + m_nodeIdFactory = ApplyCollisionDetection(Server, rebased); + } + } + /// /// Creates the NodeId for the specified node. /// /// The context. /// The node. /// The new NodeId. + /// + /// Delegates to . A NodeManager selects + /// its identifier style by assigning that factory rather than by + /// overriding this method. + /// public virtual NodeId New(ISystemContext context, NodeState node) { - if (node.NodeId.IsNull) + return m_nodeIdFactory.New(context, node); + } + + /// + public void AddNode(NodeState node) + { + AddPredefinedNodeSynchronously(node); + } + + /// + public void AddRootNotifier(NodeState notifier) + { + AddRootNotifierSynchronously(notifier); + } + + /// + /// Follows the NodeId factory onto the manager's new default + /// namespace, unless it was deliberately pointed somewhere else. + /// + /// + /// The default namespace index before the namespaces changed. + /// + private void RebaseNodeIdFactory(ushort previousDefault) + { + if (m_nodeIdFactory.DefaultNamespaceIndex == previousDefault) { - uint id = Utils.IncrementIdentifier(ref m_lastUsedNodeId); - return new NodeId(id, m_namespaceIndexes[0]); + m_nodeIdFactory = m_nodeIdFactory.WithDefaultNamespaceIndex( + DefaultNamespaceIndex); + } + } + + /// + /// Resolves the NodeId factory the server was configured with, + /// falling back to the deterministic default. + /// + private static IRebasableNodeIdFactory ResolveNodeIdFactory( + IServerInternal server, + ushort namespaceIndex) + { + if (server is INodeIdFactoryProvider { NodeIdFactory: { } configured }) + { + return ApplyCollisionDetection( + server, + configured.WithDefaultNamespaceIndex(namespaceIndex)); } - return node.NodeId; + return ApplyCollisionDetection( + server, + new DefaultNodeIdFactory(NodeIdAssignmentMode.Numeric, namespaceIndex)); } + /// + /// Applies the server's answer on collision detection to a factory. + /// + /// + /// A server that has not answered leaves the factory on its own + /// default, so a factory built with an explicit answer keeps it. + /// + private static IRebasableNodeIdFactory ApplyCollisionDetection( + IServerInternal server, + IRebasableNodeIdFactory factory) + { + if (server is INodeIdFactoryProvider { DetectNodeIdCollisions: { } detect }) + { + return factory.WithCollisionDetection(detect); + } + + return factory; + } + + /// + /// The namespace index that mints into + /// when a node's browse name is in namespace 0. + /// + private ushort DefaultNamespaceIndex + => m_namespaceIndexes.Length > 0 ? m_namespaceIndexes[0] : (ushort)0; + /// ILocalAddressSpace ILocalAddressSpaceSource.CreateLocalAddressSpace() { @@ -920,8 +1044,10 @@ namespace Opc.Ua.Server } // create the immutable table of namespaces that are used by the NodeManager. + ushort previousDefault = DefaultNamespaceIndex; m_namespaceUris = namespaceUris; m_namespaceIndexes = namespaceIndexes; + RebaseNodeIdFactory(previousDefault); } /// @@ -937,8 +1063,10 @@ namespace Opc.Ua.Server } // create the immutable table of namespaces that are used by the NodeManager. + ushort previousDefault = DefaultNamespaceIndex; m_namespaceUris = namespaceUris; m_namespaceIndexes = namespaceIndexes; + RebaseNodeIdFactory(previousDefault); } /// @@ -1164,12 +1292,25 @@ namespace Opc.Ua.Server /// /// /// This pass assigns an id to every node that still lacks one, pulls - /// namespace-0 children into the root's namespace, and rebases nodes - /// whose id collides with a type declaration. That last case covers a - /// subtree materialised with NodeState.Create(..., assignNodeIds: false), - /// whose children keep their declaration ids: they are neither null - /// nor in namespace 0, so only the collision check - /// catches them. Rebasing here rather than at registration is what + /// namespace-0 nodes into this manager's namespace, and rebases nodes + /// whose id collides with a type declaration it owns. Together those + /// cover a subtree materialised with NodeState.Create(..., + /// assignNodeIds: false), whose nodes keep their declaration ids. + /// Which of the two catches such a node depends on where the + /// declaration lives: + /// + /// a declaration in a model this manager loaded is in its + /// PredefinedNodes, so the collision check sees it; + /// a declaration in namespace 0 - every standard + /// Opc.Ua.*State - belongs to the CoreNodeManager, so + /// the collision check cannot see it and + /// is the only thing that + /// catches it. + /// + /// That second test applies to the root as well as its descendants. + /// It does not cover a namespace-0 id a caller invented, which stays + /// an error rather than becoming a silent repair. + /// Rebasing here rather than at registration is what /// keeps the ids the fluent builder hands back final — the same /// repair PrepareInstanceNodeIdsForRegistration would /// otherwise apply later, silently invalidating them. @@ -1204,9 +1345,7 @@ namespace Opc.Ua.Server NodeState candidate = nodes[i]; if (candidate.NodeId.IsNull || HasDeclarationNodeIdCollision(candidate) || - (i > 0 && - node.NodeId.NamespaceIndex != 0 && - candidate.NodeId.NamespaceIndex == 0)) + HasStandardDeclarationNodeId(candidate)) { NodeId previousNodeId = SystemContext.AssignInstanceNodeId(candidate); if (!previousNodeId.IsNull && @@ -1595,19 +1734,21 @@ namespace Opc.Ua.Server // Resolve target NodeId: honour caller-supplied NodeId when present and // valid, otherwise allocate a fresh one from this manager's namespace. - // Some derived classes override `New(...)` to produce hierarchical - // child identifiers from a parent; for service-set AddNodes the - // instance has no parent yet, so we fall back to the base - // identifier allocator to guarantee a non-null NodeId. + // + // The instance is not linked to its parent yet - that happens after + // the duplicate-BrowseName check below - so asking a factory to + // derive an identifier from the node alone would hand two + // same-named children of different parents the same one, and the + // second registration would replace the first. The parent identity + // is therefore passed explicitly, which also covers a parent owned + // by another NodeManager. NodeId newNodeId; if (item.RequestedNewNodeId.IsNull) { - newNodeId = New(systemContext, instance); + newNodeId = AllocateNodeIdForAddNodes(systemContext, instance, parentNodeId); if (newNodeId.IsNull) { - newNodeId = new NodeId( - Utils.IncrementIdentifier(ref m_lastUsedNodeId), - NamespaceIndex); + newNodeId = m_nodeIdFactory.NextCounterNodeId(); } } else @@ -1987,6 +2128,44 @@ namespace Opc.Ua.Server /// Thrown when the supplied attributes are not valid for the node class /// or when the node class is not supported. /// + /// + /// Mints the NodeId for a node added through the AddNodes service. + /// + /// + /// + /// Derives it from the parent and the browse name, so that two nodes + /// added under different parents with the same browse name stay + /// distinct. Two added under the *same* parent with the same browse + /// name would share one, and are rejected by the duplicate browse-name + /// check rather than reaching registration. + /// + /// + /// A NodeManager that assigns identifiers by a scheme of its own + /// overrides this alongside . Note that + /// is false by default, so this runs + /// only for a NodeManager that opted into the service set. + /// + /// + /// The operation context. + /// The node being added, not yet parented. + /// The NodeId of the node's parent. + /// The NodeId to give the node. + protected virtual NodeId AllocateNodeIdForAddNodes( + ServerSystemContext context, + BaseInstanceState instance, + NodeId parentNodeId) + { + // the factory's namespace rather than this NodeManager's first + // one: a manager whose instance namespace is not its first rebases + // the factory onto it, and minting into the manager's first would + // put runtime instances in the model namespace instead. + return m_nodeIdFactory.CreateChildNodeId( + parentNodeId, + instance.BrowseName, + m_nodeIdFactory.DefaultNamespaceIndex, + context.NamespaceUris); + } + private static BaseInstanceState CreateInstanceForAddNodes( AddNodesItem item, NodeId typeDefinitionId) @@ -2347,6 +2526,36 @@ namespace Opc.Ua.Server HasDeclarationNodeIdCollision(node)); } + /// + /// Whether the node carries a namespace-0 identifier it inherited + /// from a standard type declaration rather than one a caller chose. + /// + /// + /// + /// A subtree materialised from a standard Opc.Ua.*State keeps + /// that type's declaration ids. They live in namespace 0, so they + /// belong to the CoreNodeManager and + /// - which looks in this + /// manager's PredefinedNodes - structurally cannot see them. + /// This is the only thing that catches them, on the root as much as + /// on its descendants. + /// + /// + /// Every node OPC UA defines in namespace 0 has a numeric identifier, + /// so a namespace-0 identifier of any other kind cannot be a + /// declaration. That is a caller naming a namespace it does not own, + /// which ValidateAuthoredNodeId reports rather than silently + /// repairing - the two look alike here but are opposite mistakes, and + /// only one of them is the caller's. + /// + /// + private static bool HasStandardDeclarationNodeId(NodeState node) + { + return !node.NodeId.IsNull && + node.NodeId.NamespaceIndex == 0 && + node.NodeId.IdType == IdType.Numeric; + } + private bool HasDeclarationNodeIdCollision(NodeState node) { return !node.NodeId.IsNull && @@ -9124,11 +9333,10 @@ namespace Opc.Ua.Server /// consumer tasks based on the number of event monitored items. /// internal NodeIdDictionary MultiConsumerNodeIds { get; } = []; - /// - /// Counter for the NodeIdFactory.New Method + /// Assigns NodeIds to nodes created at runtime. /// - private uint m_lastUsedNodeId; + private IRebasableNodeIdFactory m_nodeIdFactory; private const byte kHistoryAccessMask = AccessLevels.HistoryRead | AccessLevels.HistoryWrite; private const int kMaxInitialHistoryPages = 100_000; diff --git a/src/Opc.Ua.Server/NodeManager/DefaultNodeIdFactory.cs b/src/Opc.Ua.Server/NodeManager/DefaultNodeIdFactory.cs new file mode 100644 index 0000000000..2653b5ca94 --- /dev/null +++ b/src/Opc.Ua.Server/NodeManager/DefaultNodeIdFactory.cs @@ -0,0 +1,1458 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace Opc.Ua.Server +{ + /// + /// Selects the identifier type that a mints. + /// + /// + /// Every mode except derives the identifier from the + /// same canonical path (see + /// ), so a node keeps the + /// same NodeId across reloads as long as its browse path is unchanged. + /// + public enum NodeIdAssignmentMode + { + /// + /// The factory mints no identifiers at all. + /// + /// + /// A node manager selects this mode when it assigns NodeIds itself, + /// typically by overriding . + /// Asking a factory in this mode for an identifier is a + /// configuration error and raises + /// . + /// + None, + + /// + /// Mints numeric identifiers from the first 32 bits of the canonical + /// path hash. + /// + /// + /// The default. Numeric identifiers are the most compact form on the + /// wire and the most readable in client UIs. A 32 bit hash is also + /// the only mode of this class where a collision between two distinct + /// browse paths is realistically possible, so the factory records + /// what it mints and raises + /// if two paths ever + /// land on one identifier rather than letting one node silently + /// replace the other. + /// + Numeric, + + /// + /// Mints string identifiers holding the canonical path verbatim. + /// + /// + /// This is the only mode that cannot collide, because it keeps the + /// canonical path whole and two different paths are never the same + /// text. The identifiers are long and opaque. + /// + String, + + /// + /// Mints Guid identifiers from the first 128 bits of the canonical + /// path hash. + /// + Guid, + + /// + /// Mints opaque identifiers holding the first 128 bits of the + /// canonical path hash. + /// + Opaque, + + /// + /// Mints numeric identifiers from a sequential counter. + /// + /// + /// The only mode that does not derive from the browse path, and so + /// the only one that stays unique when browse paths repeat - nodes + /// that come and go under the same name, such as per-session + /// diagnostics objects. The identifiers are compact but not stable + /// across restarts. + /// + Counter + } + + /// + /// Assigns deterministic NodeIds to nodes created at runtime. + /// + /// + /// + /// The classic convention for a child NodeId was to concatenate the + /// parent identifier, an underscore and the child browse name. That form + /// is ambiguous - A_B plus C and A plus B_C + /// produce the same identifier - it discards the parent's identifier + /// type, and it shifts whenever the namespace table is ordered + /// differently. + /// + /// + /// This class replaces it with a canonical path: a length prefixed + /// encoding that records the parent's identifier type, qualifies + /// cross namespace parents and browse names by URI, and therefore cannot + /// collide on separator characters. selects how that + /// path is projected onto an identifier type. + /// + /// + /// Instances are immutable and safe to register as a singleton. A + /// NodeManager that owns a different namespace calls + /// to get its own view of the + /// registered factory rather than mutating it. + /// + /// + public class DefaultNodeIdFactory : IRebasableNodeIdFactory + { + /// + /// The prefix identifying the canonical path format understood by + /// this class. + /// + /// + /// A future revision of the format increments this prefix so that + /// identifiers minted by different revisions stay distinguishable. + /// + public const string CanonicalPathVersion = "v1"; + + /// + /// Initializes a new instance of the + /// class. + /// + /// The identifier type to mint. + /// + /// The namespace index every minted NodeId belongs to, root and child + /// alike. A parent's namespace is never inherited: it can belong to a + /// companion-specification model whose NodeIds are fixed by its + /// NodeSet, or to another NodeManager. + /// + /// + /// Whether to refuse an identifier already given to a different + /// browse path, or null to follow + /// . A mode that cannot + /// collide never watches, whatever this says. + /// + public DefaultNodeIdFactory( + NodeIdAssignmentMode mode = NodeIdAssignmentMode.Numeric, + ushort defaultNamespaceIndex = 0, + bool? detectCollisions = null) + : this( + mode, + defaultNamespaceIndex, + detectCollisions ?? DetectCollisionsByDefault, + new ConcurrentDictionary()) + { + } + + /// + /// Creates a view of an existing factory that keeps its allocation + /// state. + /// + /// + /// The With… methods route here so that every view derived from + /// one factory shares one counter and one record of minted + /// identifiers per namespace. Rebuilding them per view would let two + /// NodeManagers that own the same namespace hand out the same counter + /// values - their seeds come from the clock and would be created + /// moments apart - and would hide a collision between identifiers + /// minted through different views. + /// + private DefaultNodeIdFactory( + NodeIdAssignmentMode mode, + ushort defaultNamespaceIndex, + bool detectCollisionsRequested, + ConcurrentDictionary allocationStates) + { + Mode = mode; + DefaultNamespaceIndex = defaultNamespaceIndex; + + // the requested policy is kept as asked rather than as it applies + // to this mode, so that WithMode(String).WithMode(Numeric) comes + // back watching rather than silently giving the answer up on the + // way through a mode that cannot collide. + m_detectCollisionsRequested = detectCollisionsRequested; + m_allocationStates = allocationStates; + m_allocation = allocationStates.GetOrAdd( + defaultNamespaceIndex, + static _ => new AllocationState()); + } + + /// + /// Whether a factory watches for collisions unless told otherwise. + /// + /// + /// + /// On in a debug build, off otherwise. The record costs memory that + /// grows with the address space and a lookup on every mint, which is + /// worth paying while a model is being developed - when a collision + /// is a bug to find - and not in production, where the odds are + /// remote and the cost is permanent. + /// + /// + /// This is the fallback for a factory built without an explicit + /// answer. A server sets StandardServer.DetectNodeIdCollisions + /// to decide for all of its NodeManagers at once; the test fixtures + /// turn it on there so the checking is exercised whatever + /// configuration the stack was built in. + /// + /// + public static bool DetectCollisionsByDefault { get; set; } +#if DEBUG + = true; +#else + = false; +#endif + + /// + /// Whether two distinct canonical paths can produce one identifier in + /// the specified mode. + /// + /// + /// keeps the path whole, so + /// distinct paths stay distinct text. + /// does not derive from a + /// path at all. The rest truncate a hash. + /// + private static bool CanCollide(NodeIdAssignmentMode mode) + { + return mode is NodeIdAssignmentMode.Numeric + or NodeIdAssignmentMode.Guid + or NodeIdAssignmentMode.Opaque; + } + + /// + /// The identifier type minted by this factory. + /// + public NodeIdAssignmentMode Mode { get; } + + /// + /// The namespace index every minted NodeId belongs to. + /// + /// + /// A NodeManager may only mint NodeIds in a namespace it owns, so + /// this is never inferred from the node. A parent's namespace in + /// particular can belong to a companion-specification model whose + /// NodeIds are fixed by its NodeSet, or to another NodeManager. + /// + public ushort DefaultNamespaceIndex { get; } + + /// + /// + /// A mode that cannot collide never watches, however the factory was + /// configured. The configured answer itself survives a trip through + /// such a mode, so switching back turns watching on again. + /// + public bool DetectsCollisions => m_detectCollisionsRequested && CanCollide(Mode); + + /// + /// Returns a factory with the same that mints into + /// the specified namespace. + /// + /// + /// A NodeManager calls this on the factory it resolved from + /// dependency injection, so that a single registered instance can be + /// shared by managers that own different namespaces without any of + /// them mutating shared state. A NodeManager whose instance namespace + /// is not its first one rebases the factory onto that namespace. + /// + /// The namespace to mint into. + /// + /// This instance when the namespace already matches, otherwise a copy. + /// + public virtual DefaultNodeIdFactory WithDefaultNamespaceIndex( + ushort defaultNamespaceIndex) + { + if (defaultNamespaceIndex == DefaultNamespaceIndex) + { + return this; + } + + return new DefaultNodeIdFactory( + Mode, + defaultNamespaceIndex, + m_detectCollisionsRequested, + m_allocationStates); + } + + /// + /// + /// Explicit so that the public method keeps returning the concrete + /// type: a caller holding a can go + /// on chaining without a cast, while a caller holding the interface + /// stays on the interface. + /// + IRebasableNodeIdFactory IRebasableNodeIdFactory.WithDefaultNamespaceIndex( + ushort defaultNamespaceIndex) + { + return WithDefaultNamespaceIndex(defaultNamespaceIndex); + } + + /// + /// Returns a factory that mints into the same namespace using the + /// specified mode. + /// + /// + /// This is how a NodeManager selects its identifier style: a single + /// declarative line in its constructor rather than a + /// New override. + /// + /// The identifier type to mint. + /// + /// This instance when the mode already matches, otherwise a copy. + /// + public virtual DefaultNodeIdFactory WithMode(NodeIdAssignmentMode mode) + { + if (mode == Mode) + { + return this; + } + + return new DefaultNodeIdFactory( + mode, + DefaultNamespaceIndex, + m_detectCollisionsRequested, + m_allocationStates); + } + + /// + /// Returns a factory that mints the same way and does or does not + /// watch for collisions. + /// + /// + /// A NodeManager calls this with its server's setting, so that one + /// answer covers every NodeManager the server hosts. + /// + /// Whether to watch. + /// + /// This instance when the answer already matches, otherwise a copy. + /// + public virtual DefaultNodeIdFactory WithCollisionDetection(bool detectCollisions) + { + if (detectCollisions == m_detectCollisionsRequested) + { + return this; + } + + return new DefaultNodeIdFactory( + Mode, + DefaultNamespaceIndex, + detectCollisions, + m_allocationStates); + } + + /// + /// + /// Explicit for the same reason as + /// . + /// + IRebasableNodeIdFactory IRebasableNodeIdFactory.WithCollisionDetection(bool detectCollisions) + { + return WithCollisionDetection(detectCollisions); + } + + /// + /// + /// Explicit for the same reason as + /// . + /// + IRebasableNodeIdFactory IRebasableNodeIdFactory.WithMode(NodeIdAssignmentMode mode) + { + return WithMode(mode); + } + + /// + /// Creates the NodeId for the specified node. + /// + /// The context. + /// The node. + /// + /// The NodeId already set on if there is one, + /// otherwise a freshly minted deterministic NodeId. + /// + /// + /// Thrown when is + /// . + /// + public virtual NodeId New(ISystemContext context, NodeState node) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (node is null) + { + throw new ArgumentNullException(nameof(node)); + } + + // An identifier the caller can plausibly have chosen is kept: + // one already in this NodeManager's own namespace, or one on a + // node that stands on its own. NodeState.Create hands the root + // its NodeId before running the assignment pass, so a caller + // naming a node explicitly reaches here through the first of + // those. + // + // Anything else belongs to another namespace's model. A node + // reaches here through AssignNodeIds walking a subtree that + // NodeState.CreateInstance copied from a type declaration, and + // its nodes still carry that declaration's identifiers. Keeping + // them would alias every instance of the type onto the type's own + // nodes, and the predefined-node index takes the last writer, so + // the type quietly becomes an instance rather than the clash + // being reported. + // + // A node that must shed an identifier already in this namespace - + // a generated instance whose type lives here too - says so by + // clearing the NodeId first, which is what + // ISystemContext.AssignInstanceNodeId does for a whole subtree. + if (!node.NodeId.IsNull && + (node.NodeId.NamespaceIndex == DefaultNamespaceIndex || + node is not BaseInstanceState { Parent: not null })) + { + return node.NodeId; + } + + if (Mode == NodeIdAssignmentMode.None) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The DefaultNodeIdFactory is disabled, so no NodeId can be minted for node '{0}'. " + + "Assign the NodeId before the node is added, or select a mode other than None.", + node.BrowseName); + } + + // a node with no stable browse path - an event instance and its + // fields, for example - cannot be derived from, so it falls back + // to the counter no matter which mode is selected. That keeps + // the NodeManagers themselves free of any assignment logic. + if (Mode == NodeIdAssignmentMode.Counter || !HasDerivablePath(node)) + { + return NextCounterNodeId(); + } + + return CreateChildNodeId( + GetParentNodeId(context, node), + node.BrowseName, + DefaultNamespaceIndex, + context.NamespaceUris); + } + + /// + /// Mints the next sequential NodeId. + /// + /// + /// The counter is per factory instance. Two NodeManagers that share + /// one instance therefore share the counter, which is what keeps + /// their identifiers distinct while they mint into one namespace. + /// + /// A NodeId no other caller of this factory has seen. + public NodeId NextCounterNodeId() + { + for (int attempt = 0; attempt < kMaxCounterAttempts; attempt++) + { + var nodeId = new NodeId( + Utils.IncrementIdentifier(ref m_allocation.LastUsedId), + DefaultNamespaceIndex); + + // The counter mints into the same numeric space a hash does - + // the counter base is a 32 bit value like any other - so a + // counter identifier can land on one already derived from a + // browse path, in this factory or in another view of it that + // mints into this namespace in a different mode. The counter + // is the side with freedom to move, so it steps over the + // clash instead of reporting it. + // + // This follows the requested policy rather than + // DetectsCollisions, which a Counter-mode view reports as + // false: the reservation is not about whether *this* mode can + // collide with itself, but about keeping its identifiers out + // of the way of the modes that can. + if (!m_detectCollisionsRequested || + m_allocation.MintedIdentifiers.TryAdd(nodeId, kCounterWitness)) + { + return nodeId; + } + } + + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "No free counter NodeId was found in namespace {0} after {1} attempts.", + DefaultNamespaceIndex, + kMaxCounterAttempts); + } + + /// + /// Returns true when the node carries a browse path that a + /// deterministic identifier can be derived from. + /// + /// + /// A node fails this test when it has no browse name, or when its + /// parent is itself transient and carries no NodeId - an event + /// instance and its fields, for example. Such a node has no stable + /// path, so falls back to the counter for it. + /// + /// The node to test. + /// True when a deterministic identifier can be derived. + public virtual bool HasDerivablePath(NodeState node) + { + if (node is null) + { + throw new ArgumentNullException(nameof(node)); + } + + if (node.BrowseName.IsNull || string.IsNullOrEmpty(node.BrowseName.Name)) + { + return false; + } + + return node is not BaseInstanceState { Parent: { } parent } + || !parent.NodeId.IsNull; + } + + /// + /// Mints the NodeId for a child of the specified parent. + /// + /// + /// The parent NodeId, or a null NodeId for a node that is authored at + /// the root of the address space. + /// + /// The browse name of the child. + /// + /// The namespace index the minted NodeId belongs to. + /// + /// The namespace table to resolve URIs. + /// The minted NodeId. + public NodeId CreateChildNodeId( + NodeId parentNodeId, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + if (Mode == NodeIdAssignmentMode.None) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The DefaultNodeIdFactory is disabled, so no NodeId can be minted for '{0}'.", + browseName); + } + + if (Mode == NodeIdAssignmentMode.Counter) + { + return NextCounterNodeId(); + } + + ValidatePathArguments(browseName, namespaceUris); + + StringBuilder? parentIdentifier = FormatParentIdentifier(parentNodeId); + + int length = MeasureCanonicalPath( + parentNodeId, + parentIdentifier, + browseName, + namespaceIndex, + namespaceUris); + + // the path is scratch: it is projected onto an identifier and, in + // every mode but String, never becomes a string at all. A short + // one lives on the stack, a long one comes from the pool. + Span stack = stackalloc char[kMaxStackallocChars]; + char[]? rented = length > kMaxStackallocChars + ? ArrayPool.Shared.Rent(length) + : null; + + try + { + Span buffer = rented is null ? stack : rented.AsSpan(); + + var writer = new PathBuilder(buffer); + WriteCanonicalPath( + ref writer, + parentNodeId, + parentIdentifier, + browseName, + namespaceIndex, + namespaceUris); + + // the written length rather than the measured one: a rented + // buffer is longer than the path and carries whatever the + // previous tenant left in it. + return MintFromPath(buffer[..writer.Length], namespaceIndex); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + /// + /// Projects a canonical path onto an identifier of the configured + /// type and records it so a later collision is caught. + /// + private NodeId MintFromPath(ReadOnlySpan canonicalPath, ushort namespaceIndex) + { + if (Mode == NodeIdAssignmentMode.String) + { + // A canonical path is longer than the parent identifier it + // encodes, so a parent close to the limit expands past it. + // Publishing the NodeId anyway would put a non-conformant + // identifier into the address space, which a client is + // entitled to reject. + if (canonicalPath.Length > kMaxStringIdentifierLength) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The canonical path is {0} characters, over the {1} the OPC UA " + + "String identifier allows (Part 3, 8.2.4). Select a hashing mode " + + "- Numeric, Guid or Opaque - whose identifiers are a fixed size.", + canonicalPath.Length, + kMaxStringIdentifierLength); + } + + return new NodeId(ToStringValue(canonicalPath), namespaceIndex); + } + + Span hash = stackalloc byte[HashLength]; + ComputeHash(canonicalPath, hash); + + NodeId nodeId; + + switch (Mode) + { + case NodeIdAssignmentMode.Numeric: + nodeId = new NodeId(ToIdentifier(hash), namespaceIndex); + break; + case NodeIdAssignmentMode.Guid: + nodeId = new NodeId(ToGuid(hash), namespaceIndex); + break; + default: + nodeId = new NodeId((ByteString)hash[..GuidLength].ToArray(), namespaceIndex); + break; + } + + GuardAgainstCollision(nodeId, hash, canonicalPath); + + return nodeId; + } + + /// + /// Raises an error when a second browse path lands on an identifier + /// this factory already minted for a different one. + /// + /// + /// + /// Minting the same path twice is normal - AssignNodeIds walks + /// a subtree on every create pass - so the record keeps a witness of + /// the path alongside the identifier and only a *differing* witness + /// is a collision. The witness is the tail of the same hash, so two + /// paths would have to agree on the identifier and on a further 64 + /// bits before a real collision could pass as a re-mint. + /// + /// + /// The alternative to raising is worse than a failed start: the + /// predefined-node index takes the last writer, so a collision would + /// silently replace one node with the other and the address space + /// would be quietly wrong. + /// + /// + private void GuardAgainstCollision( + NodeId nodeId, + ReadOnlySpan hash, + ReadOnlySpan canonicalPath) + { + if (!DetectsCollisions) + { + return; + } + + ulong witness = ToWitness(hash); + + if (m_allocation.MintedIdentifiers.GetOrAdd(nodeId, witness) == witness) + { + return; + } + + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The NodeId '{0}' was already minted from a different browse path, so '{1}' " + + "cannot be given it. Two distinct browse paths hashed to one {2} identifier. " + + "Select NodeIdAssignmentMode.String, which cannot collide, or Guid or Opaque, " + + "which spread the same hash over 128 bits.", + nodeId, + ToStringValue(canonicalPath), + Mode); + } + + /// + /// The lowest identifier the counter ever mints. + /// + /// + /// A NodeManager usually mints into the same namespace its NodeSet + /// occupies, and a counter that started near zero would walk into + /// that model's numeric identifiers - the predefined-node index + /// overwrites rather than rejects, so a long-running server would + /// quietly replace a type node with a runtime instance. Identifiers + /// this large are out of reach of any authored model, which makes + /// the separation structural rather than a matter of seeding luck. + /// + private const uint kCounterBase = 0x40000000; + + /// + /// Builds the canonical path that every identifier type is derived + /// from. + /// + /// + /// + /// The path is v1:<len>:<parent>:<len>:<browseName>, + /// where each segment is itself length prefixed. A segment starts + /// with l when it lives in , + /// with z when it lives in namespace 0, and with u + /// followed by the namespace URI otherwise. The parent segment is + /// empty for a node authored at the root. + /// + /// + /// Because every variable part carries its own length, no combination + /// of parent identifier and browse name can produce the same path as + /// a different combination. + /// + /// + /// + /// The parent NodeId, or a null NodeId for a root node. + /// + /// The browse name of the child. + /// + /// The namespace index the minted NodeId belongs to. + /// + /// The namespace table to resolve URIs. + /// The canonical path. + public static string CreateCanonicalPath( + NodeId parentNodeId, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + ValidatePathArguments(browseName, namespaceUris); + + StringBuilder? parentIdentifier = FormatParentIdentifier(parentNodeId); + + int length = MeasureCanonicalPath( + parentNodeId, + parentIdentifier, + browseName, + namespaceIndex, + namespaceUris); + + Span stack = stackalloc char[kMaxStackallocChars]; + char[]? rented = length > kMaxStackallocChars + ? ArrayPool.Shared.Rent(length) + : null; + + try + { + Span buffer = rented is null ? stack : rented.AsSpan(); + + var writer = new PathBuilder(buffer); + WriteCanonicalPath( + ref writer, + parentNodeId, + parentIdentifier, + browseName, + namespaceIndex, + namespaceUris); + + return ToStringValue(buffer[..writer.Length]); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + /// + /// Rejects arguments no canonical path can be built from. + /// + private static void ValidatePathArguments( + QualifiedName browseName, + NamespaceTable namespaceUris) + { + if (browseName.IsNull || string.IsNullOrEmpty(browseName.Name)) + { + throw new ArgumentException("The browse name is null.", nameof(browseName)); + } + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + } + + /// + /// Formats the parent's identifier with its type prefix but without a + /// namespace, so that a parent keeps its identity when the namespace + /// table is ordered differently. + /// + /// + /// Produced once and read by both passes over the buffer. + /// NodeId.Format is the authority on the spelling, so the path + /// keeps the exact text clients already hold. + /// + /// The identifier text, or null for a root node. + private static StringBuilder? FormatParentIdentifier(NodeId parentNodeId) + { + if (parentNodeId.IsNull) + { + return null; + } + + var identifier = new StringBuilder(); + + NodeId.Format( + CultureInfo.InvariantCulture, + identifier, + parentNodeId.IdentifierAsString, + parentNodeId.IdType, + namespaceIndex: 0); + + return identifier; + } + + /// + /// Returns the length of the canonical path without writing it. + /// + private static int MeasureCanonicalPath( + NodeId parentNodeId, + StringBuilder? parentIdentifier, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + var measure = PathBuilder.Measuring(); + + WriteCanonicalPath( + ref measure, + parentNodeId, + parentIdentifier, + browseName, + namespaceIndex, + namespaceUris); + + return measure.Length; + } + + /// + /// Writes the canonical path, or measures it when the builder is + /// measuring. + /// + /// + /// One routine serves both passes so the measured length can never + /// drift from the written one. + /// + private static void WriteCanonicalPath( + ref PathBuilder builder, + NodeId parentNodeId, + StringBuilder? parentIdentifier, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + // each segment is prefixed by its own length, so the segment has + // to be measured before it can be written. + var parentMeasure = PathBuilder.Measuring(); + WriteParentSegment( + ref parentMeasure, parentNodeId, parentIdentifier, namespaceIndex, namespaceUris); + + var browseMeasure = PathBuilder.Measuring(); + WriteBrowseNameSegment(ref browseMeasure, browseName, namespaceIndex, namespaceUris); + + builder.Append(CanonicalPathVersion); + builder.Append(':'); + builder.AppendNumber(parentMeasure.Length); + builder.Append(':'); + WriteParentSegment( + ref builder, parentNodeId, parentIdentifier, namespaceIndex, namespaceUris); + builder.Append(':'); + builder.AppendNumber(browseMeasure.Length); + builder.Append(':'); + WriteBrowseNameSegment(ref builder, browseName, namespaceIndex, namespaceUris); + } + + /// + /// Writes the parent segment. It is empty for a root node. + /// + private static void WriteParentSegment( + ref PathBuilder builder, + NodeId parentNodeId, + StringBuilder? parentIdentifier, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + if (parentIdentifier is null) + { + return; + } + + if (parentNodeId.NamespaceIndex == namespaceIndex) + { + WriteLocal(ref builder, parentIdentifier); + return; + } + + WriteQualified( + ref builder, namespaceUris, parentNodeId.NamespaceIndex, parentIdentifier); + } + + /// + /// Writes the browse name segment. + /// + private static void WriteBrowseNameSegment( + ref PathBuilder builder, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + if (browseName.NamespaceIndex == namespaceIndex) + { + WriteLocal(ref builder, browseName.Name!); + return; + } + + if (browseName.NamespaceIndex == 0) + { + builder.Append("z:"); + WriteLengthPrefixed(ref builder, browseName.Name!); + return; + } + + WriteQualified(ref builder, namespaceUris, browseName.NamespaceIndex, browseName.Name!); + } + + /// + /// Writes a segment that lives in the minted namespace. + /// + private static void WriteLocal(ref PathBuilder builder, string text) + { + builder.Append("l:"); + WriteLengthPrefixed(ref builder, text); + } + + /// + private static void WriteLocal(ref PathBuilder builder, StringBuilder text) + { + builder.Append("l:"); + WriteLengthPrefixed(ref builder, text); + } + + /// + /// Writes a segment qualified by its namespace URI. + /// + private static void WriteQualified( + ref PathBuilder builder, + NamespaceTable namespaceUris, + ushort namespaceIndex, + string text) + { + if (WriteNamespaceQualifier(ref builder, namespaceUris, namespaceIndex)) + { + WriteLengthPrefixed(ref builder, text); + } + } + + /// + private static void WriteQualified( + ref PathBuilder builder, + NamespaceTable namespaceUris, + ushort namespaceIndex, + StringBuilder text) + { + if (WriteNamespaceQualifier(ref builder, namespaceUris, namespaceIndex)) + { + WriteLengthPrefixed(ref builder, text); + } + } + + /// + /// Writes the namespace part of a qualified segment, up to and + /// including the separator before the text. + /// + private static bool WriteNamespaceQualifier( + ref PathBuilder builder, + NamespaceTable namespaceUris, + ushort namespaceIndex) + { + string? namespaceUri = namespaceUris.GetString(namespaceIndex); + + // A namespace with no URI cannot be named stably, so the segment + // falls back to the index. That form is deliberately distinct + // from the URI form, so identifiers minted while the namespace + // was missing stay distinguishable from the ones minted once it + // is registered rather than silently colliding with them. + if (string.IsNullOrEmpty(namespaceUri)) + { + builder.Append("x:"); + builder.AppendNumber(PathBuilder.DigitCount(namespaceIndex)); + builder.Append(':'); + builder.AppendNumber(namespaceIndex); + builder.Append(':'); + return true; + } + + builder.Append("u:"); + WriteLengthPrefixed(ref builder, namespaceUri!); + builder.Append(':'); + return true; + } + + /// + /// Writes the text prefixed by its own length. + /// + private static void WriteLengthPrefixed(ref PathBuilder builder, string text) + { + builder.AppendNumber(text.Length); + builder.Append(':'); + builder.Append(text); + } + + /// + private static void WriteLengthPrefixed(ref PathBuilder builder, StringBuilder text) + { + builder.AppendNumber(text.Length); + builder.Append(':'); + builder.Append(text); + } + + /// + /// Returns the NodeId of the node the minted identifier is derived + /// from. + /// + /// + /// The default implementation uses the parent of a + /// . A node manager that tracks + /// parents outside the node hierarchy overrides this to supply them. + /// + /// The context. + /// The node being assigned an identifier. + /// The parent NodeId, or a null NodeId for a root node. + protected virtual NodeId GetParentNodeId(ISystemContext context, NodeState node) + { + if (node is not BaseInstanceState { Parent: { } parent }) + { + return NodeId.Null; + } + + // NodeIds are assigned top down, so a parent that still has no + // NodeId means the caller assigned out of order. Deriving from an + // empty parent would silently alias the child onto a root. + if (parent.NodeId.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Cannot mint a NodeId for node '{0}' because its parent '{1}' has none yet.", + node.BrowseName, + parent.BrowseName); + } + + // the Objects folder is the default organizer of a root node and + // carries no information about the node itself. + if (parent.NodeId == ObjectIds.ObjectsFolder) + { + return NodeId.Null; + } + + return parent.NodeId; + } + + /// + /// Returns the span as a string. + /// + /// + /// The frameworks without a span based string constructor copy once. + /// That is still one allocation against the ten the segment by + /// segment concatenation this replaced needed, and it is only reached + /// in and when reporting a + /// collision. + /// + private static string ToStringValue(ReadOnlySpan value) + { +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + return new string(value); +#else + return new string(value.ToArray()); +#endif + } + + /// + /// Hashes the canonical path into the destination. + /// + /// + /// SHA256 is used for its stable, platform independent output, not + /// for any security property. The hash never leaves the address space + /// as anything other than a NodeId. + /// + private static void ComputeHash(ReadOnlySpan canonicalPath, Span destination) + { +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + int byteCount = Encoding.UTF8.GetByteCount(canonicalPath); + + Span stack = stackalloc byte[kMaxStackallocBytes]; + byte[]? rented = byteCount > kMaxStackallocBytes + ? ArrayPool.Shared.Rent(byteCount) + : null; + + try + { + Span bytes = rented is null ? stack : rented.AsSpan(); + int written = Encoding.UTF8.GetBytes(canonicalPath, bytes); + +#if NET5_0_OR_GREATER + SHA256.HashData(bytes[..written], destination); +#else + using SHA256 sha256 = SHA256.Create(); + sha256.TryComputeHash(bytes[..written], destination, out _); +#endif + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } +#else + byte[] bytes = Encoding.UTF8.GetBytes(canonicalPath.ToArray()); + + using SHA256 sha256 = SHA256.Create(); + sha256.ComputeHash(bytes).CopyTo(destination); +#endif + } + + /// + /// Projects the tail of the hash onto the witness that tells a + /// re-mint of one path from a collision between two. + /// + /// + /// The tail is used because no mode derives its identifier from it, + /// so the witness carries bits the identifier does not. + /// + private static ulong ToWitness(ReadOnlySpan hash) + { + ulong witness = 0; + + for (int ii = HashLength - sizeof(ulong); ii < HashLength; ii++) + { + witness = (witness << 8) | hash[ii]; + } + + return witness; + } + + /// + /// Projects the hash onto a numeric identifier. + /// + private static uint ToIdentifier(ReadOnlySpan hash) + { + uint identifier = ((uint)hash[0] << 24) + | ((uint)hash[1] << 16) + | ((uint)hash[2] << 8) + | hash[3]; + + // identifier 0 is the null NodeId, so it can never be minted. + return identifier != 0 ? identifier : uint.MaxValue; + } + + /// + /// Projects the hash onto a Guid identifier. + /// + /// + /// The fields are laid out so that the canonical text form of the + /// Guid reads the hash bytes in order. The version and variant bits + /// mark the value as name based per RFC 9562, which keeps it distinct + /// from a random Guid. + /// + private static Guid ToGuid(ReadOnlySpan hash) + { + Span bytes = stackalloc byte[GuidLength]; + hash[..GuidLength].CopyTo(bytes); + + bytes[6] = (byte)((bytes[6] & 0x0F) | 0x80); + bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); + + return new Guid( + ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3], + (ushort)((bytes[4] << 8) | bytes[5]), + (ushort)((bytes[6] << 8) | bytes[7]), + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15]); + } + + /// + /// The number of hash bytes consumed by the Guid and Opaque modes. + /// + private const int GuidLength = 16; + + /// + /// The size of a SHA256 hash. + /// + private const int HashLength = 32; + + /// + /// The longest String identifier OPC UA allows, from Part 3, 8.2.4. + /// + private const int kMaxStringIdentifierLength = 4096; + + /// + /// The witness recorded for a counter identifier, which has no browse + /// path behind it. + /// + /// + /// A path derived witness is the tail of a SHA256 hash, so it can in + /// principle be this value too. That costs nothing: the only effect + /// would be treating one collision as a re-mint, at a probability of + /// 2^-64 on top of the collision itself. + /// + private const ulong kCounterWitness = 0; + + /// + /// How many counter values are tried before giving up on finding one + /// no browse path has already claimed. + /// + private const int kMaxCounterAttempts = 1024; + + /// + /// The longest canonical path built on the stack. Longer ones come + /// from . + /// + /// + /// A path is a version prefix, a parent identifier and a browse name, + /// each length prefixed, plus a namespace URI when either crosses a + /// namespace. That fits well inside this for ordinary models, so the + /// pool is the exception rather than the rule. + /// + private const int kMaxStackallocChars = 256; + + /// + /// The longest UTF-8 encoding of a path hashed on the stack. + /// + /// + /// A canonical path is overwhelmingly ASCII, where the encoding is + /// the same length as the path, but a browse name or namespace URI + /// may carry any Unicode, so this allows the worst case expansion of + /// a stack sized path. + /// + private const int kMaxStackallocBytes = kMaxStackallocChars * 3; + + /// + /// The identifiers minted so far, each with a witness of the browse + /// path it came from, or null in a mode that cannot collide. + /// + /// + /// + /// Scoped to the factory instance, which is scoped to a namespace: + /// hands a NodeManager that + /// owns another namespace its own copy, and identifiers in different + /// namespaces cannot collide. NodeManagers sharing one namespace + /// share the instance and so are checked against each other. + /// + /// + /// It grows with the number of distinct browse paths minted, and is + /// never pruned: an identifier handed to a client stays spoken for + /// even after the node goes away, so re-minting it for a different + /// path is exactly the collision this catches. + /// + /// + private readonly ConcurrentDictionary m_allocationStates; + + /// + /// The allocation state for . + /// + private readonly AllocationState m_allocation; + + /// + /// The collision policy as configured, before the mode is consulted. + /// + private readonly bool m_detectCollisionsRequested; + + /// + /// The identifiers handed out for one namespace, and the counter that + /// mints the sequential ones. + /// + /// + /// Held per namespace and shared by every view derived from one + /// factory, so that two NodeManagers owning the same namespace draw + /// from one counter and are checked against each other. Identifiers in + /// different namespaces cannot collide, so they get separate state. + /// + private sealed class AllocationState + { + /// + /// Counter behind and + /// the fallback for nodes with no derivable browse path. The clock + /// seeds the offset so a restart is unlikely to reissue + /// identifiers a client still holds. + /// + public uint LastUsedId + = kCounterBase | ((uint)DateTime.UtcNow.Ticks & 0x0FFFFFFF); + + /// + /// The identifiers minted so far, each with a witness of the + /// browse path it came from. + /// + public readonly ConcurrentDictionary MintedIdentifiers = new(); + } + + /// + /// Writes a canonical path into a span, or measures one without a + /// span to write into. + /// + /// + /// Both passes run the same code, so the length reserved for a + /// segment cannot drift from the length written into it. + /// + private ref struct PathBuilder + { + /// + /// Creates a builder that writes into the buffer. + /// + public PathBuilder(Span buffer) + { + m_buffer = buffer; + m_writing = true; + Length = 0; + } + + /// + /// Creates a builder that counts characters without writing. + /// + public static PathBuilder Measuring() + { + return default; + } + + /// + /// The number of characters written, or counted. + /// + public int Length { get; private set; } + + /// + /// Appends one character. + /// + public void Append(char value) + { + if (m_writing) + { + m_buffer[Length] = value; + } + + Length++; + } + + /// + /// Appends the text. + /// + public void Append(ReadOnlySpan text) + { + if (m_writing) + { + text.CopyTo(m_buffer[Length..]); + } + + Length += text.Length; + } + + /// + /// Appends the builder's content without materialising it. + /// + public void Append(StringBuilder text) + { + if (m_writing) + { + for (int ii = 0; ii < text.Length; ii++) + { + m_buffer[Length + ii] = text[ii]; + } + } + + Length += text.Length; + } + + /// + /// Appends the invariant decimal form of a non-negative number. + /// + public void AppendNumber(int value) + { + int digits = DigitCount(value); + + if (m_writing) + { + for (int ii = Length + digits - 1; ii >= Length; ii--) + { + m_buffer[ii] = (char)('0' + (value % 10)); + value /= 10; + } + } + + Length += digits; + } + + /// + /// The number of decimal digits a non-negative number is written + /// as. + /// + public static int DigitCount(int value) + { + int digits = 1; + + for (int rest = value; rest >= 10; rest /= 10) + { + digits++; + } + + return digits; + } + + private readonly Span m_buffer; + private readonly bool m_writing; + } + } +} diff --git a/src/Opc.Ua.Server/NodeManager/INodeIdFactoryProvider.cs b/src/Opc.Ua.Server/NodeManager/INodeIdFactoryProvider.cs new file mode 100644 index 0000000000..93ded43181 --- /dev/null +++ b/src/Opc.Ua.Server/NodeManager/INodeIdFactoryProvider.cs @@ -0,0 +1,63 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.Server +{ + /// + /// Supplies the that NodeManagers use + /// to mint NodeIds for nodes created at runtime. + /// + /// + /// implements this so that a factory + /// registered once in dependency injection reaches every + /// the server hosts, without each + /// NodeManager having to take a dependency on the service provider. + /// + public interface INodeIdFactoryProvider + { + /// + /// The factory the server was configured with, or null to let + /// each NodeManager use its own default. + /// + IRebasableNodeIdFactory? NodeIdFactory { get; } + + /// + /// Whether the server's NodeManagers refuse to mint a NodeId they + /// already gave a different browse path, or null to leave each + /// factory on its own default. + /// + /// + /// Server-wide rather than per NodeManager, because the record a + /// factory keeps to answer the question costs memory that grows with + /// the address space - a decision about the whole server, not about + /// one part of it. + /// + bool? DetectNodeIdCollisions { get; } + } +} diff --git a/src/Opc.Ua.Server/NodeManager/INodeManager.cs b/src/Opc.Ua.Server/NodeManager/INodeManager.cs index 76f6f6e4a9..16347e0251 100644 --- a/src/Opc.Ua.Server/NodeManager/INodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/INodeManager.cs @@ -828,7 +828,8 @@ namespace Opc.Ua.Server IDeleteMonitoredItemsAsyncNodeManager, IModifyMonitoredItemsAsyncNodeManager, ICreateMonitoredItemsAsyncNodeManager, - INodeManagementAsyncNodeManager + INodeManagementAsyncNodeManager, + INodeIdFactory { /// /// Resolves the effective for a call request. @@ -838,6 +839,30 @@ namespace Opc.Ua.Server CallMethodRequest methodToCall, CancellationToken cancellationToken = default); + /// + /// Registers a node, and everything below it, with the NodeManager so + /// it can be browsed and read. + /// + /// + /// Synchronous because the fluent authoring surface builds a graph + /// inside a caller's Configure delegate, which has nowhere to + /// await. A NodeManager that cannot register synchronously - one + /// wrapping a purely synchronous - is free + /// to make this a no-op. + /// + /// The node to register. + void AddNode(NodeState node); + + /// + /// Registers a node as a root notifier, so events it reports reach + /// subscribers of the Server object. + /// + /// + /// Synchronous for the same reason as . + /// + /// The notifier to register. + void AddRootNotifier(NodeState notifier); + /// /// Returns the NamespaceUris for the Nodes belonging to the NodeManager. /// diff --git a/src/Opc.Ua.Server/NodeManager/IRebasableNodeIdFactory.cs b/src/Opc.Ua.Server/NodeManager/IRebasableNodeIdFactory.cs new file mode 100644 index 0000000000..1af58923f0 --- /dev/null +++ b/src/Opc.Ua.Server/NodeManager/IRebasableNodeIdFactory.cs @@ -0,0 +1,161 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.Server +{ + /// + /// An that a NodeManager can point at its + /// own namespace and ask for a specific identifier style. + /// + /// + /// + /// A bare only mints an identifier for a + /// node that already exists. A NodeManager needs three things a factory + /// cannot express that way: which namespace to mint into, which + /// identifier style to mint, and an identifier for a node it has not + /// built yet. Those are the members here. + /// + /// + /// The namespace is the reason this is a separate contract rather than + /// configuration passed at construction. A namespace belongs to the + /// NodeManager, not to the node, and a NodeManager learns its own + /// namespace index only once the server's namespace table has been + /// extended - which is after a factory registered in dependency + /// injection was created. Implementations are therefore expected to be + /// immutable: and + /// return a view rather than mutating, so a + /// single registered instance can be shared by NodeManagers that own + /// different namespaces. + /// + /// + /// Implement this to decorate - to + /// special-case a subtree and delegate the rest, for instance. Assigning + /// such a decorator to + /// replaces overriding on the + /// NodeManager itself, which leaves the identifier rule in one object + /// instead of spread across the NodeManager. + /// + /// + public interface IRebasableNodeIdFactory : INodeIdFactory + { + /// + /// The identifier type this factory mints. + /// + NodeIdAssignmentMode Mode { get; } + + /// + /// The namespace index every minted NodeId belongs to. + /// + /// + /// Namespace 0 is the OPC UA namespace and never a namespace a + /// NodeManager mints into, so a factory reporting 0 is read as one + /// that has not been pointed at a namespace yet. + /// + ushort DefaultNamespaceIndex { get; } + + /// + /// Whether the factory refuses to mint an identifier it already gave + /// a different browse path. + /// + /// + /// Watching costs memory that grows with the address space, so it is + /// a server-wide decision rather than a per-NodeManager one. A mode + /// that cannot collide reports false whatever it was asked + /// for. + /// + bool DetectsCollisions { get; } + + /// + /// Returns a factory with the same that mints into + /// the specified namespace. + /// + /// The namespace to mint into. + /// + /// This instance when the namespace already matches, otherwise a copy. + /// + IRebasableNodeIdFactory WithDefaultNamespaceIndex(ushort defaultNamespaceIndex); + + /// + /// Returns a factory that mints into the same namespace using the + /// specified mode. + /// + /// The identifier type to mint. + /// + /// This instance when the mode already matches, otherwise a copy. + /// + IRebasableNodeIdFactory WithMode(NodeIdAssignmentMode mode); + + /// + /// Returns a factory that mints the same way and does or does not + /// watch for collisions. + /// + /// Whether to watch. + /// + /// This instance when the answer already matches, otherwise a copy. + /// + IRebasableNodeIdFactory WithCollisionDetection(bool detectCollisions); + + /// + /// Mints the next sequential NodeId. + /// + /// + /// A NodeManager falls back to this for a node whose browse path + /// cannot yield a deterministic identifier, and for a node that must + /// be given an identifier no other node has, whatever the + /// . + /// + /// A NodeId no other caller of this factory has seen. + NodeId NextCounterNodeId(); + + /// + /// Mints the NodeId for a child of the specified parent. + /// + /// + /// Takes a parent NodeId rather than a node, so that a caller can + /// learn the identifier a node will be given before building it - + /// when wiring a reference to a node the caller creates later, for + /// instance. + /// + /// + /// The parent NodeId, or a null NodeId for a node that is authored at + /// the root of the address space. + /// + /// The browse name of the child. + /// + /// The namespace index the minted NodeId belongs to. + /// + /// The namespace table to resolve URIs. + /// The minted NodeId. + NodeId CreateChildNodeId( + NodeId parentNodeId, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris); + } +} diff --git a/src/Opc.Ua.Server/Server/ServerInternalData.cs b/src/Opc.Ua.Server/Server/ServerInternalData.cs index dabb696631..252b223ce1 100644 --- a/src/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/src/Opc.Ua.Server/Server/ServerInternalData.cs @@ -71,7 +71,8 @@ namespace Opc.Ua.Server IServerEndpointRegistryProvider, IAsyncDisposable, ITimeProviderProvider, - ISecurityPolicyRegistryProvider + ISecurityPolicyRegistryProvider, + INodeIdFactoryProvider { /// /// Initializes the datastore with the server configuration. @@ -557,6 +558,41 @@ namespace Opc.Ua.Server UserManagement = userManagement ?? throw new ArgumentNullException(nameof(userManagement)); } + /// + public IRebasableNodeIdFactory? NodeIdFactory { get; private set; } + + /// + /// Binds the factory that NodeManagers mint runtime NodeIds with. + /// + /// + /// Bound before the NodeManagers are created, so that each one picks + /// the factory up in its constructor. Passing null leaves every + /// NodeManager on its own default. + /// + /// The factory, or null. + public void SetNodeIdFactory(IRebasableNodeIdFactory? nodeIdFactory) + { + ThrowIfBindPhaseComplete(); + NodeIdFactory = nodeIdFactory; + } + + /// + public bool? DetectNodeIdCollisions { get; private set; } + + /// + /// Binds the server-wide answer to whether NodeManagers watch for + /// NodeId collisions. + /// + /// + /// Whether to watch, or null to leave each factory on its own + /// default. + /// + public void SetNodeIdCollisionDetection(bool? detectNodeIdCollisions) + { + ThrowIfBindPhaseComplete(); + DetectNodeIdCollisions = detectNodeIdCollisions; + } + /// /// Refuses a bind once the server has finished starting. A subsystem that could be /// swapped underneath a running server would leave every component that already diff --git a/src/Opc.Ua.Server/Server/StandardServer.cs b/src/Opc.Ua.Server/Server/StandardServer.cs index 53d379f8bc..44f5bcd08a 100644 --- a/src/Opc.Ua.Server/Server/StandardServer.cs +++ b/src/Opc.Ua.Server/Server/StandardServer.cs @@ -91,6 +91,36 @@ namespace Opc.Ua.Server /// public INodeManagerLifecycle NodeManagerLifecycle { get; } + /// + /// Gets or sets the factory that every + /// this server hosts mints runtime NodeIds with. + /// + /// + /// Set before the server starts; leaving it null leaves each + /// NodeManager on its own default. A server composed through + /// dependency injection picks this up from the registered + /// . + /// + public IRebasableNodeIdFactory? NodeIdFactory { get; set; } + + /// + /// Gets or sets whether this server's NodeManagers refuse to mint a + /// NodeId they already gave a different browse path. + /// + /// + /// + /// Set before the server starts. Leaving it null leaves each + /// factory on , + /// which is on in a debug build and off otherwise. + /// + /// + /// This is a server-wide decision rather than a per-NodeManager one, + /// because the record a factory keeps to answer the question costs + /// memory that grows with the address space. + /// + /// + public bool? DetectNodeIdCollisions { get; set; } + /// /// Gets the active application configuration, failing if the server has not been configured. /// @@ -3684,6 +3714,9 @@ namespace Opc.Ua.Server TimeProvider, SecurityPolicyRegistry); + m_serverInternal.SetNodeIdFactory(NodeIdFactory); + m_serverInternal.SetNodeIdCollisionDetection(DetectNodeIdCollisions); + var historianRegistry = (Historian.HistorianProviderRegistry) m_serverInternal.HistorianRegistry; diff --git a/src/Opc.Ua.Types/State/NodeInstanceExtensions.cs b/src/Opc.Ua.Types/State/NodeInstanceExtensions.cs index 42a67b8ba1..d3abedcab5 100644 --- a/src/Opc.Ua.Types/State/NodeInstanceExtensions.cs +++ b/src/Opc.Ua.Types/State/NodeInstanceExtensions.cs @@ -66,6 +66,55 @@ namespace Opc.Ua "instance NodeId cannot be assigned."); } + /// + /// Creates an instance of a generated type and gives it, and every + /// node below it, per-instance NodeIds. + /// + /// + /// A generated state object is born carrying its own type's NodeId, + /// and + /// only replaces that when the caller already knows the identifier it + /// wants. Creating an instance and leaving it on the type's + /// identifier makes the predefined-node index replace the type with + /// the instance, silently, so this is the call to reach for when the + /// identifiers are the factory's to choose. + /// + /// + /// The system context supplying the NodeIdFactory. + /// + /// The instance to create. + /// The browse name of the instance. + /// + /// The display name, or a null value to derive it from the browse + /// name. + /// + /// + /// or is null. + /// + public static void CreateInstance( + this ISystemContext context, + NodeState node, + QualifiedName browseName, + LocalizedText displayName) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (node is null) + { + throw new ArgumentNullException(nameof(node)); + } + + // the subtree is built first and rebased afterwards, so the + // children are minted from the instance's identifier rather than + // from the type's. + node.Create(context, NodeId.Null, browseName, displayName, false); + + NodeId previousNodeId = context.AssignInstanceNodeId(node); + context.AssignInstanceChildNodeIds(node, previousNodeId); + } + /// /// Recursively assigns per-instance NodeIds to every descendant of /// using the active diff --git a/src/Opc.Ua.Vision.Server/VisionNodeManager.cs b/src/Opc.Ua.Vision.Server/VisionNodeManager.cs index 5d953e03ed..fca05e52f6 100644 --- a/src/Opc.Ua.Vision.Server/VisionNodeManager.cs +++ b/src/Opc.Ua.Vision.Server/VisionNodeManager.cs @@ -91,7 +91,12 @@ namespace Opc.Ua.Vision.Server m_registry = new VisionRegistry(); m_dispatcherLogger = server.Telemetry.CreateLogger(); m_dispatcher = new VisionMethodDispatcher(m_registry, m_dispatcherLogger); - SystemContext.NodeIdFactory = this; + + // mint into the server's own instance namespace. It is listed + // after the model namespaces, which belong to the loaded + // NodeSets and whose identifiers are not ours to hand out. + NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex( + GetInstanceNamespaceIndex(SystemContext)); RegisterEncodeables(SystemContext); } @@ -110,30 +115,6 @@ namespace Opc.Ua.Vision.Server /// public ArrayOf ServerProfiles => ComputeServerProfileArrayEntries(); - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - if (node == null) - { - throw new ArgumentNullException(nameof(node)); - } - if (node is BaseInstanceState instance && instance.Parent != null) - { - return new NodeId( - $"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}", - GetInstanceNamespaceIndex(context)); - } - if (node.NodeId.IsNull) - { - return new NodeId(Guid.NewGuid(), GetInstanceNamespaceIndex(context)); - } - return node.NodeId; - } - /// /// Creates a direct build context for non-DI configuration. /// @@ -417,7 +398,7 @@ namespace Opc.Ua.Vision.Server return root; } - private ushort GetInstanceNamespaceIndex(ISystemContext context) + private ushort GetInstanceNamespaceIndex(ServerSystemContext context) { return (ushort)context.NamespaceUris.GetIndex(m_options.InstanceNamespaceUri); } diff --git a/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs index 09fd4a0f9e..2ad740c1c0 100644 --- a/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs +++ b/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs @@ -71,9 +71,14 @@ namespace Opc.Ua.WotCon.Server Namespaces.WotCon) { m_options = options; - SystemContext.NodeIdFactory = this; AssetNamespaceIndex = (ushort)server.NamespaceUris.GetIndex(options.AssetNamespaceUri); WotConNamespaceIndex = (ushort)server.NamespaceUris.GetIndex(Namespaces.WotCon); + + // counter identifiers: assets are (re)discovered at runtime and + // reuse browse names across generations. + NodeIdFactory = NodeIdFactory + .WithMode(NodeIdAssignmentMode.Counter) + .WithDefaultNamespaceIndex(AssetNamespaceIndex); m_registry = new AssetRegistry(this, options, m_logger); } @@ -87,16 +92,6 @@ namespace Opc.Ua.WotCon.Server /// public ushort WotConNamespaceIndex { get; } - /// - public override NodeId New(ISystemContext context, NodeState node) - { - if (!node.NodeId.IsNull) - { - return node.NodeId; - } - return new NodeId((uint)Interlocked.Increment(ref m_nextDynamicId), AssetNamespaceIndex); - } - /// /// Registers an EventType materialised for a WoT event affordance. /// @@ -687,7 +682,6 @@ namespace Opc.Ua.WotCon.Server private readonly SemaphoreSlim m_writeLock = new(1, 1); private readonly Lock m_changeLock = new(); private WoTAssetConnectionManagementState? m_managementObject; - private long m_nextDynamicId = 1_000_000; } internal static partial class WotConnectivityNodeManagerLog diff --git a/tests/Opc.Ua.Di.Tests/PumpAddressSpaceComplianceTests.cs b/tests/Opc.Ua.Di.Tests/PumpAddressSpaceComplianceTests.cs index cb3b4ffa7e..234ec68683 100644 --- a/tests/Opc.Ua.Di.Tests/PumpAddressSpaceComplianceTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpAddressSpaceComplianceTests.cs @@ -632,9 +632,18 @@ namespace Opc.Ua.Di.Tests NodeId deviceSetId = DeviceSetNodeId(server); ushort instanceNamespaceIndex = (ushort)server.NamespaceUris.GetIndex( "urn:localhost:" + nameof(PumpAddressSpaceComplianceTests)); - return new NodeId( - deviceSetId.IdentifierAsString + "_" + pumpName, - instanceNamespaceIndex); + + // the pump's NodeId is whatever the manager's factory mints for a + // child of DeviceSet, so derive it the same way rather than + // restating a literal identifier format here. The mode is left at + // the default for the same reason: naming one here would make the + // test pass or fail on a server setting it does not control. + return new DefaultNodeIdFactory(defaultNamespaceIndex: instanceNamespaceIndex) + .CreateChildNodeId( + deviceSetId, + new QualifiedName(pumpName, instanceNamespaceIndex), + instanceNamespaceIndex, + server.NamespaceUris); } private static string PumpBrowseName(int pumpNumber) diff --git a/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs index f286b0cb53..c53948cfde 100644 --- a/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs @@ -64,8 +64,10 @@ namespace Opc.Ua.Di.Tests var externalReferences = new Dictionary>(); await m_manager.CreateAddressSpaceAsync(externalReferences).ConfigureAwait(false); + // the manager reports the identifiers it actually minted, so the + // test does not restate the factory's identifier format. m_pump = m_manager.FindPredefinedNode( - new NodeId("5001_Pump_1", m_manager.InstanceNamespaceIndex)); + m_manager.PumpNodeIds[0]); } [OneTimeTearDown] diff --git a/tests/Opc.Ua.Di.Tests/PumpHostedReferenceTests.cs b/tests/Opc.Ua.Di.Tests/PumpHostedReferenceTests.cs index 81adcd08ed..44bad8bc0c 100644 --- a/tests/Opc.Ua.Di.Tests/PumpHostedReferenceTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpHostedReferenceTests.cs @@ -160,12 +160,18 @@ namespace Opc.Ua.Di.Tests NodeId pump2Id = ExpandedNodeId.ToNodeId( clientPumpReferences.Single(reference => reference.BrowseName.Name == "Pump_2").NodeId, session.NamespaceUris); - var pump1PressureId = new NodeId( - pump1Id.IdentifierAsString + "_Operational_Measurements_DifferentialPressure", - pump1Id.NamespaceIndex); - var pump2PressureId = new NodeId( - pump2Id.IdentifierAsString + "_Operational_Measurements_DifferentialPressure", - pump2Id.NamespaceIndex); + NodeId pump1PressureId = await ResolveBrowsePathAsync( + session, + pump1Id, + "Operational", + "Measurements", + "DifferentialPressure").ConfigureAwait(false); + NodeId pump2PressureId = await ResolveBrowsePathAsync( + session, + pump2Id, + "Operational", + "Measurements", + "DifferentialPressure").ConfigureAwait(false); DataValue initialPump1 = await ReadGoodValueAsync( session, @@ -404,10 +410,12 @@ namespace Opc.Ua.Di.Tests NodeId dynamicPumpId = ExpandedNodeId.ToNodeId( dynamicPump!.NodeId, session.NamespaceUris); - var pressureId = new NodeId( - dynamicPumpId.IdentifierAsString + - "_Operational_Measurements_DifferentialPressure", - dynamicPumpId.NamespaceIndex); + NodeId pressureId = await ResolveBrowsePathAsync( + session, + dynamicPumpId, + "Operational", + "Measurements", + "DifferentialPressure").ConfigureAwait(false); // Joining the simulation is what turns the initial // BadWaitingForInitialData into a published value. @@ -486,6 +494,69 @@ namespace Opc.Ua.Di.Tests } } + /// + /// Resolves a browse path from a starting node over the session. + /// + /// + /// The server mints instance NodeIds through its + /// DefaultNodeIdFactory, so a client cannot construct one by + /// appending to its parent's identifier - it asks the server to + /// translate the browse path, as a real client would. + /// + private static async Task ResolveBrowsePathAsync( + Opc.Ua.Client.ISession session, + NodeId startNodeId, + params string[] browseNames) + { + NodeId current = startNodeId; + foreach (string browseName in browseNames) + { + ArrayOf nodesToBrowse = + [ + new BrowseDescription + { + NodeId = current, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + ResultMask = (uint)BrowseResultMask.All + } + ]; + + BrowseResponse response = await session.BrowseAsync( + null, + null, + 0, + nodesToBrowse, + CancellationToken.None).ConfigureAwait(false); + + NodeId match = NodeId.Null; + ArrayOf references = response.Results[0].References; + for (int ii = 0; ii < references.Count; ii++) + { + // matched on the name alone: the browse names come from + // several companion-spec namespaces. + if (references[ii].BrowseName.Name == browseName) + { + match = ExpandedNodeId.ToNodeId( + references[ii].NodeId, + session.NamespaceUris); + break; + } + } + + // NodeId carries its own null sentinel, so the wrapper would + // only prove a NodeId was assigned, not that it names a node. + Assert.That( + match.IsNull, + Is.False, + string.Join("/", browseNames) + " could not be resolved at " + browseName + "."); + current = match; + } + + return current; + } + private static async Task WaitForAsync( Func condition, TimeSpan timeout) diff --git a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs index 2f820500a2..be45cfe52a 100644 --- a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs @@ -414,9 +414,13 @@ namespace Opc.Ua.Di.Tests await m_manager.CreateAddressSpaceAsync(externalReferences).ConfigureAwait(false); m_configuredPump = m_manager.FindPredefinedNode( - new NodeId("5001_Pump_1", m_manager.InstanceNamespaceIndex)); + ExpectedChildId( + DeviceSetNodeId(), + new QualifiedName("Pump_1", m_manager.InstanceNamespaceIndex))); m_secondPump = m_manager.FindPredefinedNode( - new NodeId("5001_Pump_2", m_manager.InstanceNamespaceIndex)); + ExpectedChildId( + DeviceSetNodeId(), + new QualifiedName("Pump_2", m_manager.InstanceNamespaceIndex))); } [OneTimeTearDown] @@ -496,9 +500,9 @@ namespace Opc.Ua.Di.Tests "A Variable is published more than once per simulation tick."); foreach (PumpState pump in new[] { m_configuredPump!, m_secondPump! }) { - NodeId shaftAngle = new( - pump.NodeId.IdentifierAsString + "_ShaftAngle", - pump.NodeId.NamespaceIndex); + NodeId shaftAngle = ExpectedChildId( + pump.NodeId, + FindChildByName(pump, "ShaftAngle").BrowseName); Assert.That(published.Count(id => id == shaftAngle), Is.EqualTo(1), pump.BrowseName.Name + " must publish its shaft angle exactly once."); } @@ -517,19 +521,16 @@ namespace Opc.Ua.Di.Tests var offenders = new List(); foreach (PumpState pump in new[] { m_configuredPump!, m_secondPump! }) { - Assert.That(pump.NodeId.IdentifierAsString, Is.EqualTo( - "5001_" + pump.BrowseName.Name), + Assert.That( + pump.NodeId, + Is.EqualTo(ExpectedChildId(DeviceSetNodeId(), pump.BrowseName)), "The pump root must be minted from the DeviceSet parent."); foreach (PumpNode node in CollectSubtree(pump).Where(IsGeneratedHelperNode)) { - NodeId expected = new( - string.Format( - CultureInfo.InvariantCulture, - "{0}_{1}", - node.Parent.NodeId.IdentifierAsString, - node.State.SymbolicName), - m_manager!.InstanceNamespaceIndex); + NodeId expected = ExpectedChildId( + node.Parent.NodeId, + node.State.BrowseName); if (node.State.NodeId != expected) { offenders.Add($"{pump.BrowseName.Name}/{node.Path}: " + @@ -653,25 +654,72 @@ namespace Opc.Ua.Di.Tests Assert.That( pump.NodeId, - Is.EqualTo(new NodeId("5001_Pump_3", m_manager.InstanceNamespaceIndex))); + Is.EqualTo(ExpectedChildId( + deviceSet.NodeId, + new QualifiedName("Pump_3", m_manager.InstanceNamespaceIndex)))); Assert.That(pump.Identification, Is.Not.Null); Assert.That( ((NodeState)pump.Identification!).NodeId, - Is.EqualTo(new NodeId( - "5001_Pump_3_Identification", - m_manager.InstanceNamespaceIndex))); + Is.EqualTo(ExpectedChildId( + pump.NodeId, + ((NodeState)pump.Identification!).BrowseName))); List nodes = CollectSubtree(pump); Assert.That(nodes, Is.Not.Empty); Assert.That( nodes, - Has.All.Matches(node => node.State.NodeId == new NodeId( - string.Format( - CultureInfo.InvariantCulture, - "{0}_{1}", - node.Parent.NodeId.IdentifierAsString, - node.State.SymbolicName), - m_manager.InstanceNamespaceIndex))); + Has.All.Matches(node => node.State.NodeId + == ExpectedChildId(node.Parent.NodeId, node.State.BrowseName))); + } + + /// + /// The NodeId of the DI DeviceSet every pump hangs off. + /// + private NodeId DeviceSetNodeId() + { + return NodeId.Create( + global::Opc.Ua.Di.Objects.DeviceSet, + global::Opc.Ua.Di.Namespaces.OpcUaDi, + m_manager!.Server.NamespaceUris); + } + + /// + /// The NodeId the manager's factory mints for a child of the supplied + /// parent. + /// + /// + /// Derived rather than spelled out, so the assertion says "the id the + /// factory mints from this parent and browse name" instead of + /// restating one identifier format that the factory's mode can + /// change. + /// + private NodeId ExpectedChildId(NodeId parentNodeId, QualifiedName browseName) + { + return m_manager!.NodeIdFactory.CreateChildNodeId( + parentNodeId, + browseName, + m_manager.InstanceNamespaceIndex, + m_manager.Server.NamespaceUris); + } + + /// + /// Returns the direct child with the supplied browse name, ignoring + /// which namespace that name is qualified with. + /// + private BaseInstanceState FindChildByName(NodeState parent, string browseName) + { + var children = new List(); + parent.GetChildren(m_manager!.SystemContext, children); + foreach (BaseInstanceState child in children) + { + if (child.BrowseName.Name == browseName) + { + return child; + } + } + + Assert.Fail(browseName + " was not found."); + return null!; } private static bool IsGeneratedHelperNode(PumpNode node) diff --git a/tests/Opc.Ua.Di.Tests/PumpTypeInstanceTests.cs b/tests/Opc.Ua.Di.Tests/PumpTypeInstanceTests.cs index 91a783afee..cf9c8660b4 100644 --- a/tests/Opc.Ua.Di.Tests/PumpTypeInstanceTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpTypeInstanceTests.cs @@ -68,12 +68,12 @@ namespace Opc.Ua.Di.Tests global::Opc.Ua.Di.Objects.DeviceSet, global::Opc.Ua.Di.Namespaces.OpcUaDi, server.CurrentInstance.NamespaceUris)); + // the manager reports the identifiers it actually minted, so + // the test does not restate the factory's identifier format. PumpState pump1 = manager.FindPredefinedNode( - new NodeId( - "5001_Pump_1", - manager.InstanceNamespaceIndex)); + manager.PumpNodeIds[0]); PumpState pump = manager.FindPredefinedNode( - new NodeId("5001_Pump_2", manager.InstanceNamespaceIndex)); + manager.PumpNodeIds[1]); IReadOnlyList deviceSetReferences = await BrowseForwardAsync(manager, deviceSet.NodeId) .ConfigureAwait(false); diff --git a/tests/Opc.Ua.Di.Tests/TopologyElementBuilderTests.cs b/tests/Opc.Ua.Di.Tests/TopologyElementBuilderTests.cs index 7ffd9d6982..010f8ae2ee 100644 --- a/tests/Opc.Ua.Di.Tests/TopologyElementBuilderTests.cs +++ b/tests/Opc.Ua.Di.Tests/TopologyElementBuilderTests.cs @@ -133,16 +133,20 @@ namespace Opc.Ua.Di.Tests [Test] public async Task PumpMeasurementPropertiesAreBrowsableAndReadableAsync() { - const string measurementIdentifier = - "5001_Pump_1_Operational_Measurements_FluidTemperature"; - ushort namespaceIndex = m_manager.InstanceNamespaceIndex; - var measurementId = new NodeId(measurementIdentifier, namespaceIndex); - var engineeringUnitsId = new NodeId( - $"{measurementIdentifier}_EngineeringUnits", - namespaceIndex); - var euRangeId = new NodeId( - $"{measurementIdentifier}_EURange", - namespaceIndex); + // resolved by browse path rather than by a literal identifier, so + // the test does not depend on the factory's identifier format. + NodeState measurement = FindDescendant( + m_manager.FindPredefinedNode(m_manager.PumpNodeIds[0]), + "Operational", + "Measurements", + "FluidTemperature"); + NodeId measurementId = measurement.NodeId; + NodeId engineeringUnitsId = FindDescendant( + measurement, + Opc.Ua.BrowseNames.EngineeringUnits).NodeId; + NodeId euRangeId = FindDescendant( + measurement, + Opc.Ua.BrowseNames.EURange).NodeId; object handle = await m_manager.GetManagerHandleAsync(measurementId) .ConfigureAwait(false); @@ -276,6 +280,34 @@ namespace Opc.Ua.Di.Tests Is.EqualTo((uint)StatusCodes.BadTypeMismatch)); } + /// + /// Walks a browse path by name, ignoring which namespace each browse + /// name is qualified with. + /// + private NodeState FindDescendant(NodeState root, params string[] browseNames) + { + NodeState current = root; + foreach (string browseName in browseNames) + { + var children = new List(); + current.GetChildren(m_manager.SystemContext, children); + NodeState? match = null; + foreach (BaseInstanceState child in children) + { + if (child.BrowseName.Name == browseName) + { + match = child; + break; + } + } + + Assert.That(match, Is.Not.Null, browseName + " was not found."); + current = match!; + } + + return current; + } + private async Task AssertPropertyReadableAsync( NodeId nodeId, string browseName, diff --git a/tests/Opc.Ua.Robotics.Tests/IntentHostingCoverageTests.cs b/tests/Opc.Ua.Robotics.Tests/IntentHostingCoverageTests.cs index 33be7ef9f9..de9c646dee 100644 --- a/tests/Opc.Ua.Robotics.Tests/IntentHostingCoverageTests.cs +++ b/tests/Opc.Ua.Robotics.Tests/IntentHostingCoverageTests.cs @@ -423,9 +423,11 @@ namespace Opc.Ua.Robotics.Tests Assert.Multiple(() => { - Assert.That( - childId.IdentifierAsString, - Is.EqualTo($"{fixture.Manager.Root.NodeId.IdentifierAsString}_Child")); + // The child carries a SymbolicName but no BrowseName, so it + // has no browse path to derive a deterministic identifier + // from and the factory falls back to its counter. + Assert.That(childId.IsNull, Is.False); + Assert.That(childId.IdType, Is.EqualTo(IdType.Numeric)); Assert.That(generated.IsNull, Is.False); Assert.That(fixture.Manager.New(fixture.Manager.SystemContext, existing), Is.EqualTo(existing.NodeId)); Assert.That(RobotIntentNodeManager.NormalizeProviders(default).Count, Is.EqualTo(1)); diff --git a/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs b/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs index 25ad7f89e8..e19ff0e141 100644 --- a/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs +++ b/tests/Opc.Ua.Server.TestFramework/ServerFixture.cs @@ -59,6 +59,13 @@ namespace Opc.Ua.Server.TestFramework public int ReverseConnectTimeout { get; set; } public bool AllNodeManagers { get; set; } + /// + /// Whether the fixture's server refuses to mint a NodeId it already + /// gave a different browse path. On by default, so a test run + /// exercises the check whatever configuration the stack was built in. + /// + public bool DetectNodeIdCollisions { get; set; } = true; + public int TraceMasks { get; set; } = Utils.TraceMasks.Error | Utils.TraceMasks.StackTrace | @@ -398,6 +405,15 @@ namespace Opc.Ua.Server.TestFramework T server = m_factory(m_telemetry); server.TransportBindings = TransportBindingRegistry ?? TestTransportBindings.WithAllSchemes(); + if (server is StandardServer nodeIdCollisionServer) + { + // On for every test server whatever configuration the stack + // was built in. Off is the release default, so leaving it + // alone would mean a release test run never exercises the + // check and a collision would surface as a silently replaced + // node instead of a failing test. + nodeIdCollisionServer.DetectNodeIdCollisions = DetectNodeIdCollisions; + } if (AllNodeManagers && server is StandardServer standardServer) { Quickstarts.Servers.Utils.AddDefaultNodeManagers(standardServer); diff --git a/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs b/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs index b8da73ddba..29850c0872 100644 --- a/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs +++ b/tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs @@ -237,6 +237,223 @@ namespace Opc.Ua.Server.Tests /// /// Verifies that predefined-node registration completes the node's creation lifecycle. /// + [Test] + public void NodeIdFactoryDefaultsToTheDeterministicNumericForm() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + var acnm = (TestableAsyncCustomNodeManager)manager; + + Assert.Multiple(() => + { + Assert.That( + acnm.NodeIdFactory.Mode, + Is.EqualTo(NodeIdAssignmentMode.Numeric)); + Assert.That( + acnm.NodeIdFactory.DefaultNamespaceIndex, + Is.EqualTo(manager.NamespaceIndexes[0])); + }); + } + + [Test] + public void NodeIDFactoryMintsTheSameIdForTheSameBrowsePath() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + ServerSystemContext context = manager.SystemContext; + ushort namespaceIndex = manager.NamespaceIndexes[0]; + + NodeId first = manager.New(context, CreateNamedChild(namespaceIndex)); + NodeId second = manager.New(context, CreateNamedChild(namespaceIndex)); + + Assert.Multiple(() => + { + Assert.That(first.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(first.NamespaceIndex, Is.EqualTo(namespaceIndex)); + Assert.That(second, Is.EqualTo(first)); + }); + } + + [Test] + public void ReplacingTheNodeIdFactoryChangesTheMintedIdentifierType() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + var acnm = (TestableAsyncCustomNodeManager)manager; + ushort namespaceIndex = manager.NamespaceIndexes[0]; + + acnm.NodeIdFactory = new DefaultNodeIdFactory(NodeIdAssignmentMode.Numeric); + + NodeId nodeId = manager.New(manager.SystemContext, CreateNamedChild(namespaceIndex)); + + Assert.Multiple(() => + { + // the manager keeps the assigner pointed at its own namespace. + Assert.That( + acnm.NodeIdFactory.DefaultNamespaceIndex, + Is.EqualTo(namespaceIndex)); + Assert.That(nodeId.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(namespaceIndex)); + }); + } + + [Test] + public void DisablingTheNodeIdFactoryRejectsNamedNodes() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + var acnm = (TestableAsyncCustomNodeManager)manager; + ushort namespaceIndex = manager.NamespaceIndexes[0]; + + acnm.NodeIdFactory = new DefaultNodeIdFactory(NodeIdAssignmentMode.None); + + ServiceResultException exception = Assert.Throws( + () => manager.New(manager.SystemContext, CreateNamedChild(namespaceIndex))); + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void TheNodeIdFactoryCannotBeCleared() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + var acnm = (TestableAsyncCustomNodeManager)manager; + + Assert.Throws(() => acnm.NodeIdFactory = null); + } + + [Test] + public void ADecoratingNodeIdFactoryOverridesTheIdentifierForOneNode() + { + using ITestNodeManager manager = CreateManager(); + Assume.That(manager is TestableAsyncCustomNodeManager, "Requires AsyncCustomNodeManager features"); + var acnm = (TestableAsyncCustomNodeManager)manager; + ushort namespaceIndex = manager.NamespaceIndexes[0]; + + var reserved = new NodeId("reserved", namespaceIndex); + acnm.NodeIdFactory = new ReservingNodeIdFactory( + acnm.NodeIdFactory, + "Child", + reserved); + + NodeId claimed = manager.New(manager.SystemContext, CreateNamedChild(namespaceIndex)); + BaseObjectState other = CreateNamedChild(namespaceIndex); + other.BrowseName = new QualifiedName("Other", namespaceIndex); + NodeId delegated = manager.New(manager.SystemContext, other); + + Assert.Multiple(() => + { + Assert.That(claimed, Is.EqualTo(reserved)); + + // everything the decorator does not claim still reaches the + // factory it wraps, which is what makes decorating cheaper + // than overriding New(). + Assert.That(delegated, Is.Not.EqualTo(reserved)); + Assert.That(delegated.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(delegated.NamespaceIndex, Is.EqualTo(namespaceIndex)); + }); + } + + /// + /// A NodeId factory that hands one browse name a fixed identifier and + /// delegates every other node to the factory it wraps. + /// + /// + /// Stands in for the reason is + /// an interface: a caller can put its own rule in front of + /// without subclassing it and + /// without overriding New on the NodeManager. + /// + private sealed class ReservingNodeIdFactory : IRebasableNodeIdFactory + { + public ReservingNodeIdFactory( + IRebasableNodeIdFactory inner, + string browseName, + NodeId reserved) + { + m_inner = inner; + m_browseName = browseName; + m_reserved = reserved; + } + + public NodeIdAssignmentMode Mode => m_inner.Mode; + + public ushort DefaultNamespaceIndex => m_inner.DefaultNamespaceIndex; + + public bool DetectsCollisions => m_inner.DetectsCollisions; + + public NodeId New(ISystemContext context, NodeState node) + { + return node.BrowseName.Name == m_browseName + ? m_reserved + : m_inner.New(context, node); + } + + public IRebasableNodeIdFactory WithDefaultNamespaceIndex(ushort defaultNamespaceIndex) + { + return new ReservingNodeIdFactory( + m_inner.WithDefaultNamespaceIndex(defaultNamespaceIndex), + m_browseName, + m_reserved); + } + + public IRebasableNodeIdFactory WithMode(NodeIdAssignmentMode mode) + { + return new ReservingNodeIdFactory( + m_inner.WithMode(mode), + m_browseName, + m_reserved); + } + + public IRebasableNodeIdFactory WithCollisionDetection(bool detectCollisions) + { + return new ReservingNodeIdFactory( + m_inner.WithCollisionDetection(detectCollisions), + m_browseName, + m_reserved); + } + + public NodeId NextCounterNodeId() + { + return m_inner.NextCounterNodeId(); + } + + public NodeId CreateChildNodeId( + NodeId parentNodeId, + QualifiedName browseName, + ushort namespaceIndex, + NamespaceTable namespaceUris) + { + return m_inner.CreateChildNodeId( + parentNodeId, + browseName, + namespaceIndex, + namespaceUris); + } + + private readonly IRebasableNodeIdFactory m_inner; + private readonly string m_browseName; + private readonly NodeId m_reserved; + } + + /// + /// Creates a named child of a named parent, the shape the deterministic + /// assigner derives an identifier from. + /// + private static BaseObjectState CreateNamedChild(ushort namespaceIndex) + { + var parent = new BaseObjectState(null) + { + NodeId = new NodeId("Root", namespaceIndex) + }; + + return new BaseObjectState(parent) + { + BrowseName = new QualifiedName("Child", namespaceIndex) + }; + } + [Test] public async Task AddPredefinedNodeCompletesCreateLifecycleAsync() { @@ -9453,10 +9670,6 @@ namespace Opc.Ua.Server.Tests /// NodeState Find(NodeId nodeId); /// - /// Creates or resolves the identifier for a node in the selected manager's namespace. - /// - NodeId New(ISystemContext context, NodeState node); - /// /// Adds an instance under the specified parent and returns its node identifier. /// ValueTask AddNodeAsync(ServerSystemContext context, NodeId parentId, BaseInstanceState node, CancellationToken ct = default); @@ -9893,6 +10106,22 @@ namespace Opc.Ua.Server.Tests return m_cnm2.Find(nodeId)!; } + /// + /// Registers a node through the wrapped legacy node manager. + /// + public void AddNode(NodeState node) + { + m_cnm2.AddPredefinedNodePublic(m_cnm2.SystemContext, node); + } + + /// + /// Registers a root notifier through the wrapped legacy node manager. + /// + public void AddRootNotifier(NodeState notifier) + { + m_cnm2.AddRootNotifierPublic(notifier); + } + /// /// Creates or resolves a node identifier through the wrapped legacy node manager. /// diff --git a/tests/Opc.Ua.Server.Tests/DefaultNodeIdFactoryTests.cs b/tests/Opc.Ua.Server.Tests/DefaultNodeIdFactoryTests.cs new file mode 100644 index 0000000000..f5c5c8752d --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/DefaultNodeIdFactoryTests.cs @@ -0,0 +1,838 @@ +/* ======================================================================== + * 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.Globalization; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Server.Tests +{ + /// + /// Verifies that mints deterministic, + /// non-colliding NodeIds for every identifier type it supports. + /// + [TestFixture] + [Category("DefaultNodeIdFactory")] + [Parallelizable(ParallelScope.All)] + public class DefaultNodeIdFactoryTests + { + private const string kNamespaceUri = "http://test.org/UA/factory/"; + private const string kOtherNamespaceUri = "http://test.org/UA/Other/"; + private const ushort kNamespaceIndex = 1; + private const ushort kOtherNamespaceIndex = 2; + + private NamespaceTable m_namespaceUris; + private SystemContext m_context; + + [SetUp] + public void SetUp() + { + m_namespaceUris = new NamespaceTable(); + m_namespaceUris.Append(kNamespaceUri); + m_namespaceUris.Append(kOtherNamespaceUri); + + m_context = new SystemContext(NUnitTelemetryContext.Create()) + { + NamespaceUris = m_namespaceUris + }; + } + + [Test] + public void NewKeepsAnAuthoredNodeIdOnANodeThatStandsOnItsOwn() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + var authored = new NodeId("Authored", kNamespaceIndex); + var node = new BaseObjectState(null) + { + NodeId = authored, + BrowseName = new QualifiedName("Authored", kNamespaceIndex) + }; + + Assert.That(factory.New(m_context, node), Is.EqualTo(authored)); + } + + [Test] + public void NewRebasesAChildThatStillCarriesADeclarationNodeId() + { + // a child arrives here through AssignNodeIds, walking a subtree + // copied from a type declaration. The declaration lives in the + // model's own namespace, which is not this NodeManager's, so the + // identifier is not one the caller can have chosen. + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + var declaration = new NodeId("TypeDeclaration", kOtherNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + node.NodeId = declaration; + + NodeId nodeId = factory.New(m_context, node); + + Assert.Multiple(() => + { + Assert.That(nodeId, Is.Not.EqualTo(declaration)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + Assert.That(nodeId.IdentifierAsString, Does.Contain("Child")); + }); + } + + [Test] + public void NewKeepsAChildNodeIdThatIsAlreadyInThisNamespace() + { + // NodeState.Create hands the root the NodeId it was given and + // then runs the assignment pass over the subtree, so a caller + // naming a parented node explicitly arrives here with an + // identifier that is already ours. Re-minting it would rename + // the node out from under the caller. + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + var authored = new NodeId("Pump1.Events.Cavitation.Alarm", kNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + node.NodeId = authored; + + Assert.That(factory.New(m_context, node), Is.EqualTo(authored)); + } + + [Test] + public void NoneModeRefusesToMintAnIdentifier() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.None, kNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + + ServiceResultException exception = Assert.Throws( + () => factory.New(m_context, node)); + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + + exception = Assert.Throws( + () => factory.CreateChildNodeId( + new NodeId("Root", kNamespaceIndex), + new QualifiedName("Child", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris)); + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void StringModeMintsTheCanonicalPathVerbatim() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + + NodeId nodeId = factory.CreateChildNodeId( + new NodeId("Root", kNamespaceIndex), + new QualifiedName("Group1", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.Multiple(() => + { + Assert.That(nodeId.IdType, Is.EqualTo(IdType.String)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + // the parent segment carries the "s=" type prefix, so a + // string parent "Root" never reads as a numeric parent. + Assert.That( + nodeId.IdentifierAsString, + Is.EqualTo("v1:10:l:6:s=Root:10:l:6:Group1")); + }); + } + + [Test] + public void CanonicalPathIsNotAmbiguousOnSeparators() + { + // the classic "{parent}_{child}" convention gave both of these + // the identifier "A_B_C". + string first = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("A_B", kNamespaceIndex), + new QualifiedName("C", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + string second = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("A", kNamespaceIndex), + new QualifiedName("B_C", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.That(first, Is.Not.EqualTo(second)); + } + + [Test] + public void CanonicalPathPreservesTheParentIdentifierType() + { + string numericParent = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId(42u, kNamespaceIndex), + new QualifiedName("Child", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + string stringParent = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("42", kNamespaceIndex), + new QualifiedName("Child", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.That(numericParent, Is.Not.EqualTo(stringParent)); + } + + [Test] + public void CanonicalPathQualifiesForeignNamespacesByUri() + { + var parentNodeId = new NodeId("Root", kOtherNamespaceIndex); + + string path = DefaultNodeIdFactory.CreateCanonicalPath( + parentNodeId, + new QualifiedName("Child", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.That(path, Does.Contain(kOtherNamespaceUri)); + + // the same parent reached through a differently ordered namespace + // table keeps its identity. + var reordered = new NamespaceTable(); + reordered.Append(kOtherNamespaceUri); + reordered.Append(kNamespaceUri); + + string reorderedPath = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("Root", 1), + new QualifiedName("Child", 2), + 2, + reordered); + + Assert.That(reorderedPath, Is.EqualTo(path)); + } + + [Test] + public void CanonicalPathMarksBrowseNamesInNamespaceZero() + { + string path = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("Root", kNamespaceIndex), + new QualifiedName("EnabledState", 0), + kNamespaceIndex, + m_namespaceUris); + + Assert.Multiple(() => + { + Assert.That(path, Does.Contain("z:12:EnabledState")); + Assert.That(path, Does.Not.Contain("l:12:EnabledState")); + }); + } + + [Test] + public void CanonicalPathLeavesTheParentSegmentEmptyForARoot() + { + string path = DefaultNodeIdFactory.CreateCanonicalPath( + NodeId.Null, + new QualifiedName("Root", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.That(path, Is.EqualTo("v1:0::8:l:4:Root")); + } + + [Test] + public void NewTreatsTheObjectsFolderAsNoParent() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + BaseObjectState node = CreateChild(ObjectIds.ObjectsFolder, "Root"); + + NodeId nodeId = factory.New(m_context, node); + + Assert.That(nodeId.IdentifierAsString, Is.EqualTo("v1:0::8:l:4:Root")); + } + + [TestCase(NodeIdAssignmentMode.Numeric, IdType.Numeric)] + [TestCase(NodeIdAssignmentMode.String, IdType.String)] + [TestCase(NodeIdAssignmentMode.Guid, IdType.Guid)] + [TestCase(NodeIdAssignmentMode.Opaque, IdType.Opaque)] + public void EveryModeMintsItsOwnIdentifierType( + NodeIdAssignmentMode mode, + IdType expected) + { + var factory = new DefaultNodeIdFactory(mode, kNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + + NodeId nodeId = factory.New(m_context, node); + + Assert.Multiple(() => + { + Assert.That(nodeId.IdType, Is.EqualTo(expected)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + Assert.That(nodeId.IsNull, Is.False); + }); + } + + [TestCase(NodeIdAssignmentMode.Numeric)] + [TestCase(NodeIdAssignmentMode.String)] + [TestCase(NodeIdAssignmentMode.Guid)] + [TestCase(NodeIdAssignmentMode.Opaque)] + public void EveryModeIsStableAcrossAssigners(NodeIdAssignmentMode mode) + { + NodeId first = new DefaultNodeIdFactory(mode, kNamespaceIndex) + .New(m_context, CreateChild(new NodeId("Root", kNamespaceIndex), "Child")); + + NodeId second = new DefaultNodeIdFactory(mode, kNamespaceIndex) + .New(m_context, CreateChild(new NodeId("Root", kNamespaceIndex), "Child")); + + Assert.That(second, Is.EqualTo(first)); + } + + [TestCase(NodeIdAssignmentMode.Numeric)] + [TestCase(NodeIdAssignmentMode.String)] + [TestCase(NodeIdAssignmentMode.Guid)] + [TestCase(NodeIdAssignmentMode.Opaque)] + public void EveryModeSeparatesSiblingsAndBranches(NodeIdAssignmentMode mode) + { + var factory = new DefaultNodeIdFactory(mode, kNamespaceIndex); + var minted = new HashSet(NodeIdComparer.Default); + + for (int i = 0; i < 200; i++) + { + var parentNodeId = new NodeId( + "Root" + (i % 10).ToString(CultureInfo.InvariantCulture), + kNamespaceIndex); + BaseObjectState node = CreateChild( + parentNodeId, + "Child" + i.ToString(CultureInfo.InvariantCulture)); + + Assert.That( + minted.Add(factory.New(m_context, node)), + Is.True, + $"Duplicate NodeId minted in mode {mode}."); + } + } + + [Test] + public void GuidModeMarksTheIdentifierAsNameBased() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.Guid, kNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + + Assert.That( + factory.New(m_context, node).TryGetValue(out Guid identifier), + Is.True); + byte[] bytes = identifier.ToByteArray(); + + Assert.Multiple(() => + { + // ToByteArray writes the first three fields little endian, so + // the version nibble lands in byte 7 and the variant in byte 8. + Assert.That(bytes[7] & 0xF0, Is.EqualTo(0x80), "version"); + Assert.That(bytes[8] & 0xC0, Is.EqualTo(0x80), "variant"); + }); + } + + [Test] + public void OpaqueModeMintsSixteenBytes() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.Opaque, kNamespaceIndex); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + + Assert.That( + factory.New(m_context, node).TryGetValue(out ByteString identifier), + Is.True); + + Assert.That(identifier.Length, Is.EqualTo(16)); + } + + [Test] + public void EveryNodeIsMintedIntoTheConfiguredNamespace() + { + // neither the parent's namespace nor the browse name's decides + // this: a parent can belong to a companion-specification model + // whose identifiers are fixed by its NodeSet, and a browse name + // only names the type that declared the child. + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.String, + kNamespaceIndex); + BaseObjectState node = CreateChild( + new NodeId("Root", kOtherNamespaceIndex), + new QualifiedName("Child", 0)); + + NodeId nodeId = factory.New(m_context, node); + + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + } + + [Test] + public void ARootIsMintedIntoTheDefaultNamespace() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.String, + kOtherNamespaceIndex); + BaseObjectState node = CreateChild( + ObjectIds.ObjectsFolder, + new QualifiedName("Root", kNamespaceIndex)); + + NodeId nodeId = factory.New(m_context, node); + + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kOtherNamespaceIndex)); + } + + [Test] + public void WithDefaultNamespaceIndexRebasesWithoutMutating() + { + var original = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + kNamespaceIndex); + + DefaultNodeIdFactory rebased = original.WithDefaultNamespaceIndex( + kOtherNamespaceIndex); + + Assert.Multiple(() => + { + Assert.That(original.DefaultNamespaceIndex, Is.EqualTo(kNamespaceIndex)); + Assert.That(rebased.DefaultNamespaceIndex, Is.EqualTo(kOtherNamespaceIndex)); + Assert.That(rebased.Mode, Is.EqualTo(NodeIdAssignmentMode.Numeric)); + Assert.That( + original.WithDefaultNamespaceIndex(kNamespaceIndex), + Is.SameAs(original)); + }); + } + + [Test] + public void ANodeWithoutABrowseNameFallsBackToTheCounter() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + var node = new BaseObjectState(null); + + NodeId nodeId = factory.New(m_context, node); + + Assert.Multiple(() => + { + Assert.That(factory.HasDerivablePath(node), Is.False); + Assert.That(nodeId.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(nodeId.IsNull, Is.False); + }); + } + + [Test] + public void AParentWithoutANodeIdFallsBackToTheCounter() + { + // a transient parent gives the child no stable path, so deriving + // one would silently alias it onto a root. + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + BaseObjectState node = CreateChild(NodeId.Null, "Child"); + + NodeId nodeId = factory.New(m_context, node); + + Assert.Multiple(() => + { + Assert.That(factory.HasDerivablePath(node), Is.False); + Assert.That(nodeId.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + }); + } + + [Test] + public void CounterModeMintsUniqueNumericIdentifiers() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Counter, + kNamespaceIndex); + var minted = new HashSet(NodeIdComparer.Default); + + for (int i = 0; i < 500; i++) + { + // the same browse path every time: a counter is what keeps + // repeated paths distinct. + NodeId nodeId = factory.New( + m_context, + CreateChild(new NodeId("Root", kNamespaceIndex), "Child")); + + Assert.Multiple(() => + { + Assert.That(nodeId.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That(nodeId.NamespaceIndex, Is.EqualTo(kNamespaceIndex)); + Assert.That(minted.Add(nodeId), Is.True, "Duplicate NodeId minted."); + }); + } + } + + [Test] + public void CounterModeStaysClearOfAuthoredIdentifiers() + { + // a NodeManager usually mints into the namespace its NodeSet + // occupies. Identifiers below this bound belong to the model. + const uint authoredCeiling = 0x40000000; + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Counter, + kNamespaceIndex); + + for (int i = 0; i < 100; i++) + { + Assert.That( + factory.NextCounterNodeId().TryGetValue(out uint identifier), + Is.True); + Assert.That(identifier, Is.GreaterThanOrEqualTo(authoredCeiling)); + } + } + + [Test] + public void WithModeSwitchesTypeWithoutMutating() + { + var original = new DefaultNodeIdFactory( + NodeIdAssignmentMode.String, + kNamespaceIndex); + + DefaultNodeIdFactory switched = original.WithMode(NodeIdAssignmentMode.Counter); + + Assert.Multiple(() => + { + Assert.That(original.Mode, Is.EqualTo(NodeIdAssignmentMode.String)); + Assert.That(switched.Mode, Is.EqualTo(NodeIdAssignmentMode.Counter)); + Assert.That(switched.DefaultNamespaceIndex, Is.EqualTo(kNamespaceIndex)); + Assert.That( + original.WithMode(NodeIdAssignmentMode.String), + Is.SameAs(original)); + }); + } + + [Test] + public void ViewsOfOneFactoryShareOneCounterPerNamespace() + { + var registered = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Counter, + detectCollisions: false); + + // what two NodeManagers owning the same namespace get: each + // rebases the registered factory for itself. + DefaultNodeIdFactory first = registered + .WithDefaultNamespaceIndex(kNamespaceIndex); + DefaultNodeIdFactory second = registered + .WithDefaultNamespaceIndex(kNamespaceIndex); + + var minted = new HashSet(); + for (int ii = 0; ii < 50; ii++) + { + // Separate counters would overlap rather than merely differ: + // both views seed from the clock and are created moments + // apart, so they would hand out the same values. + Assert.That(minted.Add(first.NextCounterNodeId()), Is.True); + Assert.That(minted.Add(second.NextCounterNodeId()), Is.True); + } + + // identifiers in different namespaces cannot collide, so that + // namespace keeps its own counter rather than sharing this one. + DefaultNodeIdFactory other = registered + .WithDefaultNamespaceIndex(kOtherNamespaceIndex); + Assert.That( + other.NextCounterNodeId().NamespaceIndex, + Is.EqualTo(kOtherNamespaceIndex)); + } + + [Test] + public void ViewsOfOneFactoryAreCheckedAgainstEachOther() + { + var registered = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + detectCollisions: true); + + DefaultNodeIdFactory first = registered + .WithDefaultNamespaceIndex(kNamespaceIndex); + DefaultNodeIdFactory second = registered + .WithDefaultNamespaceIndex(kNamespaceIndex); + + var parent = new NodeId("Root", kNamespaceIndex); + NodeId minted = first.New(m_context, CreateChild(parent, "Child")); + + // the same path through the other view is a re-mint, not a + // collision, and gives the same answer. + Assert.That(second.New(m_context, CreateChild(parent, "Child")), Is.EqualTo(minted)); + } + + [Test] + public void CounterAllocationStaysOutOfTheWayOfHashedIdentifiers() + { + var registered = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + detectCollisions: true); + + // one namespace minting in both modes: a NodeManager that selects + // Counter shares the namespace's allocation state with one that + // did not, and both hand out numeric identifiers. + DefaultNodeIdFactory hashed = registered + .WithDefaultNamespaceIndex(kNamespaceIndex); + DefaultNodeIdFactory sequential = hashed + .WithMode(NodeIdAssignmentMode.Counter); + + var parent = new NodeId("Root", kNamespaceIndex); + var taken = new HashSet(); + + for (int ii = 0; ii < 200; ii++) + { + NodeId fromPath = hashed.New( + m_context, + CreateChild(parent, "Node" + ii.ToString(CultureInfo.InvariantCulture))); + Assert.That(taken.Add(fromPath), Is.True, "hashed identifier reissued"); + + // Counter reports DetectsCollisions false - it cannot collide + // with itself - but it still has to avoid what the hashing + // side already reserved, or registration would replace a node. + // + // Note this loop only catches a regression if a hash happens + // to land on a counter value, which is rare over so few draws: + // it documents the property rather than policing it. What + // makes the guarantee hold is that the reservation is + // unconditional on the requested policy, which the assertion + // below pins - a Counter view reserves despite reporting that + // it does not detect collisions. + Assert.That( + taken.Add(sequential.NextCounterNodeId()), + Is.True, + "counter identifier collided with a hashed one"); + } + + Assert.Multiple(() => + { + Assert.That(sequential.DetectsCollisions, Is.False); + Assert.That(sequential.Mode, Is.EqualTo(NodeIdAssignmentMode.Counter)); + + // the policy that drives the reservation came from the + // registered factory and survived the mode switch. + Assert.That( + sequential.WithMode(NodeIdAssignmentMode.Numeric).DetectsCollisions, + Is.True); + }); + } + + [Test] + public void TheCollisionPolicySurvivesAModeThatCannotCollide() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + kNamespaceIndex, + detectCollisions: true); + + Assert.Multiple(() => + { + // String cannot collide, so it does not watch... + Assert.That( + factory.WithMode(NodeIdAssignmentMode.String).DetectsCollisions, + Is.False); + + // ...but the answer was configured, not derived, so coming + // back to a mode that can collide restores it. + Assert.That( + factory.WithMode(NodeIdAssignmentMode.String) + .WithMode(NodeIdAssignmentMode.Numeric) + .DetectsCollisions, + Is.True); + }); + } + + [Test] + public void AStringIdentifierOverTheSpecLimitIsRefused() + { + var factory = new DefaultNodeIdFactory(NodeIdAssignmentMode.String, kNamespaceIndex); + + // the canonical path is longer than the parent identifier it + // encodes, so a parent near the limit expands past it. + var parent = new NodeId(new string('p', 4096), kNamespaceIndex); + + ServiceResultException exception = Assert.Throws( + () => factory.CreateChildNodeId( + parent, + new QualifiedName("C", kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris)); + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public void TheDefaultModeIsNumeric() + { + Assert.That(new DefaultNodeIdFactory().Mode, Is.EqualTo(NodeIdAssignmentMode.Numeric)); + } + + [Test] + public void APathLongerThanTheStackBufferIsBuiltIdentically() + { + // long enough to push the builder off the stack and onto the + // pool. The pooled buffer is rented, so it is longer than the + // path and carries whatever the previous tenant left behind - + // this pins that only the written part is read back. + string longName = new('n', 512); + + string path = DefaultNodeIdFactory.CreateCanonicalPath( + new NodeId("Root", kNamespaceIndex), + new QualifiedName(longName, kNamespaceIndex), + kNamespaceIndex, + m_namespaceUris); + + Assert.Multiple(() => + { + Assert.That(path, Does.EndWith(":512:" + longName)); + Assert.That(path, Does.StartWith("v1:10:l:6:s=Root:")); + Assert.That(path, Has.Length.EqualTo(path.TrimEnd('\0').Length)); + }); + } + + [Test] + public void MintingTheSamePathTwiceIsNotACollision() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + kNamespaceIndex, + detectCollisions: true); + BaseObjectState node = CreateChild(new NodeId("Root", kNamespaceIndex), "Child"); + + NodeId first = factory.New(m_context, node); + + // AssignNodeIds walks a subtree on every create pass, so the same + // node reaches the factory repeatedly. That must not read as two + // paths landing on one identifier. + node.NodeId = NodeId.Null; + NodeId second = factory.New(m_context, node); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + [Category("LongRunning")] + public void TwoBrowsePathsOnOneNumericIdentifierAreReported() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + kNamespaceIndex, + detectCollisions: true); + var parent = new NodeId("Root", kNamespaceIndex); + + // A 32 bit identifier is a birthday problem: distinct browse + // paths are expected to land on one after roughly 2^16 of them. + // That is the whole reason the guard exists, so the test provokes + // the real thing rather than a synthetic stand-in. Not finding + // one within this many is itself a failure - it would mean the + // identifiers are not spread over the space they claim to be. + ServiceResultException collision = null; + + for (int ii = 0; ii < kCollisionSearchLimit && collision is null; ii++) + { + try + { + factory.New( + m_context, + CreateChild(parent, "Node" + ii.ToString(CultureInfo.InvariantCulture))); + } + catch (ServiceResultException exception) + { + collision = exception; + } + } + + Assert.That( + collision, + Is.Not.Null, + "no two browse paths shared an identifier within the search limit"); + Assert.Multiple(() => + { + Assert.That(collision.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + + // the identifier alone says nothing about which node was + // refused, so the message has to carry the browse path. + Assert.That(collision.Message, Does.Contain("v1:")); + Assert.That(collision.Message, Does.Contain("NodeIdAssignmentMode.String")); + }); + } + + [Test] + public void AModeThatCannotCollideKeepsNoRecord() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.String, + kNamespaceIndex, + detectCollisions: true); + var parent = new NodeId("Root", kNamespaceIndex); + + NodeId first = factory.New(m_context, CreateChild(parent, "First")); + NodeId second = factory.New(m_context, CreateChild(parent, "Second")); + + Assert.Multiple(() => + { + // String keeps the whole path, so two paths can never share an + // identifier. Asking for the watch does not turn it on, so the + // mode pays nothing for a check that cannot fire. + Assert.That(factory.DetectsCollisions, Is.False); + Assert.That(second, Is.Not.EqualTo(first)); + }); + } + + [Test] + public void CollisionsGoUncheckedWhenTheServerTurnsTheWatchOff() + { + var factory = new DefaultNodeIdFactory( + NodeIdAssignmentMode.Numeric, + kNamespaceIndex, + detectCollisions: false); + + Assert.Multiple(() => + { + Assert.That(factory.DetectsCollisions, Is.False); + Assert.That( + factory.WithCollisionDetection(true).DetectsCollisions, + Is.True); + Assert.That( + factory.WithCollisionDetection(false), + Is.SameAs(factory)); + + // the answer follows the factory through a rebase, so a + // NodeManager that retargets its namespace does not quietly + // lose the server's decision. + Assert.That( + factory.WithCollisionDetection(true) + .WithDefaultNamespaceIndex(kOtherNamespaceIndex) + .DetectsCollisions, + Is.True); + }); + } + + /// + /// How many browse paths the collision test mints before giving up. + /// A 32 bit space is expected to collide within ~77k, so reaching + /// this bound without one would be evidence of a defect. + /// + private const int kCollisionSearchLimit = 1_000_000; + + /// + /// Creates a child of a parent that carries the specified NodeId. + /// + private static BaseObjectState CreateChild(NodeId parentNodeId, string browseName) + { + return CreateChild(parentNodeId, new QualifiedName(browseName, kNamespaceIndex)); + } + + /// + /// Creates a child of a parent that carries the specified NodeId. + /// + private static BaseObjectState CreateChild(NodeId parentNodeId, QualifiedName browseName) + { + var parent = new BaseObjectState(null) { NodeId = parentNodeId }; + + return new BaseObjectState(parent) { BrowseName = browseName }; + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/Fluent/AccessLevelBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/AccessLevelBuilderExtensionsTests.cs index 8cbc47f650..eeb349fa16 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/AccessLevelBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/AccessLevelBuilderExtensionsTests.cs @@ -99,7 +99,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs index 73c058279d..cbe2e500fc 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/AlarmBuilderExtensionsTests.cs @@ -90,7 +90,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, @@ -111,7 +111,11 @@ namespace Opc.Ua.Server.Tests.Fluent Assert.That(ab.Alarm, Is.Not.Null); Assert.That(ab.Alarm.BrowseName, Is.EqualTo(new QualifiedName("OverTemp", kNs))); Assert.That(ab.Alarm.Parent, Is.SameAs(root)); - Assert.That(ab.Alarm.NodeId.IdentifierAsString, Is.EqualTo("Root_OverTemp")); + // the identifier is the factory's canonical browse path, so the + // assertion names the path rather than restating its encoding. + Assert.That( + ab.Alarm.NodeId.IdentifierAsString, + Does.Contain("Root").And.Contain("OverTemp")); } [Test] diff --git a/tests/Opc.Ua.Server.Tests/Fluent/EngineeringUnitsBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/EngineeringUnitsBuilderExtensionsTests.cs index 707b1c1af4..6af7e350fc 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/EngineeringUnitsBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/EngineeringUnitsBuilderExtensionsTests.cs @@ -100,7 +100,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/FluentNodeRegistrationTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/FluentNodeRegistrationTests.cs index 5501932e19..cf08f6c791 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/FluentNodeRegistrationTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/FluentNodeRegistrationTests.cs @@ -59,9 +59,9 @@ namespace Opc.Ua.Server.Tests.Fluent INodeBuilder nb = h.Builder.Node(rootId); nb.WithProperty("NewProp", 42); - var createdId = new NodeId("Root_NewProp", ns); - Assert.That(h.Manager.PredefinedNodes.ContainsKey(createdId), Is.True); - var created = h.Manager.PredefinedNodes[createdId] as PropertyState; + // the NodeId is whatever the manager's factory minted, so the + // property is located by browse name rather than by a literal id. + PropertyState created = FindRegisteredProperty(h, "NewProp"); Assert.That(created, Is.Not.Null); Assert.That(created!.WrappedValue.AsBoxedObject(), Is.EqualTo(42)); Assert.That(created.DataType, Is.EqualTo(DataTypeIds.Int32)); @@ -76,9 +76,7 @@ namespace Opc.Ua.Server.Tests.Fluent INodeBuilder nb = h.Builder.Node(new NodeId("Root", ns)); nb.WithProperty("Flag", Variant.From(true), p => p.Writable()); - var createdId = new NodeId("Root_Flag", ns); - Assert.That(h.Manager.PredefinedNodes.ContainsKey(createdId), Is.True); - var created = h.Manager.PredefinedNodes[createdId] as PropertyState; + PropertyState created = FindRegisteredProperty(h, "Flag"); Assert.That(created, Is.Not.Null); Assert.That( created!.AccessLevel & AccessLevels.CurrentWrite, @@ -155,6 +153,28 @@ namespace Opc.Ua.Server.Tests.Fluent Is.SameAs(property)); } + /// + /// Returns the property the fluent surface registered under the + /// supplied browse name, whatever NodeId the manager minted for it. + /// + private static PropertyState FindRegisteredProperty(Harness h, string browseName) + { + foreach (NodeState node in h.Manager.PredefinedNodes.Values) + { + if (node is PropertyState property && + property.BrowseName.Name == browseName) + { + return property; + } + } + + // returning null here would surface as a NullReferenceException + // somewhere in the caller's assertions rather than as the real + // failure, which is that the property was never registered. + Assert.Fail($"No registered property named '{browseName}'."); + return null!; + } + private static Harness CreateHarness() { var mockServer = new Mock(); diff --git a/tests/Opc.Ua.Server.Tests/Fluent/FluentTestNodeManager.cs b/tests/Opc.Ua.Server.Tests/Fluent/FluentTestNodeManager.cs new file mode 100644 index 0000000000..321256968b --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Fluent/FluentTestNodeManager.cs @@ -0,0 +1,81 @@ +/* ======================================================================== + * 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 Moq; + +namespace Opc.Ua.Server.Tests.Fluent +{ + /// + /// Builds the stand-in NodeManager the fluent builder tests wire their + /// to. + /// + /// + /// The fluent surface mints NodeIds through its owning NodeManager, so a + /// bare mock would hand every node a null NodeId and the tests would be + /// exercising nothing. This mock answers with the same + /// a real NodeManager uses, which is + /// what makes the identifiers these tests assert the identifiers a server + /// would actually produce. + /// + internal static class FluentTestNodeManager + { + /// + /// Returns a NodeManager that mints deterministic NodeIds into the + /// supplied namespace. + /// + /// The namespace to mint into. + /// The stand-in NodeManager. + public static IAsyncNodeManager Create(ushort namespaceIndex) + { + return Create(new DefaultNodeIdFactory( + NodeIdAssignmentMode.String, + namespaceIndex)); + } + + /// + /// Returns a NodeManager that mints NodeIds through the supplied + /// factory. + /// + /// The factory to mint with. + /// The stand-in NodeManager. + public static IAsyncNodeManager Create(IRebasableNodeIdFactory factory) + { + var nodeManager = new Mock(); + + nodeManager + .Setup(manager => manager.New( + It.IsAny(), + It.IsAny())) + .Returns((ISystemContext context, NodeState node) + => factory.New(context, node)); + + return nodeManager.Object; + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs index eb9c4d0d51..16900799a6 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs @@ -175,7 +175,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - Mock.Of(), + FluentTestNodeManager.Create(kNs), kNs, q => roots.TryGetValue(q, out NodeState n) ? n : null, id => byId.TryGetValue(id, out NodeState n) ? n : null, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/InstanceCreationBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/InstanceCreationBuilderExtensionsTests.cs index 2eba4e3013..5073a58a0e 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/InstanceCreationBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/InstanceCreationBuilderExtensionsTests.cs @@ -74,7 +74,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, @@ -97,7 +97,9 @@ namespace Opc.Ua.Server.Tests.Fluent Assert.That(ib.Node.BrowseName, Is.EqualTo(new QualifiedName("Pump#2", kNs))); Assert.That(ib.Node.SymbolicName, Is.EqualTo("Pump#2")); Assert.That(ib.Node.Parent, Is.SameAs(root)); - Assert.That(ib.Node.NodeId.IdentifierAsString, Is.EqualTo("Root_Pump#2")); + Assert.That( + ib.Node.NodeId.IdentifierAsString, + Does.Contain("Root").And.Contain("Pump#2")); } [Test] @@ -433,7 +435,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: _ => null!, nodeIdResolver: _ => null!, @@ -550,7 +552,7 @@ namespace Opc.Ua.Server.Tests.Fluent SystemContext ctx = CreateContext(); var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: _ => null!, nodeIdResolver: _ => null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs index 5ee3f884df..36cd0b62e3 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderAuthoringTests.cs @@ -856,6 +856,100 @@ namespace Opc.Ua.Server.Tests.Fluent }); } + [Test] + public async Task ASubtreeMaterialisedFromAStandardTypeIsRebasedOffTheDeclarationIdsAsync() + { + // The same case as above, except the type is a standard one, so + // its declaration ids are in namespace 0. Those belong to the + // CoreNodeManager rather than to this manager's PredefinedNodes, + // where the declaration-collision check looks, so the namespace-0 + // test is the only thing that can catch them - on the root as + // much as on its children. + using var manager = new AuthoringTestManager(_ => { }); + ushort ns = manager.TestNamespaceIndex; + + NodeManagerBuilder builder = manager.CreateBuilder(); + var instance = new BaseObjectState(null) + { + NodeId = ObjectIds.Server_ServerCapabilities, + BrowseName = new QualifiedName("Capabilities", ns) + }; + var child = new BaseDataVariableState(instance) + { + NodeId = VariableIds.Server_ServerCapabilities_MaxBrowseContinuationPoints, + BrowseName = new QualifiedName("MaxBrowseContinuationPoints", ns), + DataType = DataTypeIds.UInt16, + ValueRank = ValueRanks.Scalar + }; + instance.AddChild(child); + + INodeBuilder staged = builder.Add(instance); + await manager.RegisterAsync(builder).ConfigureAwait(false); + + Assert.Multiple(() => + { + // the root is the half that regressed: exempting it from the + // namespace-0 test also disabled the test for every + // descendant, because the test read the root's namespace. + Assert.That( + staged.Node.NodeId.NamespaceIndex, + Is.EqualTo(ns), + "the root must be rebased off the namespace-0 declaration id"); + Assert.That( + child.NodeId.NamespaceIndex, + Is.EqualTo(ns), + "the child must be rebased off the namespace-0 declaration id"); + Assert.That(manager.ContainsPredefined(staged.Node.NodeId), Is.True); + Assert.That(manager.ContainsPredefined(child.NodeId), Is.True); + }); + } + + [Test] + public async Task AddObjectOnAParentQualifiesAStringNameWithTheBuilderNamespaceAsync() + { + NodeState? child = null; + + using var manager = new AuthoringTestManager(builder => + { + INodeBuilder folder = builder.AddFolder("Machines"); + child = folder.AddObject("Press").Node; + }); + await manager.BuildAsync().ConfigureAwait(false); + + Assert.That( + child!.BrowseName.NamespaceIndex, + Is.EqualTo(manager.TestNamespaceIndex), + "the string overload qualifies with the builder's own namespace"); + } + + [Test] + public void AddObjectOnAParentRefusesANamespaceZeroBrowseName() + { + using var manager = new AuthoringTestManager(_ => { }); + NodeManagerBuilder builder = manager.CreateBuilder(); + INodeBuilder folder = builder.AddFolder("Machines"); + + Assert.Multiple(() => + { + // namespace 0 is the OPC UA namespace, which no NodeManager + // owns, so authoring into it is refused rather than accepted + // and silently attributed to someone else. + ServiceResultException namespaceZero = Assert.Throws( + () => folder.AddObject(new QualifiedName("Press")))!; + Assert.That( + namespaceZero.StatusCode, + Is.EqualTo(StatusCodes.BadBrowseNameInvalid)); + + ServiceResultException empty = Assert.Throws( + () => folder.AddObject(default(QualifiedName)))!; + Assert.That(empty.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameInvalid)); + + ServiceResultException emptyString = Assert.Throws( + () => folder.AddObject(string.Empty))!; + Assert.That(emptyString.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameInvalid)); + }); + } + private static bool HasInverseReference( NodeState node, NodeId referenceTypeId, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs index 7f9f1c9949..7cd4a15da6 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/NodeManagerBuilderTests.cs @@ -94,7 +94,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, @@ -598,7 +598,7 @@ namespace Opc.Ua.Server.Tests.Fluent { return new NodeManagerBuilder( CreateContext(), - Mock.Of(), + FluentTestNodeManager.Create(kNs), kNs, _ => null, _ => null, @@ -622,7 +622,7 @@ namespace Opc.Ua.Server.Tests.Fluent { return new NodeManagerBuilder( CreateContext(), - Mock.Of(), + FluentTestNodeManager.Create(kNs), kNs, _ => null, _ => null, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/PropertyInitBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/PropertyInitBuilderExtensionsTests.cs index 92ff89c5f9..6bdebd3f42 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/PropertyInitBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/PropertyInitBuilderExtensionsTests.cs @@ -114,7 +114,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, @@ -343,7 +343,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs index 2199c2b284..fc06bd0b63 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/PublishTests.cs @@ -772,7 +772,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( manager.SystemContext, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, @@ -812,7 +812,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( manager.SystemContext, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/ReferenceBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/ReferenceBuilderExtensionsTests.cs index 96d1d85d23..3ccae5aed6 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/ReferenceBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/ReferenceBuilderExtensionsTests.cs @@ -102,7 +102,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, @@ -246,8 +246,8 @@ namespace Opc.Ua.Server.Tests.Fluent Assert.That(child.Node.BrowseName, Is.EqualTo(new QualifiedName("Group1", kNs))); Assert.That(child.Node.Parent, Is.SameAs(root)); Assert.That(child.Node.NodeId.IdentifierAsString, - Is.EqualTo("Root_Group1"), - "Generated NodeId should follow parentId_childName pattern."); + Does.Contain("Root").And.Contain("Group1"), + "The NodeId is minted from the parent and the browse name."); Assert.That(child.Node.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.BaseObjectType)); var children = new List(); @@ -286,13 +286,26 @@ namespace Opc.Ua.Server.Tests.Fluent } [Test] - public void AddObjectNullBrowseNameThrowsArgumentNullException() + public void AddObjectRejectsABrowseNameItCannotAuthorWith() { (NodeManagerBuilder b, _, _, _) = CreateBuilder(); INodeBuilder nb = b.Node(new NodeId("Root", kNs)); - Assert.Throws( - () => nb.AddObject(QualifiedName.Null)); + Assert.Multiple(() => + { + // a browse name failure is reported the same way here as on + // the builder's own Add methods, rather than as an argument + // exception from this one helper. + ServiceResultException missing = Assert.Throws( + () => nb.AddObject(QualifiedName.Null))!; + Assert.That(missing.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameInvalid)); + + ServiceResultException namespaceZero = Assert.Throws( + () => nb.AddObject(new QualifiedName("Press")))!; + Assert.That( + namespaceZero.StatusCode, + Is.EqualTo(StatusCodes.BadBrowseNameInvalid)); + }); } [Test] diff --git a/tests/Opc.Ua.Server.Tests/Fluent/RuntimeValueBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/RuntimeValueBuilderExtensionsTests.cs index 4544104c08..65a20a24dd 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/RuntimeValueBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/RuntimeValueBuilderExtensionsTests.cs @@ -255,7 +255,7 @@ namespace Opc.Ua.Server.Tests.Fluent NamespaceUris = CreateNamespaceTable() }; harness.BuildVariable(); - harness.Builder = harness.CreateBuilder(ctx, Mock.Of()); + harness.Builder = harness.CreateBuilder(ctx, FluentTestNodeManager.Create(kNs)); return harness; } diff --git a/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs index ec14dd36a3..12d162ba92 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests.cs @@ -284,7 +284,7 @@ namespace Opc.Ua.Server.Tests.Fluent { m_plainBuilder = new NodeManagerBuilder( new SystemContext(telemetry: null!) { NamespaceUris = new NamespaceTable() }, - Mock.Of(), + FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: _ => null!, nodeIdResolver: _ => null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/StateMachineBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/StateMachineBuilderExtensionsTests.cs index 42393ac46d..2d49083042 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/StateMachineBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/StateMachineBuilderExtensionsTests.cs @@ -96,7 +96,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, @@ -537,7 +537,7 @@ namespace Opc.Ua.Server.Tests.Fluent Assert.That(sm.StateMachine.Parent, Is.SameAs(parent.Node)); Assert.That(sm.StateMachine.NodeId.IdentifierAsString, - Is.EqualTo("Machine_Cycle")); + Does.Contain("Machine").And.Contain("Cycle")); } [Test] diff --git a/tests/Opc.Ua.Server.Tests/Fluent/SupervisionBuilderExtensionsTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/SupervisionBuilderExtensionsTests.cs index 3518b5da6e..9fedf6f8eb 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/SupervisionBuilderExtensionsTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/SupervisionBuilderExtensionsTests.cs @@ -90,7 +90,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState? n) ? n! : null!, nodeIdResolver: id => byId.TryGetValue(id, out NodeState? n) ? n! : null!, diff --git a/tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs b/tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs index 59fe9b2080..842241804f 100644 --- a/tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs +++ b/tests/Opc.Ua.Server.Tests/Fluent/TypedBuilderTests.cs @@ -103,7 +103,7 @@ namespace Opc.Ua.Server.Tests.Fluent var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, @@ -166,7 +166,7 @@ namespace Opc.Ua.Server.Tests.Fluent var byId = new Dictionary { [folder.NodeId] = folder }; var builder = new NodeManagerBuilder( ctx, - nodeManager: Mock.Of(), + nodeManager: FluentTestNodeManager.Create(kNs), defaultNamespaceIndex: kNs, rootResolver: q => roots.TryGetValue(q, out NodeState n) ? n : null, nodeIdResolver: id => byId.TryGetValue(id, out NodeState n) ? n : null, diff --git a/tests/Opc.Ua.Server.Tests/Hosting/FluentNodeManagerFactoryCoverageTests.cs b/tests/Opc.Ua.Server.Tests/Hosting/FluentNodeManagerFactoryCoverageTests.cs index fedc559100..d6d022c63b 100644 --- a/tests/Opc.Ua.Server.Tests/Hosting/FluentNodeManagerFactoryCoverageTests.cs +++ b/tests/Opc.Ua.Server.Tests/Hosting/FluentNodeManagerFactoryCoverageTests.cs @@ -253,6 +253,75 @@ namespace Opc.Ua.Server.Tests.Hosting ((IDisposable)manager).Dispose(); } + [Test] + public void AddNodeIdFactoryRegistersTheFactoryForEveryNodeManager() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "NodeIdFactoryServer"; + o.ApplicationUri = "urn:test:NodeIdFactoryServer"; + o.ProductUri = "urn:test:product"; + }) + .AddNodeIdFactory(NodeIdAssignmentMode.Guid); + + using ServiceProvider provider = services.BuildServiceProvider(); + var registered = provider.GetService(); + + Assert.That(registered, Is.Not.Null); + Assert.That(registered!.Mode, Is.EqualTo(NodeIdAssignmentMode.Guid)); + } + + [Test] + public void AddNodeIdFactoryReplacesAPreviousRegistration() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "NodeIdFactoryServer"; + o.ApplicationUri = "urn:test:NodeIdFactoryServer"; + o.ProductUri = "urn:test:product"; + }) + .AddNodeIdFactory(NodeIdAssignmentMode.Guid) + .AddNodeIdFactory(new DefaultNodeIdFactory(NodeIdAssignmentMode.Counter, 7)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var registered = provider.GetService(); + + Assert.Multiple(() => + { + Assert.That(registered!.Mode, Is.EqualTo(NodeIdAssignmentMode.Counter)); + Assert.That(registered.DefaultNamespaceIndex, Is.EqualTo(7)); + }); + } + + [Test] + public void AddNodeIdFactoryRejectsMissingArguments() + { + var services = new ServiceCollection(); + services.AddLogging(); + IOpcUaServerBuilder builder = services.AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "NodeIdFactoryServer"; + o.ApplicationUri = "urn:test:NodeIdFactoryServer"; + o.ProductUri = "urn:test:product"; + }); + + Assert.Multiple(() => + { + Assert.Throws( + () => builder.AddNodeIdFactory((IRebasableNodeIdFactory)null!)); + Assert.Throws( + () => ((IOpcUaServerBuilder)null!).AddNodeIdFactory( + NodeIdAssignmentMode.String)); + }); + } + [Test] public void AddNodeManagerExtensionRegistersAsyncNodeManagerFactory() { diff --git a/tests/Opc.Ua.Vision.Tests/VisionNodeManagerIntegrationTests.cs b/tests/Opc.Ua.Vision.Tests/VisionNodeManagerIntegrationTests.cs index e7ab6366f7..fb9572f904 100644 --- a/tests/Opc.Ua.Vision.Tests/VisionNodeManagerIntegrationTests.cs +++ b/tests/Opc.Ua.Vision.Tests/VisionNodeManagerIntegrationTests.cs @@ -112,11 +112,13 @@ namespace Opc.Ua.Vision.Tests } [Test] - public async Task NewNodeIdSynthesisesGuidWhenNoParentAndNoExistingNodeId() + public async Task NewNodeIdFallsBackToASequentialIdWhenThereIsNoBrowsePath() { await using var fixture = new VisionServerFixture(); await fixture.StartAsync().ConfigureAwait(false); + // no browse name, so there is no path for the deterministic + // factory to derive an identifier from. var orphan = new BaseObjectState(null) { NodeId = NodeId.Null, @@ -126,7 +128,10 @@ namespace Opc.Ua.Vision.Tests NodeId result = fixture.Manager.New(fixture.Manager.SystemContext, orphan); Assert.That(result.IsNull, Is.False); - Assert.That(result.IdType, Is.EqualTo(IdType.Guid)); + Assert.That(result.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That( + result.NamespaceIndex, + Is.EqualTo(fixture.Manager.NodeIdFactory.DefaultNamespaceIndex)); } [Test] @@ -135,22 +140,47 @@ namespace Opc.Ua.Vision.Tests await using var fixture = new VisionServerFixture(); await fixture.StartAsync().ConfigureAwait(false); + NodeId result = MintChild(fixture, "Parent", "Child"); + + Assert.Multiple(() => + { + Assert.That(result.IdType, Is.EqualTo(IdType.Numeric)); + Assert.That( + result.NamespaceIndex, + Is.EqualTo(fixture.Manager.NodeIdFactory.DefaultNamespaceIndex)); + + // A numeric identifier cannot be read back for the path it + // came from, so this asserts the property the path gives + // rather than its spelling: both halves of the path move the + // result, and the same path gives the same result. + Assert.That(MintChild(fixture, "OtherParent", "Child"), Is.Not.EqualTo(result)); + Assert.That(MintChild(fixture, "Parent", "OtherChild"), Is.Not.EqualTo(result)); + Assert.That(MintChild(fixture, "Parent", "Child"), Is.EqualTo(result)); + }); + } + + /// + /// Mints the NodeId the manager would give a child of the named + /// parent. + /// + private static NodeId MintChild( + VisionServerFixture fixture, + string parentName, + string browseName) + { var parent = new BaseObjectState(null) { - NodeId = new NodeId("Parent", fixture.Manager.NamespaceIndex), - SymbolicName = "Parent" + NodeId = new NodeId(parentName, fixture.Manager.NamespaceIndex), + SymbolicName = parentName }; var child = new BaseObjectState(parent) { NodeId = NodeId.Null, - SymbolicName = "Child" + SymbolicName = browseName, + BrowseName = new QualifiedName(browseName, fixture.Manager.NamespaceIndex) }; - NodeId result = fixture.Manager.New(fixture.Manager.SystemContext, child); - - Assert.That(result.IdType, Is.EqualTo(IdType.String)); - Assert.That(result.IdentifierAsString, Does.Contain("Parent")); - Assert.That(result.IdentifierAsString, Does.Contain("Child")); + return fixture.Manager.New(fixture.Manager.SystemContext, child); } [Test] diff --git a/tests/Opc.Ua.WotCon.Samples.Tests/WotPumpAddressSpaceComparisonTests.cs b/tests/Opc.Ua.WotCon.Samples.Tests/WotPumpAddressSpaceComparisonTests.cs index 5feeb15468..a87c13234a 100644 --- a/tests/Opc.Ua.WotCon.Samples.Tests/WotPumpAddressSpaceComparisonTests.cs +++ b/tests/Opc.Ua.WotCon.Samples.Tests/WotPumpAddressSpaceComparisonTests.cs @@ -87,7 +87,10 @@ namespace Opc.Ua.WotCon.Samples.Tests ushort wotPumpNs = ResolveNamespace(wotConnection.Session, kWotPumpNamespaceUri); var wotRoot = new NodeId("Pump1", wotPumpNs); - var nativeRoot = new NodeId("5001_Pump_1", 1); + NodeId nativeRoot = await ResolveNativePumpAsync( + nativeConnection.Session, + "Pump_1", + timeout.Token).ConfigureAwait(false); AddressSpaceTree wotTree = await CaptureTreeAsync( wotConnection.Session, wotRoot, @@ -151,6 +154,41 @@ namespace Opc.Ua.WotCon.Samples.Tests return checked((ushort)namespaceIndex); } + /// + /// Finds a pump under the DI DeviceSet by browse name. + /// + /// + /// The native server mints instance NodeIds through its + /// DefaultNodeIdFactory, so a client cannot spell one out - it + /// browses for the pump the way any client would. + /// + private static async Task ResolveNativePumpAsync( + ManagedSession session, + string browseName, + CancellationToken cancellationToken) + { + var deviceSetId = new NodeId( + global::Opc.Ua.Di.Objects.DeviceSet, + (ushort)session.NamespaceUris.GetIndex(global::Opc.Ua.Di.Namespaces.OpcUaDi)); + + ArrayOf references = + await BrowseHierarchicalReferencesAsync( + session, + deviceSetId, + cancellationToken).ConfigureAwait(false); + + foreach (ReferenceDescription reference in references.ToArray() ?? []) + { + if (reference.BrowseName.Name == browseName) + { + return ExpandedNodeId.ToNodeId(reference.NodeId, session.NamespaceUris); + } + } + + Assert.Fail($"'{browseName}' was not found under the DI DeviceSet."); + return NodeId.Null; + } + private static async Task CaptureTreeAsync( ManagedSession session, NodeId rootNodeId, diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index 23e270f0b2..bf072b4e55 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -647,6 +647,7 @@ namespace Opc.Ua.SourceGeneration EmitPassThroughProperty(writer, "global::Opc.Ua.ISystemContext", "Context"); EmitPassThroughProperty(writer, "global::Opc.Ua.Server.IAsyncNodeManager", "NodeManager"); EmitPassThroughProperty(writer, "global::Opc.Ua.Server.Fluent.IFluentDispatcher", "Dispatcher"); + EmitPassThroughProperty(writer, "ushort", "DefaultNamespaceIndex"); writer.WriteLine(); writer.WriteLine(" /// "); diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs index 66cc702e28..a808128493 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs @@ -76,7 +76,6 @@ namespace Opc.Ua.SourceGeneration global::Opc.Ua.ApplicationConfiguration configuration) : base(server, configuration, {{Tokens.NamespaceUri}}{{Tokens.AdditionalNamespaceUris}}) { - SystemContext.NodeIdFactory = this; } ///