diff --git a/src/Opc.Ua.Core/Stack/Client/Channels/ClientChannelManager.cs b/src/Opc.Ua.Core/Stack/Client/Channels/ClientChannelManager.cs index 46ae8be596..b4d21afd8d 100644 --- a/src/Opc.Ua.Core/Stack/Client/Channels/ClientChannelManager.cs +++ b/src/Opc.Ua.Core/Stack/Client/Channels/ClientChannelManager.cs @@ -743,14 +743,51 @@ private async ValueTask ReconnectLeaseAsync( entry = await SwapFaultedEntryAsync(lease, ct).ConfigureAwait(false); } - Task reconnectTask = entry.RequestReconnectAsync(budget, ct); + Task reconnectTask; + try + { + reconnectTask = entry.RequestReconnectAsync(budget, ct); + } + catch (ServiceResultException sre) when (IsTerminalReconnectRace(entry, sre, ct)) + { + entry = await SwapFaultedEntryAsync(lease, ct).ConfigureAwait(false); + reconnectTask = entry.RequestReconnectAsync(budget, ct); + } + if (throwOnReconnectFailure) { - await AwaitReconnectResultAsync(reconnectTask).ConfigureAwait(false); + try + { + await AwaitReconnectResultAsync(reconnectTask).ConfigureAwait(false); + } + catch (ServiceResultException sre) when (IsTerminalReconnectRace(entry, sre, ct)) + { + entry = await SwapFaultedEntryAsync(lease, ct).ConfigureAwait(false); + await AwaitReconnectResultAsync(entry.RequestReconnectAsync(budget, ct)) + .ConfigureAwait(false); + } return; } - _ = await reconnectTask.ConfigureAwait(false); + try + { + _ = await reconnectTask.ConfigureAwait(false); + } + catch (ServiceResultException sre) when (IsTerminalReconnectRace(entry, sre, ct)) + { + entry = await SwapFaultedEntryAsync(lease, ct).ConfigureAwait(false); + _ = await entry.RequestReconnectAsync(budget, ct).ConfigureAwait(false); + } + } + + private static bool IsTerminalReconnectRace( + ChannelEntry entry, + ServiceResultException sre, + CancellationToken ct) + { + return !ct.IsCancellationRequested && + sre.StatusCode == StatusCodes.BadSecureChannelClosed && + entry.State is ChannelState.Closed or ChannelState.Faulted; } private async ValueTask SwapFaultedEntryAsync( diff --git a/src/Opc.Ua.Core/Stack/Client/Channels/Internal/ChannelEntry.cs b/src/Opc.Ua.Core/Stack/Client/Channels/Internal/ChannelEntry.cs index d2a2890376..b18740b1d1 100644 --- a/src/Opc.Ua.Core/Stack/Client/Channels/Internal/ChannelEntry.cs +++ b/src/Opc.Ua.Core/Stack/Client/Channels/Internal/ChannelEntry.cs @@ -139,11 +139,24 @@ public async Task OpenInitialAsync( ITransportChannel channel = await CreateTransportChannelAsync( clientCertificate, clientCertificateChain, ct) .ConfigureAwait(false); + bool entryClosed; lock (m_lock) { - m_underlying = channel; - m_clientCertificateVersion = clientCertificateVersion; - m_activeMetricRecorded = true; + entryClosed = m_state is ChannelState.Closed or ChannelState.Faulted; + if (!entryClosed) + { + m_underlying = channel; + m_clientCertificateVersion = clientCertificateVersion; + m_activeMetricRecorded = true; + } + } + if (entryClosed) + { + await CloseTransportBestEffortAsync(channel).ConfigureAwait(false); + throw ServiceResultException.Create( + StatusCodes.BadSecureChannelClosed, + "Channel is {0}.", + State); } OwnerManager.RecordChannelActiveChanged(this, 1); TransitionTo(ChannelState.Ready, error: null, attempt: 0); @@ -233,6 +246,9 @@ internal void ReattachParticipant( ?? throw new InvalidOperationException("Participant factory returned null."); int refCount = 0; int participantCount = 0; + ChannelState currentState; + ServiceResult? currentError; + int currentAttempt; bool attached = false; lock (m_lock) { @@ -260,11 +276,22 @@ internal void ReattachParticipant( lease.SwapEntry(this); refCount = m_refcount; participantCount = m_leases.Count(l => l.IsActive); + currentState = m_state; + currentError = m_lastError; + currentAttempt = m_lastReconnectAttempt; } if (attached) { OwnerManager.OnEntryParticipantAttached(this, participant.Id, refCount, participantCount); + if (currentState != ChannelState.Disconnected) + { + lease.RaiseStateChanged(new ChannelStateChange( + ChannelState.Disconnected, + currentState, + currentError, + currentAttempt)); + } } } @@ -895,34 +922,56 @@ private async Task EnsureTransportConnectedAsync(CancellationToken ct) clientCert, clientChain, ct).ConfigureAwait(false); ITransportChannel? old; + bool entryClosed; lock (m_lock) { - old = m_underlying; - m_underlying = fresh; - m_clientCertificateVersion = certVersion; - } - if (old != null) - { - try - { - await old.CloseAsync(default).ConfigureAwait(false); - } - catch - { - // best-effort - } - try + entryClosed = m_state is ChannelState.Closed or ChannelState.Faulted; + if (entryClosed) { - OwnerManager.CloseChannel(old); + old = null; } - catch + else { - // best-effort + old = m_underlying; + m_underlying = fresh; + m_clientCertificateVersion = certVersion; } + } + if (entryClosed) + { + await CloseTransportBestEffortAsync(fresh).ConfigureAwait(false); + throw ServiceResultException.Create( + StatusCodes.BadSecureChannelClosed, + "Channel is {0}.", + State); + } + if (old != null) + { + await CloseTransportBestEffortAsync(old).ConfigureAwait(false); OwnerManager.OnEntryClosed(this, ChannelCloseReason.Faulted); } } + private async ValueTask CloseTransportBestEffortAsync(ITransportChannel channel) + { + try + { + await channel.CloseAsync(default).ConfigureAwait(false); + } + catch (Exception ex) + { + OwnerManager.Logger?.ChannelEntryLog0(ex); + } + try + { + OwnerManager.CloseChannel(channel); + } + catch (Exception ex) + { + OwnerManager.Logger?.ChannelEntryLog1(ex); + } + } + #if !NET8_0_OR_GREATER private sealed class DelayState { diff --git a/src/Opc.Ua.PubSub/Application/PubSubApplication.cs b/src/Opc.Ua.PubSub/Application/PubSubApplication.cs index 53dc1a0b1d..095a81560d 100644 --- a/src/Opc.Ua.PubSub/Application/PubSubApplication.cs +++ b/src/Opc.Ua.PubSub/Application/PubSubApplication.cs @@ -652,6 +652,8 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) catch (Exception ex) { m_logger.FailedToEnableConnection(ex, connection.Name); + await StopAsync(CancellationToken.None).ConfigureAwait(false); + throw; } } // Start the metadata publisher AFTER the diff --git a/src/Opc.Ua.Types/BuiltIn/ExtensionObject.cs b/src/Opc.Ua.Types/BuiltIn/ExtensionObject.cs index 2decf2612d..c2d6d9e6fb 100644 --- a/src/Opc.Ua.Types/BuiltIn/ExtensionObject.cs +++ b/src/Opc.Ua.Types/BuiltIn/ExtensionObject.cs @@ -31,6 +31,9 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; using System.Text.Json.Serialization; using Opc.Ua.Types; @@ -54,7 +57,6 @@ namespace Opc.Ua /// how it is encoded. /// /// - // [Union] public readonly struct ExtensionObject : IFormattable, INullable, @@ -312,6 +314,136 @@ private static bool TypeIdMatches(ExpandedNodeId typeId, IEncodeable encodeable) typeId == encodeable.XmlEncodingId; } + private bool TryDecodeValue( + IServiceMessageContext messageContext, + [NotNullWhen(true)] out IEncodeable? encodeable) + { + try + { + encodeable = m_body switch + { + ByteString binary when !binary.IsNull => + DecodeBinary(binary, messageContext), + XmlElement xml => + DecodeXml(xml, messageContext), + string json => + DecodeJson(json, messageContext), + _ => default + }; + return encodeable != null && + IsMatchingStructureIdentifier( + TypeId, + encodeable, + messageContext.NamespaceUris); + } + catch (Exception ex) when ( + ex is ServiceResultException or + FormatException or + InvalidOperationException or + EndOfStreamException or + JsonException) + { + encodeable = default; + return false; + } + } + + private IEncodeable? DecodeBinary(ByteString binary, IServiceMessageContext messageContext) + { + if (!messageContext.Factory.TryGetEncodeableType(TypeId, out IEncodeableType? activator)) + { + return default; + } + + IEncodeable encodeable = activator.CreateInstance(); + ReadOnlyMemory memory = binary; + using BinaryDecoder decoder = MemoryMarshal.TryGetArray(memory, out ArraySegment segment) + ? new BinaryDecoder(segment, messageContext) + : new BinaryDecoder(memory.ToArray(), messageContext); + encodeable.Decode(decoder); + return encodeable; + } + + private IEncodeable? DecodeXml(XmlElement xml, IServiceMessageContext messageContext) + { + if (!messageContext.Factory.TryGetEncodeableType(TypeId, out _)) + { + return default; + } + + System.Xml.XmlElement? xmlElement = xml.AsXmlElement(); + if (xmlElement == null) + { + return default; + } + + using var decoder = new XmlDecoder(xmlElement, messageContext); + decoder.PushNamespace(xmlElement.NamespaceURI); + try + { + return decoder.ReadEncodeable( + xmlElement.LocalName, + TypeId); + } + finally + { + decoder.PopNamespace(); + } + } + + private IEncodeable? DecodeJson(string json, IServiceMessageContext messageContext) + { + if (!messageContext.Factory.TryGetEncodeableType(TypeId, out _)) + { + return default; + } + + using var decoder = new JsonDecoder( + "{\"" + JsonProperties.UaBody + "\":" + json + "}", + messageContext); + return decoder.ReadEncodeable(JsonProperties.UaBody, TypeId); + } + + private static bool IsMatchingStructureIdentifier( + ExpandedNodeId actual, + IEncodeable expected, + NamespaceTable namespaceUris) + { + return AreEquivalentStructureIdentifiers( + actual, + expected.TypeId, + namespaceUris) || + AreEquivalentStructureIdentifiers( + actual, + expected.BinaryEncodingId, + namespaceUris) || + AreEquivalentStructureIdentifiers( + actual, + expected.XmlEncodingId, + namespaceUris); + } + + private static bool AreEquivalentStructureIdentifiers( + ExpandedNodeId first, + ExpandedNodeId second, + NamespaceTable namespaceUris) + { + if (first.IsNull || second.IsNull) + { + return false; + } + if (first == second) + { + return true; + } + + var firstLocal = ExpandedNodeId.ToNodeId(first, namespaceUris); + var secondLocal = ExpandedNodeId.ToNodeId(second, namespaceUris); + return !firstLocal.IsNull && + !secondLocal.IsNull && + firstLocal == secondLocal; + } + /// public static bool operator ==(ExtensionObject left, ExtensionObject right) { @@ -403,7 +535,11 @@ public bool TryGetValue( return false; } - // TODO: Decode if possible + if (TryDecodeValue(messageContext, out encodeable)) + { + return true; + } + encodeable = default; return false; } diff --git a/src/Opc.Ua.Types/BuiltIn/TypeInfo.cs b/src/Opc.Ua.Types/BuiltIn/TypeInfo.cs index 09773e2006..4b8327ab80 100644 --- a/src/Opc.Ua.Types/BuiltIn/TypeInfo.cs +++ b/src/Opc.Ua.Types/BuiltIn/TypeInfo.cs @@ -1530,6 +1530,16 @@ public NodeId GetDataTypeId(Variant value, NamespaceTable namespaceUris, ITypeTa return ExpandedNodeId.ToNodeId(encodeable!.TypeId, namespaceUris); } + // Opaque ExtensionObjects may carry the DataTypeId itself; FindDataTypeId only resolves + // encoding ids, so accept a known type before falling back to encoding lookup. + var extensionTypeId = ExpandedNodeId.ToNodeId( + extension.TypeId, + namespaceUris); + if (typeTree.IsKnown(extensionTypeId)) + { + return extensionTypeId; + } + return typeTree.FindDataTypeId(extension.TypeId); } diff --git a/src/Opc.Ua.Types/BuiltIn/Variant.cs b/src/Opc.Ua.Types/BuiltIn/Variant.cs index 60b3da9846..d8694b32a3 100644 --- a/src/Opc.Ua.Types/BuiltIn/Variant.cs +++ b/src/Opc.Ua.Types/BuiltIn/Variant.cs @@ -57,7 +57,6 @@ namespace Opc.Ua /// OPC UA data-types. ///
/// - // [Union] public readonly struct Variant : INullable, IFormattable, @@ -1886,6 +1885,40 @@ public bool TryGetStructure([MaybeNullWhen(false)] out T value) where T : IEn return TryGetValue(out value, null); } + /// + /// Tries to get a structure value, converting a dynamically decoded + /// structure to the requested generated type when necessary. + /// + /// The requested structure type. + /// The message context used for binary conversion. + /// The converted structure value. + /// true when the structure could be converted. + /// is null. + public bool TryGetStructure( + IServiceMessageContext context, + [MaybeNullWhen(false)] out T value) + where T : class, IEncodeable, new() + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (TryGetStructure(out value)) + { + return true; + } + + if (TryGetValue(out ExtensionObject extension) && + extension.TryGetValue(out value, context)) + { + return true; + } + + value = default; + return false; + } + /// /// Try convert the variant to a value. /// @@ -2306,6 +2339,51 @@ public bool TryGetStructure(out ArrayOf value) where T : IEncodeable return TryGetValue(out value, null); } + /// + /// Tries to get an array of structure values, converting dynamically + /// decoded structures to the requested generated type when necessary. + /// + /// The requested structure element type. + /// The message context used for binary conversion. + /// The converted structure values. + /// true when every structure could be converted. + /// is null. + public bool TryGetStructure( + IServiceMessageContext context, + out ArrayOf value) + where T : class, IEncodeable, new() + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (TryGetStructure(out value)) + { + return true; + } + + if (!TryGetValue(out ArrayOf extensions)) + { + value = default; + return false; + } + + var structures = new T[extensions.Count]; + for (int ii = 0; ii < extensions.Count; ii++) + { + if (!extensions[ii].TryGetValue(out T? structure, context)) + { + value = default; + return false; + } + structures[ii] = structure; + } + + value = new ArrayOf(structures); + return true; + } + /// /// Get a enumeration value from the Variant. /// @@ -8207,42 +8285,81 @@ private static InvalidCastException CannotCast() typeof(T).Name)); } + /// + /// Stores the primitive Variant payload or array slice metadata without allocating boxed values. + /// [StructLayout(LayoutKind.Explicit, Size = 8)] internal struct Union { + /// + /// Stores a Boolean payload. + /// [FieldOffset(0)] public bool Boolean; + /// + /// Stores a signed byte payload. + /// [FieldOffset(0)] public sbyte SByte; + /// + /// Stores an unsigned byte payload. + /// [FieldOffset(0)] public byte Byte; + /// + /// Stores a 16-bit signed integer payload. + /// [FieldOffset(0)] public short Int16; + /// + /// Stores a 16-bit unsigned integer payload. + /// [FieldOffset(0)] public ushort UInt16; + /// + /// Stores a 32-bit signed integer payload. + /// [FieldOffset(0)] public int Int32; + /// + /// Stores a 32-bit unsigned integer payload. + /// [FieldOffset(0)] public uint UInt32; + /// + /// Stores a 64-bit signed integer payload. + /// [FieldOffset(0)] public long Int64; + /// + /// Stores a 64-bit unsigned integer payload. + /// [FieldOffset(0)] public ulong UInt64; + /// + /// Stores a single-precision floating-point payload. + /// [FieldOffset(0)] public float Float; + /// + /// Stores a double-precision floating-point payload. + /// [FieldOffset(0)] public double Double; + /// + /// Stores a DateTime payload. + /// [FieldOffset(0)] public DateTimeUtc DateTime; diff --git a/src/Opc.Ua.Types/Nodes/TypeTable.cs b/src/Opc.Ua.Types/Nodes/TypeTable.cs index e4f9ca5009..2824385fbf 100644 --- a/src/Opc.Ua.Types/Nodes/TypeTable.cs +++ b/src/Opc.Ua.Types/Nodes/TypeTable.cs @@ -214,6 +214,11 @@ public bool IsTypeOf(ExpandedNodeId subTypeId, ExpandedNodeId superTypeId) return false; } + if (startId == targetId) + { + return true; + } + lock (m_lock) { if (!m_nodes.TryGetValue(startId, out TypeInfo? typeInfo)) @@ -350,6 +355,15 @@ public bool IsEncodingFor(NodeId expectedTypeId, ExtensionObject value) return false; } + // In-memory encodeable bodies carry their DataTypeId directly, + // while decoded binary/xml bodies carry the encoding NodeId. + // Accept the direct DataTypeId (or one of its subtypes) before + // consulting the encoding map. + if (IsTypeOf(value.TypeId, expectedTypeId)) + { + return true; + } + // may still match if the extension type is an encoding for the expected type. if (IsEncodingOf(value.TypeId, expectedTypeId)) { @@ -778,7 +792,7 @@ private class TypeInfo /// /// The node identifier. /// - /// true if this node is type of the specified NodeId otherwise, false. + /// true if this node is type of the specified NodeId otherwise, false. /// public bool IsTypeOf(NodeId nodeId) { diff --git a/src/Opc.Ua.Types/Utils/FileSystem/CombinedFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/CombinedFileSystem.cs index 7e5fc06f1d..1f7098d25b 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/CombinedFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/CombinedFileSystem.cs @@ -123,6 +123,13 @@ public Stream OpenWrite(string path) return writeableFs.OpenWrite(path); } + /// + public void Replace(string sourcePath, string destinationPath) + { + IFileSystem writeableFs = m_usePrimaryForWrite ? m_primary : m_secondary; + writeableFs.Replace(sourcePath, destinationPath); + } + /// public DateTime GetLastWriteTime(string path) { diff --git a/src/Opc.Ua.Types/Utils/FileSystem/IFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/IFileSystem.cs index fcfe780834..e3c63325c6 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/IFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/IFileSystem.cs @@ -67,6 +67,21 @@ public interface IFileSystem /// Stream OpenWrite(string path); + /// + /// Publishes as + /// in a single indivisible step, overwriting any existing destination. + /// + /// Durable writers stage content under a temporary name and then publish it, so + /// that an interrupted write can never leave a partially written file visible at + /// the destination. The source is consumed by the operation. Observers of + /// only ever see the complete previous + /// content or the complete new content, never an intermediate state. + /// + /// + /// The staged file to publish. + /// The final name to publish it under. + void Replace(string sourcePath, string destinationPath); + /// /// Get last write time of file /// diff --git a/src/Opc.Ua.Types/Utils/FileSystem/LocalFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/LocalFileSystem.cs index 72f319d119..3037efae1d 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/LocalFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/LocalFileSystem.cs @@ -92,5 +92,29 @@ public long GetLength(string path) { return new FileInfo(path).Length; } + + /// + public void Replace(string sourcePath, string destinationPath) + { + string? directoryName = Path.GetDirectoryName(destinationPath); + if (directoryName != null && !Directory.Exists(directoryName)) + { + Directory.CreateDirectory(directoryName); + } + +#if NETCOREAPP3_0_OR_GREATER + File.Move(sourcePath, destinationPath, overwrite: true); +#else + if (File.Exists(destinationPath)) + { + // Replace keeps the destination's identity and discards the source, but + // it refuses to run when the two paths sit on different volumes. + File.Replace(sourcePath, destinationPath, null); + return; + } + + File.Move(sourcePath, destinationPath); +#endif + } } } diff --git a/src/Opc.Ua.Types/Utils/FileSystem/NullFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/NullFileSystem.cs index db94f5ca76..4ef4a16ac9 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/NullFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/NullFileSystem.cs @@ -77,6 +77,13 @@ public Stream OpenWrite(string path) "The null file system does not provide access to any path.", path); } + /// + public void Replace(string sourcePath, string destinationPath) + { + throw new FileNotFoundException( + "The null file system does not provide access to any path.", sourcePath); + } + /// public System.DateTime GetLastWriteTime(string path) { diff --git a/src/Opc.Ua.Types/Utils/FileSystem/ResourceFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/ResourceFileSystem.cs index b2b69dc027..f0f3a36122 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/ResourceFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/ResourceFileSystem.cs @@ -138,6 +138,12 @@ public void Delete(string path, bool isDirectory = false) throw new IOException("Resource file system is not writeable"); } + /// + public void Replace(string sourcePath, string destinationPath) + { + throw new IOException("Resource file system is not writeable"); + } + /// public DateTime GetLastWriteTime(string path) { diff --git a/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs index 5e57970640..6eb688d3e4 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs @@ -162,6 +162,35 @@ public bool Exists(string path, bool isDirectory = false) return m_files.ContainsKey(path) || SafeExists(path); } + /// + public void Replace(string sourcePath, string destinationPath) + { + if (!m_files.TryRemove(sourcePath, out VirtualFile? staged)) + { + throw new FileNotFoundException( + "The staged file to publish does not exist.", + sourcePath); + } + + while (true) + { + if (!m_files.TryGetValue(destinationPath, out VirtualFile? existing)) + { + if (m_files.TryAdd(destinationPath, staged)) + { + return; + } + continue; + } + + if (m_files.TryUpdate(destinationPath, staged, existing)) + { + existing.Dispose(); + return; + } + } + } + /// public DateTime GetLastWriteTime(string path) { diff --git a/tests/Opc.Ua.Client.TestFramework/ClientFixture.cs b/tests/Opc.Ua.Client.TestFramework/ClientFixture.cs index 3e3beda718..0c73e3049a 100644 --- a/tests/Opc.Ua.Client.TestFramework/ClientFixture.cs +++ b/tests/Opc.Ua.Client.TestFramework/ClientFixture.cs @@ -199,6 +199,11 @@ protected virtual async ValueTask DisposeAsyncCore() await m_application.DisposeAsync().ConfigureAwait(false); m_application = null; } + if (Config?.CertificateManager is IDisposable certificateManager) + { + certificateManager.Dispose(); + } + Config = null; } /// diff --git a/tests/Opc.Ua.Core.Tests/Stack/Client/ClientChannelManagerManagedTests.cs b/tests/Opc.Ua.Core.Tests/Stack/Client/ClientChannelManagerManagedTests.cs index 6541089c9f..4b325a9078 100644 --- a/tests/Opc.Ua.Core.Tests/Stack/Client/ClientChannelManagerManagedTests.cs +++ b/tests/Opc.Ua.Core.Tests/Stack/Client/ClientChannelManagerManagedTests.cs @@ -993,6 +993,8 @@ public async Task ReconnectAsyncSwapsFaultedLeaseEntryAsync() TaskCreationOptions.RunContinuationsAsynchronously); var reconnecting = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); + var ready = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); ch.StateChanged += (_, change) => { if (change.NewState == ChannelState.Faulted) @@ -1003,6 +1005,10 @@ public async Task ReconnectAsyncSwapsFaultedLeaseEntryAsync() { reconnecting.TrySetResult(true); } + else if (change.NewState == ChannelState.Ready) + { + ready.TrySetResult(true); + } }; _ = Assert.ThrowsAsync(async () => @@ -1028,6 +1034,7 @@ await timeProvider.WaitForTimerCreatedAsync(2) timeProvider.Advance(TimeSpan.FromMilliseconds(100)); await reconnectTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); object freshEntry = GetLeaseEntry(ch); ManagedChannelDiagnostic diagnostic = sut.GetChannelDiagnostics() diff --git a/tests/Opc.Ua.PubSub.Tests/Application/PubSubApplicationTests.cs b/tests/Opc.Ua.PubSub.Tests/Application/PubSubApplicationTests.cs index 64711fa087..873f5c84b0 100644 --- a/tests/Opc.Ua.PubSub.Tests/Application/PubSubApplicationTests.cs +++ b/tests/Opc.Ua.PubSub.Tests/Application/PubSubApplicationTests.cs @@ -187,6 +187,50 @@ public async Task BuildWithEventSamplerCreatesEventPublishedDataSetAsync() Assert.That(runtimeDataSet, Is.TypeOf()); } + [Test] + public async Task StartAsyncWhenConnectionEnableFailsPropagatesTransportFailureAsync() + { + var expected = new NotSupportedException("transport unavailable"); + var transportFactory = new Mock(); + transportFactory + .SetupGet(f => f.TransportProfileUri) + .Returns(Profiles.PubSubUdpUadpTransport); + transportFactory + .Setup(f => f.Create( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(expected); + + await using IPubSubApplication app = + new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) + .WithApplicationId("transport-failure-test") + .UseConfiguration(new PubSubConfigurationDataType + { + Connections = + [ + new PubSubConnectionDataType + { + Name = "failing", + TransportProfileUri = Profiles.PubSubUdpUadpTransport, + Address = new ExtensionObject(new NetworkAddressUrlDataType + { + Url = "opc.udp://239.0.0.1:49322" + }) + } + ], + PublishedDataSets = [] + }) + .UseAllStandardEncoders() + .AddTransportFactory(transportFactory.Object) + .Build(); + + Assert.That( + async () => await app.StartAsync(CancellationToken.None).ConfigureAwait(false), + Throws.TypeOf().With.Message.EqualTo(expected.Message)); + Assert.That(app.State.State, Is.EqualTo(PubSubState.Disabled)); + } + private static IPubSubApplication NewEmptyApplication() { var config = new PubSubConfigurationDataType diff --git a/tests/Opc.Ua.Server.TestFramework/ServerFixtureUtils.cs b/tests/Opc.Ua.Server.TestFramework/ServerFixtureUtils.cs index 3eed0b4662..8574840447 100644 --- a/tests/Opc.Ua.Server.TestFramework/ServerFixtureUtils.cs +++ b/tests/Opc.Ua.Server.TestFramework/ServerFixtureUtils.cs @@ -80,12 +80,28 @@ public static class ServerFixtureUtils // Find TCP endpoint ArrayOf endpoints = server.GetEndpoints(); - EndpointDescription endpoint = endpoints.Find(e => + EndpointDescription endpoint = useSecurity + ? endpoints.Find(e => + e.TransportProfileUri + .Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) && + e.SecurityMode == MessageSecurityMode.Sign && + e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) ?? + endpoints.Find(e => + e.TransportProfileUri + .Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal) && + e.SecurityMode == MessageSecurityMode.Sign && + e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) + : endpoints.Find(e => + e.TransportProfileUri + .Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) || + e.TransportProfileUri + .Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal)); + endpoint ??= endpoints.Find(e => e.TransportProfileUri .Equals(Profiles.UaTcpTransport, StringComparison.Ordinal) || e.TransportProfileUri .Equals(Profiles.HttpsBinaryTransport, StringComparison.Ordinal)) ?? - throw new NotSupportedException("Unsupported transport profile."); + throw new NotSupportedException("Unsupported transport profile or security policy."); if (useSecurity) { diff --git a/tests/Opc.Ua.Types.Tests/BuiltIn/ExtensionObjectTests.cs b/tests/Opc.Ua.Types.Tests/BuiltIn/ExtensionObjectTests.cs index dfe44125b9..e84f5450d5 100644 --- a/tests/Opc.Ua.Types.Tests/BuiltIn/ExtensionObjectTests.cs +++ b/tests/Opc.Ua.Types.Tests/BuiltIn/ExtensionObjectTests.cs @@ -28,7 +28,9 @@ * ======================================================================*/ using System; +using System.Text.Json; using NUnit.Framework; +using Opc.Ua.Tests; #pragma warning disable IDE0028 // Simplify collection initialization #pragma warning disable IDE0305 // Simplify collection initialization @@ -398,5 +400,141 @@ public void EqualsRejectsBodylessVersusBody() Assert.That(bodyless, Is.Not.EqualTo(withBody)); Assert.That(withBody, Is.Not.EqualTo(bodyless)); } + + [Test] + public void TryGetValueDecodesBinaryBodyWithContext() + { + IServiceMessageContext context = CreateMessageContext(); + var argument = new Argument(); + ExtensionObject extension = CreateBinaryArgument(context, "Binary", argument.BinaryEncodingId); + + bool success = extension.TryGetValue(out Argument actual, context); + + Assert.That(success, Is.True); + Assert.That(actual!.Name, Is.EqualTo("Binary")); + Assert.That(actual.DataType, Is.EqualTo(DataTypeIds.Double)); + } + + [Test] + public void TryGetValueDecodesXmlBodyWithContext() + { + IServiceMessageContext context = CreateMessageContext(); + ExtensionObject extension = CreateXmlArgument(context, "Xml"); + + bool success = extension.TryGetValue(out Argument actual, context); + + Assert.That(success, Is.True); + Assert.That(actual!.Name, Is.EqualTo("Xml")); + Assert.That(actual.DataType, Is.EqualTo(DataTypeIds.Double)); + } + + [Test] + public void TryGetValueDecodesJsonBodyWithContext() + { + IServiceMessageContext context = CreateMessageContext(); + ExtensionObject extension = CreateJsonArgument(context, "Json"); + + bool success = extension.TryGetValue(out Argument actual, context); + + Assert.That(success, Is.True); + Assert.That(actual!.Name, Is.EqualTo("Json")); + Assert.That(actual.DataType, Is.EqualTo(DataTypeIds.Double)); + } + + [Test] + public void TryGetValueReturnsFalseForRawBodyWithoutContext() + { + IServiceMessageContext context = CreateMessageContext(); + var argument = new Argument(); + ExtensionObject extension = CreateBinaryArgument(context, "Binary", argument.BinaryEncodingId); + + bool success = extension.TryGetValue(out Argument actual); + + Assert.That(success, Is.False); + Assert.That(actual, Is.Null); + } + + [Test] + public void TryGetValueReturnsFalseForUnknownType() + { + IServiceMessageContext context = CreateMessageContext(); + var argument = new Argument(); + ExtensionObject known = CreateBinaryArgument(context, "Unknown", argument.BinaryEncodingId); + Assert.That(known.TryGetAsBinary(out ByteString binary), Is.True); + var unknown = new ExtensionObject(new ExpandedNodeId(999_999u), binary); + + bool success = unknown.TryGetValue(out IEncodeable actual, context); + + Assert.That(success, Is.False); + Assert.That(actual, Is.Null); + } + + [Test] + public void TryGetValueMatchesDataTypeIdentifierForBinaryBody() + { + IServiceMessageContext context = CreateMessageContext(); + var argument = new Argument(); + ExtensionObject extension = CreateBinaryArgument(context, "DataType", argument.TypeId); + + bool success = extension.TryGetValue(out Argument actual, context); + + Assert.That(success, Is.True); + Assert.That(actual!.Name, Is.EqualTo("DataType")); + } + + [Test] + public void VariantTryGetStructureDelegatesToExtensionObjectDecoding() + { + IServiceMessageContext context = CreateMessageContext(); + var variant = new Variant(CreateJsonArgument(context, "Variant")); + + bool success = variant.TryGetStructure(context, out Argument actual); + + Assert.That(success, Is.True); + Assert.That(actual.Name, Is.EqualTo("Variant")); + } + + private static ServiceMessageContext CreateMessageContext() + { + return ServiceMessageContext.Create(NUnitTelemetryContext.Create()); + } + + private static Argument CreateArgument(string name) + { + return new Argument + { + Name = name, + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar + }; + } + + private static ExtensionObject CreateBinaryArgument( + IServiceMessageContext context, + string name, + ExpandedNodeId typeId) + { + Argument argument = CreateArgument(name); + using var encoder = new BinaryEncoder(context); + argument.Encode(encoder); + return new ExtensionObject(typeId, ByteString.From(encoder.CloseAndReturnBuffer())); + } + + private static ExtensionObject CreateXmlArgument(IServiceMessageContext context, string name) + { + Argument argument = CreateArgument(name); + XmlElement body = EncodeableObject.EncodeXml(argument, context); + return new ExtensionObject(argument.XmlEncodingId, body); + } + + private static ExtensionObject CreateJsonArgument(IServiceMessageContext context, string name) + { + Argument argument = CreateArgument(name); + using var encoder = new JsonEncoder(context); + encoder.WriteEncodeable("UaBody", argument, argument.TypeId); + using JsonDocument document = JsonDocument.Parse(encoder.CloseAndReturnText()); + string body = document.RootElement.GetProperty("UaBody").GetRawText(); + return new ExtensionObject(argument.TypeId, body); + } } } diff --git a/tests/Opc.Ua.Types.Tests/BuiltIn/VariantCoverageTests.cs b/tests/Opc.Ua.Types.Tests/BuiltIn/VariantCoverageTests.cs index 1fd630a131..4911474544 100644 --- a/tests/Opc.Ua.Types.Tests/BuiltIn/VariantCoverageTests.cs +++ b/tests/Opc.Ua.Types.Tests/BuiltIn/VariantCoverageTests.cs @@ -30,6 +30,7 @@ using System; using System.Collections.Generic; using NUnit.Framework; +using Opc.Ua.Tests; namespace Opc.Ua.Types.Tests.BuiltIn { @@ -920,6 +921,147 @@ public void CollapseMixedTypesReturnsVariantArray() }); } + [Test] + public void TryGetStructureReturnsTypedBody() + { + IServiceMessageContext context = CreateMessageContext(); + var expected = new Argument + { + Name = "Temperature", + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar + }; + var variant = new Variant(new ExtensionObject(expected)); + + bool success = variant.TryGetStructure(context, out Argument actual); + + Assert.That(success, Is.True); + Assert.That(actual, Is.SameAs(expected)); + } + + [Test] + public void TryGetStructureDecodesBinaryBody() + { + IServiceMessageContext context = CreateMessageContext(); + var variant = new Variant(CreateBinaryArgument(context, "Pressure")); + + bool success = variant.TryGetStructure(context, out Argument actual); + + Assert.That(success, Is.True); + Assert.That(actual.Name, Is.EqualTo("Pressure")); + Assert.That(actual.DataType, Is.EqualTo(DataTypeIds.Double)); + } + + [Test] + public void TryGetStructureRejectsMismatchedBody() + { + IServiceMessageContext context = CreateMessageContext(); + var variant = new Variant(new ExtensionObject(new BuildInfo + { + ProductName = "NotAnArgument" + })); + + bool success = variant.TryGetStructure(context, out Argument actual); + + Assert.That(success, Is.False); + Assert.That(actual, Is.Null); + } + + [Test] + public void TryGetStructureArrayDecodesEveryBinaryBody() + { + IServiceMessageContext context = CreateMessageContext(); + ArrayOf extensions = + [ + CreateBinaryArgument(context, "First"), + CreateBinaryArgument(context, "Second") + ]; + var variant = new Variant(extensions); + + bool success = variant.TryGetStructure(context, out ArrayOf actual); + + Assert.That(success, Is.True); + Assert.That(actual, Has.Count.EqualTo(2)); + Assert.That(actual[0].Name, Is.EqualTo("First")); + Assert.That(actual[1].Name, Is.EqualTo("Second")); + } + + [Test] + public void TryGetStructureArrayRejectsMismatchedElement() + { + IServiceMessageContext context = CreateMessageContext(); + ArrayOf extensions = + [ + CreateBinaryArgument(context, "First"), + new ExtensionObject(new BuildInfo { ProductName = "NotAnArgument" }) + ]; + var variant = new Variant(extensions); + + bool success = variant.TryGetStructure(context, out ArrayOf actual); + + Assert.That(success, Is.False); + Assert.That(actual, Is.Empty); + } + + [Test] + public void TryGetStructureRequiresMessageContext() + { + var scalar = new Variant(new ExtensionObject(new Argument())); + var array = new Variant(ArrayOf.Wrapped(new ExtensionObject(new Argument()))); + + Assert.That( + () => scalar.TryGetStructure(null!, out Argument scalarValue), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context")); + Assert.That( + () => array.TryGetStructure(null!, out ArrayOf arrayValue), + Throws.ArgumentNullException.With.Property("ParamName").EqualTo("context")); + } + + [Test] + public void TryGetStructureArrayReturnsFalseWhenVariantHoldsNoArray() + { + IServiceMessageContext context = CreateMessageContext(); + var variant = new Variant(42); + + bool success = variant.TryGetStructure(context, out ArrayOf actual); + + Assert.That(success, Is.False); + Assert.That(actual.IsNull, Is.True); + } + + [Test] + public void TryGetStructureArrayReturnsFalseWhenVariantHoldsStringArray() + { + IServiceMessageContext context = CreateMessageContext(); + var variant = new Variant(ArrayOf.Wrapped("a", "b")); + + bool success = variant.TryGetStructure(context, out ArrayOf actual); + + Assert.That(success, Is.False); + Assert.That(actual.IsNull, Is.True); + } + + private static ServiceMessageContext CreateMessageContext() + { + return ServiceMessageContext.Create(NUnitTelemetryContext.Create()); + } + + private static ExtensionObject CreateBinaryArgument( + IServiceMessageContext context, + string name) + { + var argument = new Argument + { + Name = name, + DataType = DataTypeIds.Double, + ValueRank = ValueRanks.Scalar + }; + using var encoder = new BinaryEncoder(context); + argument.Encode(encoder); + byte[] buffer = encoder.CloseAndReturnBuffer(); + return new ExtensionObject(argument.BinaryEncodingId, ByteString.From(buffer)); + } + private static IEnumerable EqualsObjectCases { get diff --git a/tests/Opc.Ua.Types.Tests/Nodes/TypeTableTests.cs b/tests/Opc.Ua.Types.Tests/Nodes/TypeTableTests.cs index 435228d203..876cda236f 100644 --- a/tests/Opc.Ua.Types.Tests/Nodes/TypeTableTests.cs +++ b/tests/Opc.Ua.Types.Tests/Nodes/TypeTableTests.cs @@ -696,6 +696,53 @@ public void IsEncodingForExtensionObjectReturnsTrueWhenEncodingMatches() Assert.That(m_typeTable.IsEncodingFor(s_dataTypeId, ext), Is.True); } + [Test] + public void IsEncodingForExtensionObjectReturnsTrueWhenDataTypeMatches() + { + var ext = new ExtensionObject(new ExpandedNodeId(s_dataTypeId)); + + Assert.That(m_typeTable.IsEncodingFor(s_dataTypeId, ext), Is.True); + } + + [Test] + public void IsTypeOfResolvesEquivalentAbsoluteAndLocalIds() + { + const string namespaceUri = "urn:types:test"; + ushort namespaceIndex = m_namespaceTable.GetIndexOrAppend(namespaceUri); + var localTypeId = new NodeId(4000u, namespaceIndex); + m_typeTable.AddSubtype(localTypeId, NodeId.Null); + var absoluteTypeId = NodeId.ToExpandedNodeId( + localTypeId, + m_namespaceTable); + + Assert.That(m_typeTable.IsTypeOf(absoluteTypeId, localTypeId), Is.True); + } + + [Test] + public void IsInstanceOfDataTypeAcceptsKnownExtensionObjectTypeId() + { + m_typeTable.AddSubtype(Ua.DataTypeIds.Structure, NodeId.Null); + m_typeTable.AddSubtype(s_dataTypeId, Ua.DataTypeIds.Structure); + var extension = new ExtensionObject( + new ExpandedNodeId(s_dataTypeId), + ByteString.From([1, 2, 3])); + var value = new Variant(extension); + NodeId actualDataTypeId = value.TypeInfo.GetDataTypeId( + value, + m_namespaceTable, + m_typeTable); + + var typeInfo = TypeInfo.IsInstanceOfDataType( + value, + s_dataTypeId, + ValueRanks.Scalar, + m_namespaceTable, + m_typeTable); + + Assert.That(actualDataTypeId, Is.EqualTo(s_dataTypeId)); + Assert.That(typeInfo.IsUnknown, Is.False); + } + [Test] public void IsEncodingForExtensionObjectReturnsFalseWhenEncodingDoesNotMatch() { diff --git a/tests/Opc.Ua.Types.Tests/Utils/FileSystem/AtomicFileReplaceTests.cs b/tests/Opc.Ua.Types.Tests/Utils/FileSystem/AtomicFileReplaceTests.cs new file mode 100644 index 0000000000..157435286a --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Utils/FileSystem/AtomicFileReplaceTests.cs @@ -0,0 +1,251 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Opc.Ua.Types.Tests.Utils.FileSystem +{ + /// + /// Tests for file systems that support atomic file replacement. + /// + [TestFixture] + [Category("FileSystem")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public class AtomicFileReplaceTests + { + [SetUp] + public void SetUp() + { + m_testDirectory = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(AtomicFileReplaceTests), + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_testDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(m_testDirectory)) + { + Directory.Delete(m_testDirectory, recursive: true); + } + } + + [Test] + public void LocalFileSystemReplaceMovesSourceWhenDestinationDoesNotExist() + { + var fileSystem = new LocalFileSystem(); + string sourcePath = GetLocalPath("staged.bin"); + string destinationPath = GetLocalPath("published.bin"); + byte[] sourceContent = Enumerable.Range(0, 257).Select(i => (byte)i).ToArray(); + File.WriteAllBytes(sourcePath, sourceContent); + + fileSystem.Replace(sourcePath, destinationPath); + + Assert.That(File.Exists(sourcePath), Is.False); + Assert.That(File.Exists(destinationPath), Is.True); + Assert.That(File.ReadAllBytes(destinationPath), Is.EqualTo(sourceContent)); + } + + [Test] + public void LocalFileSystemReplaceOverwritesExistingDestinationWithCompleteSource() + { + var fileSystem = new LocalFileSystem(); + string sourcePath = GetLocalPath("staged.bin"); + string destinationPath = GetLocalPath("published.bin"); + byte[] sourceContent = Enumerable.Range(0, 1024).Select(i => (byte)(255 - (i % 256))).ToArray(); + byte[] destinationContent = "old complete destination"u8.ToArray(); + File.WriteAllBytes(sourcePath, sourceContent); + File.WriteAllBytes(destinationPath, destinationContent); + + fileSystem.Replace(sourcePath, destinationPath); + + Assert.That(File.Exists(sourcePath), Is.False); + Assert.That(File.ReadAllBytes(destinationPath), Is.EqualTo(sourceContent)); + Assert.That(File.ReadAllBytes(destinationPath), Is.Not.EqualTo(destinationContent)); + } + + [Test] + public void LocalFileSystemReplaceMissingSourceThrowsFileNotFoundException() + { + var fileSystem = new LocalFileSystem(); + string sourcePath = GetLocalPath("missing.bin"); + string destinationPath = GetLocalPath("published.bin"); + File.WriteAllBytes(destinationPath, "existing destination"u8.ToArray()); + + Assert.That( + () => fileSystem.Replace(sourcePath, destinationPath), + Throws.TypeOf()); + Assert.That(File.ReadAllBytes(destinationPath), Is.EqualTo("existing destination"u8.ToArray())); + } + + [Test] + public void VirtualFileSystemReplaceMovesSourceWhenDestinationDoesNotExist() + { + using var fileSystem = new VirtualFileSystem(); + const string sourcePath = "staged.bin"; + const string destinationPath = "published.bin"; + byte[] sourceContent = Enumerable.Range(0, 257).Select(i => (byte)i).ToArray(); + fileSystem.Add(sourcePath, sourceContent); + + fileSystem.Replace(sourcePath, destinationPath); + + Assert.That(fileSystem.Exists(sourcePath), Is.False); + Assert.That(fileSystem.Exists(destinationPath), Is.True); + Assert.That(fileSystem.Get(destinationPath), Is.EqualTo(sourceContent)); + } + + [Test] + public void VirtualFileSystemReplaceOverwritesExistingDestinationWithCompleteSource() + { + using var fileSystem = new VirtualFileSystem(); + const string sourcePath = "staged.bin"; + const string destinationPath = "published.bin"; + byte[] sourceContent = Enumerable.Range(0, 1024).Select(i => (byte)(255 - (i % 256))).ToArray(); + byte[] destinationContent = "old complete destination"u8.ToArray(); + fileSystem.Add(sourcePath, sourceContent); + fileSystem.Add(destinationPath, destinationContent); + Assert.That(fileSystem.Get(destinationPath), Is.EqualTo(destinationContent)); + + fileSystem.Replace(sourcePath, destinationPath); + + Assert.That(fileSystem.Exists(sourcePath), Is.False); + Assert.That(fileSystem.Get(destinationPath), Is.EqualTo(sourceContent)); + Assert.That(fileSystem.Get(destinationPath), Is.Not.EqualTo(destinationContent)); + } + + [Test] + public void VirtualFileSystemReplaceMissingSourceThrowsFileNotFoundException() + { + using var fileSystem = new VirtualFileSystem(); + const string sourcePath = "missing.bin"; + const string destinationPath = "published.bin"; + byte[] destinationContent = "existing destination"u8.ToArray(); + fileSystem.Add(destinationPath, destinationContent); + + Assert.That( + () => fileSystem.Replace(sourcePath, destinationPath), + Throws.TypeOf() + .With.Property(nameof(FileNotFoundException.FileName)).EqualTo(sourcePath)); + Assert.That(fileSystem.Get(destinationPath), Is.EqualTo(destinationContent)); + } + + [Test] + public void VirtualFileSystemReplaceConcurrentlyPublishesCompleteSource() + { + using var fileSystem = new VirtualFileSystem(); + const string destinationPath = "published.bin"; + byte[] initialContent = "initial content"u8.ToArray(); + byte[][] contents = Enumerable.Range(0, 32) + .Select(i => Enumerable.Repeat((byte)i, 256 + i).ToArray()) + .ToArray(); + fileSystem.Add(destinationPath, initialContent); + + Task[] tasks = contents.Select((content, index) => Task.Run(() => + { + string sourcePath = "staged" + index + ".bin"; + fileSystem.Add(sourcePath, content); + fileSystem.Replace(sourcePath, destinationPath); + })).ToArray(); + Task.WaitAll(tasks); + + byte[] published = fileSystem.Get(destinationPath); + Assert.That(contents, Has.Some.EqualTo(published)); + for (int ii = 0; ii < contents.Length; ii++) + { + Assert.That(fileSystem.Exists("staged" + ii + ".bin"), Is.False); + } + } + + [Test] + public void CombinedFileSystemReplaceUsesSecondaryByDefault() + { + using var primary = new VirtualFileSystem(); + using var secondary = new VirtualFileSystem(); + var fileSystem = new CombinedFileSystem(primary, secondary); + byte[] sourceContent = "secondary content"u8.ToArray(); + secondary.Add("staged.bin", sourceContent); + + fileSystem.Replace("staged.bin", "published.bin"); + + Assert.That(primary.Exists("published.bin"), Is.False); + Assert.That(secondary.Get("published.bin"), Is.EqualTo(sourceContent)); + } + + [Test] + public void CombinedFileSystemReplaceUsesPrimaryWhenConfiguredForPrimaryWrites() + { + using var primary = new VirtualFileSystem(); + using var secondary = new VirtualFileSystem(); + var fileSystem = new CombinedFileSystem(primary, secondary, usePrimaryForWrite: true); + byte[] sourceContent = "primary content"u8.ToArray(); + primary.Add("staged.bin", sourceContent); + + fileSystem.Replace("staged.bin", "published.bin"); + + Assert.That(primary.Get("published.bin"), Is.EqualTo(sourceContent)); + Assert.That(secondary.Exists("published.bin"), Is.False); + } + + [Test] + public void NullFileSystemReplaceThrowsFileNotFoundException() + { + NullFileSystem fileSystem = NullFileSystem.Instance; + + Assert.That( + () => fileSystem.Replace("staged.bin", "published.bin"), + Throws.TypeOf() + .With.Property(nameof(FileNotFoundException.FileName)).EqualTo("staged.bin")); + } + + [Test] + public void ResourceFileSystemReplaceThrowsIOException() + { + var fileSystem = new ResourceFileSystem(Assembly.GetExecutingAssembly()); + + Assert.That( + () => fileSystem.Replace("staged.bin", "published.bin"), + Throws.TypeOf()); + } + + private string GetLocalPath(string fileName) + { + return Path.Combine(m_testDirectory, fileName); + } + + private string m_testDirectory = string.Empty; + } +} \ No newline at end of file diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorFileSystem.cs b/tools/Opc.Ua.SourceGeneration/SourceGeneratorFileSystem.cs index 5bf92f4939..dfb8860740 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorFileSystem.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorFileSystem.cs @@ -113,6 +113,12 @@ public Stream OpenRead(string path) throw new FileNotFoundException($"File not found: {path}"); } + /// + public void Replace(string sourcePath, string destinationPath) + { + throw new NotSupportedException("Write not allowed"); + } + /// public Stream OpenWrite(string path) {