mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 09:37:12 +08:00
ce88955f70
# Description
Hardens the encoders and decoders against untrusted input and aligns
them with OPC 10000-6 (1.05) and OPC 10000-14. This comes from an audit
of every codec reachable from a peer:
- Binary, JSON and XML decoders and encoders;
- built-in and complex types;
- UA-TCP/UASC framing;
- PubSub UADP and PubSub JSON.
The audit found 97 issues: 1 critical, 7 high, 39 medium and 50 low.
They are all fixed here, one commit per finding (IDs B1-, J1-, X1-, T1-,
S1-, U1-, P1-, E1-, E2- in the commit subjects). Every fix was then
checked against the spec a second time, and the deviations that re-check
found were fixed as well (`*-spec` commits).
## Security (decoders reachable from unauthenticated peers)
- **Allocation from length prefixes (B1-1, B1-5, U1-1, U1-3).**
- Array, string and byte-string lengths are checked against the bytes
left in the message before anything is allocated.
- Arrays read element by element grow as elements are decoded, so nested
length prefixes can no longer multiply memory.
- UADP discovery counts and chunk reassembly buffers are bounded the
same way.
- **Stack exhaustion.** Nesting is checked for remaining stack
(`RuntimeHelpers.EnsureSufficientExecutionStack` mapped to
`BadEncodingLimitsExceeded`), and the large Variant read/write methods
were split into scalar, array and matrix methods. Measured on 1 MB and
256 KB threads, no codec overflows at the default limits any more.
- **XML element depth (X1-1).** XmlElement values and raw
ExtensionObject bodies are copied node by node with a depth limit,
instead of with the recursive `InnerXml`, `ImportNode`, `InnerText` and
`DeepEquals` calls.
- **Limit bypasses:**
- JSON NodeId, QualifiedName and opaque strings now respect
MaxStringLength and MaxByteStringLength (J1-1).
- Matrix rank is limited to 32.
- Decimal size is bounded (T1-3).
- XML base64 is checked before it is decoded.
- XML arrays are checked before each element is read.
- **CPU and algorithmic cost:**
- UADP reassembly uses an O(1) overlap check with a per-message chunk
cap (U1-2).
- PubSub JSON matches fields to metadata through a name index (P1-2).
- LocalizedText "mul" text is parsed lazily (T1-5).
- Unknown SecurityTokenIds trigger a throttled background SKS refresh
instead of an inline call per datagram (U1-9).
- **PubSub metadata poisoning (P1-1).** Inbound metadata is registered
only for identities a configured reader expects, is capped, and an
unauthenticated message can no longer pin a version.
- **Exception contract.** Decoders fail with `ServiceResultException`
(BadDecodingError / BadEncodingLimitsExceeded) instead of letting
ArgumentException, InvalidOperationException, TypeLoadException and
similar escape.
- **UASC (S1-*):**
- OPN and CLO must be single final chunks.
- The certificate of an unsigned OPN is ignored.
- The padding and ExtraPadding bytes are validated.
- A renewed token is activated only after a chunk has been verified with
it.
- A policy change on renewal is rejected before the channel certificate
is touched.
- Secured chunk length arithmetic is bounded.
## Wire-visible behaviour changes
All follow the spec exactly; there is no reader for the old forms unless
noted.
- **UA Binary:**
- Variant type ids 26-31 decode as ByteString.
- Picoseconds without their timestamp are ignored, and values of 10000
and above are clamped to 9999.
- A Null-type Variant with the array flag consumes its length and
dimensions.
- **UA JSON:**
- Messages are ExtensionObjects with the body inline; there is no
`UaBody` envelope any more (5.4.9 / 5.4.2.16).
- The Variant `Value` is omitted exactly for NULLs of nullable types
(Table 41).
- RawData keeps nested UaType/UaTypeId and the StatusCode Symbol.
- Duplicate member names are rejected.
- `{}` decodes as a null ExtensionObject.
- Numbers and identifiers are parsed strictly and culture-independently.
- QualifiedName follows 5.4.2.14.
- Opaque bodies of known types carry the DataType id.
- Enumerations without a literal are written as the number.
- **UA XML:**
- The earliest DateTime is written as `0001-01-01T00:00:00Z`.
- Null typed scalars and null array elements are written with `xsi:nil`.
- An ExtensionObject without a body has no `Body` element.
- Strings XML cannot represent are rejected.
- Only the xs:dateTime and xs:date forms are accepted; a value without a
zone is still read as UTC, because published model files use it.
- Only the Part 6 Guid form is accepted.
- Booleans are case-sensitive in XmlDecoder.
- **UA-TCP:**
- OPNC/OPNA/CLOC/CLOA fault the channel. Older releases that split a
long-chain OPN into chunks are rejected, so this needs a release note.
- The sender truncates the certificate chain to fit a single chunk.
- The received certificate chain is sized from the chunk size.
- Hello buffer sizes down to 1024 bytes are honoured; below that, an ERR
with Bad_TcpInternalError is sent.
- A 4096-byte Error reason is accepted, and the SecurityPolicyUri limit
is 255 bytes.
- **PubSub UADP:**
- Chunk NetworkMessages use the Part 14 7.2.4.4.4 layout, and each chunk
is secured on its own with its own sequence number.
- The Sizes array is part of the secured payload.
- Padded RawData keeps its length prefix, and null values are written as
-1.
- DataSetMessages with the valid bit clear are skipped.
- Null discovery arrays are accepted.
- **PubSub JSON:**
- Variants use `UaType`/`Value`, with the 7.2.5.4.2 top-level collapse.
The deprecated `Type`/`Body` form is still read.
- Status is written as a `{Code,Symbol}` object; a numeric Status is
still accepted.
- Discovery messages follow Tables 188-192, with GUID MessageIds.
- Enumeration fields are written as `Name_Value`.
- Fields appended with a MinorVersion bump are accepted.
## Known deviations and limitations (documented, not changed)
- A known type whose ExtensionObject XML body fails to decode fails the
message, like a failing binary body. 5.2.2.15 only says the decoder
*should* continue, and continuing reopens the log-flooding path.
- Reserved Variant type ids 26-31 are passed to the application as
ByteString without the raw type id.
- JSON UaTypeId of an opaque body of an *unknown* type stays the
encoding id, because no type system is available to map it.
- PubSub JSON encoder:
- Structure fields without a subtype are still wrapped with a UaType,
and concrete multi-dimensional or abstract-rank fields still carry a
UaType (Table 186).
- Namespace-0 enumerations without EnumDataTypes are written as a
numeric string.
- Chunked transcoded frames have no writer group, so their sequence
numbers continue from the source number.
- Pre-existing and out of scope: the HTTPS JSON binding uses
Content-Type `application/opcua+uajson`, while 1.05 7.4.5 requires
`application/json`.
## Tests
- Every fix has a regression test.
- Tests that pinned non-conformant behaviour were updated, and their
commit messages say so.
- Fuzz seeds were regenerated where the wire changed.
Local results:
net10.0, 27 suites green (Types 11354, Core.Encoders 2933, Encoders.Fuzz
8112, Core 5955 + 1 known intermittent ActivityTraceFlags metrics test
that passes alone, Client 2732, Server 7802, Sessions 790, GDS 1234,
Subscriptions 641, SourceGeneration.Core 4266, PubSub 1490 and all
PubSub transport suites). Full UA.slnx build 0 warnings / 0 errors on
net10.0 and net48.
## Related Issues
- Follow-up: #4598 (possible throughput regression of per-element binary
array reads).
## Checklist
- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (net10.0
suites run; net48 build only)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
27 KiB
C#
636 lines
27 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.PubSub.Encoding;
|
|
using Opc.Ua.PubSub.Encoding.Uadp;
|
|
using Opc.Ua.PubSub.MetaData;
|
|
using UadpDataSetMessage = Opc.Ua.PubSub.Encoding.Uadp.UadpDataSetMessage;
|
|
using UadpNetworkMessage = Opc.Ua.PubSub.Encoding.Uadp.UadpNetworkMessage;
|
|
|
|
namespace Opc.Ua.PubSub.Tests.Encoding.Uadp
|
|
{
|
|
/// <summary>
|
|
/// Round-trip tests for every supported UADP NetworkMessage variant.
|
|
/// Validates the encoder produces bytes the decoder can rehydrate
|
|
/// back into an equivalent message.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[TestSpec("7.2.4.5")]
|
|
[TestSpec("A.2.2.4")]
|
|
public class UadpEncoderTests
|
|
{
|
|
[Test]
|
|
public async Task BareDataSetMessage_RoundTrips()
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask = UadpNetworkMessageContentMask.PublisherId,
|
|
PublisherId = PublisherId.FromByte(7),
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 100,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant(42) } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
Assert.That(((PubSubDataSetMessage[]?)decoded.DataSetMessages) ?? [], Has.Length.EqualTo(1));
|
|
var ds = (UadpDataSetMessage)decoded.DataSetMessages[0];
|
|
Assert.That(ds.Fields[0].Value, Is.EqualTo(new Variant(42)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GroupHeader_AllOptionalFields_RoundTrip()
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.GroupHeader |
|
|
UadpNetworkMessageContentMask.WriterGroupId |
|
|
UadpNetworkMessageContentMask.GroupVersion |
|
|
UadpNetworkMessageContentMask.NetworkMessageNumber |
|
|
UadpNetworkMessageContentMask.SequenceNumber,
|
|
PublisherId = PublisherId.FromUInt16(1234),
|
|
WriterGroupId = 5,
|
|
GroupVersion = 0x12345678,
|
|
NetworkMessageNumber = 9,
|
|
SequenceNumber = 42,
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 100,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant(1.5) } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.WriterGroupId, Is.EqualTo((ushort)5));
|
|
Assert.That(decoded.GroupVersion, Is.EqualTo(0x12345678u));
|
|
Assert.That(decoded.NetworkMessageNumber, Is.EqualTo((ushort)9));
|
|
Assert.That(decoded.SequenceNumber, Is.EqualTo((ushort)42));
|
|
}
|
|
|
|
[Test]
|
|
public async Task PayloadHeader_MultipleDataSetMessages_RoundTrip()
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.PayloadHeader,
|
|
PublisherId = PublisherId.FromByte(1),
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 11,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant((uint)10) } ]
|
|
},
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 12,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant((uint)20) } ]
|
|
},
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 13,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant((uint)30) } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
Assert.That(((PubSubDataSetMessage[]?)decoded.DataSetMessages) ?? [], Has.Length.EqualTo(3));
|
|
Assert.That(decoded.DataSetMessages[0].DataSetWriterId, Is.EqualTo((ushort)11));
|
|
Assert.That(decoded.DataSetMessages[1].DataSetWriterId, Is.EqualTo((ushort)12));
|
|
Assert.That(decoded.DataSetMessages[2].DataSetWriterId, Is.EqualTo((ushort)13));
|
|
}
|
|
|
|
[Test]
|
|
public void SecurityBoundaryPlacesSizesInsideThePayload()
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.PayloadHeader,
|
|
PublisherId = PublisherId.FromByte(1),
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 11,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant((uint)10) } ]
|
|
},
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 12,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant("twenty") } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
ReadOnlyMemory<byte> encoded = UadpEncoder.EncodeWithSecurityBoundary(
|
|
msg, UadpTestUtilities.NewContext(), out int payloadOffset);
|
|
|
|
// Header: UADPFlags, ExtFlags1, PublisherId, Count, 2 writer ids.
|
|
// Table 154 puts the SecurityHeader here, and Table 161 makes
|
|
// Sizes the first field of the encrypted DataSet payload.
|
|
Assert.That(payloadOffset, Is.EqualTo(1 + 1 + 1 + 1 + 4));
|
|
ReadOnlySpan<byte> payload = encoded.Span[payloadOffset..];
|
|
int size0 = payload[0] | (payload[1] << 8);
|
|
int size1 = payload[2] | (payload[3] << 8);
|
|
Assert.That(4 + size0 + size1, Is.EqualTo(payload.Length));
|
|
Assert.That(
|
|
UadpDecoder.TryReadOuterPrefix(encoded, out int prefixLength, out bool securityEnabled, out _, out _),
|
|
Is.True);
|
|
Assert.That(securityEnabled, Is.True);
|
|
Assert.That(prefixLength, Is.EqualTo(payloadOffset));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ExtendedFlags1_DataSetClassId_Timestamp_PicoSeconds_RoundTrip()
|
|
{
|
|
var classId = new Uuid("AABBCCDD-1122-3344-5566-778899AABBCC");
|
|
var ts = new DateTimeUtc(new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero));
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.DataSetClassId |
|
|
UadpNetworkMessageContentMask.Timestamp |
|
|
UadpNetworkMessageContentMask.PicoSeconds,
|
|
PublisherId = PublisherId.FromByte(2),
|
|
DataSetClassId = classId,
|
|
Timestamp = ts,
|
|
PicoSeconds = 0x4321,
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant(true) } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.DataSetClassId, Is.EqualTo(classId));
|
|
Assert.That(decoded.Timestamp, Is.EqualTo(ts));
|
|
Assert.That(decoded.PicoSeconds, Is.EqualTo((ushort)0x4321));
|
|
}
|
|
|
|
[Test]
|
|
public async Task PromotedFields_RoundTrip()
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.PromotedFields,
|
|
PublisherId = PublisherId.FromByte(3),
|
|
PromotedFields =
|
|
[
|
|
new DataSetField { Value = new Variant((uint)100) },
|
|
new DataSetField { Value = new Variant("alarm") }
|
|
],
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields = [ new DataSetField { Value = new Variant("payload") } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
Assert.That(((DataSetField[]?)decoded.PromotedFields) ?? [], Has.Length.EqualTo(2));
|
|
Assert.That(decoded.PromotedFields[0].Value, Is.EqualTo(new Variant((uint)100)));
|
|
Assert.That(decoded.PromotedFields[1].Value, Is.EqualTo(new Variant("alarm")));
|
|
}
|
|
|
|
[Test]
|
|
public async Task FieldEncoding_Variant_RoundTrips()
|
|
{
|
|
UadpDataSetMessage decoded = await SingleMessageRoundTripAsync(
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
Fields =
|
|
[
|
|
new DataSetField { Value = new Variant((short)-7) },
|
|
new DataSetField { Value = new Variant("hello") },
|
|
new DataSetField { Value = new Variant(3.14) }
|
|
]
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(((DataSetField[]?)decoded.Fields) ?? [], Has.Length.EqualTo(3));
|
|
Assert.That(decoded.Fields[0].Value, Is.EqualTo(new Variant((short)-7)));
|
|
Assert.That(decoded.Fields[1].Value, Is.EqualTo(new Variant("hello")));
|
|
Assert.That(decoded.Fields[2].Value, Is.EqualTo(new Variant(3.14)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task FieldEncoding_DataValue_RoundTrips()
|
|
{
|
|
var src = new DateTimeUtc(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero));
|
|
UadpDataSetMessage decoded = await SingleMessageRoundTripAsync(
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.DataValue,
|
|
Fields =
|
|
[
|
|
new DataSetField
|
|
{
|
|
Value = new Variant(99u),
|
|
StatusCode = StatusCodes.Good,
|
|
SourceTimestamp = src
|
|
}
|
|
]
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.Fields[0].Value, Is.EqualTo(new Variant(99u)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task FieldEncoding_RawData_RoundTrips()
|
|
{
|
|
// RawData requires DataSetMetaData; register one for the writer.
|
|
var publisherId = PublisherId.FromByte(8);
|
|
const ushort writerGroupId = 1;
|
|
const ushort dataSetWriterId = 50;
|
|
var classId = new Uuid("11223344-5566-7788-99AA-BBCCDDEEFF00");
|
|
var version = new ConfigurationVersionDataType { MajorVersion = 1, MinorVersion = 0 };
|
|
var meta = new DataSetMetaDataType
|
|
{
|
|
ConfigurationVersion = version,
|
|
Fields =
|
|
[
|
|
new FieldMetaData
|
|
{
|
|
Name = "scalar1",
|
|
BuiltInType = (byte)BuiltInType.UInt32,
|
|
ValueRank = -1
|
|
},
|
|
new FieldMetaData
|
|
{
|
|
Name = "scalar2",
|
|
BuiltInType = (byte)BuiltInType.Double,
|
|
ValueRank = -1
|
|
}
|
|
]
|
|
};
|
|
var registry = new DataSetMetaDataRegistry();
|
|
var key = new DataSetMetaDataKey(
|
|
publisherId, writerGroupId, dataSetWriterId, classId, 1);
|
|
registry.Register(key, meta);
|
|
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext(registry);
|
|
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask =
|
|
UadpNetworkMessageContentMask.PublisherId |
|
|
UadpNetworkMessageContentMask.GroupHeader |
|
|
UadpNetworkMessageContentMask.WriterGroupId |
|
|
UadpNetworkMessageContentMask.PayloadHeader |
|
|
UadpNetworkMessageContentMask.DataSetClassId,
|
|
PublisherId = publisherId,
|
|
WriterGroupId = writerGroupId,
|
|
DataSetClassId = classId,
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = dataSetWriterId,
|
|
FieldEncoding = PubSubFieldEncoding.RawData,
|
|
ContentMask = UadpDataSetMessageContentMask.MajorVersion,
|
|
MetaDataVersion = version,
|
|
Fields =
|
|
[
|
|
new DataSetField { Value = new Variant(123u) },
|
|
new DataSetField { Value = new Variant(2.5) }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
var encoder = new UadpEncoder();
|
|
ReadOnlyMemory<byte> bytes =
|
|
await encoder.EncodeAsync(msg, context).ConfigureAwait(false);
|
|
|
|
var decoder = new UadpDecoder();
|
|
PubSubNetworkMessage? decodedMsg =
|
|
await decoder.TryDecodeAsync(bytes, context).ConfigureAwait(false);
|
|
|
|
Assert.That(decodedMsg, Is.Not.Null);
|
|
var decoded = (UadpNetworkMessage)decodedMsg!;
|
|
var ds = (UadpDataSetMessage)decoded.DataSetMessages[0];
|
|
Assert.That(((DataSetField[]?)ds.Fields) ?? [], Has.Length.EqualTo(2));
|
|
Assert.That(ds.Fields[0].Value, Is.EqualTo(new Variant(123u)));
|
|
Assert.That(ds.Fields[1].Value, Is.EqualTo(new Variant(2.5)));
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataSetMessage_AllHeaderOptions_RoundTrip()
|
|
{
|
|
var ts = new DateTimeUtc(new DateTimeOffset(2026, 7, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask = UadpNetworkMessageContentMask.PublisherId,
|
|
PublisherId = PublisherId.FromByte(1),
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
ContentMask =
|
|
UadpDataSetMessageContentMask.SequenceNumber |
|
|
UadpDataSetMessageContentMask.Timestamp |
|
|
UadpDataSetMessageContentMask.PicoSeconds |
|
|
UadpDataSetMessageContentMask.Status |
|
|
UadpDataSetMessageContentMask.MajorVersion |
|
|
UadpDataSetMessageContentMask.MinorVersion,
|
|
SequenceNumber = 0xDEAD,
|
|
Timestamp = ts,
|
|
PicoSeconds = 0xBEEF,
|
|
Status = (StatusCode)0x80350000u,
|
|
MetaDataVersion = new ConfigurationVersionDataType
|
|
{
|
|
MajorVersion = 2,
|
|
MinorVersion = 3
|
|
},
|
|
MessageType = PubSubDataSetMessageType.KeyFrame,
|
|
Fields = [ new DataSetField { Value = new Variant("ok") } ]
|
|
}
|
|
]
|
|
};
|
|
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
|
|
var ds = (UadpDataSetMessage)decoded.DataSetMessages[0];
|
|
Assert.That(ds.SequenceNumber, Is.EqualTo(0xDEADu));
|
|
Assert.That(ds.PicoSeconds, Is.EqualTo((ushort)0xBEEF));
|
|
Assert.That(ds.Status, Is.EqualTo((StatusCode)0x80350000u));
|
|
Assert.That(ds.MetaDataVersion.MajorVersion, Is.EqualTo(2u));
|
|
Assert.That(ds.MetaDataVersion.MinorVersion, Is.EqualTo(3u));
|
|
Assert.That(ds.Timestamp, Is.EqualTo(ts));
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataSetMessage_DeltaFrame_RoundTrips()
|
|
{
|
|
UadpDataSetMessage decoded = await SingleMessageRoundTripAsync(
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
MessageType = PubSubDataSetMessageType.DeltaFrame,
|
|
Fields = [new DataSetField { FieldIndex = 7, Value = new Variant(42) }]
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.MessageType, Is.EqualTo(PubSubDataSetMessageType.DeltaFrame));
|
|
Assert.That(((DataSetField[]?)decoded.Fields) ?? [], Has.Length.EqualTo(1));
|
|
Assert.That(decoded.Fields[0].FieldIndex, Is.EqualTo(7),
|
|
"Part 14 Table 164 FieldIndex is the DataSetMetaData field position.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataSetMessage_KeepAlive_HasNoFields()
|
|
{
|
|
UadpDataSetMessage decoded = await SingleMessageRoundTripAsync(
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
MessageType = PubSubDataSetMessageType.KeepAlive,
|
|
Fields = []
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.MessageType, Is.EqualTo(PubSubDataSetMessageType.KeepAlive));
|
|
Assert.That(((DataSetField[]?)decoded.Fields) ?? [], Is.Empty);
|
|
}
|
|
|
|
[Test]
|
|
public async Task DataSetMessage_Event_RoundTrips()
|
|
{
|
|
UadpDataSetMessage decoded = await SingleMessageRoundTripAsync(
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
MessageType = PubSubDataSetMessageType.Event,
|
|
Fields =
|
|
[
|
|
new DataSetField { Value = new Variant("EventTrigger") },
|
|
new DataSetField { Value = new Variant((ushort)500) }
|
|
]
|
|
}).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded.MessageType, Is.EqualTo(PubSubDataSetMessageType.Event));
|
|
Assert.That(((DataSetField[]?)decoded.Fields) ?? [], Has.Length.EqualTo(2));
|
|
}
|
|
|
|
[Test]
|
|
[TestSpec("7.2.4.5.7")]
|
|
public void DataSetMessageEventDataValueEncodingThrows()
|
|
{
|
|
var message = new UadpNetworkMessage
|
|
{
|
|
DataSetMessages =
|
|
[
|
|
new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.DataValue,
|
|
MessageType = PubSubDataSetMessageType.Event,
|
|
Fields = [new DataSetField { Value = new Variant("EventTrigger") }]
|
|
}
|
|
]
|
|
};
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
|
|
Assert.That(
|
|
async () => await new UadpEncoder().EncodeAsync(message, context).ConfigureAwait(false),
|
|
Throws.InvalidOperationException.With.Message.Contains("Event DataSetMessages"),
|
|
"Part 14 Table 165 requires Event fields encoded as Variant with field-encoding bits false.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task EncodeAsync_NullMessage_Throws()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
Assert.That(
|
|
async () => await encoder.EncodeAsync(null!, context).ConfigureAwait(false),
|
|
Throws.InstanceOf<ArgumentNullException>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EncodeAsync_NullContext_Throws()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
var msg = new UadpNetworkMessage();
|
|
Assert.That(
|
|
async () => await encoder.EncodeAsync(msg, null!).ConfigureAwait(false),
|
|
Throws.InstanceOf<ArgumentNullException>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EncodeAsync_RejectsNonUadpNetworkMessage()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
var foreign = new ForeignNetworkMessage();
|
|
Assert.That(
|
|
async () => await encoder.EncodeAsync(foreign, context).ConfigureAwait(false),
|
|
Throws.ArgumentException);
|
|
}
|
|
|
|
[Test]
|
|
public void EncodeAsync_RejectsCancelledToken()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
using var cts = new System.Threading.CancellationTokenSource();
|
|
cts.Cancel();
|
|
Assert.That(
|
|
async () => await encoder.EncodeAsync(
|
|
new UadpNetworkMessage(), context, cts.Token).ConfigureAwait(false),
|
|
Throws.InstanceOf<OperationCanceledException>());
|
|
}
|
|
|
|
[Test]
|
|
public void EncodeAsync_BadUadpVersion_Throws()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
var bad = new UadpNetworkMessage { UadpVersion = 2 };
|
|
Assert.That(
|
|
async () => await encoder.EncodeAsync(bad, context).ConfigureAwait(false),
|
|
Throws.InstanceOf<InvalidOperationException>());
|
|
}
|
|
|
|
[Test]
|
|
public void Encoder_ExposesProfileAndOverhead()
|
|
{
|
|
var encoder = new UadpEncoder();
|
|
Assert.That(encoder.TransportProfileUri, Is.EqualTo(Profiles.PubSubUdpUadpTransport));
|
|
Assert.That(encoder.EstimatedHeaderOverhead, Is.GreaterThan(0));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ConfiguredSize_PadsPayloadToTarget()
|
|
{
|
|
var dataSetMessage = new UadpDataSetMessage
|
|
{
|
|
DataSetWriterId = 1,
|
|
FieldEncoding = PubSubFieldEncoding.Variant,
|
|
ConfiguredSize = 128,
|
|
Fields = [new DataSetField { Value = new Variant(1) }]
|
|
};
|
|
|
|
// Padding only changes encoded length; sanity check via raw encode.
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask = UadpNetworkMessageContentMask.PublisherId,
|
|
PublisherId = PublisherId.FromByte(0),
|
|
DataSetMessages = [dataSetMessage]
|
|
};
|
|
var encoder = new UadpEncoder();
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
ReadOnlyMemory<byte> bytes =
|
|
await encoder.EncodeAsync(msg, context).ConfigureAwait(false);
|
|
Assert.That(bytes.Length, Is.GreaterThanOrEqualTo(128));
|
|
}
|
|
|
|
private static async Task<UadpNetworkMessage> RoundTripAsync(UadpNetworkMessage msg)
|
|
{
|
|
PubSubNetworkMessageContext context = UadpTestUtilities.NewContext();
|
|
var encoder = new UadpEncoder();
|
|
ReadOnlyMemory<byte> bytes =
|
|
await encoder.EncodeAsync(msg, context).ConfigureAwait(false);
|
|
|
|
var decoder = new UadpDecoder();
|
|
PubSubNetworkMessage? decoded =
|
|
await decoder.TryDecodeAsync(bytes, context).ConfigureAwait(false);
|
|
|
|
Assert.That(decoded, Is.Not.Null);
|
|
Assert.That(decoded, Is.InstanceOf<UadpNetworkMessage>());
|
|
return (UadpNetworkMessage)decoded!;
|
|
}
|
|
|
|
private static async Task<UadpDataSetMessage> SingleMessageRoundTripAsync(
|
|
UadpDataSetMessage ds)
|
|
{
|
|
var msg = new UadpNetworkMessage
|
|
{
|
|
ContentMask = UadpNetworkMessageContentMask.PublisherId,
|
|
PublisherId = PublisherId.FromByte(1),
|
|
DataSetMessages = [ds]
|
|
};
|
|
UadpNetworkMessage decoded = await RoundTripAsync(msg).ConfigureAwait(false);
|
|
return (UadpDataSetMessage)decoded.DataSetMessages[0];
|
|
}
|
|
|
|
private sealed record ForeignNetworkMessage : PubSubNetworkMessage
|
|
{
|
|
public override string TransportProfileUri => "other://transport";
|
|
}
|
|
}
|
|
}
|