Files
UA-.NETStandard/fuzzing/Opc.Ua.Encoders.Fuzz/FuzzableCode.JsonDecoder.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

1210 lines
45 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace Opc.Ua.Fuzzing
{
/// <summary>
/// Fuzzing code for the JSON decoder and encoder.
/// </summary>
public static partial class FuzzableCode
{
internal static JsonEncoderOptions LegacyReversibleOptions { get; } = JsonEncoderOptions.Compact with
{
Name = "LegacyReversible",
IgnoreDefaultValues = false,
ForceNamespaceUri = false
};
internal static JsonEncoderOptions LegacyNonReversibleOptions { get; } = JsonEncoderOptions.RawData with
{
Name = "LegacyNonReversible"
};
internal static ArrayOf<JsonEncoderOptions> JsonEncodingModes { get; } =
[
JsonEncoderOptions.Verbose,
JsonEncoderOptions.Compact,
JsonEncoderOptions.RawData,
LegacyReversibleOptions,
LegacyNonReversibleOptions
];
/// <summary>
/// The Json decoder fuzz target for afl-fuzz.
/// </summary>
public static void AflfuzzJsonDecoder(string input)
{
_ = FuzzJsonDecoderCore(input);
}
/// <summary>
/// The Json encoder fuzz target for afl-fuzz.
/// </summary>
public static void AflfuzzJsonEncoder(string input)
{
FuzzJsonEncoderCore(input, JsonEncoderOptions.Verbose);
}
/// <summary>
/// The compact Json encoder fuzz target for afl-fuzz.
/// </summary>
public static void AflfuzzJsonEncoderCompact(string input)
{
FuzzJsonEncoderCore(input, JsonEncoderOptions.Compact);
}
/// <summary>
/// Checks RawData JSON using the decoded input's type metadata.
/// </summary>
public static void AflfuzzJsonEncoderRawData(string input)
{
FuzzJsonEncoderCore(input, JsonEncoderOptions.RawData);
}
/// <summary>
/// Exercises the supported numeric-enum, namespace-index and default-value
/// behaviors of legacy Reversible JSON, using the current wire representation.
/// </summary>
public static void AflfuzzJsonEncoderLegacyReversible(string input)
{
FuzzJsonEncoderCore(input, LegacyReversibleOptions);
}
/// <summary>
/// Exercises legacy NonReversible artifact suppression and URI-based identifiers.
/// Retired text-only LocalizedText and unwrapped Variant wire forms are not reproduced.
/// </summary>
public static void AflfuzzJsonEncoderLegacyNonReversible(string input)
{
FuzzJsonEncoderCore(input, LegacyNonReversibleOptions);
}
/// <summary>
/// The Json encoder idempotent fuzz target for afl-fuzz.
/// </summary>
public static void AflfuzzJsonEncoderIndempotent(string input)
{
FuzzJsonEncoderCore(input, JsonEncoderOptions.Verbose);
}
/// <summary>
/// The json decoder fuzz target for libfuzzer.
/// </summary>
public static void LibfuzzJsonDecoder(ReadOnlySpan<byte> input)
{
_ = FuzzJsonDecoderCore(DecodeUtf8Json(input));
}
/// <summary>
/// The verbose JSON encoder fuzz target for libfuzzer.
/// </summary>
public static void LibfuzzJsonEncoder(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoder(DecodeUtf8Json(input));
}
/// <summary>
/// The compact Json encoder fuzz target for libfuzzer.
/// </summary>
public static void LibfuzzJsonEncoderCompact(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoderCompact(DecodeUtf8Json(input));
}
/// <summary>
/// Checks RawData JSON with metadata and canonical byte/value oracles.
/// </summary>
public static void LibfuzzJsonEncoderRawData(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoderRawData(DecodeUtf8Json(input));
}
/// <summary>
/// Exercises the current option mapping for legacy Reversible behavior.
/// </summary>
public static void LibfuzzJsonEncoderLegacyReversible(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoderLegacyReversible(DecodeUtf8Json(input));
}
/// <summary>
/// Exercises the current option mapping for legacy NonReversible behavior.
/// </summary>
public static void LibfuzzJsonEncoderLegacyNonReversible(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoderLegacyNonReversible(DecodeUtf8Json(input));
}
/// <summary>
/// The Json encoder idempotent fuzz target for libfuzzer.
/// </summary>
public static void LibfuzzJsonEncoderIndempotent(ReadOnlySpan<byte> input)
{
AflfuzzJsonEncoderIndempotent(DecodeUtf8Json(input));
}
/// <summary>
/// The fuzz target for the JsonDecoder.
/// </summary>
/// <param name="json">A string with fuzz content.</param>
internal static IEncodeable? FuzzJsonDecoderCore(string json, bool throwAll = false)
{
try
{
return DecodeWithOracles(nameof(JsonDecoder), json?.Length ?? 0, () =>
{
using var decoder = new JsonDecoder(json!, MessageContext);
return decoder.DecodeMessage<IEncodeable>();
});
}
catch (ServiceResultException sre) when (!throwAll && IsExpectedDecodingError(sre))
{
return null;
}
}
/// <summary>
/// The idempotent fuzz target core for the JsonEncoder.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
internal static void FuzzJsonEncoderIndempotentCore(
string serialized,
IEncodeable encodeable,
JsonEncoderOptions? options = null,
IServiceMessageContext? context = null)
{
if (serialized == null || encodeable == null)
{
return;
}
options ??= JsonEncoderOptions.Verbose;
context ??= MessageContext;
IEncodeable encodeable2 = DecodeJsonWithMetadata(serialized, encodeable, options, context);
string serialized2 = EncodeJsonMessage(encodeable2, options, context);
// The artifact metadata has to come from the value that produced the payload being
// decoded. Generation one metadata goes stale as soon as generation one normalizes.
IEncodeable encodeable3 = DecodeJsonWithMetadata(serialized2, encodeable2, options, context);
string encodeableTypeName = encodeable2?.GetType().Name ?? "unknown type";
bool firstGenerationNormalized = !Utils.IsEqual(encodeable, encodeable2);
if (firstGenerationNormalized &&
!IsExpectedJsonSemanticLoss(encodeable, encodeable2!, options, context))
{
throw new EncodingFidelityException(
$"JSON semantic round-trip failed. Type={encodeableTypeName}, Mode={options.Name}.");
}
if (serialized2 == null || !serialized.SequenceEqual(serialized2))
{
if (!IsExpectedJsonEncodingNormalization(serialized, serialized2!, context) &&
!(firstGenerationNormalized &&
IsStableFromSecondGeneration(serialized2!, encodeable3, options, context)))
{
throw new EncodingFidelityException(
Utils.Format("Idempotent JSON encoding failed. Type={0}.", encodeableTypeName));
}
}
if (!Utils.IsEqual(encodeable2, encodeable3) &&
!IsExpectedJsonSemanticLoss(encodeable2!, encodeable3, options, context))
{
throw new EncodingFidelityException(Utils.Format(
"Idempotent JSON 3rd gen decoding failed. Type={0}.",
encodeableTypeName));
}
}
internal static void FuzzJsonRoundTripCore(
IEncodeable encodeable,
JsonEncoderOptions options,
IServiceMessageContext? context = null)
{
context ??= MessageContext;
try
{
string serialized = EncodeJsonMessage(encodeable, options, context);
FuzzJsonEncoderIndempotentCore(serialized, encodeable, options, context);
}
catch (ServiceResultException exception) when (IsExpectedJsonEncodingError(exception, encodeable, context))
{
return;
}
}
internal static string EncodeJsonMessage(
IEncodeable encodeable,
JsonEncoderOptions options,
IServiceMessageContext? context = null)
{
using var memoryStream = new MemoryStream(0x1000);
using var encoder = new JsonEncoder(memoryStream, context ?? MessageContext, options);
encoder.EncodeMessage(encodeable, encodeable.TypeId);
encoder.Close();
return Encoding.UTF8.GetString(memoryStream.ToArray());
}
internal static IEncodeable DecodeJsonWithMetadata(
string serialized,
IEncodeable encodeable,
JsonEncoderOptions options,
IServiceMessageContext context)
{
if (options.SuppressArtifacts)
{
string metadata = EncodeJsonMessage(encodeable, options with { SuppressArtifacts = false }, context);
serialized = RestoreJsonArtifacts(serialized, metadata, context);
}
using var decoder = new JsonDecoder(serialized, context);
NodeId typeId = decoder.ReadNodeId("UaTypeId");
if (typeId != ExpandedNodeId.ToNodeId(encodeable.TypeId, context.NamespaceUris))
{
throw new EncodingFidelityException("JSON message type changed during encoding.");
}
try
{
// The message body is inline next to the UaTypeId (Part 6 5.4.9, 5.4.2.16).
return decoder.DecodeMessage<IEncodeable>();
}
catch (ServiceResultException exception)
{
// The payload here is the encoder's own output, not attacker controlled bytes,
// so a decoding error is a fidelity finding rather than a robustness one: the
// stack produced a representation it cannot read back. Classifying it here
// keeps genuine decoder failures on real input reported as robustness bugs.
throw new EncodingFidelityException(
$"Re-decoding the encoded message failed. Type={encodeable.GetType().Name}.",
exception);
}
}
internal static string RestoreJsonArtifacts(
string serialized,
string metadata,
IServiceMessageContext context)
{
using var encoded = new JsonDecoder(serialized, context);
using var schema = new JsonDecoder(metadata, context);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
RestoreJsonArtifacts(encoded.Root, schema.Root, writer);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private static void FuzzJsonEncoderCore(string input, JsonEncoderOptions options)
{
IEncodeable? encodeable = FuzzJsonDecoderCore(input);
if (encodeable != null)
{
FuzzJsonRoundTripCore(encodeable, options);
}
}
private static bool IsExpectedJsonEncodingError(
ServiceResultException exception,
IEncodeable encodeable,
IServiceMessageContext context)
{
if (exception.StatusCode != StatusCodes.BadEncodingError)
{
return false;
}
if (exception.InnerException is JsonException)
{
return true;
}
return HasJsonUnencodableValue(
encodeable,
new HashSet<ReferencePair>(ReferencePairComparer.Instance),
context);
}
/// <summary>
/// Picoseconds without their timestamp are ignored (Part 6 5.2.2.17), and the
/// JSON encoder drops them like the binary encoder does.
/// </summary>
private static ushort GetEncodedPicoseconds(DateTimeUtc timestamp, ushort picoseconds)
{
return timestamp == DateTimeUtc.MinValue ? (ushort)0 : picoseconds;
}
/// <summary>
/// A first generation encoding can legitimately normalize a value that the wire form
/// cannot represent distinctly, for example a bodyless ExtensionObject whose TypeId
/// resolves to a registered type. The byte level idempotence invariant then starts at
/// the second generation: re-encoding the value decoded from the second generation must
/// reproduce that generation byte for byte, so the encoding still reaches a fixed point
/// after a single normalizing pass instead of oscillating or growing without bound.
/// </summary>
private static bool IsStableFromSecondGeneration(
string serialized2,
IEncodeable encodeable3,
JsonEncoderOptions options,
IServiceMessageContext context)
{
if (serialized2 == null || encodeable3 == null)
{
return false;
}
string serialized3 = EncodeJsonMessage(encodeable3, options, context);
return StringComparer.Ordinal.Equals(serialized2, serialized3);
}
private static bool IsExpectedJsonSemanticLoss(
IEncodeable original,
IEncodeable decoded,
JsonEncoderOptions options,
IServiceMessageContext context)
{
return IsJsonEquivalent(
original,
decoded,
new HashSet<ReferencePair>(ReferencePairComparer.Instance),
options,
context);
}
private static bool HasJsonUnencodableValue(
object value,
HashSet<ReferencePair> seen,
IServiceMessageContext context)
{
if (value == null)
{
return false;
}
if (value is QualifiedName qualifiedName)
{
return (qualifiedName.NamespaceIndex != 0 && qualifiedName.Name == null) ||
!SurvivesQualifiedNameTextForm(qualifiedName, context);
}
if (value is DataValue dataValue)
{
return HasJsonUnencodableValue(dataValue.WrappedValue, seen, context);
}
if (value is Variant variant)
{
return HasJsonUnencodableValue(GetVariantRaw(in variant), seen, context);
}
if (value is ExtensionObject extensionObject)
{
return IsJsonUnencodableExtensionObjectTypeId(in extensionObject, context) ||
(extensionObject.TryGetValue(out IEncodeable? encodeable) &&
HasJsonUnencodableValue(encodeable, seen, context));
}
Type type = value.GetType();
if (IsJsonSimpleType(type))
{
return false;
}
if (IsArrayOf(type))
{
return HasJsonUnencodableArrayOf(value, type, seen, context);
}
if (IsMatrixOf(type))
{
return HasJsonUnencodableValue(GetMatrixAsArrayOf(value, type), seen, context);
}
if (!type.IsValueType && !seen.Add(new ReferencePair(value, value)))
{
return false;
}
if (value is IEnumerable enumerable)
{
foreach (object item in enumerable)
{
if (HasJsonUnencodableValue(item, seen, context))
{
return true;
}
}
return false;
}
foreach (PropertyInfo property in GetComparableProperties(type))
{
if (HasJsonUnencodableValue(property.GetValue(value)!, seen, context))
{
return true;
}
}
return false;
}
/// <summary>
/// The textual QualifiedName form is "&lt;namespace index&gt;:&lt;name&gt;", which is
/// only unambiguous while the name itself carries no separator and parses back. A name
/// that does not survive that form cannot be represented in the JSON string form at
/// all, so the value is unencodable rather than lost by the decoder. Deciding this by
/// actually formatting and re-parsing keeps the rule tied to the real encoder output
/// instead of a hand maintained list of forbidden characters.
/// </summary>
private static bool SurvivesQualifiedNameTextForm(
QualifiedName value,
IServiceMessageContext context)
{
try
{
return QualifiedName.Parse(context, value.Format(context), false).Equals(value);
}
catch (ServiceResultException)
{
return false;
}
}
private static bool IsJsonEquivalent(
object left,
object right,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
if (ReferenceEquals(left, right))
{
return true;
}
if (left == null || right == null)
{
return false;
}
Type type = left.GetType();
if (type != right.GetType())
{
return false;
}
if (left is Variant leftVariant && right is Variant rightVariant)
{
return IsJsonEquivalentVariant(in leftVariant, in rightVariant, seen, options, context);
}
if (left is DataValue leftDataValue && right is DataValue rightDataValue)
{
return IsJsonEquivalentDataValue(in leftDataValue, in rightDataValue, seen, options, context);
}
if (left is ExtensionObject leftExtensionObject &&
right is ExtensionObject rightExtensionObject)
{
return IsJsonEquivalentExtensionObject(
in leftExtensionObject,
in rightExtensionObject,
seen,
options,
context);
}
if (left is QualifiedName leftQualifiedName &&
right is QualifiedName rightQualifiedName)
{
return IsJsonEquivalentQualifiedName(leftQualifiedName, rightQualifiedName);
}
if (left is ExpandedNodeId leftExpandedNodeId &&
right is ExpandedNodeId rightExpandedNodeId)
{
return AreJsonEquivalentExpandedNodeIds(leftExpandedNodeId, rightExpandedNodeId, context);
}
if (left is LocalizedText leftLocalizedText &&
right is LocalizedText rightLocalizedText)
{
return IsJsonEquivalentLocalizedText(leftLocalizedText, rightLocalizedText);
}
if (IsArrayOf(type))
{
return IsJsonEquivalentArrayOf(left, right, type, seen, options, context);
}
if (IsMatrixOf(type))
{
return IsJsonEquivalentMatrixOf(left, right, type, seen, options, context);
}
if (IsJsonSimpleType(type))
{
return left.Equals(right);
}
if (!type.IsValueType && !seen.Add(new ReferencePair(left, right)))
{
return true;
}
if (left is IEnumerable leftEnumerable && right is IEnumerable rightEnumerable)
{
return IsJsonEquivalentEnumerable(leftEnumerable, rightEnumerable, seen, options, context);
}
if (left is IEncodeable leftEncodeable &&
right is IEncodeable rightEncodeable &&
leftEncodeable.IsEqual(rightEncodeable))
{
return true;
}
bool comparedProperty = false;
foreach (PropertyInfo property in GetComparableProperties(type))
{
comparedProperty = true;
if (!IsJsonEquivalent(
property.GetValue(left)!,
property.GetValue(right)!,
seen,
options,
context))
{
return false;
}
}
return comparedProperty || left.Equals(right);
}
private static bool IsJsonEquivalentVariant(
in Variant left,
in Variant right,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
return left.TypeInfo == right.TypeInfo &&
IsJsonEquivalent(GetVariantRaw(in left), GetVariantRaw(in right), seen, options, context);
}
private static bool IsJsonEquivalentDataValue(
in DataValue left,
in DataValue right,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
return left.IsNull == right.IsNull &&
left.StatusCode.Equals(right.StatusCode, StatusCodeComparison.AllBits) &&
left.SourceTimestamp == right.SourceTimestamp &&
left.ServerTimestamp == right.ServerTimestamp &&
GetEncodedPicoseconds(left.SourceTimestamp, left.SourcePicoseconds) ==
GetEncodedPicoseconds(right.SourceTimestamp, right.SourcePicoseconds) &&
GetEncodedPicoseconds(left.ServerTimestamp, left.ServerPicoseconds) ==
GetEncodedPicoseconds(right.ServerTimestamp, right.ServerPicoseconds) &&
IsJsonEquivalent(left.WrappedValue, right.WrappedValue, seen, options, context);
}
private static bool IsJsonEquivalentExtensionObject(
in ExtensionObject left,
in ExtensionObject right,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
if (left.Equals(right))
{
return true;
}
if (left.Encoding == ExtensionObjectEncoding.None &&
right.Encoding == ExtensionObjectEncoding.None)
{
return AreJsonEquivalentExpandedNodeIds(left.TypeId, right.TypeId, context);
}
if (IsBodylessExtensionObjectMaterialization(in left, in right, context))
{
return true;
}
if (left.TryGetAsJson(out string? leftJson) &&
right.TryGetAsJson(out string? rightJson))
{
try
{
var normalizedLeft = new ExtensionObject(left.TypeId, CanonicalizeRawJson(leftJson));
var normalizedRight = new ExtensionObject(right.TypeId, CanonicalizeRawJson(rightJson));
return normalizedLeft.Equals(normalizedRight);
}
catch (JsonException)
{
return false;
}
}
return left.TryGetValue(out IEncodeable? leftEncodeable) &&
right.TryGetValue(out IEncodeable? rightEncodeable) &&
IsJsonEquivalent(leftEncodeable, rightEncodeable, seen, options, context);
}
/// <summary>
/// The legacy inline JSON form writes an encodeable body directly into the
/// ExtensionObject envelope, so a bodyless ExtensionObject and a default constructed
/// instance of the same type serialize to the exact same JSON. Decoding therefore
/// materializes a default instance whenever the TypeId resolves to a registered type.
/// That is inherent to the encoding rather than a decoder defect - the decoder only
/// keeps the envelope bodyless when the type is unknown and no instance can be built.
/// Accept the materialization, but only when every member of the decoded instance is
/// still at its default; any populated member means information appeared from nowhere.
/// </summary>
private static bool IsBodylessExtensionObjectMaterialization(
in ExtensionObject left,
in ExtensionObject right,
IServiceMessageContext context)
{
if (left.Encoding != ExtensionObjectEncoding.None ||
left.TypeId.IsNull ||
left.TryGetValue(out IEncodeable? _) ||
!right.TryGetValue(out IEncodeable? decoded) ||
decoded == null ||
!AreJsonEquivalentExpandedNodeIds(left.TypeId, right.TypeId, context))
{
return false;
}
IEncodeable prototype;
try
{
prototype = (Activator.CreateInstance(decoded.GetType()) as IEncodeable)!;
}
catch (MissingMethodException)
{
return false;
}
return prototype != null && Utils.IsEqual(prototype, decoded);
}
/// <summary>
/// Null and empty strings are semantically the same (Part 6 5.1.11), and the JSON
/// encoding does not encode either for Text or Locale (Part 6 5.4.2.15).
/// </summary>
private static bool IsJsonEquivalentLocalizedText(LocalizedText left, LocalizedText right)
{
return left.Equals(right) ||
(string.Equals(left.Text ?? string.Empty, right.Text ?? string.Empty, StringComparison.Ordinal) &&
string.Equals(left.Locale ?? string.Empty, right.Locale ?? string.Empty, StringComparison.Ordinal));
}
private static bool IsJsonEquivalentQualifiedName(QualifiedName left, QualifiedName right)
{
return left.Equals(right) || (left.IsNull && right.IsNull);
}
private static bool AreJsonEquivalentExpandedNodeIds(
ExpandedNodeId left,
ExpandedNodeId right,
IServiceMessageContext context)
{
if (left.IsNull || right.IsNull)
{
return left.IsNull == right.IsNull;
}
if (left == right)
{
return true;
}
NodeId leftLocal = ExpandedNodeId.ToNodeId(left, context.NamespaceUris);
NodeId rightLocal = ExpandedNodeId.ToNodeId(right, context.NamespaceUris);
return !leftLocal.IsNull &&
!rightLocal.IsNull &&
leftLocal == rightLocal;
}
private static bool IsExpectedJsonEncodingNormalization(
string serialized,
string serialized2,
IServiceMessageContext context)
{
try
{
string canonical = CanonicalizeRawJson(serialized);
if (!StringComparer.Ordinal.Equals(serialized, canonical))
{
return false;
}
using JsonDocument left = JsonDocument.Parse(serialized);
using JsonDocument right = JsonDocument.Parse(serialized2);
return IsJsonEquivalentText(left.RootElement, right.RootElement, context);
}
catch (JsonException)
{
return false;
}
}
private static bool IsJsonEquivalentText(
JsonElement left,
JsonElement right,
IServiceMessageContext context)
{
if (left.ValueKind != right.ValueKind)
{
return false;
}
switch (left.ValueKind)
{
case JsonValueKind.Object:
return IsJsonEquivalentObjectText(left, right, context);
case JsonValueKind.Array:
return IsJsonEquivalentArrayText(left, right, context);
case JsonValueKind.String:
string leftString = left.GetString()!;
string rightString = right.GetString()!;
return StringComparer.Ordinal.Equals(leftString, rightString) ||
AreJsonEquivalentIdentifierStrings(leftString!, rightString!, context);
default:
return StringComparer.Ordinal.Equals(left.GetRawText(), right.GetRawText());
}
}
private static bool IsJsonEquivalentObjectText(
JsonElement left,
JsonElement right,
IServiceMessageContext context)
{
using var leftProperties = left.EnumerateObject();
using var rightProperties = right.EnumerateObject();
while (true)
{
bool leftHasValue = leftProperties.MoveNext();
bool rightHasValue = rightProperties.MoveNext();
if (leftHasValue != rightHasValue)
{
return false;
}
if (!leftHasValue)
{
return true;
}
if (!StringComparer.Ordinal.Equals(leftProperties.Current.Name, rightProperties.Current.Name) ||
!IsJsonEquivalentText(leftProperties.Current.Value, rightProperties.Current.Value, context))
{
return false;
}
}
}
private static bool IsJsonEquivalentArrayText(
JsonElement left,
JsonElement right,
IServiceMessageContext context)
{
if (left.GetArrayLength() != right.GetArrayLength())
{
return false;
}
for (int i = 0; i < left.GetArrayLength(); i++)
{
if (!IsJsonEquivalentText(left[i], right[i], context))
{
return false;
}
}
return true;
}
private static bool AreJsonEquivalentIdentifierStrings(
string left,
string right,
IServiceMessageContext context)
{
if (left == null || right == null)
{
return false;
}
return TryParseExpandedNodeId(left, context, out ExpandedNodeId leftId) &&
TryParseExpandedNodeId(right, context, out ExpandedNodeId rightId) &&
AreJsonEquivalentExpandedNodeIds(leftId, rightId, context);
}
private static bool TryParseExpandedNodeId(
string text,
IServiceMessageContext context,
out ExpandedNodeId value)
{
try
{
value = ExpandedNodeId.Parse(text, context.NamespaceUris);
return true;
}
catch (ServiceResultException)
{
value = ExpandedNodeId.Null;
return false;
}
catch (FormatException)
{
value = ExpandedNodeId.Null;
return false;
}
}
private static bool HasJsonUnencodableArrayOf(
object value,
Type type,
HashSet<ReferencePair> seen,
IServiceMessageContext context)
{
Array array = GetArrayOfElements(value, type);
foreach (object item in array)
{
if (HasJsonUnencodableValue(item, seen, context))
{
return true;
}
}
return false;
}
private static bool IsJsonUnencodableExtensionObjectTypeId(
in ExtensionObject value,
IServiceMessageContext context)
{
return !value.TypeId.IsNull &&
ExpandedNodeId.ToNodeId(value.TypeId, context.NamespaceUris).IsNull;
}
private static bool IsJsonEquivalentArrayOf(
object left,
object right,
Type type,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
bool leftIsNull = (bool)type.GetProperty(nameof(INullable.IsNull))!.GetValue(left)!;
bool rightIsNull = (bool)type.GetProperty(nameof(INullable.IsNull))!.GetValue(right)!;
int leftCount = (int)type.GetProperty(nameof(ArrayOf<int>.Count))!.GetValue(left)!;
int rightCount = (int)type.GetProperty(nameof(ArrayOf<int>.Count))!.GetValue(right)!;
// Null, empty and zero length arrays are semantically the same for all
// DataEncodings, and a conversion may turn one into the other (Part 6 5.1.11).
if (leftCount == 0 && rightCount == 0)
{
return true;
}
return leftIsNull == rightIsNull &&
leftCount == rightCount &&
IsJsonEquivalentEnumerable(
GetArrayOfElements(left, type),
GetArrayOfElements(right, type),
seen,
options,
context);
}
private static bool IsJsonEquivalentMatrixOf(
object left,
object right,
Type type,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
bool leftIsNull = (bool)type.GetProperty(nameof(INullable.IsNull))!.GetValue(left)!;
bool rightIsNull = (bool)type.GetProperty(nameof(INullable.IsNull))!.GetValue(right)!;
int[] leftDimensions = (int[])type.GetProperty(nameof(MatrixOf<int>.Dimensions))!.GetValue(left)!;
int[] rightDimensions = (int[])type.GetProperty(nameof(MatrixOf<int>.Dimensions))!.GetValue(right)!;
return leftIsNull == rightIsNull &&
leftDimensions.SequenceEqual(rightDimensions) &&
IsJsonEquivalent(
GetMatrixAsArrayOf(left, type),
GetMatrixAsArrayOf(right, type),
seen,
options,
context);
}
private static Array GetArrayOfElements(object value, Type type)
{
object array = type.GetMethod(nameof(ArrayOf<int>.ToArray))!.Invoke(value, null)!;
return array == null ? Array.Empty<object>() : (Array)array;
}
private static object GetMatrixAsArrayOf(object value, Type type)
{
return type.GetMethod(nameof(MatrixOf<int>.ToArrayOf), Type.EmptyTypes)!.Invoke(value, null)!;
}
private static bool IsJsonEquivalentEnumerable(
IEnumerable left,
IEnumerable right,
HashSet<ReferencePair> seen,
JsonEncoderOptions options,
IServiceMessageContext context)
{
IEnumerator leftEnumerator = left.GetEnumerator();
IEnumerator rightEnumerator = right.GetEnumerator();
try
{
while (true)
{
bool leftHasValue = leftEnumerator.MoveNext();
bool rightHasValue = rightEnumerator.MoveNext();
if (leftHasValue != rightHasValue)
{
return false;
}
if (!leftHasValue)
{
return true;
}
if (!IsJsonEquivalent(
leftEnumerator.Current,
rightEnumerator.Current,
seen,
options,
context))
{
return false;
}
}
}
finally
{
(leftEnumerator as IDisposable)?.Dispose();
(rightEnumerator as IDisposable)?.Dispose();
}
}
private static object GetVariantRaw(in Variant value)
{
PropertyInfo property = typeof(Variant).GetProperty(
"Raw",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Variant.Raw property was not found.");
return property.GetValue(value)!;
}
private static IEnumerable<PropertyInfo> GetComparableProperties(Type type)
{
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (property.CanRead &&
property.GetIndexParameters().Length == 0 &&
property.Name != nameof(ArrayOf<int>.Span) &&
property.Name != nameof(ReadValueId.Handle))
{
yield return property;
}
}
}
private static bool IsArrayOf(Type type)
{
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ArrayOf<>);
}
private static bool IsMatrixOf(Type type)
{
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(MatrixOf<>);
}
private static bool IsJsonSimpleType(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
return type.IsPrimitive ||
type.IsEnum ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(DateTimeOffset) ||
type == typeof(DateTimeUtc) ||
type == typeof(NodeId) ||
type == typeof(ExpandedNodeId) ||
type == typeof(QualifiedName) ||
type == typeof(LocalizedText) ||
type == typeof(StatusCode) ||
type == typeof(ByteString) ||
type == typeof(Uuid) ||
type.Namespace == "System.Xml";
}
private static string CanonicalizeRawJson(string rawJson)
{
using JsonDocument document = JsonDocument.Parse(rawJson);
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
document.RootElement.WriteTo(writer);
}
return Encoding.UTF8.GetString(stream.ToArray());
}
private readonly struct ReferencePair
{
public ReferencePair(object left, object right)
{
Left = left;
Right = right;
}
public object Left { get; }
public object Right { get; }
}
private sealed class ReferencePairComparer : IEqualityComparer<ReferencePair>
{
public static ReferencePairComparer Instance { get; } = new();
public bool Equals(ReferencePair x, ReferencePair y)
{
return ReferenceEquals(x.Left, y.Left) &&
ReferenceEquals(x.Right, y.Right);
}
public int GetHashCode(ReferencePair obj)
{
return RuntimeHelpers.GetHashCode(obj.Left) ^
RuntimeHelpers.GetHashCode(obj.Right);
}
}
private static string DecodeUtf8Json(ReadOnlySpan<byte> input)
{
#if NETFRAMEWORK
return Encoding.UTF8.GetString(input.ToArray());
#else
return Encoding.UTF8.GetString(input);
#endif
}
private static void RestoreJsonArtifacts(JsonElement encoded, JsonElement metadata, Utf8JsonWriter writer)
{
if (encoded.ValueKind != metadata.ValueKind)
{
throw new EncodingFidelityException("RawData JSON changed the payload shape.");
}
switch (encoded.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
bool metadataOmitsArtifactFields =
metadata.TryGetProperty("SwitchField", out _) ||
metadata.TryGetProperty("EncodingMask", out _);
foreach (JsonProperty property in encoded.EnumerateObject())
{
if (!metadata.TryGetProperty(property.Name, out JsonElement fieldMetadata))
{
// Disabling SuppressArtifacts also re-enables the union SwitchField
// and the optional-field EncodingMask, and a union or optional
// structure encodes a different member set under the two option
// sets. Only then may the payload carry a field the metadata lacks;
// that is an encoder-shape difference, not a corrupted payload.
// Anywhere else an unknown field is still a real corruption.
if (!metadataOmitsArtifactFields)
{
throw new EncodingFidelityException(
$"Unexpected RawData JSON field '{property.Name}'.");
}
property.WriteTo(writer);
continue;
}
writer.WritePropertyName(property.Name);
RestoreJsonArtifacts(property.Value, fieldMetadata, writer);
}
foreach (JsonProperty property in metadata.EnumerateObject())
{
if (!encoded.TryGetProperty(property.Name, out _))
{
// Only non-semantic artifacts come from metadata; never replace or repair payload values.
if (property.Name is not ("UaType" or "UaTypeId" or "Symbol" or
"SwitchField" or "EncodingMask"))
{
throw new EncodingFidelityException($"RawData JSON lost field '{property.Name}'.");
}
if (property.Name is "UaType" or "UaTypeId" or "Symbol")
{
property.WriteTo(writer);
}
}
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
if (encoded.GetArrayLength() != metadata.GetArrayLength())
{
throw new EncodingFidelityException("RawData JSON changed an array length.");
}
writer.WriteStartArray();
for (int i = 0; i < encoded.GetArrayLength(); i++)
{
RestoreJsonArtifacts(encoded[i], metadata[i], writer);
}
writer.WriteEndArray();
break;
default:
encoded.WriteTo(writer);
break;
}
}
}
}