fix(ablegacy): refuse libplctag writes to translated status bits (#659 fix round 1)
The read translation R6:0.DN -> R6:0.CON/13 made those names parse in libplctag, which would have turned a write - previously impossible, tag creation failed - into a masked write to the control word. LibplctagLegacyTagRuntime.WriteRefusal (via IAbLegacyTagFactory.WriteRefusal, same plumbing as native) now refuses writes to every rewritten status bit (R EN/EU/DN/EM/ER/UL/IN/FD, C UA) with BadNotSupported before a runtime is created; the runtime also refuses as defence in depth. Reads stay translated. T/C status bits libplctag parses itself keep their pre-existing behaviour. 13 new tests fail with the predicate disabled. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #659 review ruling: the libplctag read translation of a status bit libplctag has no mnemonic for
|
||||
/// (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>) must not widen the WRITE surface. Before the translation libplctag could
|
||||
/// not create such a tag, so a write never reached the controller; now that the name parses, a write would go out
|
||||
/// as a masked write to the control word. Such writes are refused <c>BadNotSupported</c> before any runtime is
|
||||
/// created, health unchanged. T/C status bits libplctag itself parses keep their pre-existing (unrefused) behaviour.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LibplctagStatusBitWriteRefusalTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("R6:0.EN", true)]
|
||||
[InlineData("R6:0.EU", true)]
|
||||
[InlineData("R6:0.DN", true)]
|
||||
[InlineData("R6:0.EM", true)]
|
||||
[InlineData("R6:0.ER", true)]
|
||||
[InlineData("R6:0.UL", true)]
|
||||
[InlineData("R6:0.IN", true)]
|
||||
[InlineData("R6:0.FD", true)]
|
||||
[InlineData("C5:0.UA", true)]
|
||||
[InlineData("T4:0.DN", false)] // libplctag parses it natively: pre-existing behaviour, out of scope
|
||||
[InlineData("C5:0.CU", false)]
|
||||
[InlineData("C5:0.DN", false)]
|
||||
[InlineData("R6:0.LEN", false)]
|
||||
[InlineData("R6:0.POS", false)]
|
||||
[InlineData("S:0", false)] // translated, but not a status bit
|
||||
[InlineData("O:0/3", false)]
|
||||
[InlineData("I:0/0", false)]
|
||||
[InlineData("N7:0", false)]
|
||||
[InlineData("B3:0/4", false)]
|
||||
[InlineData("R6:0.FOO", false)]
|
||||
[InlineData("garbage", false)]
|
||||
public void The_libplctag_refusal_covers_exactly_the_rewritten_status_bits(string tag, bool refused)
|
||||
{
|
||||
var p = new AbLegacyTagCreateParams("h", 44818, "", "micrologix", tag, TimeSpan.FromSeconds(1));
|
||||
(LibplctagLegacyTagRuntime.WriteRefusal(p) is not null).ShouldBe(refused);
|
||||
(new LibplctagLegacyTagFactory().WriteRefusal(p) is not null).ShouldBe(refused);
|
||||
LibplctagLegacyName.IsRewrittenStatusBit(tag).ShouldBe(refused);
|
||||
}
|
||||
|
||||
/// <summary>Defence in depth: a runtime for a rewritten status bit refuses the write itself, without touching the
|
||||
/// network (the tag was never initialised, and the gateway is unroutable).</summary>
|
||||
[Fact]
|
||||
public async Task The_runtime_refuses_a_rewritten_status_bit_write_before_the_wire()
|
||||
{
|
||||
using var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams(
|
||||
"192.0.2.1", 44818, "", "micrologix", "R6:0.DN", TimeSpan.FromSeconds(1)));
|
||||
var ex = await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(TestContext.Current.CancellationToken));
|
||||
ex.Status.ShouldBe(AbStatus.Unsupported);
|
||||
}
|
||||
|
||||
/// <summary>The production libplctag refusal predicate behind a counting factory, so the test sees whether the
|
||||
/// driver created (and would therefore have initialised and written through) a runtime.</summary>
|
||||
private sealed class CountingLibplctagRefusalFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public FakeAbLegacyTagFactory Inner { get; } = new();
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Inner.Create(p);
|
||||
|
||||
public string? WriteRefusal(AbLegacyTagCreateParams p) => new LibplctagLegacyTagFactory().WriteRefusal(p);
|
||||
}
|
||||
|
||||
#pragma warning disable OTOPCUA0001 // direct driver calls pin the driver's own status mapping (no resilience wrapper)
|
||||
private static async Task<(AbLegacyDriver Driver, CountingLibplctagRefusalFactory Factory)> DriverAsync(string address)
|
||||
{
|
||||
const string host = "ab://10.0.0.5/";
|
||||
var factory = new CountingLibplctagRefusalFactory();
|
||||
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.MicroLogix)],
|
||||
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, AbLegacyDataType.Bit, Writable: true)]),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-libplctag-statusbit", factory);
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return (driver, factory);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R6:0.DN")]
|
||||
[InlineData("R6:0.EN")]
|
||||
[InlineData("C5:0.UA")]
|
||||
public async Task A_rewritten_status_bit_write_is_BadNotSupported_with_no_runtime_and_health_unchanged(string address)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (driver, factory) = await DriverAsync(address);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
(await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
|
||||
factory.Inner.Tags.ShouldBeEmpty();
|
||||
driver.GetHealth().ShouldBe(health);
|
||||
|
||||
// Reads stay translated and allowed: the same tag reads through a runtime.
|
||||
(await driver.ReadAsync(["Bit"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Inner.Tags.Keys.ShouldContain(address);
|
||||
await driver.ShutdownAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the refusal's scope: a T/C status bit libplctag parses itself keeps its
|
||||
/// pre-existing behaviour — the write reaches a runtime (documented hazard, out of scope for #659).</summary>
|
||||
[Fact]
|
||||
public async Task A_libplctag_native_timer_status_bit_write_still_reaches_a_runtime()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (driver, factory) = await DriverAsync("T4:0.DN");
|
||||
|
||||
(await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
factory.Inner.Tags["T4:0.DN"].WriteCount.ShouldBe(1);
|
||||
await driver.ShutdownAsync(ct);
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
Reference in New Issue
Block a user