fix(abcip): controller browse honours the authored EtherNet/IP port (#539)
The two discovery-path libplctag tags (@tags symbol-list enumerator, @udt template reader) still passed the bare Gateway host, so a browse against a device authored on a non-default port silently browsed whatever answers on 44818 — #534's defect surviving in its discovery half. Both sites now consume AbCipTagCreateParams.LibplctagGateway, with the Tag construction extracted into internal static builders so the seam is unit-assertable (the #534 tests pinned the property's composition but nothing pinned each site's consumption, which is exactly how these two survived). New guard: LibplctagGatewayConsumptionGuardTests scans both AB drivers' sources and fails on any libplctag Tag built from the bare Gateway, so a future call site cannot reintroduce the silent 44818 redirect. Live-gated against the fixture host: browse of ab://10.100.0.35:44818 returns the nine-tag symbol table; browse of ab://10.100.0.35:44999 (nothing listening — the issue's exact repro) now fails ErrorTimeout where it previously returned the 44818 fixture's table. Closes #539. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -26,17 +26,7 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
|||||||
AbCipTagCreateParams deviceParams,
|
AbCipTagCreateParams deviceParams,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Build a tag specifically for the @tags pseudo — same gateway + path as the device,
|
_tag = BuildSymbolListTag(deviceParams);
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -49,6 +39,26 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
|||||||
yield return tag;
|
yield return tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the (uninitialized) libplctag tag for the <c>@tags</c> pseudo — same gateway +
|
||||||
|
/// path as the device, distinguished by the name alone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||||
|
/// bare <see cref="AbCipTagCreateParams.Gateway"/> — 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).
|
||||||
|
/// </remarks>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
|
/// <summary>Disposes the enumerator and releases the underlying libplctag tag.</summary>
|
||||||
public void Dispose() => _tag?.Dispose();
|
public void Dispose() => _tag?.Dispose();
|
||||||
|
|
||||||
|
|||||||
@@ -19,20 +19,29 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_tag?.Dispose();
|
_tag?.Dispose();
|
||||||
_tag = new Tag
|
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
|
||||||
{
|
|
||||||
Gateway = deviceParams.Gateway,
|
|
||||||
Path = deviceParams.CipPath,
|
|
||||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
|
||||||
Protocol = Protocol.ab_eip,
|
|
||||||
Name = $"@udt/{templateInstanceId}",
|
|
||||||
Timeout = deviceParams.Timeout,
|
|
||||||
};
|
|
||||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
return _tag.GetBuffer();
|
return _tag.GetBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||||
|
/// bare <see cref="AbCipTagCreateParams.Gateway"/> — 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).
|
||||||
|
/// </remarks>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Disposes the template reader and releases the underlying libplctag tag.</summary>
|
/// <summary>Disposes the template reader and releases the underlying libplctag tag.</summary>
|
||||||
public void Dispose() => _tag?.Dispose();
|
public void Dispose() => _tag?.Dispose();
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,26 @@ public sealed class AbCipGatewayCompositionTests
|
|||||||
{
|
{
|
||||||
Params("10.100.0.35", port).LibplctagGateway.ShouldBe("10.100.0.35");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gitea #539 — every libplctag <c>Tag</c> the AB drivers construct must take its
|
||||||
|
/// <c>Gateway</c> from <c>LibplctagGateway</c> (host:port), never the bare <c>Gateway</c>
|
||||||
|
/// host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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*(?<rhs>.+?),?\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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user