[ablegacy] AbLegacy — ST string verification + length guard #353
@@ -237,6 +237,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
}
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
// ST-file string writes exceeding the 82-byte fixed element. Surfaces from
|
||||||
|
// LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so
|
||||||
|
// the OPC UA client sees a clean rejection rather than a silent truncation.
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
{
|
{
|
||||||
private readonly Tag _tag;
|
private readonly Tag _tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum payload length for an ST (string) file element on SLC / MicroLogix / PLC-5.
|
||||||
|
/// The on-wire layout is a 1-word length prefix followed by 82 ASCII bytes — libplctag's
|
||||||
|
/// <c>SetString</c> handles the framing internally, but it does NOT validate length, so a
|
||||||
|
/// 93-byte source string would silently truncate. We reject up-front so the OPC UA client
|
||||||
|
/// gets a clean <c>BadOutOfRange</c> rather than a corrupted PLC value.
|
||||||
|
/// </summary>
|
||||||
|
internal const int StFileMaxStringLength = 82;
|
||||||
|
|
||||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||||
{
|
{
|
||||||
_tag = new Tag
|
_tag = new Tag
|
||||||
@@ -87,7 +96,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.String:
|
case AbLegacyDataType.String:
|
||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
if (s.Length > StFileMaxStringLength)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(value),
|
||||||
|
$"ST string write exceeds {StFileMaxStringLength}-byte file element capacity (was {s.Length}).");
|
||||||
|
_tag.SetString(0, s);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.TimerElement:
|
case AbLegacyDataType.TimerElement:
|
||||||
case AbLegacyDataType.CounterElement:
|
case AbLegacyDataType.CounterElement:
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issue #249 — verify ST string read/write round-trips through the driver. The wire format
|
||||||
|
/// (1-word length prefix + 82 ASCII bytes) is owned by libplctag's <c>GetString</c>/
|
||||||
|
/// <c>SetString</c>; this test fixture pins the driver-level guarantees:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Reads round-trip strings of any length up to the 82-char ST cap.</item>
|
||||||
|
/// <item>Writes longer than 82 chars are rejected with <c>BadOutOfRange</c> at the driver
|
||||||
|
/// level — preventing libplctag from silently truncating.</item>
|
||||||
|
/// <item>Embedded nulls and non-ASCII characters flow through without throwing — the latter
|
||||||
|
/// is libplctag's responsibility to round-trip or degrade.</item>
|
||||||
|
/// <item>Both Slc500 and Plc5 families share the 82-byte ST file convention.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbLegacyStringEncodingTests
|
||||||
|
{
|
||||||
|
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
|
||||||
|
AbLegacyPlcFamily family,
|
||||||
|
params AbLegacyTagDefinition[] tags)
|
||||||
|
{
|
||||||
|
var factory = new FakeAbLegacyTagFactory();
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||||
|
Tags = tags,
|
||||||
|
}, "drv-1", factory);
|
||||||
|
return (drv, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Read round-trip ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, "Hello")]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Plc5, "Hello")]
|
||||||
|
public async Task Read_returns_string_value_unchanged(AbLegacyPlcFamily family, string value)
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(family,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbLegacyTag(p) { Value = value };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_returns_full_82_char_string_at_ST_capacity()
|
||||||
|
{
|
||||||
|
var full = new string('A', 82);
|
||||||
|
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbLegacyTag(p) { Value = full };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
var actual = snapshots.Single().Value.ShouldBeOfType<string>();
|
||||||
|
actual.Length.ShouldBe(82);
|
||||||
|
actual.ShouldBe(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_preserves_embedded_null_byte()
|
||||||
|
{
|
||||||
|
// libplctag returns the C-string as the .NET String with whatever bytes the PLC stored.
|
||||||
|
// We assert the driver doesn't strip or truncate at an embedded NUL.
|
||||||
|
var withNull = "AB\0CD";
|
||||||
|
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbLegacyTag(p) { Value = withNull };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe(withNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_preserves_extended_latin_payload()
|
||||||
|
{
|
||||||
|
// PLC ST files are byte-oriented; non-ASCII passes through whatever round-trip libplctag
|
||||||
|
// applies. The driver itself must not transform.
|
||||||
|
var latin = "café résumé";
|
||||||
|
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
factory.Customise = p => new FakeAbLegacyTag(p) { Value = latin };
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
snapshots.Single().Value.ShouldBe(latin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Write round-trip ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, "Short msg")]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, "AB\0CD")] // embedded NUL
|
||||||
|
[InlineData(AbLegacyPlcFamily.Plc5, "Hello PLC5")]
|
||||||
|
public async Task Write_succeeds_and_forwards_string_to_runtime(AbLegacyPlcFamily family, string value)
|
||||||
|
{
|
||||||
|
var (drv, factory) = NewDriver(family,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Msg", value)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
factory.Tags["ST9:0"].Value.ShouldBe(value);
|
||||||
|
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_succeeds_for_41_char_mid_length_string()
|
||||||
|
{
|
||||||
|
var mid = new string('M', 41);
|
||||||
|
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Msg", mid)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
factory.Tags["ST9:0"].Value.ShouldBe(mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_succeeds_at_82_char_boundary()
|
||||||
|
{
|
||||||
|
var full = new string('Z', 82);
|
||||||
|
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Msg", full)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
((string)factory.Tags["ST9:0"].Value!).Length.ShouldBe(82);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Length guard ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, 83)]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Slc500, 100)]
|
||||||
|
[InlineData(AbLegacyPlcFamily.Plc5, 200)]
|
||||||
|
public async Task Write_over_82_chars_returns_BadOutOfRange(AbLegacyPlcFamily family, int len)
|
||||||
|
{
|
||||||
|
// The runtime layer (LibplctagLegacyTagRuntime.EncodeValue) rejects with
|
||||||
|
// ArgumentOutOfRangeException; the driver maps that to BadOutOfRange so the OPC UA client
|
||||||
|
// gets a clean failure rather than a silent libplctag truncation. We use the production
|
||||||
|
// runtime for the encode step but stub the I/O via a delegating factory so the test does
|
||||||
|
// not need a real PLC.
|
||||||
|
var oversized = new string('X', len);
|
||||||
|
var factory = new FakeAbLegacyTagFactory
|
||||||
|
{
|
||||||
|
// Reuse the production EncodeValue by routing through a fake that delegates the
|
||||||
|
// length check itself — we model the runtime contract: > 82 chars must throw.
|
||||||
|
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||||
|
};
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Msg", oversized)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
|
// The write must NOT have reached libplctag's WriteAsync — guard fires before flush.
|
||||||
|
factory.Tags["ST9:0"].WriteCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_at_exactly_82_chars_does_not_trip_length_guard()
|
||||||
|
{
|
||||||
|
var atBoundary = new string('B', 82);
|
||||||
|
var factory = new FakeAbLegacyTagFactory
|
||||||
|
{
|
||||||
|
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||||
|
};
|
||||||
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||||
|
],
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var results = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Msg", atBoundary)], CancellationToken.None);
|
||||||
|
|
||||||
|
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test fake that mirrors <see cref="LibplctagLegacyTagRuntime"/>'s ST length guard so we
|
||||||
|
/// can assert the driver-level mapping (ArgumentOutOfRangeException → BadOutOfRange)
|
||||||
|
/// without instantiating a real libplctag <c>Tag</c> (which would try to open a TCP
|
||||||
|
/// connection in <c>InitializeAsync</c>).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class EncodeOnlyLengthCheckingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||||
|
{
|
||||||
|
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||||
|
{
|
||||||
|
if (type == AbLegacyDataType.String)
|
||||||
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
if (s.Length > 82)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(value),
|
||||||
|
$"ST string write exceeds 82-byte file element capacity (was {s.Length}).");
|
||||||
|
}
|
||||||
|
base.EncodeValue(type, bitIndex, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user