mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] 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. - [ ] 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. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1134 lines
55 KiB
C#
1134 lines
55 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.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Xml;
|
|
using System.Xml.Linq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Bindings;
|
|
|
|
namespace Opc.Ua.Fuzzing
|
|
{
|
|
/// <summary>
|
|
/// Regression coverage for segmented decoding and the JSON conversion callback families.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Fuzzing")]
|
|
public sealed class EncoderParityTests
|
|
{
|
|
[TestCaseSource(nameof(PositiveCallbackCases))]
|
|
public void SpanCallbacksReplayPopulatedRichMessages(string callbackName, string messageName)
|
|
{
|
|
CallbackCase target = FindCallback(callbackName);
|
|
IEncodeable original = EncoderTestMessages.Create(messageName);
|
|
EncoderTestMessages.AssertPopulated(original);
|
|
ByteString input = EncoderTestMessages.Encode(original, target.Wire);
|
|
IEncodeable decoded = EncoderTestMessages.Decode(input.Span, target.Wire)!;
|
|
EncoderTestMessages.AssertPopulated(decoded!);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
|
|
target.Span(input.Span);
|
|
|
|
Assert.That(EncoderTestMessages.Encode(original, target.Wire), Is.EqualTo(input));
|
|
}
|
|
|
|
[TestCaseSource(nameof(PositiveCallbackCases))]
|
|
public void AflCallbacksReplayTheSamePopulatedRichMessages(string callbackName, string messageName)
|
|
{
|
|
CallbackCase target = FindCallback(callbackName);
|
|
IEncodeable original = EncoderTestMessages.Create(messageName);
|
|
EncoderTestMessages.AssertPopulated(original);
|
|
ByteString input = EncoderTestMessages.Encode(original, target.Wire);
|
|
IEncodeable decoded = EncoderTestMessages.Decode(input.Span, target.Wire)!;
|
|
EncoderTestMessages.AssertPopulated(decoded!);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
|
|
ReplayAfl(target, input, true);
|
|
|
|
Assert.That(EncoderTestMessages.Encode(original, target.Wire), Is.EqualTo(input));
|
|
}
|
|
|
|
[TestCaseSource(nameof(TruncatedCallbackCases))]
|
|
public void SpanCallbacksRejectTruncatedSourceRatherThanExecutingASuccessPath(
|
|
string callbackName,
|
|
bool truncateLastByte)
|
|
{
|
|
CallbackCase target = FindCallback(callbackName);
|
|
ByteString input = TruncatedReadRequest(target.Wire, truncateLastByte);
|
|
AssertMalformedSource(input, target.Wire);
|
|
|
|
target.Span(input.Span);
|
|
|
|
Assert.That(EncoderTestMessages.Decode(input.Span, target.Wire, false), Is.Null);
|
|
}
|
|
|
|
[TestCaseSource(nameof(TruncatedCallbackCases))]
|
|
public void AflCallbacksRejectTheSameTruncatedSource(string callbackName, bool truncateLastByte)
|
|
{
|
|
CallbackCase target = FindCallback(callbackName);
|
|
ByteString input = TruncatedReadRequest(target.Wire, truncateLastByte);
|
|
AssertMalformedSource(input, target.Wire);
|
|
|
|
ReplayAfl(target, input, false);
|
|
|
|
Assert.That(EncoderTestMessages.Decode(input.Span, target.Wire, false), Is.Null);
|
|
}
|
|
|
|
[TestCaseSource(nameof(SegmentBoundaryCases))]
|
|
public void SegmentedStreamsPreserveValuesAndCanonicalBytesAcrossBoundaries(string messageName, int segmentSize)
|
|
{
|
|
IEncodeable original = EncoderTestMessages.Create(messageName);
|
|
ByteString input = EncoderTestMessages.Encode(original, "Binary");
|
|
using MemoryStream stream = FuzzableCode.PrepareArraySegmentStream(input.Span, segmentSize);
|
|
|
|
Assert.That(stream, Is.TypeOf<ArraySegmentStream>());
|
|
Assert.That(stream.CanSeek, Is.True);
|
|
Assert.That(stream.Length, Is.EqualTo(input.Length));
|
|
Assert.That(stream.Position, Is.Zero);
|
|
AssertSegmentLayout(input, segmentSize);
|
|
|
|
IEncodeable decoded = FuzzableCode.FuzzBinaryDecoderCore(stream, true)!;
|
|
EncoderTestMessages.AssertPopulated(decoded!);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
ByteString canonical = EncoderTestMessages.Encode(decoded, "Binary");
|
|
Assert.That(canonical, Is.EqualTo(input));
|
|
FuzzableCode.FuzzBinaryEncoderIndempotentCore(canonical.ToArray(), original, true);
|
|
|
|
using MemoryStream truncated = FuzzableCode.PrepareArraySegmentStream(input.Span[..^1], segmentSize);
|
|
Assert.That(FuzzableCode.FuzzBinaryDecoderCore(truncated), Is.Null);
|
|
}
|
|
|
|
[TestCase(0)]
|
|
[TestCase(-1)]
|
|
public void SegmentedStreamsRejectNonPositiveSegmentSizes(int segmentSize)
|
|
{
|
|
ByteString input = EncoderTestMessages.Encode(Testcases.CreateRichReadRequest(), "Binary");
|
|
|
|
Assert.That(
|
|
() => FuzzableCode.PrepareArraySegmentStream(input.Span, segmentSize),
|
|
Throws.TypeOf<ArgumentOutOfRangeException>().With.Property("ParamName").EqualTo("segmentSize"));
|
|
Assert.That(input.Length, Is.GreaterThan(64));
|
|
}
|
|
|
|
[Test]
|
|
public void EmptySegmentedInputHasOneEmptyBufferAndAnEndOfStream()
|
|
{
|
|
using MemoryStream stream = FuzzableCode.PrepareArraySegmentStream(ReadOnlySpan<byte>.Empty, 1);
|
|
|
|
Assert.That(stream, Is.TypeOf<ArraySegmentStream>());
|
|
Assert.That(stream.Length, Is.Zero);
|
|
Assert.That(stream.Position, Is.Zero);
|
|
Assert.That(stream.ReadByte(), Is.EqualTo(-1));
|
|
BufferCollection buffers = ((ArraySegmentStream)stream).GetBuffers(nameof(EncoderParityTests));
|
|
Assert.That(buffers.Count, Is.EqualTo(1));
|
|
Assert.That(buffers[0].Count, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void SegmentedCallbacksPreserveACompleteMessageEndingAtABufferBoundary()
|
|
{
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
ByteString initial = EncoderTestMessages.Encode(original, "Binary");
|
|
original.RequestHeader.AuditEntryId = new string('a', 64 - initial.Length % 64);
|
|
ByteString input = EncoderTestMessages.Encode(original, "Binary");
|
|
|
|
Assert.That(input.Length % 64, Is.Zero);
|
|
Assert.That(input.Length, Is.GreaterThan(64));
|
|
foreach (CallbackCase callback in s_callbacks.Where(
|
|
callback => callback.Span.Method.Name.EndsWith("Segmented", StringComparison.Ordinal)))
|
|
{
|
|
callback.Span(input.Span);
|
|
ReplayAfl(callback, input, true);
|
|
}
|
|
using MemoryStream stream = FuzzableCode.PrepareArraySegmentStream(input.Span);
|
|
IEncodeable decoded = FuzzableCode.FuzzBinaryDecoderCore(stream, true)!;
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
Assert.That(EncoderTestMessages.Encode(decoded!, "Binary"), Is.EqualTo(input));
|
|
}
|
|
|
|
[Test]
|
|
public void CallbackDiscoveryIncludesAllNewPairsWithoutDuplicateSegmentedAflTargets()
|
|
{
|
|
string[] spans = FuzzMethods.FindFuzzMethods(typeof(FuzzMethods.LibFuzzSpan))
|
|
.Select(callback => callback.Method.Name)
|
|
.ToArray();
|
|
string[] afl = FuzzMethods.FindFuzzMethods(typeof(FuzzMethods.AflFuzzStream))
|
|
.Concat(FuzzMethods.FindFuzzMethods(typeof(FuzzMethods.AflFuzzString)))
|
|
.Select(callback => callback.Method.Name)
|
|
.ToArray();
|
|
|
|
Assert.That(s_callbacks.Length, Is.EqualTo(16));
|
|
Assert.That(s_callbacks.Select(callback => callback.Span.Method.Name).Distinct().Count(), Is.EqualTo(16));
|
|
Assert.That(s_callbacks.Count(callback => !callback.Span.Method.Name.EndsWith(
|
|
"Segmented",
|
|
StringComparison.Ordinal)), Is.EqualTo(13));
|
|
foreach (CallbackCase target in s_callbacks)
|
|
{
|
|
Assert.That(spans.Count(name => name == target.Span.Method.Name), Is.EqualTo(1));
|
|
Assert.That(afl.Count(name => name == target.Afl.Method.Name), Is.EqualTo(1));
|
|
}
|
|
Assert.That(afl, Does.Not.Contain("AflfuzzBinaryDecoderSegmented"));
|
|
Assert.That(afl, Does.Not.Contain("AflfuzzBinaryEncoderSegmented"));
|
|
Assert.That(afl, Does.Not.Contain("AflfuzzBinaryEncoderIndempotentSegmented"));
|
|
Assert.That(spans.Concat(afl).All(name => name.StartsWith("Libfuzz", StringComparison.Ordinal) ||
|
|
name.StartsWith("Aflfuzz", StringComparison.Ordinal)), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void MessageContextsHaveDeterministicIndependentNamespaceAndServerTables()
|
|
{
|
|
ServiceMessageContext first = FuzzableCode.CreateMessageContext();
|
|
ServiceMessageContext second = FuzzableCode.CreateMessageContext();
|
|
|
|
Assert.That(first, Is.Not.SameAs(second));
|
|
Assert.That(first.NamespaceUris, Is.Not.SameAs(second.NamespaceUris));
|
|
Assert.That(first.ServerUris, Is.Not.SameAs(second.ServerUris));
|
|
Assert.That(first.NamespaceUris.ToArray(), Is.EqualTo(s_namespaceUris));
|
|
Assert.That(second.NamespaceUris.ToArray(), Is.EqualTo(s_namespaceUris));
|
|
Assert.That(first.ServerUris.GetIndex("urn:opcfoundation:fuzzing:server"), Is.Zero);
|
|
Assert.That(second.ServerUris.Count, Is.EqualTo(1));
|
|
|
|
first.NamespaceUris.Append("urn:encoder-test:private");
|
|
first.ServerUris.Append("urn:encoder-test:private-server");
|
|
|
|
Assert.That(first.NamespaceUris.Count, Is.EqualTo(6));
|
|
Assert.That(first.ServerUris.Count, Is.EqualTo(2));
|
|
Assert.That(second.NamespaceUris.ToArray(), Is.EqualTo(s_namespaceUris));
|
|
Assert.That(second.ServerUris.Count, Is.EqualTo(1));
|
|
Assert.That(FuzzableCode.MessageContext.NamespaceUris.ToArray(), Is.EqualTo(s_namespaceUris));
|
|
Assert.That(FuzzableCode.MessageContext.ServerUris.Count, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void StandardMessageDecoderResolvesTheXmlWrapperAndRegisteredModelQualifiedName()
|
|
{
|
|
ReadRequest message = Testcases.CreateRichReadRequest();
|
|
Assert.That(
|
|
FuzzableCode.MessageContext.Factory.TryGetEncodeableType(message.TypeId, out IEncodeableType? type),
|
|
Is.True);
|
|
var name = new XmlQualifiedName(nameof(ReadRequest), Namespaces.OpcUa);
|
|
Assert.That(type!.XmlName, Is.EqualTo(name));
|
|
Assert.That(FuzzableCode.MessageContext.Factory.TryGetType(name, out IType? resolved), Is.True);
|
|
Assert.That(resolved, Is.SameAs(type));
|
|
ByteString serialized = EncoderTestMessages.Encode(message, "Xml");
|
|
IEncodeable decoded = EncoderTestMessages.Decode(serialized.Span, "Xml")!;
|
|
Assert.That(Utils.IsEqual(message, decoded), Is.True);
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void JsonModesPreserveZeroDiagnosticStringTableIndices(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadResponse message = Testcases.CreateRichReadResponse();
|
|
message.ResponseHeader.ServiceDiagnostics = new DiagnosticInfo
|
|
{
|
|
SymbolicId = 0,
|
|
NamespaceUri = 0,
|
|
Locale = 0,
|
|
LocalizedText = 0
|
|
};
|
|
string serialized = FuzzableCode.EncodeJsonMessage(message, options);
|
|
using JsonDocument document = JsonDocument.Parse(serialized);
|
|
JsonElement diagnostic = document.RootElement
|
|
.GetProperty("ResponseHeader").GetProperty("ServiceDiagnostics");
|
|
|
|
Assert.That(diagnostic.GetProperty("SymbolicId").GetInt32(), Is.Zero);
|
|
Assert.That(diagnostic.GetProperty("NamespaceUri").GetInt32(), Is.Zero);
|
|
Assert.That(diagnostic.GetProperty("Locale").GetInt32(), Is.Zero);
|
|
Assert.That(diagnostic.GetProperty("LocalizedText").GetInt32(), Is.Zero);
|
|
FuzzableCode.FuzzJsonRoundTripCore(message, options);
|
|
}
|
|
|
|
[Test]
|
|
public void JsonModeCatalogMapsSupportedLegacyOptionsWithoutRetiredWireShapes()
|
|
{
|
|
ArrayOf<JsonEncoderOptions> modes = FuzzableCode.JsonEncodingModes;
|
|
|
|
Assert.That(modes.IsNull, Is.False);
|
|
Assert.That(modes.Count, Is.EqualTo(5));
|
|
Assert.That(modes.ToArray()!.Select(mode => mode.Name), Is.EqualTo(s_modeNames));
|
|
Assert.That(modes[0], Is.EqualTo(JsonEncoderOptions.Verbose));
|
|
Assert.That(modes[1], Is.EqualTo(JsonEncoderOptions.Compact));
|
|
Assert.That(modes[2], Is.EqualTo(JsonEncoderOptions.RawData));
|
|
Assert.That(modes[3], Is.EqualTo(JsonEncoderOptions.Compact with
|
|
{
|
|
Name = "LegacyReversible",
|
|
IgnoreDefaultValues = false,
|
|
ForceNamespaceUri = false
|
|
}));
|
|
Assert.That(modes[4], Is.EqualTo(JsonEncoderOptions.RawData with { Name = "LegacyNonReversible" }));
|
|
}
|
|
|
|
[TestCaseSource(nameof(RichJsonModeCases))]
|
|
public void JsonRoundTripCorePreservesRichTypedValuesAndCanonicalText(string messageName, string modeName)
|
|
{
|
|
IEncodeable original = EncoderTestMessages.Create(messageName);
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
EncoderTestMessages.AssertPopulated(original);
|
|
|
|
FuzzableCode.FuzzJsonRoundTripCore(original, options);
|
|
|
|
string serialized = FuzzableCode.EncodeJsonMessage(original, options);
|
|
IEncodeable decoded = FuzzableCode.DecodeJsonWithMetadata(
|
|
serialized,
|
|
original,
|
|
options,
|
|
FuzzableCode.MessageContext);
|
|
EncoderTestMessages.AssertPopulated(decoded);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
Assert.That(FuzzableCode.EncodeJsonMessage(decoded, options), Is.EqualTo(serialized));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void JsonModesEncodeNamespaceOneEnumsAndDefaultValuesAsConfigured(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest request = Testcases.CreateRichReadRequest();
|
|
request.MaxAge = 0;
|
|
string serialized = FuzzableCode.EncodeJsonMessage(request, options);
|
|
using JsonDocument document = JsonDocument.Parse(serialized);
|
|
// The message body is inline next to its type id (Part 6 5.4.9, 5.4.2.16).
|
|
JsonElement body = document.RootElement;
|
|
JsonElement timestampMode = body.GetProperty("TimestampsToReturn");
|
|
|
|
// The message envelope retains its type id; RawData suppresses artifacts inside the body.
|
|
Assert.That(document.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=629"));
|
|
JsonElement header = body.GetProperty("RequestHeader").GetProperty("AdditionalHeader");
|
|
// The AdditionalHeader is an ExtensionObject (abstract Structure), so RawData keeps
|
|
// its UaTypeId (Part 6 5.4.1).
|
|
Assert.That(header.TryGetProperty("UaTypeId", out _), Is.True);
|
|
// A structure's abstract Variant still carries its type; retired unwrapped Variants are not emitted.
|
|
Assert.That(
|
|
header.GetProperty("Parameters")[0].GetProperty("Value").GetProperty("UaType").GetInt32(),
|
|
Is.EqualTo((int)BuiltInType.String));
|
|
Assert.That(
|
|
body.TryGetProperty("MaxAge", out JsonElement maxAge),
|
|
Is.EqualTo(!options.IgnoreDefaultValues));
|
|
if (!options.IgnoreDefaultValues)
|
|
{
|
|
Assert.That(maxAge.GetDouble(), Is.Zero);
|
|
}
|
|
if (options.EnumerationAsNumber)
|
|
{
|
|
Assert.That(timestampMode.ValueKind, Is.EqualTo(JsonValueKind.Number));
|
|
Assert.That(timestampMode.GetInt32(), Is.EqualTo((int)TimestampsToReturn.Source));
|
|
}
|
|
else
|
|
{
|
|
Assert.That(timestampMode.ValueKind, Is.EqualTo(JsonValueKind.String));
|
|
Assert.That(timestampMode.GetString(), Is.EqualTo("Source_0"));
|
|
}
|
|
string prefix = options.ForceNamespaceUri ? "nsu=urn:opcfoundation:fuzzing:application;" : "ns=1;";
|
|
Assert.That(
|
|
body.GetProperty("NodesToRead")[3].GetProperty("NodeId").GetString(),
|
|
Is.EqualTo(prefix + "g=00112233-4455-6677-8899-aabbccddeeff"));
|
|
FuzzableCode.FuzzJsonRoundTripCore(request, options);
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void StatusCodeSymbolsFollowTheSelectedJsonModeOptions(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
var response = new ReadResponse
|
|
{
|
|
Results = [new DataValue(Variant.From("status payload"), StatusCodes.BadDataLost)]
|
|
};
|
|
string serialized = FuzzableCode.EncodeJsonMessage(response, options);
|
|
using JsonDocument document = JsonDocument.Parse(serialized);
|
|
JsonElement value = document.RootElement.GetProperty("Results")[0];
|
|
JsonElement status = value.GetProperty("Status");
|
|
|
|
Assert.That(value.GetProperty("Value").GetString(), Is.EqualTo("status payload"));
|
|
Assert.That(status.ValueKind, Is.EqualTo(JsonValueKind.Object));
|
|
Assert.That(status.GetProperty("Code").GetUInt32(), Is.EqualTo(StatusCodes.BadDataLost));
|
|
// RawData omits type artifacts only; the Symbol follows OmitStatusCodeSymbol.
|
|
Assert.That(
|
|
status.TryGetProperty("Symbol", out JsonElement symbol),
|
|
Is.EqualTo(!options.OmitStatusCodeSymbol));
|
|
if (!options.OmitStatusCodeSymbol)
|
|
{
|
|
Assert.That(symbol.GetString(), Is.EqualTo("BadDataLost"));
|
|
}
|
|
}
|
|
|
|
[TestCase("RawData")]
|
|
[TestCase("LegacyNonReversible")]
|
|
public void RawDataRequiresTypeMetadataAndRestoresPopulatedValues(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
WriteRequest original = Testcases.CreateRichWriteRequest();
|
|
string serialized = FuzzableCode.EncodeJsonMessage(original, options);
|
|
string metadata = FuzzableCode.EncodeJsonMessage(original, options with { SuppressArtifacts = false });
|
|
using JsonDocument raw = JsonDocument.Parse(serialized);
|
|
using JsonDocument schema = JsonDocument.Parse(metadata);
|
|
|
|
Assert.That(raw.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=671"));
|
|
// Only the envelope keeps its UaTypeId; the body is inline next to it.
|
|
Assert.That(CountTypeArtifacts(raw.RootElement), Is.EqualTo(1));
|
|
Assert.That(CountTypeArtifacts(schema.RootElement), Is.GreaterThanOrEqualTo(11));
|
|
Assert.That(
|
|
raw.RootElement.GetProperty("NodesToWrite").GetArrayLength(),
|
|
Is.EqualTo(10));
|
|
|
|
string restored = FuzzableCode.RestoreJsonArtifacts(serialized, metadata, FuzzableCode.MessageContext);
|
|
using JsonDocument result = JsonDocument.Parse(restored);
|
|
Assert.That(CountTypeArtifacts(result.RootElement), Is.EqualTo(CountTypeArtifacts(schema.RootElement)));
|
|
IEncodeable decoded = FuzzableCode.DecodeJsonWithMetadata(
|
|
serialized,
|
|
original,
|
|
options,
|
|
FuzzableCode.MessageContext);
|
|
EncoderTestMessages.AssertPopulated(decoded);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
FuzzableCode.FuzzJsonEncoderIndempotentCore(serialized, original, options);
|
|
|
|
JsonObject withoutEnvelopeType = JsonNode.Parse(serialized)!.AsObject();
|
|
Assert.That(withoutEnvelopeType.Remove("UaTypeId"), Is.True);
|
|
IEncodeable restoredEnvelope = FuzzableCode.DecodeJsonWithMetadata(
|
|
withoutEnvelopeType.ToJsonString(),
|
|
original,
|
|
options,
|
|
FuzzableCode.MessageContext);
|
|
EncoderTestMessages.AssertPopulated(restoredEnvelope);
|
|
Assert.That(Utils.IsEqual(original, restoredEnvelope), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void MetadataRestorationAddsOnlyMissingArtifactsAndDoesNotRepairPayloadScalars()
|
|
{
|
|
const string serialized =
|
|
"""{"Results":[{"Value":"changed"},{"Value":99}],"Sequence":8}""";
|
|
|
|
string restored = FuzzableCode.RestoreJsonArtifacts(
|
|
serialized,
|
|
k_artifactSchema,
|
|
FuzzableCode.MessageContext);
|
|
|
|
using JsonDocument result = JsonDocument.Parse(restored);
|
|
JsonElement body = result.RootElement;
|
|
JsonElement values = body.GetProperty("Results");
|
|
Assert.That(result.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=634"));
|
|
Assert.That(values.GetArrayLength(), Is.EqualTo(2));
|
|
Assert.That(values[0].GetProperty("UaType").GetInt32(), Is.EqualTo(12));
|
|
Assert.That(values[0].GetProperty("Value").GetString(), Is.EqualTo("changed"));
|
|
Assert.That(values[1].GetProperty("UaType").GetInt32(), Is.EqualTo(7));
|
|
Assert.That(values[1].GetProperty("Value").GetInt32(), Is.EqualTo(99));
|
|
Assert.That(body.GetProperty("Sequence").GetInt32(), Is.EqualTo(8));
|
|
Assert.That(CountTypeArtifacts(result.RootElement), Is.EqualTo(3));
|
|
}
|
|
|
|
[Test]
|
|
public void MetadataRestorationDoesNotOverwriteExistingIncorrectArtifacts()
|
|
{
|
|
const string serialized = """
|
|
{"UaTypeId":"i=999",
|
|
"Results":[{"UaType":6,"Value":"changed"},{"Value":99}],"Sequence":8}
|
|
""";
|
|
|
|
string restored = FuzzableCode.RestoreJsonArtifacts(
|
|
serialized,
|
|
k_artifactSchema,
|
|
FuzzableCode.MessageContext);
|
|
|
|
using JsonDocument result = JsonDocument.Parse(restored);
|
|
JsonElement values = result.RootElement.GetProperty("Results");
|
|
Assert.That(result.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=999"));
|
|
Assert.That(values[0].GetProperty("UaType").GetInt32(), Is.EqualTo(6));
|
|
Assert.That(values[0].GetProperty("Value").GetString(), Is.EqualTo("changed"));
|
|
Assert.That(values[1].GetProperty("UaType").GetInt32(), Is.EqualTo(7));
|
|
}
|
|
|
|
[TestCase("[]", "payload shape")]
|
|
[TestCase("""{"Results":{},"Sequence":7}""", "payload shape")]
|
|
[TestCase("""{"Results":[{"Value":null},{"Value":42}],"Sequence":7}""", "payload shape")]
|
|
[TestCase("""{"Results":[{"Value":"original"},{"Value":42}]}""", "lost field 'Sequence'")]
|
|
[TestCase("""{"Results":[{},{"Value":42}],"Sequence":7}""", "lost field 'Value'")]
|
|
[TestCase("""{"Results":[{"Value":"original"}],"Sequence":7}""", "array length")]
|
|
[TestCase("""{"Results":[{"Value":"original"},{"Value":42},{}],"Sequence":7}""", "array length")]
|
|
[TestCase(
|
|
"""{"Results":[{"Value":"original","Unexpected":1},{"Value":42}],"Sequence":7}""",
|
|
"Unexpected RawData JSON field 'Unexpected'")]
|
|
public void MetadataRestorationRejectsStructuralPayloadCorruption(string serialized, string failure)
|
|
{
|
|
Assert.That(
|
|
() => FuzzableCode.RestoreJsonArtifacts(serialized, k_artifactSchema, FuzzableCode.MessageContext),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.Contains(failure));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void StrictJsonOracleDetectsSemanticCorruptionInsteadOfRepairingIt(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
JsonNode payload = JsonNode.Parse(FuzzableCode.EncodeJsonMessage(original, options))!;
|
|
payload!["MaxAge"] = 1001;
|
|
string corrupted = payload.ToJsonString();
|
|
|
|
IEncodeable decoded = FuzzableCode.DecodeJsonWithMetadata(
|
|
corrupted,
|
|
original,
|
|
options,
|
|
FuzzableCode.MessageContext);
|
|
Assert.That(decoded, Is.TypeOf<ReadRequest>());
|
|
Assert.That(((ReadRequest)decoded).MaxAge, Is.EqualTo(1001));
|
|
Assert.That(((ReadRequest)decoded).NodesToRead.Count, Is.EqualTo(6));
|
|
Assert.That(original.MaxAge, Is.EqualTo(1000));
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.False);
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonEncoderIndempotentCore(corrupted, original, options),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.Contains("JSON semantic round-trip failed"));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void StrictJsonOracleRejectsArrayTruncation(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
JsonNode payload = JsonNode.Parse(FuzzableCode.EncodeJsonMessage(original, options))!;
|
|
JsonArray nodes = payload!["NodesToRead"]!.AsArray();
|
|
nodes.RemoveAt(5);
|
|
|
|
Assert.That(original.NodesToRead.Count, Is.EqualTo(6));
|
|
Assert.That(nodes.Count, Is.EqualTo(5));
|
|
string failure = options.SuppressArtifacts ? "array length" : "JSON semantic round-trip failed";
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonEncoderIndempotentCore(payload.ToJsonString(), original, options),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.Contains(failure));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void StrictJsonOracleRejectsNonCanonicalTextEvenWhenValuesMatch(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
string canonical = FuzzableCode.EncodeJsonMessage(original, options);
|
|
string nonCanonical = canonical + " ";
|
|
IEncodeable decoded = FuzzableCode.DecodeJsonWithMetadata(
|
|
nonCanonical,
|
|
original,
|
|
options,
|
|
FuzzableCode.MessageContext);
|
|
|
|
EncoderTestMessages.AssertPopulated(decoded);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
Assert.That(FuzzableCode.EncodeJsonMessage(decoded, options), Is.EqualTo(canonical));
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonEncoderIndempotentCore(nonCanonical, original, options),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.Contains("Idempotent JSON encoding failed"));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void TypedMetadataDecodeAndStrictOracleRejectChangedMessageType(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
JsonNode payload = JsonNode.Parse(FuzzableCode.EncodeJsonMessage(original, options))!;
|
|
payload!["UaTypeId"] = "i=671";
|
|
string corrupted = payload.ToJsonString();
|
|
|
|
Assert.That(
|
|
() => FuzzableCode.DecodeJsonWithMetadata(corrupted, original, options, FuzzableCode.MessageContext),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.EqualTo(
|
|
"JSON message type changed during encoding."));
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonEncoderIndempotentCore(corrupted, original, options),
|
|
Throws.TypeOf<EncodingFidelityException>().With.Message.EqualTo(
|
|
"JSON message type changed during encoding."));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void GeneratedJsonDecodeErrorsEscapeTheStrictOracle(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
string serialized = FuzzableCode.EncodeJsonMessage(original, options);
|
|
string truncated = serialized[..^1];
|
|
|
|
Assert.That(FuzzableCode.FuzzJsonDecoderCore(truncated), Is.Null);
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonEncoderIndempotentCore(truncated, original, options),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadDecodingError));
|
|
Assert.That(original.NodesToRead[2].IndexRange, Is.EqualTo("1:2"));
|
|
}
|
|
|
|
[TestCaseSource(nameof(JsonModeCases))]
|
|
public void GeneratedRoundTripEncodingLimitFailuresAreNotSwallowed(string modeName)
|
|
{
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ServiceMessageContext context = FuzzableCode.CreateMessageContext();
|
|
context.MaxStringLength = 4;
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzJsonRoundTripCore(original, options, context),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadEncodingLimitsExceeded));
|
|
Assert.That(original.NodesToRead[2].NodeId, Is.EqualTo(new NodeId("RevisionCounter", 3)));
|
|
Assert.That(FuzzableCode.MessageContext.MaxStringLength, Is.GreaterThan(context.MaxStringLength));
|
|
}
|
|
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "None", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "EndOfStream", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "Xml", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "Json", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "Format", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "NestedExpected", true)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "NestedUnexpected", false)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "InvalidOperation", false)]
|
|
[TestCase(nameof(StatusCodes.BadDecodingError), "Io", false)]
|
|
[TestCase(nameof(StatusCodes.BadEncodingLimitsExceeded), "None", true)]
|
|
[TestCase(nameof(StatusCodes.BadEncodingLimitsExceeded), "InvalidOperation", false)]
|
|
[TestCase(nameof(StatusCodes.BadUnexpectedError), "None", false)]
|
|
[TestCase(nameof(StatusCodes.BadUnexpectedError), "Format", false)]
|
|
public void SourceFailurePolicyOnlySwallowsExpectedStatusAndInnerExceptions(
|
|
string statusName,
|
|
string innerKind,
|
|
bool expected)
|
|
{
|
|
StatusCode statusCode = statusName switch
|
|
{
|
|
nameof(StatusCodes.BadDecodingError) => StatusCodes.BadDecodingError,
|
|
nameof(StatusCodes.BadEncodingLimitsExceeded) => StatusCodes.BadEncodingLimitsExceeded,
|
|
nameof(StatusCodes.BadUnexpectedError) => StatusCodes.BadUnexpectedError,
|
|
_ => throw new ArgumentException("Unknown status.", nameof(statusName))
|
|
};
|
|
var failure = new ServiceResultException(
|
|
statusCode,
|
|
"injected source failure",
|
|
CreateInnerFailure(innerKind));
|
|
ByteString input = EncoderTestMessages.Encode(Testcases.CreateRichReadRequest(), "Xml");
|
|
using var stream = new NonSeekableInputStream(input.Span, failure);
|
|
|
|
if (expected)
|
|
{
|
|
Assert.That(FuzzableCode.FuzzXmlDecoderCore(stream), Is.Null);
|
|
}
|
|
else
|
|
{
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzXmlDecoderCore(stream),
|
|
Throws.TypeOf<ServiceResultException>().And.SameAs(failure));
|
|
}
|
|
Assert.That(stream.ReadCalls, Is.GreaterThan(0));
|
|
|
|
using var strictStream = new NonSeekableInputStream(input.Span, failure);
|
|
Assert.That(
|
|
() => FuzzableCode.FuzzXmlDecoderCore(strictStream, true),
|
|
Throws.TypeOf<ServiceResultException>().And.SameAs(failure));
|
|
Assert.That(strictStream.ReadCalls, Is.GreaterThan(0));
|
|
}
|
|
|
|
[TestCase("Verbose")]
|
|
[TestCase("Compact")]
|
|
[TestCase("RawData")]
|
|
[TestCase("LegacyNonReversible")]
|
|
public void UriModesPreserveAbsoluteIdentifiersAcrossNamespaceTableReordering(string modeName)
|
|
{
|
|
ServiceMessageContext sourceContext = FuzzableCode.CreateMessageContext();
|
|
ServiceMessageContext targetContext = CreateReorderedContext();
|
|
JsonEncoderOptions options = FindMode(modeName);
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
string serialized = FuzzableCode.EncodeJsonMessage(original, options, sourceContext);
|
|
|
|
IEncodeable decoded = FuzzableCode.DecodeJsonWithMetadata(serialized, original, options, targetContext);
|
|
|
|
Assert.That(decoded, Is.TypeOf<ReadRequest>());
|
|
AssertRemappedIdentifiers(original, (ReadRequest)decoded, sourceContext, targetContext);
|
|
ReadRequest remapped = CreateRemappedRequest(sourceContext, targetContext);
|
|
Assert.That(Utils.IsEqual(remapped, decoded), Is.True);
|
|
Assert.That(FuzzableCode.EncodeJsonMessage(decoded, options, targetContext), Is.EqualTo(serialized));
|
|
FuzzableCode.FuzzJsonEncoderIndempotentCore(serialized, remapped, options, targetContext);
|
|
}
|
|
|
|
[Test]
|
|
public void IndexModeNeedsMappingTablesToPreserveAbsoluteIdentifiersInADifferentContext()
|
|
{
|
|
ServiceMessageContext sourceContext = FuzzableCode.CreateMessageContext();
|
|
ServiceMessageContext targetContext = CreateReorderedContext();
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
JsonEncoderOptions options = FuzzableCode.LegacyReversibleOptions;
|
|
string serialized = FuzzableCode.EncodeJsonMessage(original, options, sourceContext);
|
|
IEncodeable sameContext = FuzzableCode.DecodeJsonWithMetadata(serialized, original, options, sourceContext);
|
|
EncoderTestMessages.AssertPopulated(sameContext);
|
|
Assert.That(Utils.IsEqual(original, sameContext), Is.True);
|
|
|
|
var unmapped = (ReadRequest)FuzzableCode.DecodeJsonWithMetadata(
|
|
serialized,
|
|
original,
|
|
options,
|
|
targetContext);
|
|
Assert.That(unmapped.NodesToRead[3].NodeId.NamespaceIndex, Is.EqualTo(1));
|
|
Assert.That(
|
|
NodeId.ToExpandedNodeId(unmapped.NodesToRead[3].NodeId, targetContext.NamespaceUris).NamespaceUri,
|
|
Is.EqualTo("urn:opcfoundation:fuzzing:types"));
|
|
|
|
using var decoder = new JsonDecoder(serialized, targetContext);
|
|
decoder.SetMappingTables(sourceContext.NamespaceUris, sourceContext.ServerUris);
|
|
IEncodeable decoded = decoder.DecodeMessage<IEncodeable>();
|
|
Assert.That(decoded, Is.TypeOf<ReadRequest>());
|
|
AssertRemappedIdentifiers(original, (ReadRequest)decoded, sourceContext, targetContext);
|
|
Assert.That(Utils.IsEqual(CreateRemappedRequest(sourceContext, targetContext), decoded), Is.True);
|
|
Assert.That(FuzzableCode.EncodeJsonMessage(decoded, options, targetContext), Is.Not.EqualTo(serialized));
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void XmlDecoderResolvesStandardQNameToAPopulatedReadRequest(bool usePrefix)
|
|
{
|
|
ReadRequest original = Testcases.CreateRichReadRequest();
|
|
string serialized = Encoding.UTF8.GetString(EncoderTestMessages.Encode(original, "Xml").ToArray());
|
|
XDocument document = XDocument.Parse(serialized);
|
|
Assert.That(document.Root!.Name, Is.EqualTo(XName.Get(nameof(ReadRequest), Namespaces.OpcUaXsd)));
|
|
if (usePrefix)
|
|
{
|
|
document.Root.Attribute("xmlns")?.Remove();
|
|
document.Root.SetAttributeValue(XNamespace.Xmlns + "ua", Namespaces.OpcUaXsd);
|
|
serialized = document.ToString(SaveOptions.DisableFormatting);
|
|
Assert.That(serialized, Does.Contain("<ua:ReadRequest"));
|
|
}
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(serialized));
|
|
|
|
IEncodeable decoded = FuzzableCode.FuzzXmlDecoderCore(stream, true)!;
|
|
|
|
EncoderTestMessages.AssertPopulated(decoded!);
|
|
Assert.That(Utils.IsEqual(original, decoded), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void XmlDecoderRejectsTheRightLocalTypeNameInTheWrongNamespace()
|
|
{
|
|
string valid = Encoding.UTF8.GetString(
|
|
EncoderTestMessages.Encode(Testcases.CreateRichReadRequest(), "Xml").ToArray());
|
|
XDocument document = XDocument.Parse(valid);
|
|
document.Root!.Name = XName.Get(nameof(ReadRequest), "urn:encoder-test:wrong-namespace");
|
|
document.Root.SetAttributeValue("xmlns", "urn:encoder-test:wrong-namespace");
|
|
string wrongNamespace = document.ToString(SaveOptions.DisableFormatting);
|
|
Assert.That(document.Root.Name.LocalName, Is.EqualTo(nameof(ReadRequest)));
|
|
Assert.That(document.Root.Name.NamespaceName, Is.EqualTo("urn:encoder-test:wrong-namespace"));
|
|
ByteString input = ByteString.From(Encoding.UTF8.GetBytes(wrongNamespace));
|
|
AssertMalformedSource(input, "Xml");
|
|
foreach (CallbackCase target in s_callbacks.Where(callback => callback.Wire == "Xml"))
|
|
{
|
|
target.Span(input.Span);
|
|
ReplayAfl(target, input, false);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void MalformedXmlIsRejectedWithItsParserExceptionPreservedInStrictMode()
|
|
{
|
|
ByteString input = ByteString.From(Encoding.UTF8.GetBytes(
|
|
"""<ReadRequest xmlns="http://opcfoundation.org/UA/2008/02/Types.xsd"""));
|
|
|
|
Assert.That(EncoderTestMessages.Decode(input.Span, "Xml", false), Is.Null);
|
|
Assert.That(
|
|
() => EncoderTestMessages.Decode(input.Span, "Xml"),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadDecodingError)
|
|
.And.Property(nameof(Exception.InnerException)).InstanceOf<XmlException>());
|
|
}
|
|
|
|
private static IEnumerable<TestCaseData> JsonModeCases()
|
|
{
|
|
foreach (string name in s_modeNames)
|
|
{
|
|
yield return new TestCaseData(name);
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<TestCaseData> RichJsonModeCases()
|
|
{
|
|
for (int i = 0; i < EncoderTestMessages.RichNames.Count; i++)
|
|
{
|
|
foreach (string modeName in s_modeNames)
|
|
{
|
|
yield return new TestCaseData(EncoderTestMessages.RichNames[i], modeName);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static JsonEncoderOptions FindMode(string name)
|
|
{
|
|
return FuzzableCode.JsonEncodingModes.ToArray()!.Single(mode => mode.Name == name);
|
|
}
|
|
|
|
private static Exception CreateInnerFailure(string kind)
|
|
{
|
|
return (kind switch
|
|
{
|
|
"None" => null,
|
|
"EndOfStream" => new EndOfStreamException("truncated"),
|
|
"Xml" => new XmlException("malformed"),
|
|
"Json" => new JsonException("malformed"),
|
|
"Format" => new FormatException("malformed"),
|
|
"NestedExpected" => new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded),
|
|
"NestedUnexpected" => new ServiceResultException(StatusCodes.BadUnexpectedError),
|
|
"InvalidOperation" => new InvalidOperationException("unexpected implementation failure"),
|
|
"Io" => new IOException("unexpected infrastructure failure"),
|
|
_ => throw new ArgumentException("Unknown inner exception.", nameof(kind))
|
|
})!;
|
|
}
|
|
|
|
private static void AssertSegmentLayout(ByteString input, int segmentSize)
|
|
{
|
|
using MemoryStream stream = FuzzableCode.PrepareArraySegmentStream(input.Span, segmentSize);
|
|
BufferCollection buffers = ((ArraySegmentStream)stream).GetBuffers(nameof(EncoderParityTests));
|
|
Assert.That(buffers.Count, Is.EqualTo((input.Length + segmentSize - 1) / segmentSize));
|
|
int offset = 0;
|
|
for (int i = 0; i < buffers.Count; i++)
|
|
{
|
|
int length = Math.Min(segmentSize, input.Length - offset);
|
|
Assert.That(buffers[i].Offset, Is.Zero);
|
|
Assert.That(buffers[i].Count, Is.EqualTo(length));
|
|
Assert.That(
|
|
buffers[i].AsSpan().ToArray(),
|
|
Is.EqualTo(input.Span.Slice(offset, length).ToArray()));
|
|
if (i > 0)
|
|
{
|
|
Assert.That(buffers[i].Array, Is.Not.SameAs(buffers[i - 1].Array));
|
|
}
|
|
offset += length;
|
|
}
|
|
Assert.That(offset, Is.EqualTo(input.Length));
|
|
}
|
|
|
|
private static int CountTypeArtifacts(JsonElement element)
|
|
{
|
|
int count = 0;
|
|
if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (JsonProperty property in element.EnumerateObject())
|
|
{
|
|
if (property.Name is "UaType" or "UaTypeId")
|
|
{
|
|
count++;
|
|
}
|
|
count += CountTypeArtifacts(property.Value);
|
|
}
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (JsonElement item in element.EnumerateArray())
|
|
{
|
|
count += CountTypeArtifacts(item);
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static ServiceMessageContext CreateReorderedContext()
|
|
{
|
|
ServiceMessageContext context = FuzzableCode.CreateMessageContext();
|
|
context.NamespaceUris.Update(
|
|
[
|
|
Namespaces.OpcUa,
|
|
"urn:opcfoundation:fuzzing:types",
|
|
"urn:opcfoundation:fuzzing:diagnostics",
|
|
"urn:opcfoundation:fuzzing:application",
|
|
"urn:opcfoundation:fuzzing:devices"
|
|
]);
|
|
return context;
|
|
}
|
|
|
|
private static ReadRequest CreateRemappedRequest(
|
|
ServiceMessageContext sourceContext,
|
|
ServiceMessageContext targetContext)
|
|
{
|
|
ReadRequest remapped = Testcases.CreateRichReadRequest();
|
|
foreach (ReadValueId value in remapped.NodesToRead)
|
|
{
|
|
ExpandedNodeId absolute = NodeId.ToExpandedNodeId(value.NodeId, sourceContext.NamespaceUris);
|
|
value.NodeId = ExpandedNodeId.ToNodeId(absolute, targetContext.NamespaceUris);
|
|
}
|
|
return remapped;
|
|
}
|
|
|
|
private static void AssertRemappedIdentifiers(
|
|
ReadRequest original,
|
|
ReadRequest decoded,
|
|
ServiceMessageContext sourceContext,
|
|
ServiceMessageContext targetContext)
|
|
{
|
|
Assert.That(decoded.MaxAge, Is.EqualTo(1000));
|
|
Assert.That(decoded.TimestampsToReturn, Is.EqualTo(TimestampsToReturn.Source));
|
|
Assert.That(decoded.NodesToRead.Count, Is.EqualTo(6));
|
|
Assert.That(original.NodesToRead[3].NodeId.NamespaceIndex, Is.EqualTo(1));
|
|
Assert.That(decoded.NodesToRead[3].NodeId.NamespaceIndex, Is.EqualTo(3));
|
|
Assert.That(decoded.NodesToRead[1].NodeId.NamespaceIndex, Is.EqualTo(4));
|
|
Assert.That(decoded.NodesToRead[2].NodeId.NamespaceIndex, Is.EqualTo(2));
|
|
Assert.That(decoded.NodesToRead[5].NodeId.NamespaceIndex, Is.EqualTo(1));
|
|
for (int i = 0; i < original.NodesToRead.Count; i++)
|
|
{
|
|
Assert.That(
|
|
NodeId.ToExpandedNodeId(decoded.NodesToRead[i].NodeId, targetContext.NamespaceUris),
|
|
Is.EqualTo(NodeId.ToExpandedNodeId(original.NodesToRead[i].NodeId, sourceContext.NamespaceUris)));
|
|
Assert.That(decoded.NodesToRead[i].AttributeId, Is.EqualTo(original.NodesToRead[i].AttributeId));
|
|
}
|
|
Assert.That(decoded.NodesToRead[2].IndexRange, Is.EqualTo("1:2"));
|
|
}
|
|
|
|
private static IEnumerable<TestCaseData> PositiveCallbackCases()
|
|
{
|
|
foreach (CallbackCase target in s_callbacks)
|
|
{
|
|
for (int i = 0; i < EncoderTestMessages.RichNames.Count; i++)
|
|
{
|
|
yield return new TestCaseData(target.Span.Method.Name, EncoderTestMessages.RichNames[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<TestCaseData> TruncatedCallbackCases()
|
|
{
|
|
foreach (CallbackCase target in s_callbacks)
|
|
{
|
|
yield return new TestCaseData(target.Span.Method.Name, true);
|
|
yield return new TestCaseData(target.Span.Method.Name, false);
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<TestCaseData> SegmentBoundaryCases()
|
|
{
|
|
for (int i = 0; i < EncoderTestMessages.RichNames.Count; i++)
|
|
{
|
|
foreach (int segmentSize in s_segmentSizes)
|
|
{
|
|
yield return new TestCaseData(EncoderTestMessages.RichNames[i], segmentSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static CallbackCase FindCallback(string name)
|
|
{
|
|
return s_callbacks.Single(callback => callback.Span.Method.Name == name);
|
|
}
|
|
|
|
private static ByteString TruncatedReadRequest(string wire, bool truncateLastByte)
|
|
{
|
|
ByteString valid = EncoderTestMessages.Encode(Testcases.CreateRichReadRequest(), wire);
|
|
int length = truncateLastByte ? valid.Length - 1 : valid.Length / 2;
|
|
return ByteString.From(valid.Span[..length]);
|
|
}
|
|
|
|
private static void AssertMalformedSource(ByteString input, string wire)
|
|
{
|
|
Assert.That(input.Length, Is.GreaterThan(32), "Exercise truncated payloads, not just empty envelopes.");
|
|
Assert.That(EncoderTestMessages.Decode(input.Span, wire, false), Is.Null);
|
|
Assert.That(
|
|
() => EncoderTestMessages.Decode(input.Span, wire),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadDecodingError));
|
|
}
|
|
|
|
private static void ReplayAfl(CallbackCase target, ByteString input, bool completeMessage)
|
|
{
|
|
if (target.Afl is FuzzMethods.AflFuzzString textCallback)
|
|
{
|
|
textCallback(Encoding.UTF8.GetString(input.ToArray()));
|
|
return;
|
|
}
|
|
|
|
Assert.That(target.Afl, Is.InstanceOf<FuzzMethods.AflFuzzStream>());
|
|
using var stream = new NonSeekableInputStream(input.Span);
|
|
((FuzzMethods.AflFuzzStream)target.Afl)(stream);
|
|
Assert.That(stream.BytesRead, Is.GreaterThan(0), "The AFL stream wrapper must consume its input.");
|
|
if (completeMessage)
|
|
{
|
|
Assert.That(stream.BytesRead, Is.EqualTo(input.Length));
|
|
}
|
|
}
|
|
|
|
private sealed record CallbackCase(FuzzMethods.LibFuzzSpan Span, Delegate Afl, string Wire);
|
|
|
|
private sealed class NonSeekableInputStream : Stream
|
|
{
|
|
public NonSeekableInputStream(ReadOnlySpan<byte> input, ServiceResultException? failure = null)
|
|
{
|
|
m_stream = new MemoryStream(input.ToArray(), false);
|
|
m_failure = failure!;
|
|
}
|
|
|
|
public override bool CanRead => m_stream.CanRead;
|
|
public override bool CanSeek => false;
|
|
public override bool CanWrite => false;
|
|
public override long Length => throw new NotSupportedException();
|
|
public override long Position
|
|
{
|
|
get => throw new NotSupportedException();
|
|
set => throw new NotSupportedException();
|
|
}
|
|
|
|
internal int BytesRead { get; private set; }
|
|
internal int ReadCalls { get; private set; }
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
{
|
|
ReadCalls++;
|
|
if (m_failure != null)
|
|
{
|
|
throw m_failure;
|
|
}
|
|
int read = m_stream.Read(buffer, offset, count);
|
|
BytesRead += read;
|
|
return read;
|
|
}
|
|
|
|
public override void Flush()
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public override long Seek(long offset, SeekOrigin origin)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public override void SetLength(long value)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public override void Write(byte[] buffer, int offset, int count)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
m_stream.Dispose();
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
|
|
private readonly MemoryStream m_stream;
|
|
private readonly ServiceResultException m_failure = null!;
|
|
}
|
|
|
|
private const string k_artifactSchema =
|
|
"""
|
|
{"UaTypeId":"i=634",
|
|
"Results":[{"UaType":12,"Value":"original"},{"UaType":7,"Value":42}],"Sequence":7}
|
|
""";
|
|
|
|
private static readonly string[] s_modeNames =
|
|
[
|
|
"Verbose", "Compact", "RawData", "LegacyReversible", "LegacyNonReversible"
|
|
];
|
|
private static readonly string[] s_namespaceUris =
|
|
[
|
|
Namespaces.OpcUa,
|
|
"urn:opcfoundation:fuzzing:application",
|
|
"urn:opcfoundation:fuzzing:devices",
|
|
"urn:opcfoundation:fuzzing:diagnostics",
|
|
"urn:opcfoundation:fuzzing:types"
|
|
];
|
|
private static readonly int[] s_segmentSizes = [1, 2, 7, 63, 64, 65];
|
|
private static readonly CallbackCase[] s_callbacks =
|
|
[
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryDecoderSegmented,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryDecoder),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryEncoderSegmented,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryEncoder),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryEncoderIndempotentSegmented,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryEncoderIndempotent),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzJsonEncoderRawData,
|
|
new FuzzMethods.AflFuzzString(FuzzableCode.AflfuzzJsonEncoderRawData),
|
|
"Json"),
|
|
new(
|
|
FuzzableCode.LibfuzzJsonEncoderLegacyReversible,
|
|
new FuzzMethods.AflFuzzString(FuzzableCode.AflfuzzJsonEncoderLegacyReversible),
|
|
"Json"),
|
|
new(
|
|
FuzzableCode.LibfuzzJsonEncoderLegacyNonReversible,
|
|
new FuzzMethods.AflFuzzString(FuzzableCode.AflfuzzJsonEncoderLegacyNonReversible),
|
|
"Json"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryJsonEncoder,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryJsonEncoder),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryJsonEncoderCompact,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryJsonEncoderCompact),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryJsonEncoderRawData,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryJsonEncoderRawData),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryJsonEncoderLegacyReversible,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryJsonEncoderLegacyReversible),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzBinaryJsonEncoderLegacyNonReversible,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzBinaryJsonEncoderLegacyNonReversible),
|
|
"Binary"),
|
|
new(
|
|
FuzzableCode.LibfuzzXmlJsonEncoder,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzXmlJsonEncoder),
|
|
"Xml"),
|
|
new(
|
|
FuzzableCode.LibfuzzXmlJsonEncoderCompact,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzXmlJsonEncoderCompact),
|
|
"Xml"),
|
|
new(
|
|
FuzzableCode.LibfuzzXmlJsonEncoderRawData,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzXmlJsonEncoderRawData),
|
|
"Xml"),
|
|
new(
|
|
FuzzableCode.LibfuzzXmlJsonEncoderLegacyReversible,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzXmlJsonEncoderLegacyReversible),
|
|
"Xml"),
|
|
new(
|
|
FuzzableCode.LibfuzzXmlJsonEncoderLegacyNonReversible,
|
|
new FuzzMethods.AflFuzzStream(FuzzableCode.AflfuzzXmlJsonEncoderLegacyNonReversible),
|
|
"Xml")
|
|
];
|
|
}
|
|
}
|