feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority, Core.Abstractions DriverArrayWriteSupport, says which (driver, element type) pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters), S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else stays read-only, so the server never advertises a write the driver refuses. - Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable .BackingArrayWritable at compose time (both seams, byte-parity); the applier's one writability rule is Writable && (!IsArray || ArrayWritable) for materialise and surgical paths, raw and UNS alike. - ArrayWritable is on the classifier's surgical raw-tag whitelist and mirrored on the UNS record (UnsReferenceBackingParityTests updated). - Node manager refuses a wrong-length / non-array value on an array node (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline, before dispatch; WriteOperate/ACL gates and the revert are unchanged. - Per-driver parity tests pin the table against the real Modbus write path, S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded: array covariance reinterprets their bits). - Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests). - ValueRank > 1 documented as out of scope by decision (docs/Uns.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1940,7 +1940,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
|
||||
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
|
||||
// caller) but BEFORE the optimistic dispatch below.
|
||||
var structure = EvaluateEquipmentWriteStructure(value, node);
|
||||
var structure = EvaluateEquipmentWriteRange(indexRange) ?? EvaluateEquipmentWriteStructure(value, node);
|
||||
if (structure is not null) return structure;
|
||||
|
||||
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
|
||||
@@ -2057,6 +2057,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
|
||||
}
|
||||
|
||||
// Gitea #610 — an array node (writable only when its driver encodes whole-array writes) takes a whole
|
||||
// 1-D array of its declared length: every array-capable driver refuses a short or long block (Modbus
|
||||
// FC15/FC16, S7's contiguous block), so a wrong shape is rejected here instead of being optimistically
|
||||
// applied, dispatched, refused and reverted. ArrayDimensions [0] / absent means unbounded ⇒ any length.
|
||||
if (node is BaseDataVariableState { ValueRank: ValueRanks.OneDimension } arrayNode)
|
||||
{
|
||||
if (value is not Array { Rank: 1 } array)
|
||||
{
|
||||
return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
|
||||
}
|
||||
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
|
||||
if (declared > 0 && array.Length != declared)
|
||||
{
|
||||
return new ServiceResult(
|
||||
StatusCodes.BadTypeMismatch,
|
||||
$"array write of {array.Length} element(s) to a {declared}-element array node");
|
||||
}
|
||||
}
|
||||
|
||||
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
|
||||
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
|
||||
// resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
|
||||
@@ -2084,6 +2103,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #610 — an equipment-tag write must be a WHOLE-value write. A partial (IndexRange) write to an
|
||||
/// array node is refused: every array-capable driver writes the whole block, so honouring a range would
|
||||
/// mean a read-modify-write of the device array the server does not hold authoritatively (the node value
|
||||
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
|
||||
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
|
||||
/// </summary>
|
||||
/// <param name="indexRange">The write's parsed IndexRange (<see cref="NumericRange.Empty"/> when absent).</param>
|
||||
/// <returns><c>null</c> to proceed; otherwise <c>BadWriteNotSupported</c>.</returns>
|
||||
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
|
||||
indexRange == NumericRange.Empty
|
||||
? null
|
||||
: new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
|
||||
|
||||
/// <summary>
|
||||
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
|
||||
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
|
||||
|
||||
Reference in New Issue
Block a user