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."); + } + } +}