Files
UA-.NETStandard/tests/Opc.Ua.Core.Diagnostics.Tests/DependencyInjection/PcapServiceCollectionExtensionsTests.cs
Marc Schier 80db326567 Fix MCP diagnostics tool contracts and capture failures (#4241)
# Description

Fix the MCP packet-diagnostics tool contract and the capture failures
reported in #4238.

- Accept and emit canonical hyphenated capture source and format names
while preserving CLR-name and integer compatibility.
- Restore typed enum constraints in generated MCP schemas and surface
actionable diagnostics errors to MCP clients.
- Stop `list_interfaces` from reading `LinkType` on unopened SharpPcap
devices.
- Close failed SharpPcap handles before retrying NIC opens and report
permission failures immediately.
- Normalize capture roots, remove duplicated path segments, and use a
lazy secure per-user or atomic temporary fallback.
- Align MCP/diagnostics documentation and interop-agent guidance.

## Related Issues

- Fixes #4238

## Checklist

- [ ] 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.
- [ ] I have addressed **all** PR feedback received.

---------

Copilot-Session: 641a4356-aa8d-45e6-9077-eeb50b9b7e0a
2026-08-12 10:33:56 +02:00

540 lines
21 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.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Opc.Ua.Bindings;
using Opc.Ua.Pcap.Bindings;
using Opc.Ua.Pcap.Capture;
using Opc.Ua.Pcap.Capture.Sources;
using Opc.Ua.Pcap.DependencyInjection;
using Opc.Ua.Pcap.Formats;
using Opc.Ua.Pcap.Models;
using Opc.Ua.Pcap.Replay;
namespace Opc.Ua.Pcap.Tests.DependencyInjection
{
/// <summary>
/// Behavioural tests for the DI extension methods that wire the OPC UA
/// pcap binding into <c>Microsoft.Extensions.DependencyInjection</c>.
/// </summary>
[TestFixture]
public sealed class PcapServiceCollectionExtensionsTests : TempDirectoryFixture
{
[Test]
public void AddPcapThrowsOnNullServices()
{
IServiceCollection? services = null;
Assert.That(
() => services!.AddPcap(),
Throws.TypeOf<ArgumentNullException>()
.With.Property("ParamName").EqualTo("services"));
}
[Test]
public void AddPcapWithConfigureThrowsOnNullConfigure()
{
var services = new ServiceCollection();
Action<PcapOptions>? configure = null;
Assert.That(
() => services.AddPcap(configure!),
Throws.TypeOf<ArgumentNullException>()
.With.Property("ParamName").EqualTo("configure"));
}
[Test]
public void AddPcapRegistersCoreSingletonsExactlyOnce()
{
var services = new ServiceCollection();
services.AddPcap();
int optionsCount = services.Count(d => d.ServiceType == typeof(PcapOptions));
int registryCount = services.Count(d => d.ServiceType == typeof(IChannelCaptureRegistry));
int factoryCount = services.Count(d => d.ServiceType == typeof(ICaptureSourceFactory));
int managerCount = services.Count(d => d.ServiceType == typeof(CaptureSessionManager));
Assert.That(optionsCount, Is.EqualTo(1));
Assert.That(registryCount, Is.EqualTo(1));
Assert.That(factoryCount, Is.EqualTo(1));
Assert.That(managerCount, Is.EqualTo(1));
}
[Test]
public void AddPcapRegistersServicesAsSingletons()
{
var services = new ServiceCollection();
services.AddPcap();
ServiceLifetime optionsLifetime = services
.First(d => d.ServiceType == typeof(PcapOptions)).Lifetime;
ServiceLifetime registryLifetime = services
.First(d => d.ServiceType == typeof(IChannelCaptureRegistry)).Lifetime;
ServiceLifetime factoryLifetime = services
.First(d => d.ServiceType == typeof(ICaptureSourceFactory)).Lifetime;
ServiceLifetime managerLifetime = services
.First(d => d.ServiceType == typeof(CaptureSessionManager)).Lifetime;
Assert.That(optionsLifetime, Is.EqualTo(ServiceLifetime.Singleton));
Assert.That(registryLifetime, Is.EqualTo(ServiceLifetime.Singleton));
Assert.That(factoryLifetime, Is.EqualTo(ServiceLifetime.Singleton));
Assert.That(managerLifetime, Is.EqualTo(ServiceLifetime.Singleton));
}
[Test]
public void AddPcapFormattersBuilderOverloadReturnsSameBuilder()
{
var services = new ServiceCollection();
IOpcUaBuilder builder = services.AddOpcUa();
IOpcUaBuilder returned = builder.AddPcapFormatters();
Assert.That(returned, Is.SameAs(builder));
Assert.That(services.Any(d => d.ServiceType == typeof(TraceFormatterRegistry)), Is.True);
}
[Test]
public void AddPcapReplayBuilderOverloadReturnsSameBuilder()
{
var services = new ServiceCollection();
IOpcUaBuilder builder = services.AddOpcUa();
IOpcUaBuilder returned = builder.AddPcapReplay();
Assert.That(returned, Is.SameAs(builder));
Assert.That(services.Any(d => d.ServiceType == typeof(ReplaySessionManager)), Is.True);
}
[Test]
public async Task AddPcapResolvesAllRequiredServices()
{
var services = new ServiceCollection();
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
PcapOptions options = provider.GetRequiredService<PcapOptions>();
IChannelCaptureRegistry registry = provider.GetRequiredService<IChannelCaptureRegistry>();
ICaptureSourceFactory factory = provider.GetRequiredService<ICaptureSourceFactory>();
await using CaptureSessionManager manager = provider.GetRequiredService<CaptureSessionManager>();
Assert.That(options, Is.Not.Null);
Assert.That(registry, Is.InstanceOf<ChannelCaptureRegistry>());
Assert.That(factory, Is.InstanceOf<DefaultCaptureSourceFactory>());
Assert.That(manager, Is.Not.Null);
}
[Test]
public async Task AddPcapResolvesSameSingletonAcrossCalls()
{
var services = new ServiceCollection();
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
IChannelCaptureRegistry registry1 = provider.GetRequiredService<IChannelCaptureRegistry>();
IChannelCaptureRegistry registry2 = provider.GetRequiredService<IChannelCaptureRegistry>();
CaptureSessionManager manager1 = provider.GetRequiredService<CaptureSessionManager>();
CaptureSessionManager manager2 = provider.GetRequiredService<CaptureSessionManager>();
Assert.That(registry2, Is.SameAs(registry1));
Assert.That(manager2, Is.SameAs(manager1));
}
[Test]
[Platform("Linux,MacOSX")]
public async Task CaptureSessionManagerBaseFolderHasUserOnlyMode()
{
if (OperatingSystem.IsWindows())
{
Assert.Ignore("Unix file modes are not available on Windows.");
return;
}
string baseFolder = CreateTempPath("manager-base");
await using var manager = new CaptureSessionManager(
new DefaultCaptureSourceFactory(new ChannelCaptureRegistry()),
baseFolder);
Assert.That(
File.GetUnixFileMode(baseFolder),
Is.EqualTo(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute));
}
[Test]
public async Task AddPcapInvokesUserConfigureCallback()
{
var services = new ServiceCollection();
string desiredFolder = Path.Combine(Path.GetTempPath(), "pcap-di-test-" + Guid.NewGuid().ToString("N"));
services.AddPcap(opts =>
{
opts.BaseFolder = desiredFolder;
opts.MaxActiveSessions = 3;
});
await using ServiceProvider provider = services.BuildServiceProvider();
PcapOptions options = provider.GetRequiredService<PcapOptions>();
Assert.That(options.BaseFolder, Is.EqualTo(desiredFolder));
Assert.That(options.MaxActiveSessions, Is.EqualTo(3));
}
[Test]
public async Task AddPcapPropagatesMaxActiveSessionsToManager()
{
var services = new ServiceCollection();
string desiredFolder = Path.Combine(Path.GetTempPath(), "pcap-di-cap-" + Guid.NewGuid().ToString("N"));
services.AddPcap(opts =>
{
opts.BaseFolder = desiredFolder;
opts.MaxActiveSessions = 2;
});
await using ServiceProvider provider = services.BuildServiceProvider();
CaptureSessionManager manager = provider.GetRequiredService<CaptureSessionManager>();
// The cap configured on PcapOptions must reach the manager
// instance (this is what makes the option meaningful for
// DI consumers; without the wiring the manager would
// silently fall back to DefaultMaxActiveSessions).
Assert.That(manager.MaxActiveSessions, Is.EqualTo(2));
}
[Test]
public async Task AddPcapDefaultOptionsUsePerUserLocalAppData()
{
var services = new ServiceCollection();
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
PcapOptions options = provider.GetRequiredService<PcapOptions>();
Assert.That(Path.IsPathRooted(options.BaseFolder), Is.True);
Assert.That(
options.BaseFolder,
Does.StartWith(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)));
Assert.That(options.BaseFolder, Does.Contain("OPCFoundation"));
Assert.That(options.BaseFolder, Does.Contain("opcua-pcap"));
Assert.That(options.MaxActiveSessions,
Is.EqualTo(CaptureSessionManager.DefaultMaxActiveSessions));
}
[Test]
public void DefaultBaseFolderFallsBackToAbsoluteTemporaryPathWithoutUserProfile()
{
string temporaryFolder = Path.Combine(TempDirectory, "opcua-pcap-random-token");
bool temporaryFolderCreated = false;
string baseFolder = PcapOptions.CreateDefaultBaseFolder(
string.Empty,
() =>
{
temporaryFolderCreated = true;
Directory.CreateDirectory(temporaryFolder);
return temporaryFolder;
});
Assert.That(Path.IsPathRooted(baseFolder), Is.True);
Assert.That(baseFolder, Is.EqualTo(Path.GetFullPath(temporaryFolder)));
Assert.That(temporaryFolderCreated, Is.True);
}
[Test]
public void DefaultBaseFolderIsStableWithinProcess()
{
string first = PcapOptions.DefaultBaseFolder;
string second = PcapOptions.DefaultBaseFolder;
Assert.That(second, Is.EqualTo(first));
}
[Test]
public void ExplicitBaseFolderDoesNotResolveTemporaryFallback()
{
bool fallbackResolved = false;
var options = new PcapOptions(() =>
{
fallbackResolved = true;
return Path.Combine(TempDirectory, "unused-fallback");
});
string configuredBaseFolder = Path.Combine(TempDirectory, "configured");
options.BaseFolder = configuredBaseFolder;
Assert.That(options.BaseFolder, Is.EqualTo(configuredBaseFolder));
Assert.That(fallbackResolved, Is.False);
}
[Test]
public void BaseFolderSetterNormalizesRelativePath()
{
var options = new PcapOptions(() => Path.Combine(TempDirectory, "fallback"));
string relativeFolder = "relative-pcap-" + Path.GetRandomFileName();
options.BaseFolder = relativeFolder;
Assert.That(options.BaseFolder, Is.EqualTo(Path.GetFullPath(relativeFolder)));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void BaseFolderSetterUsesDefaultForBlankValue(string? value)
{
string fallback = Path.Combine(TempDirectory, "fallback");
int fallbackReads = 0;
var options = new PcapOptions(() =>
{
fallbackReads++;
return fallback;
});
options.BaseFolder = value!;
Assert.That(options.BaseFolder, Is.EqualTo(fallback));
Assert.That(fallbackReads, Is.EqualTo(1));
}
[Test]
public void UserProfilePathDoesNotRequireExistingDirectory()
{
string localApplicationData = Path.Combine(
TempDirectory,
"not-created",
"local-share");
bool fallbackResolved = false;
string baseFolder = PcapOptions.CreateDefaultBaseFolder(
localApplicationData,
() =>
{
fallbackResolved = true;
return Path.Combine(TempDirectory, "fallback");
});
Assert.That(
baseFolder,
Is.EqualTo(Path.Combine(
localApplicationData,
"OPCFoundation",
"opcua-pcap")));
Assert.That(fallbackResolved, Is.False);
}
[Test]
public async Task AddPcapInstallsPcapBindingIntoTransportRegistry()
{
var services = new ServiceCollection();
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
ITransportBindingRegistry bindings = provider.GetRequiredService<ITransportBindingRegistry>();
ITransportChannelFactory? binding = bindings.GetChannelFactory(
Utils.UriSchemeOpcTcp);
Assert.That(binding, Is.Not.Null);
Assert.That(binding, Is.InstanceOf<PcapTransportChannelBinding>());
}
[Test]
public async Task AddPcapInstallsOnlyServerBindingForServerApplicationType()
{
var services = new ServiceCollection();
services.AddSingleton(
new ApplicationConfiguration { ApplicationType = ApplicationType.Server });
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
// Apply the pcap configurator to a registry that already has the
// default opc.tcp listener/channel so the server listener binding
// has something to wrap.
var bindings = DefaultTransportBindingRegistry.WithDefaultTcp();
ApplyConfigurators(provider, bindings);
Assert.That(
bindings.GetListenerFactory(Utils.UriSchemeOpcTcp),
Is.InstanceOf<PcapTransportListenerBinding>(),
"A Server application must install the pcap listener binding.");
Assert.That(
bindings.GetChannelFactory(Utils.UriSchemeOpcTcp),
Is.Not.InstanceOf<PcapTransportChannelBinding>(),
"A Server application must not install the client channel binding.");
}
[Test]
public async Task AddPcapInstallsOnlyClientBindingForClientApplicationType()
{
var services = new ServiceCollection();
services.AddSingleton(
new ApplicationConfiguration { ApplicationType = ApplicationType.Client });
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
var bindings = DefaultTransportBindingRegistry.WithDefaultTcp();
ApplyConfigurators(provider, bindings);
Assert.That(
bindings.GetChannelFactory(Utils.UriSchemeOpcTcp),
Is.InstanceOf<PcapTransportChannelBinding>(),
"A Client application must install the pcap channel binding.");
Assert.That(
bindings.GetListenerFactory(Utils.UriSchemeOpcTcp),
Is.Not.InstanceOf<PcapTransportListenerBinding>(),
"A Client application must not wrap the server listener binding.");
}
[Test]
public async Task AddPcapInstallsBothBindingsWhenApplicationTypeUnknown()
{
var services = new ServiceCollection();
// No ApplicationConfiguration registered -> historic default (both).
services.AddPcap();
await using ServiceProvider provider = services.BuildServiceProvider();
var bindings = DefaultTransportBindingRegistry.WithDefaultTcp();
ApplyConfigurators(provider, bindings);
Assert.That(
bindings.GetChannelFactory(Utils.UriSchemeOpcTcp),
Is.InstanceOf<PcapTransportChannelBinding>());
Assert.That(
bindings.GetListenerFactory(Utils.UriSchemeOpcTcp),
Is.InstanceOf<PcapTransportListenerBinding>());
}
private static void ApplyConfigurators(
IServiceProvider provider,
ITransportBindingRegistry bindings)
{
foreach (ITransportBindingConfigurator configurator in
provider.GetServices<ITransportBindingConfigurator>())
{
configurator.Configure(bindings);
}
}
[Test]
public void AddPcapFormattersThrowsOnNullServices()
{
IServiceCollection? services = null;
Assert.That(
() => services!.AddPcapFormatters(),
Throws.TypeOf<ArgumentNullException>()
.With.Property("ParamName").EqualTo("services"));
}
[Test]
public void AddPcapFormattersRegistersRegistryAsSingleton()
{
var services = new ServiceCollection();
services.AddPcapFormatters();
ServiceDescriptor descriptor = services
.Single(d => d.ServiceType == typeof(TraceFormatterRegistry));
Assert.That(descriptor.Lifetime, Is.EqualTo(ServiceLifetime.Singleton));
}
[Test]
public void AddPcapFormattersResolvesNonEmptyRegistry()
{
var services = new ServiceCollection();
services.AddPcapFormatters();
using ServiceProvider provider = services.BuildServiceProvider();
TraceFormatterRegistry registry1 = provider.GetRequiredService<TraceFormatterRegistry>();
TraceFormatterRegistry registry2 = provider.GetRequiredService<TraceFormatterRegistry>();
Assert.That(registry1, Is.SameAs(registry2));
Assert.That(registry1.Available, Contains.Item(FormatKind.Pcap),
"Default registry must contain a binary pcap formatter.");
Assert.That(registry1.Available, Contains.Item(FormatKind.Json),
"Default registry must contain a JSON formatter.");
}
[Test]
public void AddPcapReplayThrowsOnNullServices()
{
IServiceCollection? services = null;
Assert.That(
() => services!.AddPcapReplay(),
Throws.TypeOf<ArgumentNullException>()
.With.Property("ParamName").EqualTo("services"));
}
[Test]
public void AddPcapReplayRegistersReplaySessionManagerAsSingleton()
{
var services = new ServiceCollection();
services.AddPcapReplay();
ServiceDescriptor descriptor = services
.Single(d => d.ServiceType == typeof(ReplaySessionManager));
Assert.That(descriptor.Lifetime, Is.EqualTo(ServiceLifetime.Singleton));
}
[Test]
public async Task AddPcapReplayResolvesSameSingleton()
{
var services = new ServiceCollection();
services.AddPcapReplay();
await using ServiceProvider provider = services.BuildServiceProvider();
ReplaySessionManager manager1 = provider.GetRequiredService<ReplaySessionManager>();
ReplaySessionManager manager2 = provider.GetRequiredService<ReplaySessionManager>();
Assert.That(manager2, Is.SameAs(manager1));
}
[Test]
public void PcapOptionsDefaultsAreSane()
{
var options = new PcapOptions();
Assert.That(options.BaseFolder, Does.Contain("opcua-pcap"));
Assert.That(options.MaxActiveSessions, Is.GreaterThan(0));
Assert.That(options.MaxActiveSessions,
Is.EqualTo(CaptureSessionManager.DefaultMaxActiveSessions));
}
}
}