Skip to content
16 changes: 10 additions & 6 deletions fuzzing/Common/Fuzz.Tests/FuzzTargetTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,22 +109,26 @@ public void FuzzCrashAssets(FuzzTargetFunction fuzzableCode)
}
catch (Exception ex)
{
string entry = $"asset={messageEncoder} -> {ex.GetType().Name}: {ex.Message}";
Exception cause = ex is TargetInvocationException { InnerException: not null } ?
ex.InnerException :
ex;
string entry = $"asset={messageEncoder} -> {cause.GetType().Name}: {cause.Message}";
if (IsFidelityFinding(ex) && !IsCuratedAsset(messageEncoder))
{
// Tolerated, so only the one line summary below is reported. The
// external corpus is private, so its bytes are never emitted for it.
fidelityFindings.Add(entry);
}
else
{
failures.Add(entry);
continue;
}

failures.Add(entry);
TestContext.Error.WriteLine($"Failed: {messageEncoder}\n{ex}");

// Crash corpora are frequently supplied by the pipeline rather than the
// tree, so a failure there is otherwise impossible to reproduce locally.
// Emit the reproducer inline, bounded so a systemic failure cannot bury
// the log. A passing run emits nothing.
// the log. Only failures consume the budget, so tolerated findings cannot
// exhaust it ahead of a failure. A passing run emits nothing.
if (reproducers++ < kMaxEmittedReproducers &&
messageEncoder.Testcase.Length <= kMaxEmittedReproducerBytes)
{
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
21 changes: 20 additions & 1 deletion fuzzing/Opc.Ua.Encoders.Fuzz/FuzzableCode.JsonDecoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,12 @@ private static bool IsJsonEquivalent(
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);
Expand Down Expand Up @@ -716,6 +722,17 @@ private static bool IsBodylessExtensionObjectMaterialization(
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);
Expand Down Expand Up @@ -916,7 +933,9 @@ private static bool IsJsonEquivalentArrayOf(
int leftCount = (int)type.GetProperty(nameof(ArrayOf<int>.Count))!.GetValue(left)!;
int rightCount = (int)type.GetProperty(nameof(ArrayOf<int>.Count))!.GetValue(right)!;

if (options.IgnoreNullValues && leftCount == 0 && rightCount == 0)
// 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;
}
Expand Down
6 changes: 4 additions & 2 deletions src/Opc.Ua.Types/BuiltIn/ExpandedNodeId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,7 @@ public static NodeId ToNodeId(
/// <returns>The formatted identifier.</returns>
public string? Format(IServiceMessageContext context, bool useUris = false)
{
if (m_nodeId.IsNull)
if (IsNull)
{
return null;
}
Expand Down Expand Up @@ -816,7 +816,9 @@ public static NodeId ToNodeId(
.Append(';');
}

string id = m_nodeId.Format(context, useUris);
// A server index or namespace URI keeps the value non-null even when the
// identifier is, so the identifier must still be written for it to parse back.
string id = m_nodeId.IsNull ? "i=0" : m_nodeId.Format(context, useUris);
buffer.Append(id);

return buffer.ToString();
Expand Down
57 changes: 52 additions & 5 deletions src/Opc.Ua.Types/Encoders/JsonEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ public void WriteInt64Array(string? fieldName, ArrayOf<long> values)
/// <inheritdoc/>
public void WriteLocalizedText(string? fieldName, LocalizedText value)
{
if (value.IsNull)
if (IsJsonNull(value))
{
WriteNull(fieldName);
return;
Expand Down Expand Up @@ -1044,7 +1044,7 @@ private void WriteDataValue(in DataValue value)
{
WriteVariantUaTypeByte(value.WrappedValue);
}
if (!m_options.IgnoreDefaultValues || !value.WrappedValue.ValueIsDefaultOrNull)
if (!CanOmitVariantValue(value.WrappedValue))
{
m_writer.WritePropertyName(JsonProperties.Value);
WriteVariantContents(value.WrappedValue, false, m_options.SuppressArtifacts);
Expand Down Expand Up @@ -1565,20 +1565,33 @@ private void WriteInt64Array(ArrayOf<long> values)
/// </summary>
private void WriteLocalizedText(LocalizedText value)
{
if (value.IsNull)
if (IsJsonNull(value))
{
m_writer.WriteNullValue();
return;
}
StartObject();
WriteString(JsonProperties.Text, value.Text);
if (!string.IsNullOrEmpty(value.Text))
{
WriteString(JsonProperties.Text, value.Text);
}
if (!string.IsNullOrEmpty(value.Locale))
{
WriteString(JsonProperties.Locale, value.Locale);
}
EndObject();
}

/// <summary>
/// Text and Locale are not encoded if they are null or empty (Part 6 5.4.2.15), so a
/// value without either is the null LocalizedText (all fields default, Part 6 5.1.2).
/// </summary>
private static bool IsJsonNull(LocalizedText value)
{
return value.IsNull ||
(string.IsNullOrEmpty(value.Text) && string.IsNullOrEmpty(value.Locale));
}

/// <summary>
/// Write localized text values
/// </summary>
Expand Down Expand Up @@ -1899,14 +1912,48 @@ private void WriteVariant(in Variant value, bool suppressUaType)
{
WriteVariantUaTypeByte(value);
}
if (!m_options.IgnoreDefaultValues || !value.ValueIsDefaultOrNull)
if (!CanOmitVariantValue(value))
{
m_writer.WritePropertyName(JsonProperties.Value);
WriteVariantContents(in value, false, suppressUaType);
}
EndObject();
}

/// <summary>
/// The Value field is not encoded if the value is a NULL for a nullable built-in
/// type (Part 6 5.4.2.17, Table 1). Default values of the other types are always
/// written, and a null array is written as an empty array, which is semantically
/// the same (Part 6 5.1.11), so a decoder never has to guess the value rank.
/// </summary>
private static bool CanOmitVariantValue(in Variant value)
{
if (!value.TypeInfo.IsScalar)
{
return false;
}
switch (value.TypeInfo.BuiltInType)
{
case BuiltInType.LocalizedText:
return IsJsonNull(value.GetLocalizedText());
case BuiltInType.String:
case BuiltInType.DateTime:
case BuiltInType.Guid:
case BuiltInType.ByteString:
case BuiltInType.XmlElement:
case BuiltInType.NodeId:
case BuiltInType.ExpandedNodeId:
case BuiltInType.QualifiedName:
case BuiltInType.ExtensionObject:
case BuiltInType.DataValue:
return value.ValueIsDefaultOrNull;
default:
// Includes Variant and DiagnosticInfo, which are not valid Variant
// contents and must still reach the writer to be rejected.
return false;
}
}

/// <summary>
/// Write variant values
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion tests/Opc.Ua.Core.Encoders.Tests/JsonEncoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ public class JsonEncoderTests : EncoderCommon
{
BuiltInType.LocalizedText,
new LocalizedText(kLocale, string.Empty),
$$"""{"Text":"","Locale":"{{kLocale}}"}""",
$$"""{"Locale":"{{kLocale}}"}""",
null
},
{
Expand Down
121 changes: 121 additions & 0 deletions tests/Opc.Ua.Types.Tests/Encoders/JsonEncoderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,127 @@ public void WriteVariantThrowsIfNestingLimitsExceeded()
Assert.Fail("Exception not thrown");
}

[TestCase(null, null)]
[TestCase("", null)]
[TestCase(null, "")]
[TestCase("", "")]
public void WriteLocalizedTextWithoutTextAndLocaleWritesNull(string locale, string text)
{
// Text and Locale are not encoded if null or empty (Part 6 5.4.2.15), so the
// value is the null LocalizedText: JSON null in Verbose, omitted in Compact.
var value = new LocalizedText(locale, text);

Assert.Multiple(() =>
{
Assert.That(
Encode(JsonEncoderOptions.Verbose, w => w.WriteLocalizedText(JsonProperties.Value, value)),
Is.EqualTo("""{"Value":null}"""));
Assert.That(
Encode(JsonEncoderOptions.Compact, w => w.WriteLocalizedText(JsonProperties.Value, value)),
Is.EqualTo("{}"));
Assert.That(
Encode(JsonEncoderOptions.Verbose, w => w.WriteLocalizedTextArray(JsonProperties.Value, [value])),
Is.EqualTo("""{"Value":[null]}"""));
});
}

[Test]
public void WriteLocalizedTextOmitsEmptyText()
{
var value = new LocalizedText("en-US", string.Empty);

Assert.That(
Encode(JsonEncoderOptions.Verbose, w => w.WriteLocalizedText(JsonProperties.Value, value)),
Is.EqualTo("""{"Value":{"Locale":"en-US"}}"""));
}

[Test]
public void WriteVariantWithNullArrayKeepsArrayRank()
{
// Only a NULL of a nullable built-in type may omit Value (Part 6 5.4.2.17); a
// null array is written as an empty array, which is semantically the same.
ITelemetryContext telemetryContext = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetryContext);
Variant value = Variant.CreateDefault(TypeInfo.Arrays.UInt16);

foreach (JsonEncoderOptions options in new[] { JsonEncoderOptions.Verbose, JsonEncoderOptions.Compact })
{
using var buffer = new PooledBufferWriter();
using (var writer = new JsonEncoder(buffer, messageContext, options))
{
writer.WriteVariant(JsonProperties.Value, value);
}

using var decoder = new JsonDecoder(buffer.WrittenMemory.ToReadOnlySequence(16), messageContext);
Variant decoded = decoder.ReadVariant(JsonProperties.Value);

Assert.That(decoded.TypeInfo, Is.EqualTo(TypeInfo.Arrays.UInt16), options.Name);
}
}

[Test]
public void WriteVariantWithDefaultScalarOfNonNullableTypeWritesValue()
{
var value = new Variant((ushort)0);

Assert.That(
Encode(JsonEncoderOptions.Compact, w => w.WriteVariant(JsonProperties.Value, value)),
Is.EqualTo("""{"Value":{"UaType":5,"Value":0}}"""));
}

[Test]
public void WriteVariantWithNullOfNullableTypeOmitsValue()
{
var value = new Variant(NodeId.Null);

Assert.Multiple(() =>
{
Assert.That(
Encode(JsonEncoderOptions.Verbose, w => w.WriteVariant(JsonProperties.Value, value)),
Is.EqualTo("""{"Value":{"UaType":17}}"""));
Assert.That(
Encode(JsonEncoderOptions.Compact, w => w.WriteVariant(JsonProperties.Value, value)),
Is.EqualTo("""{"Value":{"UaType":17}}"""));
});
}

[Test]
public void WriteExpandedNodeIdWithServerIndexAndNullIdentifierRoundTrips()
{
ITelemetryContext telemetryContext = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetryContext);
var expected = new ExpandedNodeId(NodeId.Null, null, 1);
using var buffer = new PooledBufferWriter();
using (var writer = new JsonEncoder(buffer, messageContext))
{
writer.WriteExpandedNodeId(JsonProperties.Value, expected);
}

using var decoder = new JsonDecoder(buffer.WrittenMemory.ToReadOnlySequence(16), messageContext);
ExpandedNodeId actual = decoder.ReadExpandedNodeId(JsonProperties.Value);

Assert.Multiple(() =>
{
Assert.That(
System.Text.Encoding.UTF8.GetString(buffer.WrittenMemory.ToArray()),
Is.EqualTo("""{"Value":"svr=1;i=0"}"""));
Assert.That(actual.ServerIndex, Is.EqualTo(1u));
Assert.That(actual.IsNull, Is.False);
});
}

private static string Encode(JsonEncoderOptions options, Action<JsonEncoder> write)
{
ITelemetryContext telemetryContext = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetryContext);
using var buffer = new PooledBufferWriter();
using (var writer = new JsonEncoder(buffer, messageContext, options))
{
write(writer);
}
return System.Text.Encoding.UTF8.GetString(buffer.WrittenMemory.ToArray());
}

public static Argument CreateType => new()
{
Description = LocalizedText.From("Test"),
Expand Down
Loading