/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Opc.Ua.Client { /// /// Address space browsing functionality. /// public class Browser { /// /// Creates new instance of a browser and attaches it to a session. /// public Browser( ITelemetryContext telemetry, BrowserOptions? options = null) { m_telemetry = telemetry; m_logger = m_telemetry.CreateLogger(); State = options ?? new BrowserOptions(); } /// /// Creates new instance of a browser and attaches it to a session. /// public Browser( ISessionClient session, BrowserOptions? options = null) { m_telemetry = session.MessageContext.Telemetry; m_logger = m_telemetry.CreateLogger(); State = options ?? new BrowserOptions(); Session = session; } /// /// Creates an unattached instance of a browser. /// [Obsolete("Use constructor with ISessionClient or ITelemetryContext.")] public Browser(BrowserOptions? options = null) { m_logger = m_telemetry.CreateLogger(); State = options ?? new BrowserOptions(); } /// /// Creates a copy of a browser. /// [Obsolete("Use the constructor that accepts BrowserOptions instead.")] public Browser(Browser template) { if (template == null) { throw new ArgumentNullException(nameof(template)); } m_logger = template.m_logger; m_telemetry = template.m_telemetry; State = template.State; Session = template.Session; ContinueUntilDone = template.ContinueUntilDone; } /// /// Browwser state /// public BrowserOptions State { get; private set; } /// /// The session that the browse is attached to. /// public ISessionClient? Session { get; set { if (value is ISession session) { MaxNodesPerBrowse = session.OperationLimits.MaxNodesPerBrowse; MaxBrowseContinuationPoints = session.ServerCapabilities.MaxBrowseContinuationPoints; ContinuationPointPolicy = session.ContinuationPointPolicy; } field = value; } } /// /// The view to use for the browse operation. /// public RequestHeader? RequestHeader { get => State.RequestHeader; set => State = State with { RequestHeader = value }; } /// /// The continuation point strategy to use /// public ContinuationPointPolicy ContinuationPointPolicy { get => State.ContinuationPointPolicy; set => State = State with { ContinuationPointPolicy = value }; } /// /// Max nodes to browse in a single operation /// public uint MaxNodesPerBrowse { get => State.MaxNodesPerBrowse; set => State = State with { MaxNodesPerBrowse = value }; } /// /// Max continuation point limit to use /// public ushort MaxBrowseContinuationPoints { get => State.MaxBrowseContinuationPoints; set => State = State with { MaxBrowseContinuationPoints = value }; } /// /// The view to use for the browse operation. /// public ViewDescription? View { get => State.View; set => State = State with { View = value }; } /// /// The maximum number of references to return in a single browse operation. /// public uint MaxReferencesReturned { get => State.MaxReferencesReturned; set => State = State with { MaxReferencesReturned = value }; } /// /// The direction to browse. /// public BrowseDirection BrowseDirection { get => State.BrowseDirection; set => State = State with { BrowseDirection = value }; } /// /// The reference type to follow. /// public NodeId ReferenceTypeId { get => State.ReferenceTypeId; set => State = State with { ReferenceTypeId = value }; } /// /// Whether subtypes of the reference type should be included. /// public bool IncludeSubtypes { get => State.IncludeSubtypes; set => State = State with { IncludeSubtypes = value }; } /// /// The classes of the target nodes. /// public uint NodeClassMask { get => Utils.ToUInt32(State.NodeClassMask); set => State = State with { NodeClassMask = Utils.ToInt32(value) }; } /// /// The results to return. /// public uint ResultMask { get => State.ResultMask; set => State = State with { ResultMask = value }; } /// /// Raised when a browse operation halted because of a continuation point. /// public event BrowserEventHandler MoreReferences { add => m_MoreReferences += value; remove => m_MoreReferences -= value; } /// /// Whether subsequent continuation points should be processed automatically. /// public bool ContinueUntilDone { get; set; } /// /// Browses the specified node. /// [Obsolete("Use BrowseAsync(NodeId) instead.")] public ArrayOf Browse(NodeId nodeId) { return BrowseAsync(nodeId).AsTask().GetAwaiter().GetResult(); } /// /// Browses the specified node. /// /// public async ValueTask> BrowseAsync( NodeId nodeId, CancellationToken ct = default) { BrowserOptions state = State; ISessionClient session = Session ?? throw new ServiceResultException( StatusCodes.BadServerNotConnected, "Cannot browse if not connected to a server."); // construct request. var nodeToBrowse = new BrowseDescription { NodeId = nodeId, BrowseDirection = state.BrowseDirection, ReferenceTypeId = state.ReferenceTypeId, IncludeSubtypes = state.IncludeSubtypes, NodeClassMask = Utils.ToUInt32(state.NodeClassMask), ResultMask = state.ResultMask }; ArrayOf nodesToBrowse = [nodeToBrowse]; // make the call to the server. BrowseResponse browseResponse = await session.BrowseAsync( state.RequestHeader, state.View, state.MaxReferencesReturned, nodesToBrowse, ct).ConfigureAwait(false); ArrayOf results = browseResponse.Results; ArrayOf diagnosticInfos = browseResponse.DiagnosticInfos; ResponseHeader responseHeader = browseResponse.ResponseHeader; // ensure that the server returned valid results. ClientBase.ValidateResponse(results, nodesToBrowse); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, nodesToBrowse); // check if valid. if (StatusCode.IsBad(results[0].StatusCode)) { throw ServiceResultException.Create( results[0].StatusCode, 0, diagnosticInfos, responseHeader.StringTable); } // fetch initial set of references. ByteString continuationPoint = results[0].ContinuationPoint; ArrayOf references = results[0].References; try { // process any continuation point. while (!continuationPoint.IsEmpty) { ArrayOf additionalReferences; if (!ContinueUntilDone && m_MoreReferences != null) { var args = new BrowserEventArgs(references); m_MoreReferences(this, args); // cancel browser and return the references fetched so far. if (args.Cancel) { session = Session; if (session != null) { (_, continuationPoint) = await BrowseNextAsync( session, continuationPoint, true, ct).ConfigureAwait(false); } return references; } ContinueUntilDone = args.ContinueUntilDone; } // See if the session was updated session = Session ?? throw new ServiceResultException( StatusCodes.BadServerNotConnected, "Cannot browse if not connected to a server."); (additionalReferences, continuationPoint) = await BrowseNextAsync( session, continuationPoint, false, ct).ConfigureAwait(false); if (!additionalReferences.IsEmpty) { references = references.AddItems(additionalReferences); } else { m_logger.LogWarning( "Browser: Continuation point exists, but the browse results are null/empty."); break; } } } catch (OperationCanceledException) when (!continuationPoint.IsEmpty) { session = Session; if (session != null) { (_, _) = await BrowseNextAsync( session, continuationPoint, true, default).ConfigureAwait(false); } } // return the results. return references; } /// /// 1. if, during a Browse or BrowseNext, service call one of the status codes /// BadNoContinuationPoint or BadContinuationPointInvalid is returned, /// the node is browsed again. /// 2. tries to avoid the status code BadNoContinuationPoint by creating /// packages of size at most MaxNodesPerBrowse, taken from the Operationlimits /// retrieved from the server at client startup. /// 3. with the help of a new property of the session the new method calls /// Browse for at most /// min(MaxNodesPerBrowse, MaxBrowseContinuationPoints) /// nodes in one call, to further reduce the risk of the status code /// BadNoContinuationPoint /// 4. calls BrowseNext, if necessary, before working on a new package. This is /// the reason that the packages have to be created directly in this method /// and package creation cannot be delegated to the SessionClientBatched class. /// This call sequence avoids the cannibalization of continuation points from /// previously worked on packages, at least if no concurrent browse operation /// is started. (The server is supposed to manage the continuation point quota /// on session level.The reference server, which is used in the tests, does /// this correctly). /// 5. the maximum number of browse continuation points is retrieved from /// the server capabilities in a new method, which may also be viewed as /// a prototype for evaluation of all server capabilities (this is not an /// operation limit). /// /// The nodes to browse /// Cancellation token to use /// /// public async ValueTask>> BrowseAsync( ArrayOf nodesToBrowse, CancellationToken ct = default) { BrowserOptions state = State; ISessionClient session = Session ?? throw new ServiceResultException( StatusCodes.BadServerNotConnected, "Cannot browse if not connected to a server."); int count = nodesToBrowse.Count; var result = new List>(count); var errors = new List(count); // first attempt for implementation: create the references for the output in advance. // optimize later, when everything works fine. for (int i = 0; i < nodesToBrowse.Count; i++) { result.Add([]); errors.Add(new ServiceResult(StatusCodes.Good)); } // in the first pass, we browse all nodes from the input. // Some nodes may need to be browsed again, these are then fed into the next pass. var nodesToBrowseForPass = new List(count); nodesToBrowseForPass.AddRange(nodesToBrowse); var resultForPass = new List>(count); resultForPass.AddRange(result); var errorsForPass = new List(count); errorsForPass.AddRange(errors); int passCount = 0; do { int badNoCPErrorsPerPass = 0; int badCPInvalidErrorsPerPass = 0; int otherErrorsPerPass = 0; uint maxNodesPerBrowse = MaxNodesPerBrowse; if (ContinuationPointPolicy == ContinuationPointPolicy.Balanced && MaxBrowseContinuationPoints > 0) { maxNodesPerBrowse = MaxBrowseContinuationPoints < maxNodesPerBrowse ? MaxBrowseContinuationPoints : maxNodesPerBrowse; } // split input into batches int batchOffset = 0; var nodesToBrowseForNextPass = new List(); var referenceDescriptionsForNextPass = new List>(); var errorsForNextPass = new List(); // loop over the batches foreach (ArrayOf nodesToBrowseBatch in nodesToBrowseForPass.ToArrayOf().Batch((int)maxNodesPerBrowse)) { int nodesToBrowseBatchCount = nodesToBrowseBatch.Count; ResultSet> results = await BrowseAsync( session, state.RequestHeader, state.View, nodesToBrowseBatch, state.MaxReferencesReturned, state.BrowseDirection, state.ReferenceTypeId, state.IncludeSubtypes, state.NodeClassMask, ct) .ConfigureAwait(false); int resultOffset = batchOffset; for (int ii = 0; ii < nodesToBrowseBatchCount; ii++) { StatusCode statusCode = results.Errors[ii].StatusCode; if (StatusCode.IsBad(statusCode)) { bool addToNextPass = false; if (statusCode == StatusCodes.BadNoContinuationPoints) { addToNextPass = true; badNoCPErrorsPerPass++; } else if (statusCode == StatusCodes.BadContinuationPointInvalid) { addToNextPass = true; badCPInvalidErrorsPerPass++; } else { otherErrorsPerPass++; } if (addToNextPass) { nodesToBrowseForNextPass.Add( nodesToBrowseForPass[resultOffset]); referenceDescriptionsForNextPass.Add( resultForPass[resultOffset]); errorsForNextPass.Add(errorsForPass[resultOffset]); } } resultForPass[resultOffset].Clear(); resultForPass[resultOffset].AddRange(results.Results[ii]); errorsForPass[resultOffset] = results.Errors[ii]; errors[resultOffset] = results.Errors[ii]; resultOffset++; } batchOffset += nodesToBrowseBatchCount; } resultForPass = referenceDescriptionsForNextPass; errorsForPass = errorsForNextPass; nodesToBrowseForPass = nodesToBrowseForNextPass; if (badCPInvalidErrorsPerPass > 0) { m_logger.LogDebug( "ManagedBrowse: in pass {Pass}, {Count} error(s) occured with a status code {StatusCode}.", passCount, badCPInvalidErrorsPerPass, nameof(StatusCodes.BadContinuationPointInvalid)); } if (badNoCPErrorsPerPass > 0) { m_logger.LogDebug( "ManagedBrowse: in pass {Pass}, {Count} error(s) occured with a status code {StatusCode}.", passCount, badNoCPErrorsPerPass, nameof(StatusCodes.BadNoContinuationPoints)); } if (otherErrorsPerPass > 0) { m_logger.LogDebug( "ManagedBrowse: in pass {Pass}, {Count} error(s) occured with a status code {StatusCode}.", passCount, otherErrorsPerPass, $"different from {nameof(StatusCodes.BadNoContinuationPoints)} or {nameof(StatusCodes.BadContinuationPointInvalid)}"); } if (otherErrorsPerPass == 0 && badCPInvalidErrorsPerPass == 0 && badNoCPErrorsPerPass == 0) { m_logger.LogTrace("ManagedBrowse completed with no errors."); } passCount++; } while (nodesToBrowseForPass.Count > 0); return ResultSet.From(result.ConvertAll(l => (ArrayOf)l), errors); } /// /// Browse the server address space returning results as an async stream. /// Automatically handles continuation points and releases them on /// disposal/cancellation. Uses the supplied /// verbatim — the caller controls /// the browse description (NodeId, BrowseDirection, ReferenceTypeId, /// IncludeSubtypes, NodeClassMask, ResultMask). /// /// The request header. /// The view to browse. /// The set of browse operations to perform. /// The cancellation token. /// An async stream of browse results. /// Thrown when the browser /// is not attached to a session. public async IAsyncEnumerable BrowseStreamAsync( RequestHeader? requestHeader, ViewDescription? view, ArrayOf nodesToBrowse, [EnumeratorCancellation] CancellationToken ct = default) { ISessionClient session = Session ?? throw new ServiceResultException( StatusCodes.BadServerNotConnected, "Cannot browse if not connected to a server."); BrowseResponse first = await session.BrowseAsync( requestHeader, view, 0, nodesToBrowse, ct).ConfigureAwait(false); ClientBase.ValidateResponse(first.Results, nodesToBrowse); ClientBase.ValidateDiagnosticInfos(first.DiagnosticInfos, nodesToBrowse); var continuationPoints = new List(); for (int i = 0; i < first.Results.Count; i++) { BrowseResult result = first.Results[i]; if (StatusCode.IsGood(result.StatusCode) && !result.ContinuationPoint.IsNull && result.ContinuationPoint.Length != 0) { if (result.References.Count > 0) { continuationPoints.Add(result.ContinuationPoint); } else { m_logger.LogWarning( "Browser: Server returned empty references but a " + "continuation point. Stopping to prevent denial of service."); yield return new BrowseResult { StatusCode = StatusCodes.BadNoData }; continue; } } yield return result; } try { while (continuationPoints.Count > 0) { BrowseNextResponse next = await session.BrowseNextAsync( requestHeader, false, continuationPoints.ToArrayOf(), ct).ConfigureAwait(false); ClientBase.ValidateResponse( next.Results, continuationPoints.ToArrayOf()); ClientBase.ValidateDiagnosticInfos( next.DiagnosticInfos, continuationPoints.ToArrayOf()); continuationPoints = []; for (int i = 0; i < next.Results.Count; i++) { BrowseResult result = next.Results[i]; if (StatusCode.IsGood(result.StatusCode) && !result.ContinuationPoint.IsNull && result.ContinuationPoint.Length != 0) { if (result.References.Count > 0) { continuationPoints.Add(result.ContinuationPoint); } else { m_logger.LogWarning( "Browser: Server returned empty references " + "but a continuation point. Stopping to prevent " + "denial of service."); yield return new BrowseResult { StatusCode = StatusCodes.BadNoData }; continue; } } yield return result; } } } finally { if (continuationPoints.Count > 0) { try { await session.BrowseNextAsync( requestHeader, true, continuationPoints.ToArrayOf(), default).ConfigureAwait(false); } catch (Exception ex) { m_logger.LogError(ex, "Browser: Failed to release continuation points."); } } } } /// /// Call the browse service asynchronously and call browse next, /// if applicable, immediately afterwards. Observe proper treatment /// of specific service results, specifically /// BadNoContinuationPoint and BadContinuationPointInvalid /// private static async ValueTask>> BrowseAsync( ISessionClient session, RequestHeader? requestHeader, ViewDescription? view, ArrayOf nodeIds, uint maxResultsToReturn, BrowseDirection browseDirection, NodeId referenceTypeId, bool includeSubtypes, int nodeClassMask, CancellationToken ct = default) { requestHeader?.RequestHandle = 0; ( _, ArrayOf continuationPoints, ArrayOf> referenceDescriptions, ArrayOf errors ) = await session.BrowseAsync( requestHeader, view, nodeIds, maxResultsToReturn, browseDirection, referenceTypeId, includeSubtypes, (uint)nodeClassMask, ct) .ConfigureAwait(false); var result = new List>(nodeIds.Count); result.AddRange(referenceDescriptions.ConvertAll(l => l.ToList()).ToList()); // process any continuation point. List> previousResults = [.. result]; var errorAnchors = new List>(); var previousErrors = new List>(); foreach (ServiceResult error in errors) { previousErrors.Add(new ReferenceWrapper { Reference = error }); errorAnchors.Add(previousErrors[^1]); } var nextContinuationPoints = new List(); var nextResults = new List>(); var nextErrors = new List>(); for (int ii = 0; ii < nodeIds.Count; ii++) { if (!continuationPoints[ii].IsEmpty && !StatusCode.IsBad(previousErrors[ii].Reference.StatusCode)) { nextContinuationPoints.Add(continuationPoints[ii]); nextResults.Add(previousResults[ii]); nextErrors.Add(previousErrors[ii]); } } while (nextContinuationPoints.Count > 0) { requestHeader?.RequestHandle = 0; ( _, ArrayOf revisedContinuationPoints, ArrayOf> browseNextResults, ArrayOf browseNextErrors ) = await session.BrowseNextAsync( requestHeader, nextContinuationPoints, false, ct).ConfigureAwait(false); for (int ii = 0; ii < browseNextResults.Count; ii++) { nextResults[ii].AddRange(browseNextResults[ii]); nextErrors[ii].Reference = browseNextErrors[ii]; } previousResults = nextResults; previousErrors = nextErrors; nextResults = []; nextErrors = []; nextContinuationPoints = []; for (int ii = 0; ii < revisedContinuationPoints.Count; ii++) { if (!revisedContinuationPoints[ii].IsEmpty && !StatusCode.IsBad(browseNextErrors[ii].StatusCode)) { nextContinuationPoints.Add(revisedContinuationPoints[ii]); nextResults.Add(previousResults[ii]); nextErrors.Add(previousErrors[ii]); } } } var finalErrors = new List(errorAnchors.Count); foreach (ReferenceWrapper errorReference in errorAnchors) { finalErrors.Add(errorReference.Reference); } return ResultSet.From(result.ConvertAll(l => (ArrayOf)l), finalErrors); } /// /// Fetches the next batch of references. /// /// /// The continuation point. /// if set to true the browse operation is cancelled. /// The cancellation token. /// The next batch of references /// private static async ValueTask<(ArrayOf, ByteString)> BrowseNextAsync( ISessionClient session, ByteString continuationPoint, bool cancel, CancellationToken ct = default) { ArrayOf continuationPoints = [continuationPoint]; // make the call to the server. BrowseNextResponse browseResponse = await session.BrowseNextAsync( null, cancel, continuationPoints, ct) .ConfigureAwait(false); ArrayOf results = browseResponse.Results; ArrayOf diagnosticInfos = browseResponse.DiagnosticInfos; ResponseHeader responseHeader = browseResponse.ResponseHeader; // ensure that the server returned valid results. ClientBase.ValidateResponse(results, continuationPoints); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, continuationPoints); // check if valid. if (StatusCode.IsBad(results[0].StatusCode)) { throw ServiceResultException.Create( results[0].StatusCode, 0, diagnosticInfos, responseHeader.StringTable); } // return references and continuation point if provided. return (results[0].References, results[0].ContinuationPoint); } /// /// Creates the browser from a persisted stream /// public static Browser? Load(Stream stream, ITelemetryContext telemetry) { var context = ServiceMessageContext.Create(telemetry); using var decoder = new BinaryDecoder(stream, context, true); ArrayOf nsUris = decoder.ReadStringArray(null); ArrayOf serverUris = decoder.ReadStringArray(null); // Namespace and server URI tables on the wire are encoded as nullable string // arrays, but the runtime contract requires non-null entries; the bangs reflect // that the decoder yields no null elements for these fields. context.NamespaceUris = new NamespaceTable(nsUris.Memory.ToArray()!); context.ServerUris = new StringTable(serverUris.Memory.ToArray()!); var options = new BrowserOptions(); options.Decode(decoder); return new Browser(telemetry, options); } /// /// Saves the state to the stream /// public void Save(Stream stream) { IServiceMessageContext context = AmbientMessageContext.CurrentContext ?? ServiceMessageContext.Create(m_telemetry); using var encoder = new BinaryEncoder(stream, context, true); encoder.WriteStringArray( null, context.NamespaceUris.ToArrayOf()); encoder.WriteStringArray( null, context.ServerUris.ToArrayOf()); State.Encode(encoder); } /// /// Used to pass on references to the Service results in the loop in ManagedBrowseAsync. /// /// private class ReferenceWrapper { public required T Reference { get; set; } } private readonly ILogger m_logger; private readonly ITelemetryContext? m_telemetry; private event BrowserEventHandler? m_MoreReferences; } /// /// The event arguments provided a browse operation returns a continuation point. /// public class BrowserEventArgs : EventArgs { /// /// Creates a new instance. /// internal BrowserEventArgs(ArrayOf references) { References = references; } /// /// Whether the browse operation should be cancelled. /// public bool Cancel { get; set; } /// /// Whether subsequent continuation points should be processed automatically. /// public bool ContinueUntilDone { get; set; } /// /// The references that have been fetched so far. /// public ArrayOf References { get; } } /// /// A delegate used to received browser events. /// public delegate void BrowserEventHandler(Browser sender, BrowserEventArgs e); }