mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 06:31:35 +08:00
be50f02700
# Description Expose existing stack APIs through **337 additional domain MCP tools**: 114 industrial-companion tools, 35 WoT/xRegistry/document tools, and 188 higher-level stack-workflow tools. The fully enabled catalogue contains **492 tools**; profile selection is not an execution grant. The packages now follow the reviewed structure: **`Opc.Ua.Mcp.Workflows`** contains roles/users, files, alarms and history; **`Opc.Ua.Mcp.WotCon`** contains management/documents and independently registered live WoT execution. GDS and schema tools remain separate packages. The executable composes **18 libraries**. | Workflow | Added domain tools | Profiles | | --- | ---: | --- | | GDS directory, certificates, reference-only credentials/tokens and Part 21 onboarding | 46 | `gds`, `certificates`, `credentials`, `onboarding` | | Roles/users, regular and temporary files, alarms/conditions and historical access | 74 | `roles`, `users`, `files`, `alarms`, `history` | | Bounded runtime types, XSD/BSD/UA JSON, PubSub and REST OpenAPI artifacts | 6 | `schemas` | | Named PubSub transports, remote configuration, declarative adapters/bridges and SKS | 52 | `pubsub-runtime`, `pubsub-configuration`, `pubsub-bridge`, `pubsub-security` | | Live HTTP(S), MQTT, Modbus TCP and OPC UA WoT forms | 10 | `wot-runtime` | - Reuse typed clients, generated proxies, SDK transports, schema providers and injected services rather than introducing another session manager or protocol engine. - Require host enablement plus exact caller/session/secret/network grants. Protected generic Call/Write/node-management/history/file paths enforce the same policy. - Bind classifications, dispatches, file handles and historical cursors to actual UA generations. Recovery cannot retarget an old handle or authorized mutation. Preserve acknowledged results after late cancellation; do not imply atomicity or retry business commands. - Use opaque secret-store references, explicit writable-store registration, issuer/resource/caller binding and reserved capacity before issuance. Report remote success/local persistence failure as partial completion; redact provider-controlled diagnostic text. - Bound new named runtimes to eight per host, two per caller, five-minute idle expiry and a thirty-minute absolute lease by default. Pending/closing resources consume quota; failed cleanup remains visible. Credential invalidation and session recovery close dependent runtimes. - Keep document planning separate from execution, artifacts root-confined and real JSON inputs typed. Preserve old profile ordinals while widening the set to 64 bits. CI grades fully enabled privileged catalogues rather than connection-only views. **Limitations:** live Kafka remains explicitly unavailable because the backend cannot enforce policy on broker-discovered connections; configuration/schema/capability inspection stays available. The latest public Dekaf v1.23.5 was also checked and its DNS injection remains internal. No private-API workaround or bootstrap-only security claim was added. Full legacy Core registration retains its documented JSON-reflection limitation: native tests isolate its four connection descriptors and validate the expanded production registrations, not whole-host NativeAOT support. ## Related Issues - Fixes #4730. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [x] I have addressed **all** PR feedback received.
458 lines
22 KiB
C#
458 lines
22 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.FileSystem;
|
|
using Opc.Ua.Client.Tests.SessionContexts;
|
|
|
|
namespace Opc.Ua.Client.Tests.FileSystem
|
|
{
|
|
/// <summary>
|
|
/// Verifies bounded transfers and private handle ownership through real generation-checked dispatch.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Parallelizable(ParallelScope.All)]
|
|
public sealed class SessionFileHandleTests
|
|
{
|
|
/// <summary>
|
|
/// Neither a write nor disposal can deliver an old handle to a recreated session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapturedFileHandleRejectsWriteAndCleanupAfterRecreation()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
SessionFileHandle handle = await context.OpenFileAsync(s_file, 6).ConfigureAwait(false);
|
|
await using (handle.ConfigureAwait(false))
|
|
{
|
|
fixture.Recreate();
|
|
using var source = new MemoryStream([1]);
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await handle.WriteFromAsync(source, 1).ConfigureAwait(false))!;
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(handle.SessionId, Is.EqualTo(NodeId.Parse("s=original-session")));
|
|
}
|
|
Assert.That(Methods(fixture), Is.EqualTo([MethodIds.FileType_Open]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open is checked after waiting for recovery, not just when the caller obtained the context.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapturedOpenRejectsGenerationChangeAtDispatchGate()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
Task<SessionFileHandle> opening;
|
|
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
|
|
{
|
|
opening = context.OpenFileAsync(s_file, 6).AsTask();
|
|
Assert.That(opening.IsCompleted, Is.False);
|
|
fixture.Recreate();
|
|
}
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await opening.ConfigureAwait(false))!;
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(fixture.Requests, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Short reads are drained, the exact byte bound is accepted, and the terminal status is retained.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScopedDownloadContinuesShortReadsAndPreservesCloseResult()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var chunks = new Queue<ByteString>([ByteString.From([1]), ByteString.From([2, 3]), ByteString.Empty]);
|
|
var expected = new CallMethodResult
|
|
{
|
|
StatusCode = StatusCodes.GoodResultsMayBeIncomplete,
|
|
InputArgumentResults = [StatusCodes.BadOutOfRange],
|
|
InputArgumentDiagnosticInfos = [new DiagnosticInfo { AdditionalInfo = "close diagnostic" }]
|
|
};
|
|
Script(fixture, chunks, (method, _) => method.MethodId == MethodIds.FileType_Close ? expected : null);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
using var destination = new MemoryStream();
|
|
(long total, CallMethodResult result) = await context.DownloadToStreamWithResultAsync(
|
|
s_file, destination, 3, 4).ConfigureAwait(false);
|
|
Assert.That(total, Is.EqualTo(3));
|
|
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
|
|
Assert.That(result, Is.SameAs(expected));
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.FileType_Open, MethodIds.FileType_Read, MethodIds.FileType_Read,
|
|
MethodIds.FileType_Read, MethodIds.FileType_Close
|
|
]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Oversized server chunks are refused before any bytes reach the destination.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScopedDownloadRejectsOversizedChunkBeforeWriting()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture, new Queue<ByteString>([ByteString.From([1, 2, 3, 4])]));
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
using var destination = new MemoryStream();
|
|
Assert.ThrowsAsync<IOException>(async () => await context.DownloadToStreamWithResultAsync(
|
|
s_file, destination, 3, 4).ConfigureAwait(false));
|
|
Assert.That(destination.Length, Is.Zero);
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.FileType_Open, MethodIds.FileType_Read, MethodIds.FileType_Close
|
|
]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalid or already oversized seekable uploads do not allocate a remote handle.
|
|
/// </summary>
|
|
[TestCase(0)]
|
|
[TestCase(2)]
|
|
public async Task ScopedUploadRejectsInvalidBoundBeforeOpen(int limit)
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
using var source = new MemoryStream([1, 2, 3]);
|
|
if (limit == 0)
|
|
{
|
|
Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
|
async () => await context.UploadFromWithResultAsync(s_file, source, limit).ConfigureAwait(false));
|
|
}
|
|
else
|
|
{
|
|
Assert.ThrowsAsync<IOException>(
|
|
async () => await context.UploadFromWithResultAsync(s_file, source, limit).ConfigureAwait(false));
|
|
}
|
|
Assert.That(fixture.Requests, Is.Empty);
|
|
Assert.That(source.Position, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nonseekable short reads are counted as they arrive, before the excess chunk is written.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ScopedUploadBoundsNonSeekableShortReads()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
using var source = new ShortSource([1, 2, 3, 4]);
|
|
Assert.ThrowsAsync<IOException>(
|
|
async () => await context.UploadFromWithResultAsync(s_file, source, 3, 4).ConfigureAwait(false));
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.FileType_Open, MethodIds.FileType_Write, MethodIds.FileType_Close
|
|
]));
|
|
CallMethodRequest write = ((CallRequest)fixture.Requests[1]).MethodsToCall[0];
|
|
Assert.That(write.InputArguments[1].TryGetValue(out ByteString bytes), Is.True);
|
|
Assert.That(bytes, Is.EqualTo(ByteString.From([1, 2])));
|
|
Assert.That(source.CanRead, Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A successful temporary commit remains successful when cancellation and recreation follow its effect.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TemporaryUploadPreservesCommitAfterCancellationAndRecreation()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
using var cancellation = new CancellationTokenSource();
|
|
NodeId completion = new(90u, 1);
|
|
var expected = new CallMethodResult
|
|
{
|
|
StatusCode = StatusCodes.GoodCompletesAsynchronously,
|
|
OutputArguments = [Variant.From(completion)]
|
|
};
|
|
Script(fixture, overrideResult: (method, _) =>
|
|
{
|
|
if (method.MethodId != MethodIds.TemporaryFileTransferType_CloseAndCommit)
|
|
{
|
|
return null;
|
|
}
|
|
fixture.Recreate();
|
|
cancellation.Cancel();
|
|
return expected;
|
|
});
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(
|
|
context.Session, s_transfer, new FileSystemClientOptions { ChunkSize = 2 });
|
|
using var source = new MemoryStream([1, 2, 3]);
|
|
(long count, NodeId file, NodeId state, CallMethodResult result) = await transfer.UploadFromAsync(
|
|
context, source, 3, ct: cancellation.Token).ConfigureAwait(false);
|
|
Assert.That(count, Is.EqualTo(3));
|
|
Assert.That(file, Is.EqualTo(s_file));
|
|
Assert.That(state, Is.EqualTo(completion));
|
|
Assert.That(result, Is.SameAs(expected));
|
|
Assert.That(cancellation.IsCancellationRequested, Is.True);
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
|
|
MethodIds.FileType_Write, MethodIds.FileType_Write,
|
|
MethodIds.TemporaryFileTransferType_CloseAndCommit
|
|
]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Temporary reads use the generated handle, preserve short reads and close without an extra Open.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TemporaryDownloadUsesGeneratedHandleAndPreservesBound()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture, new Queue<ByteString>([ByteString.From([1]), ByteString.From([2, 3]), ByteString.Empty]));
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(
|
|
context.Session, s_transfer, new FileSystemClientOptions { ChunkSize = 2 });
|
|
using var destination = new MemoryStream();
|
|
(long count, NodeId file, CallMethodResult result) = await transfer.DownloadToAsync(
|
|
context, destination, 3).ConfigureAwait(false);
|
|
Assert.That(count, Is.EqualTo(3));
|
|
Assert.That(file, Is.EqualTo(s_file));
|
|
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.TemporaryFileTransferType_GenerateFileForRead,
|
|
MethodIds.FileType_Read, MethodIds.FileType_Read, MethodIds.FileType_Read, MethodIds.FileType_Close
|
|
]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Readiness metadata from an old generation never causes a Read or Close on its replacement.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TemporaryReadReadinessRejectsReplacementBeforeReadOrCleanup()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
fixture.Handler = (request, _) =>
|
|
{
|
|
if (request is CallRequest)
|
|
{
|
|
return new ValueTask<IServiceResponse>(new CallResponse
|
|
{
|
|
Results =
|
|
[
|
|
new CallMethodResult
|
|
{
|
|
OutputArguments =
|
|
[Variant.From(s_file), Variant.From(kHandle), Variant.From(new NodeId(99u, 1))]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
Assert.That(request, Is.TypeOf<TranslateBrowsePathsToNodeIdsRequest>());
|
|
fixture.Recreate();
|
|
return new ValueTask<IServiceResponse>(new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
Targets =
|
|
[
|
|
new BrowsePathTarget
|
|
{
|
|
TargetId = new ExpandedNodeId(100u, 1),
|
|
RemainingPathIndex = uint.MaxValue
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
};
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await transfer.GenerateFileForReadAsync(context).ConfigureAwait(false))!;
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
|
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
|
|
Assert.That(fixture.Requests[0], Is.TypeOf<CallRequest>());
|
|
Assert.That(fixture.Requests[1], Is.TypeOf<TranslateBrowsePathsToNodeIdsRequest>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// A refused commit is returned unchanged and only the original file handle is closed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TemporaryCommitPreservesRefusalAndClosesOriginalHandle()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
var expected = new CallMethodResult { StatusCode = StatusCodes.BadUserAccessDenied };
|
|
Script(fixture, overrideResult: (method, _) =>
|
|
method.MethodId == MethodIds.TemporaryFileTransferType_CloseAndCommit ? expected : null);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
|
|
using var source = new MemoryStream([1]);
|
|
(long count, _, NodeId state, CallMethodResult result) = await transfer.UploadFromAsync(
|
|
context, source, 1).ConfigureAwait(false);
|
|
Assert.That(count, Is.EqualTo(1));
|
|
Assert.That(state.IsNull, Is.True);
|
|
Assert.That(result, Is.SameAs(expected));
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
|
|
MethodIds.FileType_Write, MethodIds.TemporaryFileTransferType_CloseAndCommit, MethodIds.FileType_Close
|
|
]));
|
|
CallMethodRequest close = ((CallRequest)fixture.Requests[3]).MethodsToCall[0];
|
|
Assert.That(close.ObjectId, Is.EqualTo(s_file));
|
|
Assert.That(close.InputArguments, Is.EqualTo([Variant.From(kHandle)]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A client cannot adopt another captured context, even for the same current server session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TemporaryClientRejectsAnotherContextBeforeGenerate()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture);
|
|
await using SessionRequestContext original =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
await using SessionRequestContext other =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(original.Session, s_transfer);
|
|
Assert.ThrowsAsync<ArgumentException>(
|
|
async () => await transfer.GenerateFileForWriteAsync(other).ConfigureAwait(false));
|
|
Assert.That(fixture.Requests, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A malformed successful commit is known to be committed and is not followed by an unsafe Close.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task MalformedCompletedCommitDoesNotCloseConsumedHandle()
|
|
{
|
|
await using var fixture = new SessionRequestContextTestContext();
|
|
Script(fixture, overrideResult: (method, _) =>
|
|
method.MethodId == MethodIds.TemporaryFileTransferType_CloseAndCommit
|
|
? new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = [] } : null);
|
|
await using SessionRequestContext context =
|
|
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
|
|
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
|
|
SessionFileHandle handle = await transfer.GenerateFileForWriteAsync(context).ConfigureAwait(false);
|
|
await using (handle.ConfigureAwait(false))
|
|
{
|
|
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await handle.CommitAsync().ConfigureAwait(false))!;
|
|
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
|
|
Assert.That(handle.IsCommitted, Is.True);
|
|
}
|
|
Assert.That(Methods(fixture), Is.EqualTo(
|
|
[
|
|
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
|
|
MethodIds.TemporaryFileTransferType_CloseAndCommit
|
|
]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs a minimal FileType and TemporaryFileTransfer service script.
|
|
/// </summary>
|
|
private static void Script(
|
|
SessionRequestContextTestContext fixture, Queue<ByteString>? chunks = null,
|
|
Func<CallMethodRequest, CancellationToken, CallMethodResult?>? overrideResult = null)
|
|
{
|
|
fixture.Handler = (request, token) =>
|
|
{
|
|
var call = (CallRequest)request;
|
|
Assert.That(call.MethodsToCall.Count, Is.EqualTo(1));
|
|
CallMethodRequest method = call.MethodsToCall[0];
|
|
CallMethodResult? result = overrideResult?.Invoke(method, token);
|
|
ArrayOf<Variant> output = method.MethodId == MethodIds.FileType_Open
|
|
? [Variant.From(kHandle)]
|
|
: method.MethodId == MethodIds.FileType_Read
|
|
? [Variant.From(chunks!.Dequeue())]
|
|
: method.MethodId == MethodIds.TemporaryFileTransferType_GenerateFileForWrite
|
|
? [Variant.From(s_file), Variant.From(kHandle)]
|
|
: method.MethodId == MethodIds.TemporaryFileTransferType_GenerateFileForRead
|
|
? [Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]
|
|
: [];
|
|
return new ValueTask<IServiceResponse>(new CallResponse
|
|
{
|
|
Results = [result ?? new CallMethodResult { OutputArguments = output }]
|
|
});
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets only the methods that reached the transport.
|
|
/// </summary>
|
|
private static NodeId[] Methods(SessionRequestContextTestContext fixture)
|
|
{
|
|
return [.. fixture.Requests.Cast<CallRequest>().Select(request => request.MethodsToCall[0].MethodId)];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Produces two-byte reads without exposing an advance length.
|
|
/// </summary>
|
|
private sealed class ShortSource(byte[] bytes) : MemoryStream(bytes)
|
|
{
|
|
/// <inheritdoc/>
|
|
public override bool CanSeek => false;
|
|
|
|
/// <inheritdoc/>
|
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
|
|
{
|
|
return base.ReadAsync(buffer, offset, Math.Min(count, 2), ct);
|
|
}
|
|
|
|
#if NET6_0_OR_GREATER
|
|
/// <inheritdoc/>
|
|
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
|
|
{
|
|
return base.ReadAsync(buffer[..Math.Min(buffer.Length, 2)], ct);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private const uint kHandle = 73;
|
|
private static readonly NodeId s_file = new(80u, 1);
|
|
private static readonly NodeId s_transfer = new(81u, 1);
|
|
}
|
|
}
|