/* ======================================================================== * 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.Linq; using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Client.FileSystem { /// /// A System.IO-style asynchronous client over the OPC UA /// file-system primitives defined in Part 5 §C and Part 20 §4 (the /// FileType, FileDirectoryType and /// TemporaryFileTransferType object types). /// /// /// /// A is rooted at any object of /// type FileDirectoryType (or a subtype thereof). The /// helper /// roots at the standard Server.FileSystem object /// (NodeId i=16314). Other directories — for example a /// vendor-specific location reached via Browse — can be passed to /// the regular constructor. /// /// /// Path syntax is documented on . Briefly: /// segments are s, separated by /// forward slashes ('/'), with optional /// "<ns>:" namespace prefix per segment. The empty /// string and "/" both mean "the root". /// /// /// All operations are async-only. When a server returns a /// well-known file-system status code /// (BadNoMatch, BadNotFound, BadNodeIdUnknown, /// BadBrowseNameDuplicated, BadUserAccessDenied, …) /// the failure is translated into the familiar System.IO /// equivalent (, /// , /// , /// ); other Bad codes propagate as /// per the existing OPC UA /// convention. /// /// public sealed class FileSystemClient { /// /// Creates a new rooted at the /// supplied . /// /// The OPC UA session used for all /// service calls. /// The NodeId of the /// FileDirectoryType instance to use as the root. Must /// not be null. /// Optional configuration; defaults are /// applied when null. public FileSystemClient( ISession session, NodeId rootDirectoryId, FileSystemClientOptions? options = null) { Session = session ?? throw new ArgumentNullException(nameof(session)); if (rootDirectoryId.IsNull) { throw new ArgumentNullException(nameof(rootDirectoryId)); } Options = (options ?? new FileSystemClientOptions()).Clone(); Options.Validate(); m_pathCache = new PathCache(Options.PathCacheSize); Root = new UaDirectoryInfo(this, parent: null, rootDirectoryId, kRootBrowseName, []); } /// /// Returns a rooted at the /// standard OPC UA Server.FileSystem object /// (NodeId i=16314). Servers that do not expose this /// object will fail on the first operation with /// . /// public static FileSystemClient OpenServerFileSystem( ISession session, FileSystemClientOptions? options = null) { return new FileSystemClient( session, ObjectIds.FileSystem, options); } /// /// The session used for all service calls. /// public ISession Session { get; } /// /// The (cloned, immutable) configuration. /// public FileSystemClientOptions Options { get; } /// /// The root directory. /// public UaDirectoryInfo Root { get; } // ---------------------------------------------------------------- // Public path-based API // ---------------------------------------------------------------- /// /// Resolves as a directory; throws /// if missing or if /// the resolved object is not a directory. /// public async ValueTask GetDirectoryAsync( string path, CancellationToken ct = default) { UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); if (info is UaDirectoryInfo dir) { return dir; } throw FileSystemErrors.NotFound(path, targetIsDirectory: true); } /// /// Resolves as a file; throws /// if missing or if the /// resolved object is not a file. /// public async ValueTask GetFileAsync( string path, CancellationToken ct = default) { UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); if (info is UaFileInfo file) { return file; } throw FileSystemErrors.NotFound(path, targetIsDirectory: false); } /// /// Resolves and returns the matching /// info object, or null when nothing exists. /// public async ValueTask GetInfoAsync( string path, CancellationToken ct = default) { QualifiedName[] segments = UaPath.Parse(path); if (segments.Length == 0) { return Root; } ResolvedNode? resolved = await ResolveSegmentsAsync(segments, throwOnMissing: false, ct) .ConfigureAwait(false); if (resolved == null) { return null; } return await BuildInfoAsync(resolved.Value, segments, ct).ConfigureAwait(false); } /// /// Returns true when something (file or directory) /// exists at . /// public async ValueTask ExistsAsync(string path, CancellationToken ct = default) { return await GetInfoAsync(path, ct).ConfigureAwait(false) != null; } /// /// Returns true when a file exists at /// . /// public async ValueTask FileExistsAsync(string path, CancellationToken ct = default) { UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); return info is UaFileInfo; } /// /// Returns true when a directory exists at /// . /// public async ValueTask DirectoryExistsAsync(string path, CancellationToken ct = default) { UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); return info is UaDirectoryInfo; } /// /// Creates the directory at , including /// any missing intermediate directories when /// is true. /// /// The target path. /// When true, missing /// parent directories are created in turn. /// Cancellation token. /// /// public async ValueTask CreateDirectoryAsync( string path, bool createIntermediate = true, CancellationToken ct = default) { QualifiedName[] segments = UaPath.Parse(path); if (segments.Length == 0) { return Root; } UaDirectoryInfo current = Root; for (int i = 0; i < segments.Length; i++) { bool isLast = i == segments.Length - 1; QualifiedName segment = segments[i]; NodeId? childId = await TryResolveSingleAsync(current.NodeId, segment, ct) .ConfigureAwait(false); if (childId != null) { UaFileSystemInfo info = await BuildInfoAsync( new ResolvedNode(childId.Value, segment), [.. segments.Take(i + 1)], ct).ConfigureAwait(false); if (info is UaDirectoryInfo dir) { current = dir; continue; } throw new IOException( $"Cannot create directory '{path}': '{string.Join("/", segments.Take(i + 1).Select(UaPath.FormatSegment))}' is not a directory."); } if (!createIntermediate && !isLast) { throw FileSystemErrors.NotFound( UaPath.Format([.. segments.Take(i + 1)]), targetIsDirectory: true); } if (segment.NamespaceIndex != 0) { throw new ArgumentException( $"Cannot create '{UaPath.FormatSegment(segment)}': leaf segments must not include a namespace prefix; the server picks the BrowseName namespace.", nameof(path)); } current = await CreateDirectoryInAsync(current, segment.Name!, ct) .ConfigureAwait(false); } return current; } /// /// Creates the file at , optionally /// creating any missing intermediate directories. The server /// is asked NOT to immediately open the file /// (requestFileOpen: false). /// /// public async ValueTask CreateFileAsync( string path, bool createIntermediate = true, CancellationToken ct = default) { QualifiedName[] segments = UaPath.Parse(path); if (segments.Length == 0) { throw new ArgumentException("Cannot create a file at the root path.", nameof(path)); } UaDirectoryInfo parent; if (segments.Length == 1) { parent = Root; } else { string parentPath = UaPath.Format([.. segments.Take(segments.Length - 1)]); if (createIntermediate) { parent = await CreateDirectoryAsync(parentPath, true, ct).ConfigureAwait(false); } else { parent = await GetDirectoryAsync(parentPath, ct).ConfigureAwait(false); } } QualifiedName leaf = segments[^1]; if (leaf.NamespaceIndex != 0) { throw new ArgumentException( $"Cannot create file '{UaPath.FormatSegment(leaf)}': leaf segments must not include a namespace prefix; the server picks the BrowseName namespace.", nameof(path)); } return await CreateFileInAsync(parent, leaf.Name!, ct).ConfigureAwait(false); } /// /// Deletes the file or directory at . /// When is true and the /// target is a directory, the server's recursive /// Delete primitive is invoked (no client-side /// traversal). /// public async ValueTask DeleteAsync( string path, bool recursive = false, CancellationToken ct = default) { UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false) ?? throw FileSystemErrors.NotFound(path, targetIsDirectory: false); await DeleteCoreAsync(info, recursive, ct).ConfigureAwait(false); } /// /// Moves a file or directory from /// to . /// public async ValueTask MoveAsync( string srcPath, string destPath, CancellationToken ct = default) { UaFileSystemInfo? src = await GetInfoAsync(srcPath, ct).ConfigureAwait(false) ?? throw FileSystemErrors.NotFound(srcPath, targetIsDirectory: false); return await MoveOrCopyAsync(src, destPath, copy: false, ct).ConfigureAwait(false); } /// /// Copies a file or directory from /// to . /// public async ValueTask CopyAsync( string srcPath, string destPath, CancellationToken ct = default) { UaFileSystemInfo? src = await GetInfoAsync(srcPath, ct).ConfigureAwait(false) ?? throw FileSystemErrors.NotFound(srcPath, targetIsDirectory: false); return await MoveOrCopyAsync(src, destPath, copy: true, ct).ConfigureAwait(false); } /// /// Opens the file at for reading. /// public async ValueTask OpenReadAsync( string path, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.OpenReadAsync(ct).ConfigureAwait(false); } /// /// Opens the file at for writing /// (truncates). /// public async ValueTask OpenWriteAsync( string path, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.OpenWriteAsync(ct).ConfigureAwait(false); } /// /// Opens the file at for appending. /// public async ValueTask OpenAppendAsync( string path, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.OpenAppendAsync(ct).ConfigureAwait(false); } /// /// Opens the file at with the supplied /// . /// public async ValueTask OpenAsync( string path, UaFileMode mode, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.OpenAsync(mode, ct).ConfigureAwait(false); } /// /// Reads the entire file at into /// memory. /// public async ValueTask ReadAllBytesAsync( string path, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.ReadAllBytesAsync(ct).ConfigureAwait(false); } /// /// Reads the entire file at as text. /// public async ValueTask ReadAllTextAsync( string path, Encoding? encoding = null, CancellationToken ct = default) { UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); await file.RefreshAsync(ct).ConfigureAwait(false); return await file.ReadAllTextAsync(encoding, ct).ConfigureAwait(false); } /// /// Truncates and overwrites the file at /// with . Creates the file (and any /// missing intermediate directories) when it does not exist. /// public async ValueTask WriteAllBytesAsync( string path, ReadOnlyMemory bytes, CancellationToken ct = default) { UaFileInfo file = await GetOrCreateFileAsync(path, ct).ConfigureAwait(false); await file.WriteAllBytesAsync(bytes, ct).ConfigureAwait(false); } /// /// Truncates and overwrites the file at /// with . Creates the file (and any /// missing intermediate directories) when it does not exist. /// public async ValueTask WriteAllTextAsync( string path, string contents, Encoding? encoding = null, CancellationToken ct = default) { UaFileInfo file = await GetOrCreateFileAsync(path, ct).ConfigureAwait(false); await file.WriteAllTextAsync(contents, encoding, ct).ConfigureAwait(false); } /// /// Enumerates the immediate children of the directory at /// . /// public async IAsyncEnumerable EnumerateAsync( string path = UaPath.Root, [EnumeratorCancellation] CancellationToken ct = default) { UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); await foreach (UaFileSystemInfo child in dir.EnumerateAsync(ct).ConfigureAwait(false)) { yield return child; } } /// /// Enumerates the immediate file children of the directory at /// . /// public async IAsyncEnumerable EnumerateFilesAsync( string path = UaPath.Root, [EnumeratorCancellation] CancellationToken ct = default) { UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); await foreach (UaFileInfo child in dir.EnumerateFilesAsync(ct).ConfigureAwait(false)) { yield return child; } } /// /// Enumerates the immediate directory children of the /// directory at . /// public async IAsyncEnumerable EnumerateDirectoriesAsync( string path = UaPath.Root, [EnumeratorCancellation] CancellationToken ct = default) { UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); await foreach (UaDirectoryInfo child in dir.EnumerateDirectoriesAsync(ct) .ConfigureAwait(false)) { yield return child; } } // ---------------------------------------------------------------- // Internal helpers (called from UaFileSystemInfo / Ua*Info) // ---------------------------------------------------------------- internal async IAsyncEnumerable EnumerateChildrenAsync( UaDirectoryInfo directory, bool includeFiles, bool includeDirectories, [EnumeratorCancellation] CancellationToken ct = default) { await EnsureTypeTreeFetchedAsync(ct).ConfigureAwait(false); ITypeTable typeTree = Session.TypeTree; ByteString continuation; ArrayOf references; (_, continuation, references) = await Session.BrowseAsync( requestHeader: null, view: null, directory.NodeId, maxResultsToReturn: 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, includeSubtypes: true, (uint)NodeClass.Object, ct).ConfigureAwait(false); while (true) { // Materialise to an array first — ReadOnlySpan.Enumerator // (returned by ArrayOf.GetEnumerator) cannot cross an // async iterator's `yield return` boundary. var snapshot = new ReferenceDescription[references.Count]; for (int i = 0; i < references.Count; i++) { snapshot[i] = references[i]; } foreach (ReferenceDescription reference in snapshot) { UaFileSystemInfo? info = TryClassifyChild( directory, reference, typeTree, includeFiles, includeDirectories); if (info != null) { yield return info; } } if (continuation.IsNull || continuation.Length == 0) { yield break; } (_, continuation, references) = await Session.BrowseNextAsync( requestHeader: null, releaseContinuationPoint: false, continuation, ct).ConfigureAwait(false); } } internal async ValueTask CreateDirectoryInAsync( UaDirectoryInfo parent, string name, CancellationToken ct) { ValidateLeafName(name); NodeId newId; try { newId = await parent.Proxy.CreateDirectoryAsync(name, ct).ConfigureAwait(false); } catch (ServiceResultException ex) { throw FileSystemErrors.Translate(ex, parent.FullPath + "/" + name, targetIsDirectory: true); } QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); m_pathCache.InvalidateChildrenOf(parent.NodeId); m_pathCache.Put(parent.NodeId, actualName, newId); return new UaDirectoryInfo( this, parent, newId, actualName, AppendSegment(parent.Segments, actualName)); } internal async ValueTask CreateFileInAsync( UaDirectoryInfo parent, string name, CancellationToken ct) { ValidateLeafName(name); NodeId newId; try { (newId, _) = await parent.Proxy .CreateFileAsync(name, requestFileOpen: false, ct) .ConfigureAwait(false); } catch (ServiceResultException ex) { throw FileSystemErrors.Translate(ex, parent.FullPath + "/" + name, targetIsDirectory: false); } QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); m_pathCache.InvalidateChildrenOf(parent.NodeId); m_pathCache.Put(parent.NodeId, actualName, newId); return new UaFileInfo( this, parent, newId, actualName, AppendSegment(parent.Segments, actualName)); } internal async ValueTask DeleteCoreAsync( UaFileSystemInfo target, bool recursive, CancellationToken ct) { if (target.Parent == null) { throw new IOException("Cannot delete the root directory."); } if (target is UaDirectoryInfo dir && !recursive) { // Empty-check before delegating to server. await foreach (UaFileSystemInfo _ in dir.EnumerateAsync(ct).ConfigureAwait(false)) { throw new IOException( $"Directory '{target.FullPath}' is not empty; pass recursive: true to delete recursively."); } } try { await target.Parent.Proxy .DeleteFileSystemObjectAsync(target.NodeId, ct) .ConfigureAwait(false); } catch (ServiceResultException ex) { throw FileSystemErrors.Translate(ex, target.FullPath, target.IsDirectory); } m_pathCache.InvalidateChildrenOf(target.Parent.NodeId); if (target is UaDirectoryInfo) { m_pathCache.InvalidateChildrenOf(target.NodeId); } } internal async ValueTask MoveOrCopyAsync( UaFileSystemInfo source, string destPath, bool copy, CancellationToken ct) { QualifiedName[] segments = UaPath.Parse(destPath); if (segments.Length == 0) { throw new ArgumentException( "Destination path must include at least one segment.", nameof(destPath)); } UaDirectoryInfo destDir; if (segments.Length == 1) { destDir = Root; } else { string parentPath = UaPath.Format([.. segments.Take(segments.Length - 1)]); destDir = await GetDirectoryAsync(parentPath, ct).ConfigureAwait(false); } return await MoveOrCopyAsync(source, destDir, segments[^1].Name!, copy, ct) .ConfigureAwait(false); } internal async ValueTask MoveOrCopyAsync( UaFileSystemInfo source, UaDirectoryInfo destinationDirectory, string newName, bool copy, CancellationToken ct) { ValidateLeafName(newName); if (source.Parent == null) { throw new IOException("Cannot move or copy the root directory."); } NodeId newId; try { newId = await source.Parent.Proxy.MoveOrCopyAsync( source.NodeId, destinationDirectory.NodeId, createCopy: copy, newName, ct).ConfigureAwait(false); } catch (ServiceResultException ex) { throw FileSystemErrors.Translate(ex, source.FullPath, source.IsDirectory); } // Invalidate caches for both ends. m_pathCache.InvalidateChildrenOf(source.Parent.NodeId); m_pathCache.InvalidateChildrenOf(destinationDirectory.NodeId); QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); IReadOnlyList destSegments = AppendSegment( destinationDirectory.Segments, actualName); if (source is UaDirectoryInfo) { return new UaDirectoryInfo( this, destinationDirectory, newId, actualName, destSegments); } return new UaFileInfo( this, destinationDirectory, newId, actualName, destSegments); } internal async ValueTask ReadFileMetadataAsync( NodeId fileNodeId, string? path, CancellationToken ct) { // Build a single TranslateBrowsePathsToNodeIds call for the // seven well-known property browse names. var browsePathsList = new List(kFileTypePropertyNames.Length); for (int i = 0; i < kFileTypePropertyNames.Length; i++) { var element = new RelativePathElement { ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName(kFileTypePropertyNames[i]) }; browsePathsList.Add(new BrowsePath { StartingNode = fileNodeId, RelativePath = new RelativePath { Elements = [element] } }); } var browsePaths = browsePathsList.ToArrayOf(); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) .ConfigureAwait(false); ClientBase.ValidateResponse(response.Results, browsePaths); ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths); // Collect resolved property NodeIds. var nodesToReadList = new List(kFileTypePropertyNames.Length); int[] indexToProperty = new int[kFileTypePropertyNames.Length]; for (int i = 0; i < kFileTypePropertyNames.Length; i++) { BrowsePathResult result = response.Results[i]; bool optional = i >= kMandatoryPropertyCount; bool empty = result.Targets.Count == 0; if (StatusCode.IsBad(result.StatusCode) || empty) { uint code = result.StatusCode.Code; if (optional || code == StatusCodes.BadNoMatch || code == StatusCodes.BadNodeIdUnknown) { continue; } throw FileSystemErrors.Translate( new ServiceResultException(result.StatusCode), path, targetIsDirectory: false); } var nodeId = ExpandedNodeId.ToNodeId( result.Targets[0].TargetId, Session.MessageContext.NamespaceUris); indexToProperty[nodesToReadList.Count] = i; nodesToReadList.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }); } if (nodesToReadList.Count == 0) { return default; } var nodesToRead = nodesToReadList.ToArrayOf(); ReadResponse readResponse = await Session.ReadAsync( null, 0.0, TimestampsToReturn.Neither, nodesToRead, ct).ConfigureAwait(false); ClientBase.ValidateResponse(readResponse.Results, nodesToRead); ClientBase.ValidateDiagnosticInfos(readResponse.DiagnosticInfos, nodesToRead); ulong? size = null; bool? writable = null; bool? userWritable = null; ushort? openCount = null; string? mimeType = null; uint? maxByteStringLength = null; DateTime? lastModifiedTime = null; for (int j = 0; j < nodesToRead.Count; j++) { int property = indexToProperty[j]; DataValue dv = readResponse.Results[j]; if (StatusCode.IsBad(dv.StatusCode)) { bool optional = property >= kMandatoryPropertyCount; uint code = dv.StatusCode.Code; if (optional || code == StatusCodes.BadNoMatch || code == StatusCodes.BadNodeIdUnknown) { continue; } throw FileSystemErrors.Translate( new ServiceResultException(dv.StatusCode), path, targetIsDirectory: false); } Variant value = dv.WrappedValue; switch (property) { case 0: size = TryGetUInt64(value); break; case 1: writable = TryGetBool(value); break; case 2: userWritable = TryGetBool(value); break; case 3: openCount = TryGetUInt16(value); break; case 4: mimeType = TryGetString(value); break; case 5: maxByteStringLength = TryGetUInt32(value); break; case 6: lastModifiedTime = TryGetDateTime(value); break; } } return new FileMetadata { Size = size, Writable = writable, UserWritable = userWritable, OpenCount = openCount, MimeType = mimeType, MaxByteStringLength = maxByteStringLength, LastModifiedTime = lastModifiedTime }; } // ---------------------------------------------------------------- // Internals // ---------------------------------------------------------------- private async ValueTask GetOrCreateFileAsync(string path, CancellationToken ct) { UaFileSystemInfo? existing = await GetInfoAsync(path, ct).ConfigureAwait(false); if (existing is UaFileInfo file) { return file; } if (existing is UaDirectoryInfo) { throw new IOException( $"Cannot write to '{path}': path refers to a directory."); } return await CreateFileAsync(path, true, ct).ConfigureAwait(false); } private async ValueTask ResolveSegmentsAsync( QualifiedName[] segments, bool throwOnMissing, CancellationToken ct) { // Walk segment-by-segment, leveraging the path cache when // possible. A single TranslateBrowsePathsToNodeIds call would // be more efficient on a cache miss, but per-segment walking // is simpler, lets us populate the cache, and gives precise // error reporting (we know which segment failed). For most // workloads the cache covers the prefix so we still avoid // round-trips. NodeId currentParent = Root.NodeId; for (int i = 0; i < segments.Length; i++) { bool isLast = i == segments.Length - 1; QualifiedName segment = segments[i]; NodeId? cached = m_pathCache.TryGet(currentParent, segment); if (cached != null) { if (isLast) { return new ResolvedNode(cached.Value, segment); } currentParent = cached.Value; continue; } NodeId? resolved = await TryResolveSingleAsync(currentParent, segment, ct) .ConfigureAwait(false); if (resolved == null) { if (throwOnMissing) { throw FileSystemErrors.NotFound( UaPath.Format([.. segments.Take(i + 1)]), targetIsDirectory: !isLast); } return null; } m_pathCache.Put(currentParent, segment, resolved.Value); if (isLast) { return new ResolvedNode(resolved.Value, segment); } currentParent = resolved.Value; } // segments.Length == 0 case is handled by callers. return null; } private async ValueTask TryResolveSingleAsync( NodeId parent, QualifiedName segment, CancellationToken ct) { var element = new RelativePathElement { ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IsInverse = false, IncludeSubtypes = true, TargetName = segment }; ArrayOf browsePaths = new[] { new BrowsePath { StartingNode = parent, RelativePath = new RelativePath { Elements = [element] } } }.ToArrayOf(); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) .ConfigureAwait(false); ClientBase.ValidateResponse(response.Results, browsePaths); ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths); BrowsePathResult result = response.Results[0]; uint code = result.StatusCode.Code; if (code == StatusCodes.BadNoMatch || code == StatusCodes.BadNodeIdUnknown) { return null; } if (StatusCode.IsBad(result.StatusCode)) { throw new ServiceResultException(result.StatusCode); } ArrayOf targets = result.Targets; if (targets.Count == 0) { return null; } if (targets.Count > 1) { throw FileSystemErrors.Ambiguous(UaPath.FormatSegment(segment), targets.Count); } return ExpandedNodeId.ToNodeId( targets[0].TargetId, Session.MessageContext.NamespaceUris); } private async ValueTask BuildInfoAsync( ResolvedNode resolved, QualifiedName[] segments, CancellationToken ct) { NodeId typeDef = await ReadTypeDefinitionAsync(resolved.NodeId, ct).ConfigureAwait(false); UaDirectoryInfo? parentInfo = null; if (segments.Length > 1) { ResolvedNode? parent = await ResolveSegmentsAsync( [.. segments.Take(segments.Length - 1)], throwOnMissing: true, ct).ConfigureAwait(false); if (parent != null) { parentInfo = new UaDirectoryInfo( this, parent: null, // grandparent reference omitted for the synthesized parent stub parent.Value.NodeId, parent.Value.BrowseName, [.. segments.Take(segments.Length - 1)]); } } else { parentInfo = Root; } await EnsureTypeTreeFetchedAsync(ct).ConfigureAwait(false); ITypeTable typeTree = Session.TypeTree; bool isFile = IsFileType(typeDef, typeTree); bool isDir = IsDirectoryType(typeDef, typeTree); if (isDir) { return new UaDirectoryInfo(this, parentInfo, resolved.NodeId, resolved.BrowseName, segments); } if (isFile) { return new UaFileInfo(this, parentInfo, resolved.NodeId, resolved.BrowseName, segments); } throw new IOException( $"Path '/{string.Join("/", segments.Select(UaPath.FormatSegment))}' resolves to a NodeId whose TypeDefinition is neither FileType nor FileDirectoryType."); } private UaFileSystemInfo? TryClassifyChild( UaDirectoryInfo parent, ReferenceDescription reference, ITypeTable typeTree, bool includeFiles, bool includeDirectories) { var childId = ExpandedNodeId.ToNodeId( reference.NodeId, Session.MessageContext.NamespaceUris); var typeDef = ExpandedNodeId.ToNodeId( reference.TypeDefinition, Session.MessageContext.NamespaceUris); QualifiedName name = reference.BrowseName; bool isDir = IsDirectoryType(typeDef, typeTree); bool isFile = IsFileType(typeDef, typeTree); if (isDir && includeDirectories) { m_pathCache.Put(parent.NodeId, name, childId); return new UaDirectoryInfo( this, parent, childId, name, AppendSegment(parent.Segments, name)); } if (isFile && includeFiles) { m_pathCache.Put(parent.NodeId, name, childId); return new UaFileInfo( this, parent, childId, name, AppendSegment(parent.Segments, name)); } return null; } private bool IsFileType(NodeId typeDef, ITypeTable typeTree) { if (typeDef.IsNull) { return false; } if (typeDef.Equals(kFileTypeId)) { return true; } return Options.IncludeFileTypeSubtypes && typeTree.IsTypeOf(typeDef, kFileTypeId); } private bool IsDirectoryType(NodeId typeDef, ITypeTable typeTree) { if (typeDef.IsNull) { return false; } if (typeDef.Equals(kFileDirectoryTypeId)) { return true; } return Options.IncludeFileDirectoryTypeSubtypes && typeTree.IsTypeOf(typeDef, kFileDirectoryTypeId); } private async ValueTask EnsureTypeTreeFetchedAsync(CancellationToken ct) { if (m_typeTreeFetched) { return; } await Session.FetchTypeTreeAsync(kFileTypeId, ct).ConfigureAwait(false); await Session.FetchTypeTreeAsync(kFileDirectoryTypeId, ct).ConfigureAwait(false); m_typeTreeFetched = true; } private async ValueTask ReadTypeDefinitionAsync(NodeId nodeId, CancellationToken ct) { (_, _, ArrayOf references) = await Session.BrowseAsync( requestHeader: null, view: null, nodeId, maxResultsToReturn: 1, BrowseDirection.Forward, ReferenceTypeIds.HasTypeDefinition, includeSubtypes: false, (uint)NodeClass.ObjectType, ct).ConfigureAwait(false); if (references.Count == 0) { return NodeId.Null; } return ExpandedNodeId.ToNodeId( references[0].NodeId, Session.MessageContext.NamespaceUris); } private async ValueTask ReadBrowseNameAsync(NodeId nodeId, CancellationToken ct) { ArrayOf nodesToRead = new[] { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.BrowseName } }.ToArrayOf(); ReadResponse response = await Session.ReadAsync( null, 0.0, TimestampsToReturn.Neither, nodesToRead, ct).ConfigureAwait(false); ClientBase.ValidateResponse(response.Results, nodesToRead); DataValue dv = response.Results[0]; if (StatusCode.IsBad(dv.StatusCode)) { throw new ServiceResultException(dv.StatusCode); } return TryGetQualifiedName(dv.WrappedValue) ?? QualifiedName.Null; } private static QualifiedName[] AppendSegment( IReadOnlyList parent, QualifiedName name) { var combined = new QualifiedName[parent.Count + 1]; for (int i = 0; i < parent.Count; i++) { combined[i] = parent[i]; } combined[^1] = name; return combined; } private static void ValidateLeafName(string name) { if (string.IsNullOrEmpty(name)) { throw new ArgumentException("Name must be non-empty.", nameof(name)); } if (name.Contains(UaPath.Separator, StringComparison.Ordinal)) { throw new ArgumentException( $"Leaf name '{name}' must not contain a path separator.", nameof(name)); } if (name.Contains(':', StringComparison.Ordinal)) { throw new ArgumentException( $"Leaf name '{name}' must not include a namespace prefix; the server picks the BrowseName namespace.", nameof(name)); } } private static ulong? TryGetUInt64(Variant value) { if (value.TryGetValue(out ulong u64)) { return u64; } if (value.TryGetValue(out long i64) && i64 >= 0) { return (ulong)i64; } if (value.TryGetValue(out uint u32)) { return u32; } if (value.TryGetValue(out int i32) && i32 >= 0) { return (ulong)i32; } return null; } private static uint? TryGetUInt32(Variant value) { if (value.TryGetValue(out uint u32)) { return u32; } if (value.TryGetValue(out int i32) && i32 >= 0) { return (uint)i32; } if (value.TryGetValue(out ulong u64) && u64 <= uint.MaxValue) { return (uint)u64; } return null; } private static ushort? TryGetUInt16(Variant value) { if (value.TryGetValue(out ushort u16)) { return u16; } if (value.TryGetValue(out short i16) && i16 >= 0) { return (ushort)i16; } if (value.TryGetValue(out uint u32) && u32 <= ushort.MaxValue) { return (ushort)u32; } if (value.TryGetValue(out int i32) && i32 >= 0 && i32 <= ushort.MaxValue) { return (ushort)i32; } return null; } private static bool? TryGetBool(Variant value) { if (value.TryGetValue(out bool b)) { return b; } return null; } private static string? TryGetString(Variant value) { if (value.TryGetValue(out string s)) { return s; } return null; } private static DateTime? TryGetDateTime(Variant value) { if (value.TryGetValue(out DateTimeUtc dtu)) { return dtu.ToDateTime(); } return null; } private static QualifiedName? TryGetQualifiedName(Variant value) { // QualifiedName is a struct; cast through Raw which preserves // boxed value types unchanged. object? raw = value.AsBoxedObject(); if (raw is QualifiedName qn) { return qn; } return null; } private readonly struct ResolvedNode { public ResolvedNode(NodeId nodeId, QualifiedName browseName) { NodeId = nodeId; BrowseName = browseName; } public NodeId NodeId { get; } public QualifiedName BrowseName { get; } } /// /// Indices 0..3 are mandatory in the FileType definition; 4..6 are optional. /// private const int kMandatoryPropertyCount = 4; private static readonly string[] kFileTypePropertyNames = [ "Size", "Writable", "UserWritable", "OpenCount", "MimeType", "MaxByteStringLength", "LastModifiedTime" ]; private static readonly NodeId kFileTypeId = ObjectTypeIds.FileType; private static readonly NodeId kFileDirectoryTypeId = ObjectTypeIds.FileDirectoryType; private static readonly QualifiedName kRootBrowseName = new("FileSystem"); private readonly PathCache m_pathCache; private bool m_typeTreeFetched; } }