diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index e7e55e3c..b7ed45f9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -26,17 +26,7 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken) { - // Build a tag specifically for the @tags pseudo — same gateway + path as the device, - // distinguished by the name alone. - _tag = new Tag - { - Gateway = deviceParams.Gateway, - Path = deviceParams.CipPath, - PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), - Protocol = Protocol.ab_eip, - Name = "@tags", - Timeout = deviceParams.Timeout, - }; + _tag = BuildSymbolListTag(deviceParams); await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false); await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); @@ -49,6 +39,26 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator yield return tag; } + /// + /// Builds the (uninitialized) libplctag tag for the @tags pseudo — same gateway + + /// path as the device, distinguished by the name alone. + /// + /// + /// Gateway must be (host:port), never the + /// bare — libplctag substitutes 44818 for a bare + /// host, so a browse against a device authored on another port silently returns whatever + /// answers on 44818 (Gitea #539, the discovery half of #534). + /// + internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams) => new() + { + Gateway = deviceParams.LibplctagGateway, + Path = deviceParams.CipPath, + PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, + Name = "@tags", + Timeout = deviceParams.Timeout, + }; + /// Disposes the enumerator and releases the underlying libplctag tag. public void Dispose() => _tag?.Dispose(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs index be67216b..5af05c01 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs @@ -19,20 +19,29 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader CancellationToken cancellationToken) { _tag?.Dispose(); - _tag = new Tag - { - Gateway = deviceParams.Gateway, - Path = deviceParams.CipPath, - PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), - Protocol = Protocol.ab_eip, - Name = $"@udt/{templateInstanceId}", - Timeout = deviceParams.Timeout, - }; + _tag = BuildTemplateTag(deviceParams, templateInstanceId); await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false); await _tag.ReadAsync(cancellationToken).ConfigureAwait(false); return _tag.GetBuffer(); } + /// Builds the (uninitialized) libplctag tag for the @udt/{id} pseudo. + /// + /// Gateway must be (host:port), never the + /// bare — libplctag substitutes 44818 for a bare + /// host, so a browse against a device authored on another port silently returns whatever + /// answers on 44818 (Gitea #539, the discovery half of #534). + /// + internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new() + { + Gateway = deviceParams.LibplctagGateway, + Path = deviceParams.CipPath, + PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute), + Protocol = Protocol.ab_eip, + Name = $"@udt/{templateInstanceId}", + Timeout = deviceParams.Timeout, + }; + /// Disposes the template reader and releases the underlying libplctag tag. public void Dispose() => _tag?.Dispose(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs index 017e60b0..6707610f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs @@ -41,4 +41,26 @@ public sealed class AbCipGatewayCompositionTests { Params("10.100.0.35", port).LibplctagGateway.ShouldBe("10.100.0.35"); } + + // Gitea #539 — #534 fixed the data path but both DISCOVERY call sites kept passing the bare + // Gateway, so "Browse controller" against a device authored on a non-default port silently + // browsed whatever answers on 44818. These pin the composed gateway at the actual libplctag + // Tag construction seam, which the property-only tests above structurally cannot: they proved + // LibplctagGateway composes correctly while nothing proved each Tag builder consumes it. + + [Fact] + public void The_symbol_list_browse_tag_carries_the_authored_port() + { + using var tag = LibplctagTagEnumerator.BuildSymbolListTag(Params("10.100.0.35", 44999)); + tag.Gateway.ShouldBe("10.100.0.35:44999"); + tag.Name.ShouldBe("@tags"); + } + + [Fact] + public void The_template_read_tag_carries_the_authored_port() + { + using var tag = LibplctagTemplateReader.BuildTemplateTag(Params("10.100.0.35", 44999), templateInstanceId: 0x123); + tag.Gateway.ShouldBe("10.100.0.35:44999"); + tag.Name.ShouldBe("@udt/291"); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagGatewayConsumptionGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagGatewayConsumptionGuardTests.cs new file mode 100644 index 00000000..aefd4378 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagGatewayConsumptionGuardTests.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Gitea #539 — every libplctag Tag the AB drivers construct must take its +/// Gateway from LibplctagGateway (host:port), never the bare Gateway +/// host. +/// +/// +/// libplctag substitutes 44818 for a bare host, so a dropped port does not fail — it +/// silently redirects to whatever answers on the default port, which on a plant network is a +/// different PLC. #534 fixed the data-path sites and #539 found the two discovery sites had +/// the same defect; this scan pins ALL current and future construction sites in both AB +/// drivers, because the defect recurred precisely by being reintroduced at a call site the +/// per-site tests did not cover. +/// +[Trait("Category", "Unit")] +public sealed class LibplctagGatewayConsumptionGuardTests +{ + // Matches a Gateway assignment inside an object initializer (property definitions and the + // LibplctagGateway composition expression itself do not start a line with "Gateway ="). + private static readonly Regex GatewayAssignment = new(@"^\s*Gateway\s*=\s*(?.+?),?\s*$", RegexOptions.Multiline); + + [Fact] + public void Every_Tag_construction_in_both_AB_drivers_uses_LibplctagGateway() + { + var repoRoot = LocateRepoRoot(); + var driverDirs = new[] + { + Path.Combine(repoRoot, "src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.AbCip"), + Path.Combine(repoRoot, "src", "Drivers", "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy"), + }; + + var offending = new List(); + var assignmentCount = 0; + + foreach (var dir in driverDirs) + { + Directory.Exists(dir).ShouldBeTrue($"Expected driver source directory {dir} to exist."); + var files = Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")); + + foreach (var file in files) + { + foreach (Match match in GatewayAssignment.Matches(File.ReadAllText(file))) + { + assignmentCount++; + if (!match.Groups["rhs"].Value.Contains("LibplctagGateway")) + offending.Add($"{file}: {match.Value.Trim()}"); + } + } + } + + offending.ShouldBeEmpty( + "A libplctag Tag built from the bare Gateway host drops the authored EtherNet/IP port and " + + "silently connects to whatever answers on 44818 (Gitea #534/#539). Use LibplctagGateway."); + + // The four known sites: AbCip runtime + symbol-list enumerator + template reader, AbLegacy + // runtime. If this drops below 4, the scan's assignment pattern no longer matches the code + // shape and the guard has gone blind — fix the regex, don't lower the floor. + assignmentCount.ShouldBeGreaterThanOrEqualTo(4); + } + + private static string LocateRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull("Could not locate the repo root (ZB.MOM.WW.OtOpcUa.slnx) above the test assembly."); + return dir.FullName; + } +}