From d3295d1463ec5717f6ae6db1cf5cf4135b1ec5e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:27:00 +0700 Subject: [PATCH 1/2] fix(browser): admit explicit reads only through active live model inventory --- Models/IedBrowserReadPlan.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs index dab093c42..7ee279ca7 100644 --- a/Models/IedBrowserReadPlan.cs +++ b/Models/IedBrowserReadPlan.cs @@ -108,10 +108,24 @@ public static IReadOnlyList Build( }; } + // Page payloads can outlive a discovery/association change. Never turn + // their detached DA objects into MMS targets merely because they contain + // a plausible reference. Resolve every selection back through THIS live + // model's exact MMS reference and FC, and use the model's declared type. + // Duplicate identities in the model are ambiguous and fail closed. + var authorized = all + .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)) + .GroupBy(a => (a.MmsReference, Fc: a.FunctionalConstraint.ToUpperInvariant())) + .Where(group => group.Count() == 1) + .ToDictionary(group => group.Key, group => group.Single()); + var attributes = selected.Where(a => ReadableFc.Contains(a.FunctionalConstraint)) .Where(a => !string.IsNullOrWhiteSpace(a.MmsReference)) + .Select(a => authorized.GetValueOrDefault( + (a.MmsReference, a.FunctionalConstraint.ToUpperInvariant()))) + .Where(a => a is not null) .Select(a => new IedBrowserReadTarget( - a.MmsReference, a.FunctionalConstraint, + a!.MmsReference, a.FunctionalConstraint, string.IsNullOrWhiteSpace(a.SclBType) ? a.MmsType : a.SclBType)); // Exact, model-inventory-proven control state is an additional // read-only projection. It is never added to cyclic or Read All scans. From 80e4836affce0b1f5f142c2966199a2ef5af41b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 17:27:03 +0700 Subject: [PATCH 2/2] test(browser): reject detached UI targets, wrong FC and duplicate MMS identities --- .../P0HBrowserModelInventoryAuthorityTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs diff --git a/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs b/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs new file mode 100644 index 000000000..2c2c5d704 --- /dev/null +++ b/tests/ARSAS.Tests/P0HBrowserModelInventoryAuthorityTests.cs @@ -0,0 +1,110 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// Ensures Browser selection never trusts stale/detached UI nodes as MMS inventory. +/// All targets and types must originate in the active live discovery model. +/// +public sealed class P0HBrowserModelInventoryAuthorityTests +{ + private const string Prefix = "IEDLD0/XCBR1"; + + private static LiveIedDataAttributeModel Leaf( + string leaf, string fc, string type = "Boolean") => new() + { + ObjectReference = Prefix + ".Pos", + AttributePath = "Pos." + leaf, + MmsReference = Prefix + "$" + fc + "$Pos$" + leaf, + FunctionalConstraint = fc, + SclBType = type, + MmsType = type, + TypeDiscoveryStatus = "Exact" + }; + + private static LiveIedModelDiscoveryDocument Model( + params LiveIedDataAttributeModel[] attributes) => new() + { + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "XCBR1", + LnClass = "XCBR", + DataObjects = + [ + new LiveIedDataObjectModel + { + Name = "Pos", + Reference = Prefix + ".Pos", + Attributes = attributes + } + ] + } + ] + } + ] + }; + + private static IedBrowserTreeRow DetachedPage( + params LiveIedDataAttributeModel[] attributes) => + new(Prefix + ".Pos", "Pos", "DO", 2, new LiveIedDataObjectModel + { + Name = "Pos", + Reference = Prefix + ".Pos", + Attributes = attributes + }); + + [Fact] + public void DetachedUiPage_CannotInjectUnknownSvOrCoReferences() + { + var live = Model(Leaf("subEna", "SV"), Leaf("stVal", "ST")); + var page = DetachedPage( + Leaf("subEna", "SV", "ForgedDataType"), + Leaf("opRcvd", "OR"), // not in active model + Leaf("blkEna", "BL"), // not in active model + Leaf("Oper", "CO")); // control is never a scalar Read + var selected = IedBrowserReadPlan.Build(live, page, readAll: false); + var target = Assert.Single(selected); + Assert.Equal(Prefix + "$SV$Pos$subEna", target.Reference); + Assert.Equal("SV", target.FunctionalConstraint); + Assert.Equal("Boolean", target.DataType); // type comes from live model, not UI + } + + [Fact] + public void SameReferenceWrongFc_IsNotAuthorizedByCatalog() + { + var live = Model(Leaf("stVal", "ST")); + var page = DetachedPage(Leaf("stVal", "SV")); + Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false)); + } + + [Fact] + public void DuplicateModelWireIdentity_FailsClosedInsteadOfChoosingFirstType() + { + var live = Model(Leaf("subVal", "SV", "Dbpos"), + Leaf("subVal", "SV", "Boolean")); + var page = DetachedPage(Leaf("subVal", "SV", "Dbpos")); + Assert.Empty(IedBrowserReadPlan.Build(live, page, readAll: false)); + Assert.Empty(IedBrowserReadPlan.Build(live, null, readAll: true)); + } + + [Fact] + public void LegitimateLiveModel_StillReadsExactSvOrBlAndSt() + { + var live = Model(Leaf("subEna", "SV"), + Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST")); + var page = DetachedPage(Leaf("subEna", "SV"), + Leaf("opOk", "OR"), Leaf("blkEna", "BL"), Leaf("stVal", "ST")); + var selected = IedBrowserReadPlan.Build(live, page, readAll: false); + Assert.Equal(4, selected.Count); + Assert.Equal(new[] { "SV", "OR", "BL", "ST" }, + selected.Select(target => target.FunctionalConstraint)); + } +}