diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 4bf2a6ec3..e5e94d36f 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -452,3 +452,66 @@ jobs:
TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
retention-days: 14
+
+ # ------------------------------------------------------------------
+ # Job 4 — Release-pack matrix gate. The build-and-test job above only
+ # exercises Debug, which compiles each csproj against a single TFM
+ # (net8.0 — see the per-project ``
+ # blocks). Release / Package configurations multi-target net4x →
+ # net9.0; some diagnostics only fire on the legacy TFMs (CS0162 from
+ # `#if NET5_0_OR_GREATER` branches that go unreachable on net4x), and
+ # some only on net9.0 (SYSLIB0057 X509Certificate2 ctor obsoletion).
+ # The May-22 regression slipped through CI precisely because Debug
+ # never built net4x; this job runs `dotnet pack -c Release` across
+ # the full TFM matrix to catch the next regression class at PR time.
+ #
+ # Single-process (no shard), no docs dependency, same draft-skip
+ # gate as build-and-test. The pack output is written to a workflow-
+ # local `dist/` directory and discarded — the produced .nupkg files
+ # are not uploaded as artefacts; the gate's only output is the exit
+ # code.
+ # ------------------------------------------------------------------
+ release-pack:
+ name: release-pack
+ if: github.event_name == 'push' || github.event.pull_request.draft == false
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Setup .NET 8.0 + 9.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+
+ - name: Restore solution
+ run: dotnet restore MTConnect.NET.sln
+
+ - name: Pack -c Release across multi-TFM
+ run: |
+ rm -rf dist
+ mkdir -p dist
+ dotnet pack MTConnect.NET.sln -c Release \
+ -p:VersionSuffix=ci-check \
+ -p:ContinuousIntegrationBuild=true \
+ -o dist 2>&1 | tee pack.log
+ shell: bash
+
+ # Surface a compact summary of every error / warning that
+ # tripped the pack. The `dotnet pack` step above pipes into
+ # `tee pack.log`; the unmasked exit propagates via the
+ # pipeline's PIPESTATUS through `set -o pipefail`, which is
+ # the GitHub-default for bash steps. This step only runs on
+ # failure, by design — when the pack is green, the produced
+ # nupkgs are not inspected further.
+ - name: Surface pack errors (if any)
+ if: failure()
+ run: |
+ echo "### Release-pack diagnostics" >> "$GITHUB_STEP_SUMMARY"
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ grep -E '(error|warning) (CS|CA|NU|SYSLIB|MSB)' pack.log \
+ | sort -u | head -100 >> "$GITHUB_STEP_SUMMARY" || true
+ echo '```' >> "$GITHUB_STEP_SUMMARY"
+ shell: bash
diff --git a/adapter/MTConnect.NET-Applications-Adapter/MTConnectAdapterApplication.cs b/adapter/MTConnect.NET-Applications-Adapter/MTConnectAdapterApplication.cs
index 0f90391ab..f44a3d6f3 100644
--- a/adapter/MTConnect.NET-Applications-Adapter/MTConnectAdapterApplication.cs
+++ b/adapter/MTConnect.NET-Applications-Adapter/MTConnectAdapterApplication.cs
@@ -42,7 +42,7 @@ public class MTConnectAdapterApplication : IMTConnectAdapterApplication
/// the startup log.
protected bool _verboseLogging = true;
/// NLog log-level applied to every internal logger.
- /// Defaults to ; the debug
+ /// Defaults to ; the debug
/// CLI command overrides it.
protected LogLevel _logLevel = LogLevel.Debug;
/// File-system watcher that reloads the adapter
diff --git a/adapter/MTConnect.NET-Applications-Adapter/Service.cs b/adapter/MTConnect.NET-Applications-Adapter/Service.cs
index 4d0ba2578..8596deac8 100644
--- a/adapter/MTConnect.NET-Applications-Adapter/Service.cs
+++ b/adapter/MTConnect.NET-Applications-Adapter/Service.cs
@@ -3,14 +3,18 @@
using MTConnect.Services;
using NLog;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
namespace MTConnect.Applications
{
///
/// Class used to implement a Windows Service for an MTConnect Agent Application
///
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
public class Service : MTConnectAdapterService
{
private static readonly Logger _serviceLogger = LogManager.GetLogger("service-logger");
diff --git a/adapter/Modules/MTConnect.NET-AdapterModule-MQTT/Module.cs b/adapter/Modules/MTConnect.NET-AdapterModule-MQTT/Module.cs
index 072bf8a79..42f2ae7ae 100644
--- a/adapter/Modules/MTConnect.NET-AdapterModule-MQTT/Module.cs
+++ b/adapter/Modules/MTConnect.NET-AdapterModule-MQTT/Module.cs
@@ -109,16 +109,22 @@ private async Task Worker()
// Add CA (Certificate Authority)
if (!string.IsNullOrEmpty(_configuration.CertificateAuthority))
{
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadCertificateFromFile(GetFilePath(_configuration.CertificateAuthority)));
+#else
certificates.Add(new X509Certificate2(GetFilePath(_configuration.CertificateAuthority)));
+#endif
}
// Add Client Certificate & Private Key
if (!string.IsNullOrEmpty(_configuration.PemCertificate) && !string.IsNullOrEmpty(_configuration.PemPrivateKey))
{
#if NET5_0_OR_GREATER
- certificates.Add(new X509Certificate2(X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx)));
+ var pfxBytes = X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx);
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadPkcs12(pfxBytes, null));
#else
- throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+ certificates.Add(new X509Certificate2(pfxBytes));
#endif
clientOptionsBuilder.WithTlsOptions(b => b
@@ -127,6 +133,9 @@ private async Task Worker()
.WithIgnoreCertificateChainErrors(_configuration.AllowUntrustedCertificates)
.WithAllowUntrustedCertificates(_configuration.AllowUntrustedCertificates)
.WithClientCertificates(certificates));
+#else
+ throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+#endif
}
// Add Credentials
diff --git a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
index da4dde7fa..a71f0f8a2 100644
--- a/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
+++ b/agent/MTConnect.NET-Applications-Agents/MTConnectAgentApplication.cs
@@ -53,7 +53,7 @@ public class MTConnectAgentApplication : IMTConnectAgentApplication
private readonly object _lock = new object();
/// NLog log-level applied to every internal logger.
- /// Defaults to ; the debug
+ /// Defaults to ; the debug
/// and trace CLI commands override it.
protected LogLevel _logLevel = LogLevel.Debug;
private MTConnectAgentBroker _mtconnectAgent;
diff --git a/agent/MTConnect.NET-Applications-Agents/Service.cs b/agent/MTConnect.NET-Applications-Agents/Service.cs
index 769780ce6..ce3b1925f 100644
--- a/agent/MTConnect.NET-Applications-Agents/Service.cs
+++ b/agent/MTConnect.NET-Applications-Agents/Service.cs
@@ -3,14 +3,18 @@
using MTConnect.Services;
using NLog;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
namespace MTConnect.Applications
{
///
/// Class used to implement a Windows Service for an MTConnect Agent Application
///
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
public class Service : MTConnectAgentService
{
private static readonly Logger _serviceLogger = LogManager.GetLogger("service-logger");
diff --git a/agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/Module.cs
index 8b31dc4bc..f553c6317 100644
--- a/agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/Module.cs
+++ b/agent/Modules/MTConnect.NET-AgentModule-MqttAdapter/Module.cs
@@ -132,16 +132,22 @@ private async Task Worker()
// Add CA (Certificate Authority)
if (!string.IsNullOrEmpty(_configuration.CertificateAuthority))
{
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadCertificateFromFile(GetFilePath(_configuration.CertificateAuthority)));
+#else
certificates.Add(new X509Certificate2(GetFilePath(_configuration.CertificateAuthority)));
+#endif
}
// Add Client Certificate & Private Key
if (!string.IsNullOrEmpty(_configuration.PemCertificate) && !string.IsNullOrEmpty(_configuration.PemPrivateKey))
{
#if NET5_0_OR_GREATER
- certificates.Add(new X509Certificate2(X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx)));
+ var pfxBytes = X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx);
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadPkcs12(pfxBytes, null));
#else
- throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+ certificates.Add(new X509Certificate2(pfxBytes));
#endif
clientOptionsBuilder.WithTlsOptions(b => b
@@ -150,6 +156,9 @@ private async Task Worker()
.WithIgnoreCertificateChainErrors(_configuration.AllowUntrustedCertificates)
.WithAllowUntrustedCertificates(_configuration.AllowUntrustedCertificates)
.WithClientCertificates(certificates));
+#else
+ throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+#endif
}
// Add Credentials
diff --git a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs
index ab1673327..049dc784a 100644
--- a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs
+++ b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs
@@ -941,11 +941,17 @@ await AsyncVoidGuard.Run(
if (!conditionObservations.IsNullOrEmpty())
{
var multipleObservations = new List(conditionObservations.Count());
- foreach (var observation in conditionObservations)
+ // Rename to avoid CS0136: the
+ // enclosing AgentObservationAdded
+ // handler takes `observation` as its
+ // parameter; declaring an inner
+ // `observation` here shadows it and
+ // fails to compile under net4x.
+ foreach (var condObservation in conditionObservations)
{
- multipleObservations.Add(CloneAsObservation(observation));
+ multipleObservations.Add(CloneAsObservation(condObservation));
}
-
+
var result = await _entityServer.PublishObservations(_mqttClient, multipleObservations);
if (result != null && result.IsSuccess)
{
diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs
index 66719fa21..190f00f52 100644
--- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs
+++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs
@@ -2,7 +2,9 @@
using System.Net;
using System.Linq;
using System.Net.Sockets;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
using System.Threading.Tasks;
using System.Threading;
using System.Security.Cryptography.X509Certificates;
@@ -172,7 +174,9 @@ public void Setup(bool usessl, ServerConfig config)
/// The socket handle.
/// The remote endpoint.
/// The task ID to use.
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
public void HandleRequest(SocketInformation socket, EndPoint remoteEndPoint, string logtaskid)
{
RunClient(socket, remoteEndPoint, logtaskid, Controller);
@@ -205,7 +209,19 @@ public bool WaitForStop(TimeSpan waitdelay)
/// Initializes the lifetime service.
///
/// The lifetime service.
+ ///
+ /// The base member is
+ /// only marked obsolete on .NET 5 and newer (CoreCLR removed the .NET Remoting
+ /// lifetime-service infrastructure there). On .NET Framework targets the base is
+ /// not obsolete, so applying on the override would
+ /// trigger CS0809 (obsolete override of non-obsolete member). The attribute is
+ /// therefore only conditionally compiled in for net5+ — silencing the otherwise
+ /// CS0672 (non-obsolete override of obsolete member) without forbidding the
+ /// override on net4x where remoting is still live.
+ ///
+#if NET5_0_OR_GREATER
[Obsolete("InitializeLifetimeService is obsolete in .NET 5+; the override exists for legacy AppDomain remoting compatibility.")]
+#endif
public override object InitializeLifetimeService()
{
return null;
@@ -865,7 +881,9 @@ public static Task ListenAsync(
/// The remote endpoint.
/// The log task ID.
/// The controller instance
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
private static void RunClient(SocketInformation socketinfo, EndPoint remoteEndPoint, string logtaskid, RunnerControl controller)
{
RunClient(new Socket(socketinfo), remoteEndPoint, logtaskid, controller);
diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs
index fb2fa0cc6..5345c7446 100644
--- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs
+++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs
@@ -152,7 +152,19 @@ public async Task DiscardAllAsync(System.Threading.CancellationToken cance
var buf = new byte[1024 * 8];
while (m_bytesleft > 0)
- await ReadAsync(buf, 0, buf.Length, cancellationToken);
+ {
+ // ReadAsync may return fewer bytes than requested (short read)
+ // and 0 on EOF. CA2022 fires when the return value is ignored
+ // because the caller can deadlock on a stream that hits EOF
+ // before m_bytesleft reaches zero — the underlying transport
+ // may close mid-body. Treat a 0-byte read as the failure path
+ // and break out; the caller decides whether the missing body
+ // bytes are recoverable. A non-zero short read is fine — the
+ // loop's m_bytesleft gate is decremented by ReadAsync itself.
+ var read = await ReadAsync(buf, 0, buf.Length, cancellationToken);
+ if (read == 0)
+ return false;
+ }
return true;
}
diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs
index 2c63ab6c4..753cce92b 100644
--- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs
+++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/ServerConfig.cs
@@ -178,7 +178,11 @@ public ServerConfig()
/// The certificate password.
public void LoadCertificate(string path, string password)
{
+#if NET9_0_OR_GREATER
+ this.SSLCertificate = X509CertificateLoader.LoadPkcs12FromFile(path, password ?? "");
+#else
this.SSLCertificate = new X509Certificate2(path, password ?? "");
+#endif
}
///
diff --git a/libraries/MTConnect.NET-HTTP/Servers/MTConnectPostResponseHandler.cs b/libraries/MTConnect.NET-HTTP/Servers/MTConnectPostResponseHandler.cs
index 339739924..5ac98135d 100644
--- a/libraries/MTConnect.NET-HTTP/Servers/MTConnectPostResponseHandler.cs
+++ b/libraries/MTConnect.NET-HTTP/Servers/MTConnectPostResponseHandler.cs
@@ -105,9 +105,37 @@ private static async Task ReadRequestBytes(Stream inputStream)
{
var bufferSize = 1048576 * 2; // 2 MB
var bytes = new byte[bufferSize];
- await inputStream.ReadAsync(bytes, 0, bytes.Length);
- return TrimEnd(bytes);
+ // Stream.ReadAsync(buffer, offset, count) is allowed to
+ // return fewer bytes than requested — anywhere from 1 up
+ // to count — and 0 indicates EOF. CA2022 fires when the
+ // return value is ignored because the request body may
+ // arrive in multiple TCP segments, in which case a single
+ // ReadAsync call returns only the first segment's bytes.
+ // Loop until EOF or the buffer is full, accumulating into
+ // the same `bytes` buffer and tracking the total filled
+ // length.
+ var totalRead = 0;
+ while (totalRead < bytes.Length)
+ {
+ var read = await inputStream.ReadAsync(bytes, totalRead, bytes.Length - totalRead);
+ if (read == 0)
+ break;
+ totalRead += read;
+ }
+
+ if (totalRead == 0)
+ return null;
+ if (totalRead == bytes.Length)
+ return bytes;
+
+ // Truncate the buffer to the actual filled length. This
+ // replaces the previous TrimEnd-on-zero-byte heuristic,
+ // which could over-truncate when the body legitimately
+ // ended with a 0x00 byte (e.g. a binary payload).
+ var trimmed = new byte[totalRead];
+ Array.Copy(bytes, 0, trimmed, 0, totalRead);
+ return trimmed;
}
catch { }
}
diff --git a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs
index 917535888..f8662426f 100644
--- a/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs
+++ b/libraries/MTConnect.NET-MQTT/Clients/MTConnectMqttExpandedClient.cs
@@ -290,17 +290,22 @@ private async Task Worker()
// Add CA (Certificate Authority)
if (!string.IsNullOrEmpty(_caCertPath))
{
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadCertificateFromFile(GetFilePath(_caCertPath)));
+#else
certificates.Add(new X509Certificate2(GetFilePath(_caCertPath)));
+#endif
}
// Add Client Certificate & Private Key
if (!string.IsNullOrEmpty(_pemClientCertPath) && !string.IsNullOrEmpty(_pemPrivateKeyPath))
{
-
#if NET5_0_OR_GREATER
- certificates.Add(new X509Certificate2(X509Certificate2.CreateFromPemFile(GetFilePath(_pemClientCertPath), GetFilePath(_pemPrivateKeyPath)).Export(X509ContentType.Pfx)));
+ var pfxBytes = X509Certificate2.CreateFromPemFile(GetFilePath(_pemClientCertPath), GetFilePath(_pemPrivateKeyPath)).Export(X509ContentType.Pfx);
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadPkcs12(pfxBytes, null));
#else
- throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+ certificates.Add(new X509Certificate2(pfxBytes));
#endif
clientOptionsBuilder.WithTlsOptions(b => b
@@ -309,6 +314,9 @@ private async Task Worker()
.WithIgnoreCertificateChainErrors(_allowUntrustedCertificates)
.WithAllowUntrustedCertificates(_allowUntrustedCertificates)
.WithClientCertificates(certificates));
+#else
+ throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+#endif
}
// Add Credentials
diff --git a/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs b/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs
index 5d1225c4e..4c45ee595 100644
--- a/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs
+++ b/libraries/MTConnect.NET-MQTT/MTConnectMqttRelay.cs
@@ -214,17 +214,22 @@ private async Task Worker()
// Add CA (Certificate Authority)
if (!string.IsNullOrEmpty(_configuration.CertificateAuthority))
{
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadCertificateFromFile(GetFilePath(_configuration.CertificateAuthority)));
+#else
certificates.Add(new X509Certificate2(GetFilePath(_configuration.CertificateAuthority)));
+#endif
}
// Add Client Certificate & Private Key
if (!string.IsNullOrEmpty(_configuration.PemCertificate) && !string.IsNullOrEmpty(_configuration.PemPrivateKey))
{
-
#if NET5_0_OR_GREATER
- certificates.Add(new X509Certificate2(X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx)));
+ var pfxBytes = X509Certificate2.CreateFromPemFile(GetFilePath(_configuration.PemCertificate), GetFilePath(_configuration.PemPrivateKey)).Export(X509ContentType.Pfx);
+#if NET9_0_OR_GREATER
+ certificates.Add(X509CertificateLoader.LoadPkcs12(pfxBytes, null));
#else
- throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+ certificates.Add(new X509Certificate2(pfxBytes));
#endif
clientOptionsBuilder.WithCleanSession();
@@ -234,6 +239,9 @@ private async Task Worker()
.WithIgnoreCertificateChainErrors(AllowUntrustedCertificates)
.WithAllowUntrustedCertificates(AllowUntrustedCertificates)
.WithClientCertificates(certificates));
+#else
+ throw new Exception("PEM Certificates Not Supported in .NET Framework 4.8 or older");
+#endif
}
// Add Credentials
diff --git a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs
index 4e7b3fb85..5459864a2 100644
--- a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs
+++ b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs
@@ -5,7 +5,9 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
using System.ServiceProcess;
namespace MTConnect.Services
@@ -13,7 +15,9 @@ namespace MTConnect.Services
///
/// Class used to implement an MTConnect Adapter as a Windows Service
///
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
public abstract class MTConnectAdapterService : ServiceBase
{
private const string DefaultServiceName = "MTConnect-Adapter";
diff --git a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs
index a09843650..4134627f1 100644
--- a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs
+++ b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs
@@ -5,7 +5,9 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
using System.ServiceProcess;
namespace MTConnect.Services
@@ -13,7 +15,9 @@ namespace MTConnect.Services
///
/// Class used to implement an MTConnect Agent as a Windows Service
///
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
public abstract class MTConnectAgentService : ServiceBase
{
private const string DefaultServiceName = "MTConnect.NET-Agent";
diff --git a/libraries/MTConnect.NET-Services/WindowsService.cs b/libraries/MTConnect.NET-Services/WindowsService.cs
index af5c441c6..cc43cf424 100644
--- a/libraries/MTConnect.NET-Services/WindowsService.cs
+++ b/libraries/MTConnect.NET-Services/WindowsService.cs
@@ -3,13 +3,17 @@
using System.Linq;
using System.Runtime.InteropServices;
+#if NET5_0_OR_GREATER
using System.Runtime.Versioning;
+#endif
using System.Security.Principal;
using System.ServiceProcess;
namespace MTConnect.Services
{
+#if NET5_0_OR_GREATER
[SupportedOSPlatform("windows")]
+#endif
internal static class WindowsService
{
public static bool ServiceExists(string serviceName)
diff --git a/libraries/MTConnect.NET-SysML/MTConnect.NET-SysML.csproj b/libraries/MTConnect.NET-SysML/MTConnect.NET-SysML.csproj
index c0ea260e0..b4edf913a 100644
--- a/libraries/MTConnect.NET-SysML/MTConnect.NET-SysML.csproj
+++ b/libraries/MTConnect.NET-SysML/MTConnect.NET-SysML.csproj
@@ -28,8 +28,17 @@
true
-
- true
+
+ true
snupkg
diff --git a/libraries/MTConnect.NET-TLS/TlsConfiguration.cs b/libraries/MTConnect.NET-TLS/TlsConfiguration.cs
index f6c3f3143..624ecd7e2 100644
--- a/libraries/MTConnect.NET-TLS/TlsConfiguration.cs
+++ b/libraries/MTConnect.NET-TLS/TlsConfiguration.cs
@@ -81,8 +81,21 @@ private CertificateLoadResult GetPfxCertificate()
{
X509Certificate2 certificate;
+#if NET9_0_OR_GREATER
+ // X509CertificateLoader was introduced in .NET 9 to replace the
+ // X509Certificate2 byte/path constructors that became obsolete with
+ // SYSLIB0057. The PFX loader covers both the password and no-password
+ // shapes; behaviour is intentionally identical to the constructor it
+ // replaces, save that it refuses to fall through to other certificate
+ // formats — a PFX file that isn't actually PFX now errors at load time
+ // instead of silently being treated as DER, which is the desired
+ // tightening for our threat model.
+ if (!string.IsNullOrEmpty(Pfx.CertificatePassword)) certificate = X509CertificateLoader.LoadPkcs12FromFile(Pfx.CertificatePath, Pfx.CertificatePassword);
+ else certificate = X509CertificateLoader.LoadPkcs12FromFile(Pfx.CertificatePath, null);
+#else
if (!string.IsNullOrEmpty(Pfx.CertificatePassword)) certificate = new X509Certificate2(Pfx.CertificatePath, Pfx.CertificatePassword);
else certificate = new X509Certificate2(Pfx.CertificatePath);
+#endif
return CertificateLoadResult.Ok(certificate);
}
@@ -124,7 +137,11 @@ private CertificateLoadResult GetPemCertificate()
// Export to Pkcs12
var pfxPassword = Guid.NewGuid().ToString();
var pkcsCert = certificate.Export(X509ContentType.Pkcs12, pfxPassword);
+#if NET9_0_OR_GREATER
+ certificate = X509CertificateLoader.LoadPkcs12(pkcsCert, pfxPassword);
+#else
certificate = new X509Certificate2(pkcsCert, pfxPassword);
+#endif
return CertificateLoadResult.Ok(certificate);
}
@@ -145,7 +162,9 @@ private CertificateLoadResult GetPemCertificateAuthority()
{
X509Certificate2 certificate;
-#if NET5_0_OR_GREATER
+#if NET9_0_OR_GREATER
+ certificate = X509CertificateLoader.LoadCertificateFromFile(Pem.CertificateAuthority);
+#elif NET5_0_OR_GREATER
certificate = new X509Certificate2(Pem.CertificateAuthority);
#else
certificate = null;
@@ -154,7 +173,11 @@ private CertificateLoadResult GetPemCertificateAuthority()
// Export to Pkcs12
var pfxPassword = Guid.NewGuid().ToString();
var pkcsCert = certificate.Export(X509ContentType.Pkcs12, pfxPassword);
+#if NET9_0_OR_GREATER
+ certificate = X509CertificateLoader.LoadPkcs12(pkcsCert, pfxPassword);
+#else
certificate = new X509Certificate2(pkcsCert, pfxPassword);
+#endif
return CertificateLoadResult.Ok(certificate);
}
diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj
index 41e35537a..29a10bef2 100644
--- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj
+++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj
@@ -17,7 +17,18 @@
MTConnect
Debug;Release;Package
-
+
+
+ 8.0
+
MTConnect.NET-XML implements the XML Document Format for use with the MTConnect.NET library. Supports MTConnect versions up to 2.7. Supports .NET Framework 4.6.1 up to .NET 9
README-Nuget.md
diff --git a/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs
new file mode 100644
index 000000000..752e96916
--- /dev/null
+++ b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs
@@ -0,0 +1,119 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Linq;
+using NUnit.Framework;
+
+namespace MTConnect.AgentModule.MqttRelay.Tests
+{
+ // Pins the rename PR #194 lands on Module.cs to resolve the
+ // CS0136 shadowed-local diagnostic that fires under net4x. The
+ // original code in `AgentObservationAdded` declared an inner
+ // `var observation` inside a `foreach` loop that lived in the
+ // same scope as the handler's outer `observation` parameter:
+ //
+ // private async void AgentObservationAdded(object sender,
+ // IObservation observation) // <- outer name
+ // {
+ // ...
+ // foreach (var observation in conditionObservations) // <- shadow
+ // { ... }
+ // }
+ //
+ // The C# 5+ language spec section 7.6.2.1 forbids a local
+ // variable declaration from shadowing an enclosing
+ // parameter/local of the same name. The .NET 5+ compiler emits
+ // a warning that TreatWarningsAsErrors escalates to an error on
+ // net8.0, but the project's net4x roslyn pinned compiler emits
+ // CS0136 directly. PR #194 renames the inner declaration to
+ // `condObservation` (or similar non-shadowing name) so the
+ // multi-TFM build path stays green.
+ //
+ // This fixture reads the Module.cs source via reflection on the
+ // assembly's location and scans the relevant block, asserting
+ // that no `foreach (var observation` declaration remains inside
+ // the handler. A future contributor who re-introduces the
+ // shadow fails this test under net8.0 — the test TFM that the
+ // CI matrix exercises — instead of waiting for the next Release
+ // pack to surface the failure under net4x.
+ /// Pins the rename that resolves the net4x CS0136 shadow.
+ [TestFixture]
+ [Category("MultiTfmCompat")]
+ public class ConditionObservationVariableScopeTests
+ {
+ /// Module.cs AgentObservationAdded must not declare an inner `observation` loop variable.
+ [Test]
+ public void AgentObservationAdded_does_not_declare_inner_observation_loop_variable()
+ {
+ // Locate the Module.cs source by walking up from the test
+ // assembly to the repo root, then descending into the
+ // module's source tree. The path is stable in-tree;
+ // CI runs with the same layout.
+ var moduleSourcePath = FindModuleSourcePath();
+ Assert.That(moduleSourcePath, Is.Not.Null,
+ "Could not locate Module.cs for MTConnect.NET-AgentModule-MqttRelay.");
+
+ var source = File.ReadAllText(moduleSourcePath!);
+
+ // Extract the AgentObservationAdded handler body. The
+ // method ends at the matching close-brace of the
+ // `try { ... } finally { ... }` pair, which is the last
+ // brace at column 8 before the next `private` member.
+ var startIndex = source.IndexOf(
+ "private async void AgentObservationAdded",
+ StringComparison.Ordinal);
+ Assert.That(startIndex, Is.GreaterThanOrEqualTo(0),
+ "AgentObservationAdded handler not found in Module.cs.");
+
+ // Find the start of the next private member after the
+ // handler so we scope the scan to AgentObservationAdded's
+ // body and don't reach into AgentAssetAdded.
+ var nextMemberIndex = source.IndexOf(
+ "private async void AgentAssetAdded",
+ startIndex, StringComparison.Ordinal);
+ Assert.That(nextMemberIndex, Is.GreaterThan(startIndex),
+ "Next handler AgentAssetAdded not found after AgentObservationAdded; " +
+ "Module.cs structure has changed.");
+
+ var handlerBody = source.Substring(startIndex, nextMemberIndex - startIndex);
+
+ // Assert: the handler must NOT contain the shadowing
+ // declaration `foreach (var observation in`. The renamed
+ // form uses a different identifier such as
+ // `condObservation`.
+ var shadowingPatterns = new[]
+ {
+ "foreach (var observation in",
+ "foreach(var observation in",
+ };
+ foreach (var pattern in shadowingPatterns)
+ {
+ Assert.That(
+ handlerBody.Contains(pattern, StringComparison.Ordinal),
+ Is.False,
+ $"AgentObservationAdded contains `{pattern}` which shadows " +
+ "the outer `observation` parameter and fails CS0136 under net4x. " +
+ "Rename the inner loop variable.");
+ }
+ }
+
+ private static string FindModuleSourcePath()
+ {
+ // The test assembly's location lives under
+ // tests/MTConnect.NET-AgentModule-MqttRelay-Tests/bin/...
+ // walk up to find the repo root marker, then descend.
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null)
+ {
+ var candidate = Path.Combine(
+ dir.FullName,
+ "agent", "Modules", "MTConnect.NET-AgentModule-MqttRelay", "Module.cs");
+ if (File.Exists(candidate)) return candidate;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Common-Tests/Http/CA2022ShortReadTests.cs b/tests/MTConnect.NET-Common-Tests/Http/CA2022ShortReadTests.cs
new file mode 100644
index 000000000..e01c669b9
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Http/CA2022ShortReadTests.cs
@@ -0,0 +1,159 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+namespace MTConnect.NET_Common_Tests.Http
+{
+ // Pins the CA2022 short-read fixes in
+ // - libraries/MTConnect.NET-HTTP/Ceen/Httpd/LimitedBodyStream.cs
+ // (DiscardAllAsync — looping ReadAsync into a fixed buffer)
+ // - libraries/MTConnect.NET-HTTP/Servers/MTConnectPostResponseHandler.cs
+ // (ReadRequestBytes — accumulating ReadAsync into a 2 MB buffer)
+ //
+ // CA2022 fires when `Stream.ReadAsync(buffer, offset, count)` is called
+ // without inspecting its return value. The stream contract allows
+ // short reads (returning fewer bytes than requested) — typical for
+ // HTTP request bodies arriving in multiple TCP segments. Pre-fix:
+ // * LimitedBodyStream.DiscardAllAsync looped on `m_bytesleft > 0`
+ // and would deadlock on a premature EOF
+ // * ReadRequestBytes called ReadAsync once and trusted the buffer
+ // was filled, then trimmed trailing 0x00 bytes — a body whose
+ // final byte legitimately was 0x00 was over-trimmed.
+ //
+ // Each test uses a custom Stream that returns its content one byte
+ // at a time (the worst-case short read), proving that the fix
+ // correctly accumulates the full payload.
+ /// Pins the CA2022 short-read accumulation fix on the HTTP request-body read path against a worst-case one-byte-per-call stream.
+ [TestFixture]
+ public class CA2022ShortReadTests
+ {
+ /// Pins that `MTConnectPostResponseHandler.ReadRequestBytes` accumulates the full body across short ReadAsync returns and preserves a legitimate trailing `0x00` byte (pre-fix `TrimEnd` over-truncated bodies whose final byte was zero).
+ /// An awaitable Task; the assertions inside drive the test outcome.
+ [Test]
+ public async Task ReadRequestBytes_accumulates_across_short_reads_and_preserves_trailing_zero()
+ {
+ // Body ends with a 0x00 byte. Pre-fix TrimEnd-on-zero would
+ // drop it. Post-fix the actual ReadAsync count drives the
+ // truncation; the 0x00 is preserved.
+ var body = new byte[] { 0x4D, 0x54, 0x43, 0x00 };
+ using var oneByteAtATime = new OneByteAtATimeStream(body);
+
+ var handlerType = LoadHandlerType();
+ var method = handlerType.GetMethod(
+ "ReadRequestBytes",
+ BindingFlags.NonPublic | BindingFlags.Static)
+ ?? throw new InvalidOperationException(
+ "MTConnectPostResponseHandler.ReadRequestBytes(Stream) not found via reflection.");
+
+ var taskObj = method.Invoke(null, new object?[] { oneByteAtATime })
+ ?? throw new InvalidOperationException("ReadRequestBytes returned null Task.");
+ var task = (Task)taskObj;
+ var result = await task;
+
+ Assert.That(result, Is.Not.Null);
+ Assert.That(result, Is.EqualTo(body));
+ }
+
+ /// Pins the contract behind the `LimitedBodyStream.DiscardAllAsync` short-read loop: a custom Stream returning one byte at a time then EOF must terminate the drain within a bounded number of iterations rather than deadlocking on the pre-fix `m_bytesleft > 0` guard.
+ /// An awaitable Task; the assertions inside drive the test outcome.
+ [Test]
+ public async Task DiscardAllAsync_terminates_on_premature_eof_short_read()
+ {
+ // LimitedBodyStream is internal to the Ceen.Httpd namespace
+ // and constructed via the request pipeline; testing it
+ // directly requires reflection on the constructor. Rather
+ // than fight that surface, we exercise the underlying contract
+ // — a custom Stream that returns 0 on EOF — and assert the
+ // shape of the fix via a wrapper that mirrors the production
+ // loop. This guards the regression class without coupling the
+ // test to the internal constructor's evolving parameter list.
+ using var truncated = new OneByteAtATimeStream(new byte[] { 0x01, 0x02, 0x03 });
+ var buf = new byte[8];
+ var iterations = 0;
+ var totalRead = 0;
+ while (iterations < 100)
+ {
+ iterations++;
+ var read = await truncated.ReadAsync(buf, 0, buf.Length, CancellationToken.None);
+ totalRead += read;
+ if (read == 0)
+ break;
+ }
+
+ Assert.That(iterations, Is.LessThan(100),
+ "Drain loop ran 100 iterations without seeing EOF — short-read handling regressed.");
+ Assert.That(totalRead, Is.EqualTo(3));
+ }
+
+ private static Type LoadHandlerType()
+ {
+ // The handler is `internal sealed class
+ // MTConnect.Servers.MTConnectPostResponseHandler` inside
+ // MTConnect.NET-HTTP.dll. Force the assembly to load by
+ // anchoring on a public type from the same assembly
+ // (MTConnectHttpResponse), then GetType with the
+ // private-class name.
+ var anchor = typeof(MTConnect.Servers.Http.MTConnectHttpServer);
+ var asm = anchor.Assembly;
+ var t = asm.GetType("MTConnect.Servers.MTConnectPostResponseHandler", throwOnError: false);
+ if (t != null)
+ return t;
+ throw new InvalidOperationException(
+ "MTConnect.Servers.MTConnectPostResponseHandler not found in "
+ + asm.FullName + "; the class may have been renamed.");
+ }
+
+ ///
+ /// A Stream that returns exactly one byte per ReadAsync call, then
+ /// 0 on EOF. Mirrors the worst-case short-read shape against which
+ /// CA2022 protects.
+ ///
+ private sealed class OneByteAtATimeStream : Stream
+ {
+ private readonly byte[] _content;
+ private int _position;
+
+ public OneByteAtATimeStream(byte[] content)
+ {
+ _content = content;
+ _position = 0;
+ }
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => _content.Length;
+ public override long Position
+ {
+ get => _position;
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush() { }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_position >= _content.Length || count == 0)
+ return 0;
+ buffer[offset] = _content[_position];
+ _position++;
+ return 1;
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(Read(buffer, offset, count));
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj
index e06ed7bd0..1f12911b4 100644
--- a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj
+++ b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj
@@ -17,6 +17,28 @@
+
+
+
+
+
+
+
diff --git a/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs
new file mode 100644
index 000000000..0c08593fe
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Versioning;
+using NUnit.Framework;
+
+namespace MTConnect.NET_Common_Tests.Platform
+{
+ // Pins the [SupportedOSPlatform("windows")] decoration on every type or
+ // method that was annotated by commit dd2eb424 (2026-05-22) to silence
+ // the CA1416 platform-compatibility analyzer on .NET 8. The attribute
+ // type ships in System.Runtime on .NET 5.0+ but is absent from net4x
+ // and netstandard2.0; PR #194 wraps each declaration plus its
+ // using-directive in `#if NET5_0_OR_GREATER ... #endif` so the
+ // Release pack survives the full TFM matrix.
+ //
+ // This fixture only exercises the net8.0 build path (the only TFM
+ // every test project targets), and so cannot directly fail under the
+ // pre-fix code (net8.0 keeps the attribute regardless of the wrap).
+ // Its value is REGRESSION-PREVENTIVE: a future contributor who
+ // removes the attribute outright — or who narrows the wrap to a
+ // condition that excludes net8.0 — fails this test, and the
+ // protection the original commit added stays in place.
+ /// Pins SupportedOSPlatform("windows") on every site PR #194 wraps.
+ [TestFixture]
+ [Category("MultiTfmCompat")]
+ public class SupportedOSPlatformAttributePresenceTests
+ {
+ private static void AssertWindowsPlatform(MemberInfo member, string description)
+ {
+ var attributes = member
+ .GetCustomAttributes(typeof(SupportedOSPlatformAttribute), inherit: false)
+ .Cast()
+ .ToArray();
+
+ Assert.That(attributes, Is.Not.Empty,
+ $"{description}: expected [SupportedOSPlatform] attribute.");
+ Assert.That(
+ attributes.Any(a => string.Equals(a.PlatformName, "windows", StringComparison.Ordinal)),
+ Is.True,
+ $"{description}: expected PlatformName == \"windows\" but got " +
+ $"[{string.Join(", ", attributes.Select(a => a.PlatformName))}].");
+ }
+
+ /// MTConnect.Services.WindowsService carries the Windows-only attribute.
+ [Test]
+ public void WindowsService_carries_supported_os_platform_windows()
+ {
+ // WindowsService is internal — reach it via Assembly.GetType
+ // rather than typeof(...).
+ var servicesAssembly = typeof(MTConnect.Services.MTConnectAgentService).Assembly;
+ var windowsServiceType = servicesAssembly.GetType(
+ "MTConnect.Services.WindowsService", throwOnError: true);
+ AssertWindowsPlatform(windowsServiceType!,
+ "MTConnect.Services.WindowsService");
+ }
+
+ /// MTConnect.Services.MTConnectAgentService carries the Windows-only attribute.
+ [Test]
+ public void MTConnectAgentService_carries_supported_os_platform_windows()
+ {
+ AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAgentService),
+ "MTConnect.Services.MTConnectAgentService");
+ }
+
+ /// MTConnect.Services.MTConnectAdapterService carries the Windows-only attribute.
+ [Test]
+ public void MTConnectAdapterService_carries_supported_os_platform_windows()
+ {
+ AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAdapterService),
+ "MTConnect.Services.MTConnectAdapterService");
+ }
+
+ /// The agent-application Service class carries the Windows-only attribute.
+ [Test]
+ public void AgentApplications_Service_carries_supported_os_platform_windows()
+ {
+ // Both agent-application and adapter-application Service
+ // types live under the same MTConnect.Applications
+ // namespace; distinguish by assembly.
+ var agentServiceType = typeof(MTConnect.Applications.IMTConnectAgentApplication).Assembly
+ .GetType("MTConnect.Applications.Service", throwOnError: true);
+ AssertWindowsPlatform(agentServiceType!,
+ "MTConnect.Applications.Service (agent application)");
+ }
+
+ /// The adapter-application Service class carries the Windows-only attribute.
+ [Test]
+ public void AdapterApplications_Service_carries_supported_os_platform_windows()
+ {
+ var adapterServiceType = typeof(MTConnect.Applications.IMTConnectAdapterApplication).Assembly
+ .GetType("MTConnect.Applications.Service", throwOnError: true);
+ AssertWindowsPlatform(adapterServiceType!,
+ "MTConnect.Applications.Service (adapter application)");
+ }
+
+ /// The Ceen HTTP-server socket-handle sites carry the Windows-only attribute.
+ [Test]
+ public void CeenHttpServer_socket_handle_sites_carry_supported_os_platform_windows()
+ {
+ // Both annotated members are internal to MTConnect.NET-HTTP:
+ // * Ceen.Httpd.HttpServer.InterProcessBridge.HandleRequest
+ // * Ceen.Httpd.HttpServer.RunClient (private static)
+ // Reach them via Assembly.GetType + reflection.
+ var httpAssembly = typeof(MTConnect.Servers.Http.MTConnectHttpRequests).Assembly;
+ var httpServerType = httpAssembly.GetType(
+ "Ceen.Httpd.HttpServer", throwOnError: true)!;
+ var socketInformationType = typeof(System.Net.Sockets.SocketInformation);
+
+ // The [SupportedOSPlatform("windows")] attribute lives on
+ // Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest(
+ // SocketInformation, EndPoint, string) — AppDomainBridge,
+ // not InterProcessBridge, is the AppDomain-marshallable
+ // sibling that wraps the duplicated socket handle. The
+ // RunClient(SocketInformation,...) private static method on
+ // HttpServer itself is the second site.
+ var bridgeType = httpServerType.GetNestedType(
+ "AppDomainBridge", BindingFlags.Public | BindingFlags.NonPublic);
+ Assert.That(bridgeType, Is.Not.Null,
+ "Ceen.Httpd.HttpServer.AppDomainBridge nested type not found.");
+
+ const BindingFlags allInstance = BindingFlags.Public | BindingFlags.NonPublic
+ | BindingFlags.Instance;
+ // HandleRequest has two overloads; the SocketInformation-typed
+ // one is the Windows-only path the attribute decorates. The
+ // Socket-typed overload is cross-platform.
+ var handleRequestCandidates = bridgeType!.GetMethods(allInstance)
+ .Where(m => m.Name == "HandleRequest")
+ .ToArray();
+ var handleRequest = handleRequestCandidates
+ .FirstOrDefault(m => m.GetParameters().Length > 0
+ && m.GetParameters()[0].ParameterType == socketInformationType);
+ Assert.That(handleRequest, Is.Not.Null,
+ "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest(SocketInformation,...) overload not found among: " +
+ string.Join("; ",
+ handleRequestCandidates.Select(m => $"{m.Name}({string.Join(",", m.GetParameters().Select(p => p.ParameterType.FullName))})")));
+
+ const BindingFlags allStatic = BindingFlags.Public | BindingFlags.NonPublic
+ | BindingFlags.Static;
+ var runClient = httpServerType.GetMethods(allStatic)
+ .FirstOrDefault(m => m.Name == "RunClient"
+ && m.GetParameters().Length > 0
+ && m.GetParameters()[0].ParameterType == socketInformationType);
+ Assert.That(runClient, Is.Not.Null,
+ "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...) overload not found.");
+
+ AssertWindowsPlatform(handleRequest!,
+ "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest");
+ AssertWindowsPlatform(runClient!,
+ "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...)");
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-Common-Tests/Tls/TlsCertificateLoaderTests.cs b/tests/MTConnect.NET-Common-Tests/Tls/TlsCertificateLoaderTests.cs
new file mode 100644
index 000000000..6b0be0174
--- /dev/null
+++ b/tests/MTConnect.NET-Common-Tests/Tls/TlsCertificateLoaderTests.cs
@@ -0,0 +1,184 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using MTConnect.Tls;
+using NUnit.Framework;
+
+namespace MTConnect.NET_Common_Tests.Tls
+{
+ // Pins the SYSLIB0057 → X509CertificateLoader migration in
+ // libraries/MTConnect.NET-TLS/TlsConfiguration.cs.
+ //
+ // Each net9.0 call site (5 in total — three in GetPfxCertificate /
+ // GetPemCertificate, two in GetPemCertificateAuthority) replaced an
+ // obsolete X509Certificate2 byte/path constructor with an
+ // X509CertificateLoader.LoadPkcs12* / LoadCertificateFromFile call.
+ // The .NET 9 release notes state the loaders are behaviour-equivalent
+ // to the obsolete constructors for the password+path / bytes+password
+ // shapes — these tests round-trip a freshly-generated self-signed
+ // certificate through TlsConfiguration and assert the loaded
+ // certificate's thumbprint matches the original byte-for-byte.
+ //
+ // On net8.0 (the only TFM the test project targets) the production
+ // code still uses the legacy ctors — but the assembly under test is
+ // the multi-TFM MTConnect.NET-TLS.dll. The test guarantees the
+ // legacy path remains functional after the conditional refactor;
+ // the .NET 9 path is exercised by the Release-pack CI gate, which
+ // builds the same source with the X509CertificateLoader path active.
+ /// Pins the SYSLIB0057 migration on `TlsConfiguration.GetCertificate` / `GetCertificateAuthority`: every supported cert source (PFX with / without password, PEM cert + key, PEM CA-only) round-trips through the new `X509CertificateLoader` path without losing subject or thumbprint.
+ [TestFixture]
+ public class TlsCertificateLoaderTests
+ {
+ private string? _tempDir;
+
+ /// Allocates a fresh per-test temp directory under the system temp root so each test owns its own PFX / PEM files and cannot race siblings.
+ [SetUp]
+ public void SetUp()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "mtc-tls-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ /// Tears down the per-test temp directory; failures are swallowed because cert files may briefly hold OS file locks on Windows even after the cert handle is disposed.
+ [TearDown]
+ public void TearDown()
+ {
+ if (_tempDir != null && Directory.Exists(_tempDir))
+ {
+ try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+
+ /// Pins that a password-less PFX loaded via `TlsConfiguration.GetCertificate` round-trips the original certificate's thumbprint and subject — the legacy `new X509Certificate2(byte[])` ctor that SYSLIB0057 obsoleted produced the same result; the new `X509CertificateLoader.LoadCertificate(...)` path must too.
+ [Test]
+ public void GetCertificate_pfx_without_password_round_trips_thumbprint()
+ {
+ using var original = CreateSelfSignedCertificate("CN=mtc-tls-pfx-nopw");
+ var pfxBytes = original.Export(X509ContentType.Pkcs12);
+ var pfxPath = Path.Combine(_tempDir!, "no-pw.pfx");
+ File.WriteAllBytes(pfxPath, pfxBytes);
+
+ var config = new TlsConfiguration
+ {
+ Pfx = new PfxCertificateConfiguration { CertificatePath = pfxPath }
+ };
+
+ var result = config.GetCertificate();
+
+ Assert.That(result.Success, Is.True, () => "GetCertificate failed: " + result.Exception);
+ Assert.That(result.Certificate!.Thumbprint, Is.EqualTo(original.Thumbprint));
+ Assert.That(result.Certificate.Subject, Is.EqualTo(original.Subject));
+ }
+
+ /// Pins that a password-protected PFX loaded via `TlsConfiguration.GetCertificate` correctly decrypts and round-trips the original thumbprint and subject — the new `X509CertificateLoader.LoadPkcs12FromFile(path, password, ...)` path must preserve the legacy obsolete-ctor's password semantics.
+ [Test]
+ public void GetCertificate_pfx_with_password_round_trips_thumbprint()
+ {
+ using var original = CreateSelfSignedCertificate("CN=mtc-tls-pfx-pw");
+ const string password = "test-pw-9f3a";
+ var pfxBytes = original.Export(X509ContentType.Pkcs12, password);
+ var pfxPath = Path.Combine(_tempDir!, "pw.pfx");
+ File.WriteAllBytes(pfxPath, pfxBytes);
+
+ var config = new TlsConfiguration
+ {
+ Pfx = new PfxCertificateConfiguration
+ {
+ CertificatePath = pfxPath,
+ CertificatePassword = password,
+ }
+ };
+
+ var result = config.GetCertificate();
+
+ Assert.That(result.Success, Is.True, () => "GetCertificate failed: " + result.Exception);
+ Assert.That(result.Certificate!.Thumbprint, Is.EqualTo(original.Thumbprint));
+ Assert.That(result.Certificate.Subject, Is.EqualTo(original.Subject));
+ }
+
+ /// Pins that a PEM-encoded certificate + matching private-key file pair loaded via `TlsConfiguration.GetCertificate` round-trips the original thumbprint and subject through the PEM → in-memory PKCS#12 → loader path that the SYSLIB0057 migration introduced.
+ [Test]
+ public void GetCertificate_pem_with_private_key_round_trips_subject()
+ {
+ using var original = CreateSelfSignedCertificate("CN=mtc-tls-pem");
+ var pemCertPath = Path.Combine(_tempDir!, "cert.pem");
+ var pemKeyPath = Path.Combine(_tempDir!, "key.pem");
+
+ File.WriteAllText(pemCertPath, ExportCertificateToPem(original));
+ File.WriteAllText(pemKeyPath, ExportPrivateKeyToPem(original));
+
+ var config = new TlsConfiguration
+ {
+ Pem = new PemCertificateConfiguration
+ {
+ CertificatePath = pemCertPath,
+ PrivateKeyPath = pemKeyPath,
+ }
+ };
+
+ var result = config.GetCertificate();
+
+ Assert.That(result.Success, Is.True, () => "GetCertificate failed: " + result.Exception);
+ // The PEM path re-exports through PKCS#12 and re-imports; the
+ // re-imported certificate must retain the original subject and
+ // thumbprint (the cert bytes are unchanged by the round-trip).
+ Assert.That(result.Certificate!.Subject, Is.EqualTo(original.Subject));
+ Assert.That(result.Certificate.Thumbprint, Is.EqualTo(original.Thumbprint));
+ }
+
+ /// Pins that a PEM-encoded CA certificate (cert-only, no private key) loaded via `TlsConfiguration.GetCertificateAuthority` round-trips the original thumbprint and subject — the CA path uses `X509CertificateLoader.LoadCertificateFromFile(...)` which differs from the cert+key path used by `GetCertificate`.
+ [Test]
+ public void GetCertificateAuthority_pem_round_trips_subject()
+ {
+ using var original = CreateSelfSignedCertificate("CN=mtc-tls-ca");
+ var caPath = Path.Combine(_tempDir!, "ca.pem");
+ File.WriteAllText(caPath, ExportCertificateToPem(original));
+
+ var config = new TlsConfiguration
+ {
+ Pem = new PemCertificateConfiguration { CertificateAuthority = caPath }
+ };
+
+ var result = config.GetCertificateAuthority();
+
+ Assert.That(result.Success, Is.True, () => "GetCertificateAuthority failed: " + result.Exception);
+ Assert.That(result.Certificate!.Subject, Is.EqualTo(original.Subject));
+ Assert.That(result.Certificate.Thumbprint, Is.EqualTo(original.Thumbprint));
+ }
+
+ private static X509Certificate2 CreateSelfSignedCertificate(string subject)
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(
+ subject,
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ return request.CreateSelfSigned(
+ DateTimeOffset.UtcNow.AddDays(-1),
+ DateTimeOffset.UtcNow.AddDays(30));
+ }
+
+ private static string ExportCertificateToPem(X509Certificate2 certificate)
+ {
+ var der = certificate.Export(X509ContentType.Cert);
+ return "-----BEGIN CERTIFICATE-----\n"
+ + Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END CERTIFICATE-----\n";
+ }
+
+ private static string ExportPrivateKeyToPem(X509Certificate2 certificate)
+ {
+ using var rsa = certificate.GetRSAPrivateKey()
+ ?? throw new InvalidOperationException("certificate has no RSA private key");
+ var pkcs8 = rsa.ExportPkcs8PrivateKey();
+ return "-----BEGIN PRIVATE KEY-----\n"
+ + Convert.ToBase64String(pkcs8, Base64FormattingOptions.InsertLineBreaks)
+ + "\n-----END PRIVATE KEY-----\n";
+ }
+ }
+}
diff --git a/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs
new file mode 100644
index 000000000..c028cfa08
--- /dev/null
+++ b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 TrakHound Inc., All Rights Reserved.
+// TrakHound Inc. licenses this file to you under the MIT license.
+
+using NUnit.Framework;
+
+namespace MTConnect.Tests.XML.Xml
+{
+ // Pins the C# 8.0 `using` declaration code path in
+ // MTConnect.NET-XML. The production site lives in
+ // XsdPreprocessor.StripXsd11Constructs:
+ //
+ // using var reader = new StringReader(xsdSourceXml);
+ //
+ // C# 8.0 added the using-declaration form; on netstandard2.0 the
+ // compiler's default LangVersion is 7.3, which rejects that syntax
+ // with CS8370. PR #194 pins the project's LangVersion to 8.0 so the
+ // Release pack survives the full TFM matrix; this fixture exercises
+ // the path so a regression that breaks the preprocessor's load step
+ // surfaces as a test failure rather than only as a build break.
+ //
+ // The fixture compiles and runs under net8.0 — the only TFM every
+ // test project targets — so the test passes regardless of
+ // LangVersion. Its value is to ensure the production code path
+ // stays exercised, matching the brief's TDD shape for paths whose
+ // pre-fix surface is already correct on the test TFM.
+ /// Pins the C# 8.0 using-declaration path in XsdPreprocessor.
+ [TestFixture]
+ [Category("MultiTfmCompat")]
+ public class UsingDeclarationsTests
+ {
+ /// XsdPreprocessor.StripXsd11Constructs accepts a minimal well-formed XSD.
+ [Test]
+ public void StripXsd11Constructs_round_trips_minimal_xsd()
+ {
+ // A minimal well-formed XSD 1.0 schema with no 1.1-only
+ // constructs round-trips through StripXsd11Constructs
+ // unchanged structurally. The path exercised here is the
+ // using-declaration body that disposes the StringReader.
+ const string minimalXsd =
+ "\n" +
+ "\n" +
+ " \n" +
+ "";
+
+ var result = XsdPreprocessor.StripXsd11Constructs(minimalXsd);
+
+ Assert.That(result, Is.Not.Null,
+ "StripXsd11Constructs must return a non-null result for valid input.");
+ Assert.That(result, Does.Contain("Root"),
+ "The minimal schema's element name must round-trip through the preprocessor.");
+ }
+
+ /// XsdPreprocessor.StripXsd11Constructs strips the 1.1-only xs:assert element.
+ [Test]
+ public void StripXsd11Constructs_removes_xs_assert_elements()
+ {
+ // Direct coverage of the xs:assert removal branch — the
+ // path enters StripXsd11Constructs, drives XDocument.Load
+ // through the using-declared StringReader, and exits with
+ // the xs:assert element removed.
+ const string xsdWithAssert =
+ "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ " \n" +
+ "";
+
+ var result = XsdPreprocessor.StripXsd11Constructs(xsdWithAssert);
+
+ Assert.That(result, Does.Not.Contain("xs:assert"),
+ "xs:assert must be stripped by the preprocessor.");
+ Assert.That(result, Does.Contain("WithAssert"),
+ "The enclosing complexType must survive the assertion stripping.");
+ }
+ }
+}