From e48e26313c9fe2fec3b9b28ae149f7fa7f5bede1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Mon, 17 Aug 2026 22:12:14 +0200 Subject: [PATCH 1/5] fix(xml): route v2.6/v2.7 namespaces + default unknown to Max MTConnectVersion.GetByNamespace capped its dispatch chain at Version25 and fell through to an empty Version for any newer namespace, even though the Version26/Version27 namespace classes and MTConnectVersions constants already existed. A document declaring MTConnectStreams:2.7 (or :2.6) therefore resolved to an empty version. --- .../MTConnect.NET-XML.csproj | 4 + .../MTConnect.NET-XML/MTConnectVersion.cs | 5 +- .../MTConnectVersionDispatchTests.cs | 123 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj index 64ad33b8e..8b5a6ef99 100644 --- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj +++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj @@ -72,6 +72,10 @@ + + + + True diff --git a/libraries/MTConnect.NET-XML/MTConnectVersion.cs b/libraries/MTConnect.NET-XML/MTConnectVersion.cs index e81738ed0..6f332636a 100644 --- a/libraries/MTConnect.NET-XML/MTConnectVersion.cs +++ b/libraries/MTConnect.NET-XML/MTConnectVersion.cs @@ -23,6 +23,8 @@ public static Version GetByNamespace(string ns) { if (ns != null) { + if (Namespaces.Version27.Match(ns)) return MTConnectVersions.Version27; + if (Namespaces.Version26.Match(ns)) return MTConnectVersions.Version26; if (Namespaces.Version25.Match(ns)) return MTConnectVersions.Version25; if (Namespaces.Version24.Match(ns)) return MTConnectVersions.Version24; if (Namespaces.Version23.Match(ns)) return MTConnectVersions.Version23; @@ -40,7 +42,8 @@ public static Version GetByNamespace(string ns) if (Namespaces.Version10.Match(ns)) return MTConnectVersions.Version10; } - return new Version(); + // unknown namespace → default to latest supported version + return MTConnectVersions.Max; } } } \ No newline at end of file diff --git a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs new file mode 100644 index 000000000..a2b3d6d58 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +// Pins the fix for the `MTConnectVersion.GetByNamespace` dispatch-chain +// omission: the switch capped out at `Namespaces.Version25.Match(ns)` and +// fell through to `return new Version()` (empty, "0.0") for any namespace +// declared by a document newer than v2.5 - even though `Namespaces.Version26` +// / `Namespaces.Version27` and `MTConnectVersions.Version26` / +// `MTConnectVersions.Version27` both already existed. A document declaring +// `urn:mtconnect.org:MTConnectStreams:2.7` (or `:2.6`) therefore resolved to +// an empty version instead of its real one. +// +// Fix (libraries/MTConnect.NET-XML/MTConnectVersion.cs): +// - Prepended `Namespaces.Version27.Match(ns)` / `Namespaces.Version26.Match(ns)` +// branches ahead of `Namespaces.Version25.Match(ns)`, so v2.6/v2.7 +// namespaces resolve highest-first, mirroring the existing pattern. +// - Changed the fallback from `new Version()` to `MTConnectVersions.Max` +// so an unrecognised namespace defaults to the latest supported release +// rather than an empty version. +// +// `MTConnectVersion` and `Namespaces` are `internal` to +// MTConnect.NET-XML; this fixture reaches them via the assembly's +// `InternalsVisibleTo` grant to MTConnect.NET-XML-Tests. + +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace MTConnect.Tests.XML +{ + /// Pins the namespace-to-version dispatch chain in . + [TestFixture] + public class MTConnectVersionDispatchTests + { + /// Every currently-declared MTConnect Devices namespace paired with the version it must resolve to. + /// The (namespace, expected version) pairs. + public static IEnumerable DevicesNamespacesByVersion() + { + yield return new TestCaseData(Namespaces.Version10.Devices, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Devices_1_0"); + yield return new TestCaseData(Namespaces.Version11.Devices, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Devices_1_1"); + yield return new TestCaseData(Namespaces.Version12.Devices, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Devices_1_2"); + yield return new TestCaseData(Namespaces.Version13.Devices, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Devices_1_3"); + yield return new TestCaseData(Namespaces.Version14.Devices, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Devices_1_4"); + yield return new TestCaseData(Namespaces.Version15.Devices, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Devices_1_5"); + yield return new TestCaseData(Namespaces.Version16.Devices, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Devices_1_6"); + yield return new TestCaseData(Namespaces.Version17.Devices, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Devices_1_7"); + yield return new TestCaseData(Namespaces.Version18.Devices, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Devices_1_8"); + yield return new TestCaseData(Namespaces.Version20.Devices, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Devices_2_0"); + yield return new TestCaseData(Namespaces.Version21.Devices, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Devices_2_1"); + yield return new TestCaseData(Namespaces.Version22.Devices, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Devices_2_2"); + yield return new TestCaseData(Namespaces.Version23.Devices, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Devices_2_3"); + yield return new TestCaseData(Namespaces.Version24.Devices, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Devices_2_4"); + yield return new TestCaseData(Namespaces.Version25.Devices, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Devices_2_5"); + yield return new TestCaseData(Namespaces.Version26.Devices, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Devices_2_6"); + yield return new TestCaseData(Namespaces.Version27.Devices, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Devices_2_7"); + } + + /// Every currently-declared v2.6/v2.7 namespace (Assets, Devices, Error, Streams) paired with the version it must resolve to. + /// The (namespace, expected version) pairs. + public static IEnumerable V26AndV27NamespacesByKind() + { + yield return new TestCaseData(Namespaces.Version26.Assets, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Assets_2_6"); + yield return new TestCaseData(Namespaces.Version26.Devices, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Devices_2_6_ByKind"); + yield return new TestCaseData(Namespaces.Version26.Error, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Error_2_6"); + yield return new TestCaseData(Namespaces.Version26.Streams, MTConnectVersions.Version26).SetName("GetByNamespace_resolves_Streams_2_6"); + + yield return new TestCaseData(Namespaces.Version27.Assets, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Assets_2_7"); + yield return new TestCaseData(Namespaces.Version27.Devices, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Devices_2_7_ByKind"); + yield return new TestCaseData(Namespaces.Version27.Error, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Error_2_7"); + yield return new TestCaseData(Namespaces.Version27.Streams, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Streams_2_7"); + } + + /// Pins that every currently-declared Devices namespace resolves to its matching version. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(DevicesNamespacesByVersion))] + public void GetByNamespace_returns_matching_version_for_every_declared_namespace(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// Pins that v2.6 and v2.7 resolve correctly for every document kind (Assets, Devices, Error, Streams), not just Devices. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(V26AndV27NamespacesByKind))] + public void GetByNamespace_returns_matching_version_for_v26_and_v27_across_document_kinds(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + // Regression guard for the specific bug: before the fix, a v2.7 + // namespace fell all the way through the chain (Version25 was the + // highest branch present) and returned `new Version()` - equal to + // "0.0", not `MTConnectVersions.Version27`. + /// Pins that a v2.7 namespace does not fall through to an empty version. + [Test] + public void GetByNamespace_v27_namespace_does_not_fall_through_to_empty_version() + { + var actual = MTConnectVersion.GetByNamespace(Namespaces.Version27.Streams); + Assert.That(actual, Is.Not.EqualTo(new Version())); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Version27)); + } + + /// Pins that an unrecognised namespace defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_unknown_namespace_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace("urn:mtconnect.org:MTConnectStreams:99.9"); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + Assert.That(actual, Is.Not.EqualTo(new Version())); + } + + /// Pins that a namespace also defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_null_namespace_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace(null); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + } +} From d6a274e7d20953bffdadee6cdc2d7d3325f3d33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 18:08:20 +0200 Subject: [PATCH 2/5] test(xml): pin every kind + boundary + Get() surface for dispatch chain Extend MTConnectVersionDispatchTests to close coverage-FLOOR gaps: - Every enum-arm of every Namespaces.Version{XX}.Match disjunction (Assets / Devices / Error / Streams) across every declared version (v1.0 through v2.7), not just the Devices arm - fixes a 68-arm enum-arm gap in the original fixture. - Boundary inputs: empty string, whitespace-only (space / tab / newline / mixed), leading/trailing padded, and upper-case / mixed-case variant of a canonical URI. - Public-API surface: MTConnectVersion.Get(string xml) round-trip from a well-formed XML declaration through Namespaces.Get to GetByNamespace, plus the no-namespace boundary. The Assets-arm sweep goes RED against the current SUT on Namespaces.Version12.Assets - the Version12.Match disjunction omits its declared Assets constant, resolving v1.2 Assets documents to Max (the fallback) instead of Version12. Fix follows in the next commit (TDD: red before green). --- .../MTConnectVersionDispatchTests.cs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs index a2b3d6d58..e52ec6a37 100644 --- a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs +++ b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs @@ -70,6 +70,73 @@ public static IEnumerable V26AndV27NamespacesByKind() yield return new TestCaseData(Namespaces.Version27.Streams, MTConnectVersions.Version27).SetName("GetByNamespace_resolves_Streams_2_7"); } + /// Every currently-declared MTConnect namespace (Assets, Devices, Error, Streams) across every version, paired with the version it must resolve to. Pins every enum-arm of every Namespaces.Version{XX}.Match disjunction, not just the Devices arm. + /// The (namespace, expected version) pairs. + public static IEnumerable AllKindsAllVersions() + { + // v1.0 and v1.1: no Assets namespace declared (Match is Devices || Error || Streams). + yield return new TestCaseData(Namespaces.Version10.Devices, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Devices_1_0_AllKinds"); + yield return new TestCaseData(Namespaces.Version10.Error, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Error_1_0"); + yield return new TestCaseData(Namespaces.Version10.Streams, MTConnectVersions.Version10).SetName("GetByNamespace_resolves_Streams_1_0"); + + yield return new TestCaseData(Namespaces.Version11.Devices, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Devices_1_1_AllKinds"); + yield return new TestCaseData(Namespaces.Version11.Error, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Error_1_1"); + yield return new TestCaseData(Namespaces.Version11.Streams, MTConnectVersions.Version11).SetName("GetByNamespace_resolves_Streams_1_1"); + + // v1.2+ declare Assets. + yield return new TestCaseData(Namespaces.Version12.Assets, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Assets_1_2"); + yield return new TestCaseData(Namespaces.Version12.Error, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Error_1_2"); + yield return new TestCaseData(Namespaces.Version12.Streams, MTConnectVersions.Version12).SetName("GetByNamespace_resolves_Streams_1_2"); + + yield return new TestCaseData(Namespaces.Version13.Assets, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Assets_1_3"); + yield return new TestCaseData(Namespaces.Version13.Error, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Error_1_3"); + yield return new TestCaseData(Namespaces.Version13.Streams, MTConnectVersions.Version13).SetName("GetByNamespace_resolves_Streams_1_3"); + + yield return new TestCaseData(Namespaces.Version14.Assets, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Assets_1_4"); + yield return new TestCaseData(Namespaces.Version14.Error, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Error_1_4"); + yield return new TestCaseData(Namespaces.Version14.Streams, MTConnectVersions.Version14).SetName("GetByNamespace_resolves_Streams_1_4"); + + yield return new TestCaseData(Namespaces.Version15.Assets, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Assets_1_5"); + yield return new TestCaseData(Namespaces.Version15.Error, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Error_1_5"); + yield return new TestCaseData(Namespaces.Version15.Streams, MTConnectVersions.Version15).SetName("GetByNamespace_resolves_Streams_1_5"); + + yield return new TestCaseData(Namespaces.Version16.Assets, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Assets_1_6"); + yield return new TestCaseData(Namespaces.Version16.Error, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Error_1_6"); + yield return new TestCaseData(Namespaces.Version16.Streams, MTConnectVersions.Version16).SetName("GetByNamespace_resolves_Streams_1_6"); + + yield return new TestCaseData(Namespaces.Version17.Assets, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Assets_1_7"); + yield return new TestCaseData(Namespaces.Version17.Error, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Error_1_7"); + yield return new TestCaseData(Namespaces.Version17.Streams, MTConnectVersions.Version17).SetName("GetByNamespace_resolves_Streams_1_7"); + + yield return new TestCaseData(Namespaces.Version18.Assets, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Assets_1_8"); + yield return new TestCaseData(Namespaces.Version18.Error, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Error_1_8"); + yield return new TestCaseData(Namespaces.Version18.Streams, MTConnectVersions.Version18).SetName("GetByNamespace_resolves_Streams_1_8"); + + yield return new TestCaseData(Namespaces.Version20.Assets, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Assets_2_0"); + yield return new TestCaseData(Namespaces.Version20.Error, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Error_2_0"); + yield return new TestCaseData(Namespaces.Version20.Streams, MTConnectVersions.Version20).SetName("GetByNamespace_resolves_Streams_2_0"); + + yield return new TestCaseData(Namespaces.Version21.Assets, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Assets_2_1"); + yield return new TestCaseData(Namespaces.Version21.Error, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Error_2_1"); + yield return new TestCaseData(Namespaces.Version21.Streams, MTConnectVersions.Version21).SetName("GetByNamespace_resolves_Streams_2_1"); + + yield return new TestCaseData(Namespaces.Version22.Assets, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Assets_2_2"); + yield return new TestCaseData(Namespaces.Version22.Error, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Error_2_2"); + yield return new TestCaseData(Namespaces.Version22.Streams, MTConnectVersions.Version22).SetName("GetByNamespace_resolves_Streams_2_2"); + + yield return new TestCaseData(Namespaces.Version23.Assets, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Assets_2_3"); + yield return new TestCaseData(Namespaces.Version23.Error, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Error_2_3"); + yield return new TestCaseData(Namespaces.Version23.Streams, MTConnectVersions.Version23).SetName("GetByNamespace_resolves_Streams_2_3"); + + yield return new TestCaseData(Namespaces.Version24.Assets, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Assets_2_4"); + yield return new TestCaseData(Namespaces.Version24.Error, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Error_2_4"); + yield return new TestCaseData(Namespaces.Version24.Streams, MTConnectVersions.Version24).SetName("GetByNamespace_resolves_Streams_2_4"); + + yield return new TestCaseData(Namespaces.Version25.Assets, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Assets_2_5"); + yield return new TestCaseData(Namespaces.Version25.Error, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Error_2_5"); + yield return new TestCaseData(Namespaces.Version25.Streams, MTConnectVersions.Version25).SetName("GetByNamespace_resolves_Streams_2_5"); + } + /// Pins that every currently-declared Devices namespace resolves to its matching version. /// The namespace under test. /// The version must resolve to. @@ -119,5 +186,95 @@ public void GetByNamespace_null_namespace_defaults_to_Max() var actual = MTConnectVersion.GetByNamespace(null); Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); } + + /// Pins that every declared Assets/Devices/Error/Streams namespace across every supported version resolves to its matching version. Exercises every enum-arm of every Namespaces.Version{XX}.Match disjunction, not just the Devices arm. + /// The namespace under test. + /// The version must resolve to. + [TestCaseSource(nameof(AllKindsAllVersions))] + public void GetByNamespace_returns_matching_version_for_every_kind_of_every_version(string ns, Version expected) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(expected)); + } + + /// Pins that an empty-string namespace (the value XmlDocument.LoadXml yields for a document with no xmlns) defaults to the latest supported version rather than an empty one. + [Test] + public void GetByNamespace_empty_string_defaults_to_Max() + { + var actual = MTConnectVersion.GetByNamespace(string.Empty); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that a whitespace-only namespace defaults to the latest supported version. Whitespace-only strings do not equal any declared namespace constant, so they must fall through the dispatch chain to the Max fallback. + /// The whitespace-only namespace under test. + [TestCase(" ")] + [TestCase("\t")] + [TestCase("\n")] + [TestCase(" \t\n ")] + public void GetByNamespace_whitespace_only_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that the dispatch is case-sensitive per the XML namespace-URI spec: an upper-case variant of a canonical namespace URI does not match, and falls through to Max. Guards against a future well-intentioned case-fold refactor that would silently accept malformed documents. + /// The case-variant namespace under test. + [TestCase("URN:MTCONNECT.ORG:MTCONNECTSTREAMS:2.7")] + [TestCase("Urn:Mtconnect.Org:MTConnectStreams:2.7")] + [TestCase("urn:mtconnect.org:mtconnectstreams:2.7")] + [TestCase("urn:mtconnect.org:MTConnectSTREAMS:2.6")] + public void GetByNamespace_case_variant_of_declared_namespace_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that a namespace with leading/trailing whitespace does not match a declared constant (string equality is exact) and therefore falls through to Max. + /// The padded namespace under test. + [TestCase(" urn:mtconnect.org:MTConnectStreams:2.7")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.7 ")] + [TestCase(" urn:mtconnect.org:MTConnectStreams:2.7 ")] + public void GetByNamespace_padded_namespace_defaults_to_Max(string ns) + { + var actual = MTConnectVersion.GetByNamespace(ns); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins the public entry point: a well-formed XML document declaring a canonical MTConnect namespace resolves through Namespaces.Get to the matching version. Exercises the full public-API surface of the class, not just the GetByNamespace internal. + /// The namespace URI to embed as the root element's default namespace. + /// The version must resolve to. + [TestCase("urn:mtconnect.org:MTConnectStreams:1.0", "1.0")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.5", "2.5")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.6", "2.6")] + [TestCase("urn:mtconnect.org:MTConnectStreams:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectDevices:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectAssets:2.7", "2.7")] + [TestCase("urn:mtconnect.org:MTConnectError:2.7", "2.7")] + public void Get_extracts_namespace_from_xml_and_dispatches_to_matching_version(string xmlNamespace, string expected) + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(Version.Parse(expected))); + } + + /// Pins that against a well-formed XML document with no namespace declaration defaults to Max — exercises the empty-string boundary of GetByNamespace reached from the Get(xml) entry point. + [Test] + public void Get_returns_Max_when_xml_has_no_namespace() + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that resolves an XML document declaring a v2.7 namespace to Version27, not to an empty version — the exact bug the PR fixes, re-asserted through the Get(xml) entry point instead of just GetByNamespace. + [Test] + public void Get_v27_xml_does_not_fall_through_to_empty_version() + { + var xml = ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Version27)); + Assert.That(actual.Major, Is.EqualTo(2)); + Assert.That(actual.Minor, Is.EqualTo(7)); + } } } From 450050cb994445693d9ca2f121240db04ec6ff48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 18:08:29 +0200 Subject: [PATCH 3/5] fix(xml): include Assets arm in Namespaces.Version12.Match Version12 declares urn:mtconnect.org:MTConnectAssets:1.2 as a namespace constant but its Match(ns) disjunction omitted the Assets arm, so a v1.2 Assets document fell through the GetByNamespace dispatch chain and resolved to MTConnectVersions.Max (post-#229 fallback; previously new Version()) instead of Version12. Every version from v1.3 onwards already lists Assets as the first arm of its Match; v1.0 and v1.1 do not declare Assets at all, so their three-arm Match is correct. Version12 is the single asymmetric case, found by the expanded coverage-FLOOR sweep in the preceding test commit. Bug-class atomicity: the sibling bug sitting next to the primary v2.6/v2.7 dispatch omission ships in the discovering PR, not a follow-up. --- libraries/MTConnect.NET-XML/Namespaces.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/MTConnect.NET-XML/Namespaces.cs b/libraries/MTConnect.NET-XML/Namespaces.cs index f2fa26786..49fb73a26 100644 --- a/libraries/MTConnect.NET-XML/Namespaces.cs +++ b/libraries/MTConnect.NET-XML/Namespaces.cs @@ -478,7 +478,7 @@ internal static class Version12 public static bool Match(string ns) { - return ns == Devices || ns == Error || ns == Streams; + return ns == Assets || ns == Devices || ns == Error || ns == Streams; } } From 86fdb9d0aab8de79567e9efa71d5b9a795be1576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 18:13:31 +0200 Subject: [PATCH 4/5] fix(xml): harden Namespaces.Get(string) against XXE and entity expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Namespaces.Get(string xml) delegated straight to XmlDocument.LoadXml, whose DtdProcessing default and legacy XmlResolver behavior varied across the library's TFMs (net461–net10.0). The v2.6/v2.7 dispatch routing (fix at e96ae6eb) plus the new MTConnectVersions.Max fallback widened the number of documents that flow through this parse. Route the parse through an explicit XmlReader with DtdProcessing set to Prohibit and XmlResolver set to null, so DOCTYPE-carrying payloads (including billion-laughs-style entity expansion) and external-entity (file:// / http://) references are refused before parsing rather than resolved. Catch XmlException so downstream callers see the same Max-fallback signal on malformed input rather than an exception. Also short-circuit null / empty input at the entry point instead of letting LoadXml throw ArgumentException. Ultrareview finding F-SEC-001 (HIGH, A05_security-misconfiguration). --- libraries/MTConnect.NET-XML/Namespaces.cs | 30 ++++++++-- .../MTConnectVersionDispatchTests.cs | 59 ++++++++++++++++++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/libraries/MTConnect.NET-XML/Namespaces.cs b/libraries/MTConnect.NET-XML/Namespaces.cs index 49fb73a26..749a26a54 100644 --- a/libraries/MTConnect.NET-XML/Namespaces.cs +++ b/libraries/MTConnect.NET-XML/Namespaces.cs @@ -21,12 +21,34 @@ internal static class Namespaces public static string Get(string xml) { - var doc = new XmlDocument(); - doc.LoadXml(xml); - if (doc != null && doc.DocumentElement != null) + if (string.IsNullOrEmpty(xml)) return null; + + // XmlDocument.LoadXml delegates to an internal XmlReader whose + // DtdProcessing default varies across TFMs and whose XmlResolver + // historically resolved external entities. Route the parse through + // an explicit XmlReader with DTD processing prohibited and no + // resolver so unknown or hostile documents cannot exercise + // external-entity or entity-expansion (billion-laughs) paths. + var settings = new XmlReaderSettings { - return doc.DocumentElement.NamespaceURI; + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }; + + try + { + using (var stringReader = new StringReader(xml)) + using (var xmlReader = XmlReader.Create(stringReader, settings)) + { + var doc = new XmlDocument { XmlResolver = null }; + doc.Load(xmlReader); + if (doc.DocumentElement != null) + { + return doc.DocumentElement.NamespaceURI; + } + } } + catch (XmlException) { } return null; } diff --git a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs index e52ec6a37..f19eaf073 100644 --- a/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs +++ b/tests/MTConnect.NET-XML-Tests/MTConnectVersionDispatchTests.cs @@ -4,7 +4,7 @@ // Pins the fix for the `MTConnectVersion.GetByNamespace` dispatch-chain // omission: the switch capped out at `Namespaces.Version25.Match(ns)` and // fell through to `return new Version()` (empty, "0.0") for any namespace -// declared by a document newer than v2.5 - even though `Namespaces.Version26` +// declared by a document newer than v2.5 — even though `Namespaces.Version26` // / `Namespaces.Version27` and `MTConnectVersions.Version26` / // `MTConnectVersions.Version27` both already existed. A document declaring // `urn:mtconnect.org:MTConnectStreams:2.7` (or `:2.6`) therefore resolved to @@ -159,7 +159,7 @@ public void GetByNamespace_returns_matching_version_for_v26_and_v27_across_docum // Regression guard for the specific bug: before the fix, a v2.7 // namespace fell all the way through the chain (Version25 was the - // highest branch present) and returned `new Version()` - equal to + // highest branch present) and returned `new Version()` — equal to // "0.0", not `MTConnectVersions.Version27`. /// Pins that a v2.7 namespace does not fall through to an empty version. [Test] @@ -276,5 +276,60 @@ public void Get_v27_xml_does_not_fall_through_to_empty_version() Assert.That(actual.Major, Is.EqualTo(2)); Assert.That(actual.Minor, Is.EqualTo(7)); } + + /// Pins that rejects a document that declares a DTD — the hardened Namespaces.Get sets DtdProcessing = Prohibit, so any DOCTYPE-carrying payload (including billion-laughs entity-expansion attempts) is refused rather than parsed. Guards the XXE / entity-expansion surface introduced by XmlDocument.LoadXml's historical default settings. + [Test] + public void Get_rejects_document_with_dtd_and_defaults_to_Max() + { + var xml = "" + + "]>" + + ""; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that rejects a billion-laughs entity-expansion payload without parsing it — the hardened Namespaces.Get refuses the DOCTYPE up-front, so the exponentially-expanding entity chain never materialises. Regression guard for the XXE / entity-expansion surface. + [Test] + public void Get_rejects_billion_laughs_payload_and_defaults_to_Max() + { + var xml = "" + + "" + + " " + + " " + + "]>" + + "&lol3;"; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that rejects a document that references an external entity without resolving it — the hardened Namespaces.Get sets XmlResolver = null, so file:// / http:// references cannot exfiltrate host data. Regression guard for the classic XXE surface. + [Test] + public void Get_rejects_external_entity_reference_and_defaults_to_Max() + { + var xml = "" + + "]>" + + "&xxe;"; + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } + + /// Pins that returns the Max fallback on malformed input rather than propagating an XmlException. Guards downstream callers from having to catch XML-parse errors on every dispatch call. + [Test] + public void Get_returns_Max_on_malformed_xml() + { + var actual = MTConnectVersion.Get("Pins that returns the Max fallback on a null or empty input rather than throwing an ArgumentException. + /// The input under test. + [TestCase(null)] + [TestCase("")] + public void Get_returns_Max_on_null_or_empty_input(string xml) + { + var actual = MTConnectVersion.Get(xml); + Assert.That(actual, Is.EqualTo(MTConnectVersions.Max)); + } } } From 03949eee57d6686a35188e88db116f5cd32cb680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 19 Aug 2026 18:13:41 +0200 Subject: [PATCH 5/5] docs(xml): clarify Get/GetByNamespace docs; annotate InternalsVisibleTo The XML doc summaries on MTConnectVersion.Get and .GetByNamespace were verbatim copies of each other, obscuring the actual contract: Get takes a raw XML document (delegating to Namespaces.Get for the extraction step), GetByNamespace takes an already-resolved namespace URI. Rewrite each summary to state its own signature and mention the Max-fallback behavior on GetByNamespace. Annotate the InternalsVisibleTo grant in MTConnect.NET-XML.csproj so a future reader sees why the library reaches into MTConnect.NET-XML-Tests (the tests reach the internal MTConnectVersion / Namespaces helpers, which have no public equivalent). Also tighten the trailing newline of MTConnectVersion.cs and update two comment breaks in the test file to tight em-dashes. Ultrareview findings F-SIMP-003, F-CR-002, F-CR-004, F-CR-006 (LOW/NIT). --- libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj | 1 + libraries/MTConnect.NET-XML/MTConnectVersion.cs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj index 8b5a6ef99..216fc9365 100644 --- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj +++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj @@ -73,6 +73,7 @@ + diff --git a/libraries/MTConnect.NET-XML/MTConnectVersion.cs b/libraries/MTConnect.NET-XML/MTConnectVersion.cs index 6f332636a..104e8efb3 100644 --- a/libraries/MTConnect.NET-XML/MTConnectVersion.cs +++ b/libraries/MTConnect.NET-XML/MTConnectVersion.cs @@ -8,7 +8,7 @@ namespace MTConnect internal static class MTConnectVersion { /// - /// Gets the Version of the MTConnect standard being used based on the XML Namespace that is used + /// Gets the Version of the MTConnect standard from a raw XML document by extracting the root element's namespace URI and dispatching through . /// public static Version Get(string xml) { @@ -17,7 +17,7 @@ public static Version Get(string xml) } /// - /// Gets the Version of the MTConnect standard being used based on the XML Namespace that is used + /// Gets the Version of the MTConnect standard from an already-resolved namespace URI. Returns when the namespace is null, empty, or does not match a declared MTConnect namespace. /// public static Version GetByNamespace(string ns) { @@ -46,4 +46,4 @@ public static Version GetByNamespace(string ns) return MTConnectVersions.Max; } } -} \ No newline at end of file +}