fix(ablegacy): refuse every libplctag write the #659 translation changes (fix round 2)

Review M1: round 1 refused only the rewritten status bits, so S:/O:/I: writes - which
never worked on libplctag because tag creation failed - would have started reaching
the PLC (outputs, status-file words) on the default transport. WriteRefusal now keys
on LibplctagLegacyName.IsTranslated: file number supplied (S/O/I, incl. bit writes
like O:0/3, refused ahead of the parent-word RMW) or status bit rewritten. Reads
unaffected. Docs/comments now true; gate doc notes R bit-number wire evidence is
non-discriminating (L1). 12 new cases fail with the round-1 predicate.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 05:43:50 -04:00
parent 7dbb060f92
commit 7ba86f7857
6 changed files with 88 additions and 45 deletions
@@ -7,14 +7,15 @@ 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.
/// #659 review rulings: the libplctag read translation must not widen the WRITE surface of the default transport.
/// Before it, libplctag could not create a tag for <c>S:</c>/<c>O:</c>/<c>I:</c> (no file number) or for an R/UA
/// status bit, so no write to one ever reached the controller; now that the translated names parse, a write would
/// drive an output, a status-file word or (masked) a control word. Every write whose address the translation
/// changes is 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
public sealed class LibplctagTranslatedWriteRefusalTests
{
[Theory]
[InlineData("R6:0.EN", true)]
@@ -31,19 +32,27 @@ public sealed class LibplctagStatusBitWriteRefusalTests
[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("S:0", true)] // file number supplied by the translation (S2:0)
[InlineData("S:1/5", true)]
[InlineData("S:57", true)]
[InlineData("O:0", true)]
[InlineData("O:0/3", true)]
[InlineData("I:0", true)]
[InlineData("I:0/0", true)]
[InlineData("N7:0", false)]
[InlineData("N7:0/3", false)]
[InlineData("F8:0", false)]
[InlineData("ST14:10", false)]
[InlineData("T4:0.PRE", 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)
public void The_libplctag_refusal_covers_exactly_the_translated_addresses(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);
LibplctagLegacyName.IsTranslated(tag).ShouldBe(refused);
}
/// <summary>Defence in depth: a runtime for a rewritten status bit refuses the write itself, without touching the
@@ -69,14 +78,15 @@ public sealed class LibplctagStatusBitWriteRefusalTests
}
#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)
private static async Task<(AbLegacyDriver Driver, CountingLibplctagRefusalFactory Factory)> DriverAsync(
string address, AbLegacyDataType type = AbLegacyDataType.Bit)
{
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)]),
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, type, Writable: true)]),
Timeout = TimeSpan.FromSeconds(1),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-libplctag-statusbit", factory);
@@ -84,17 +94,25 @@ public sealed class LibplctagStatusBitWriteRefusalTests
return (driver, factory);
}
/// <summary>Includes bit writes to O/I/S (<c>O:0/3</c>): the refusal runs ahead of the bit read-modify-write, so
/// not even the parent-word runtime (<c>O:0</c>) is created — nothing is read or written.</summary>
[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)
[InlineData("R6:0.DN", AbLegacyDataType.Bit, true)]
[InlineData("R6:0.EN", AbLegacyDataType.Bit, true)]
[InlineData("C5:0.UA", AbLegacyDataType.Bit, true)]
[InlineData("O:0/3", AbLegacyDataType.Bit, true)]
[InlineData("I:0/0", AbLegacyDataType.Bit, true)]
[InlineData("S:1/5", AbLegacyDataType.Bit, true)]
[InlineData("O:0", AbLegacyDataType.Int, (short)1)]
[InlineData("S:1", AbLegacyDataType.Int, (short)1)]
public async Task A_translated_address_write_is_BadNotSupported_with_no_runtime_and_health_unchanged(
string address, AbLegacyDataType type, object value)
{
var ct = TestContext.Current.CancellationToken;
var (driver, factory) = await DriverAsync(address);
var (driver, factory) = await DriverAsync(address, type);
var health = driver.GetHealth();
(await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode
(await driver.WriteAsync([new WriteRequest("Bit", value)], ct)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
factory.Inner.Tags.ShouldBeEmpty();
@@ -120,5 +138,21 @@ public sealed class LibplctagStatusBitWriteRefusalTests
factory.Inner.Tags["T4:0.DN"].WriteCount.ShouldBe(1);
await driver.ShutdownAsync(ct);
}
/// <summary>Positive control: an untranslated bit write (<c>B3:0/4</c>) still runs the parent-word
/// read-modify-write, so the refusal is not swallowing bit writes in general.</summary>
[Fact]
public async Task An_untranslated_bit_write_still_runs_the_parent_word_read_modify_write()
{
var ct = TestContext.Current.CancellationToken;
var (driver, factory) = await DriverAsync("B3:0/4");
factory.Inner.Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 };
(await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.Good);
factory.Inner.Tags["B3:0"].WriteCount.ShouldBe(1);
await driver.ShutdownAsync(ct);
}
#pragma warning restore OTOPCUA0001
}