Skip to content

Commit fa77f63

Browse files
paulirwinclaude
andauthored
More BytesRef/UnicodeUtil UTF-8-to-string fixes, #1024 (#1171)
* UnicodeUtil: Change UTF8toUTF16 to throw DecoderFallbackException, #1024 Changed UTF8toUTF16 method to throw DecoderFallbackException instead of FormatException when invalid UTF-8 is encountered. This aligns with .NET conventions where DecoderFallbackException is the appropriate exception type for character decoding issues (equivalent to Java's CharacterCodingException). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * BytesRef: Use Utf8ToStringWithFallback in DebuggerDisplay, #1024 Changed DebuggerDisplay attribute to use Utf8ToStringWithFallback() instead of Utf8ToString() to prevent exceptions when debugging BytesRef instances that contain invalid UTF-8 sequences. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * BytesRef: Show "Invalid UTF-8" label and string-before-bytes in DebuggerDisplay, #1024 Address NightOwl888's review on #1171. The prior DebuggerDisplay used Utf8ToStringWithFallback, which silently substitutes U+FFFD for malformed input — indistinguishable from a real U+FFFD in the data. Switch to a private DebuggerDisplay property that uses TryUtf8ToString, shows the literal "Invalid UTF-8" on decode failure, and puts the decoded string before the raw bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * UnicodeUtil: Fix UTF8toUTF16WithFallback byte[] overload to use fallback path, #1024 The byte[]/offset/length overload was delegating to the throwing UTF8toUTF16 variant, so callers like BytesRef.Utf8ToStringWithFallback() would throw DecoderFallbackException on invalid UTF-8 instead of substituting U+FFFD as the method name and docs promise. Delegate to the ReadOnlySpan<byte> fallback overload and add a regression test covering the 4-arg signature with a non-zero offset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a1103d commit fa77f63

4 files changed

Lines changed: 94 additions & 8 deletions

File tree

src/Lucene.Net.Tests/Util/TestBytesRef.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using NUnit.Framework;
44
using System;
55
using System.Collections.Generic;
6+
using System.Diagnostics;
7+
using System.Reflection;
68
using System.Runtime.CompilerServices;
79
using System.Runtime.InteropServices;
810
using Assert = Lucene.Net.TestFramework.Assert;
@@ -612,5 +614,64 @@ public static void Test_CopyChars_UnpairedSurrogates()
612614
}
613615

614616
#endregion
617+
618+
#region DebuggerDisplay
619+
620+
private static string GetDebuggerDisplay(BytesRef br)
621+
{
622+
var prop = typeof(BytesRef).GetProperty("DebuggerDisplay", BindingFlags.Instance | BindingFlags.NonPublic);
623+
Assert.IsNotNull(prop, "BytesRef.DebuggerDisplay private property not found");
624+
return (string)prop.GetValue(br);
625+
}
626+
627+
[Test]
628+
[LuceneNetSpecific]
629+
public static void Test_DebuggerDisplayAttribute_ReferencesExistingMember()
630+
{
631+
// Guards against typos/renames in the [DebuggerDisplay] expression.
632+
var attr = typeof(BytesRef).GetCustomAttribute<DebuggerDisplayAttribute>();
633+
Assert.IsNotNull(attr);
634+
Assert.AreEqual("{DebuggerDisplay,nq}", attr.Value);
635+
636+
var prop = typeof(BytesRef).GetProperty("DebuggerDisplay", BindingFlags.Instance | BindingFlags.NonPublic);
637+
Assert.IsNotNull(prop);
638+
}
639+
640+
[Test]
641+
[LuceneNetSpecific]
642+
public static void Test_DebuggerDisplay_ValidUtf8_ShowsStringThenBytes()
643+
{
644+
var br = new BytesRef("abc");
645+
Assert.AreEqual("abc [61 62 63]", GetDebuggerDisplay(br));
646+
}
647+
648+
[Test]
649+
[LuceneNetSpecific]
650+
public static void Test_DebuggerDisplay_InvalidUtf8_ShowsInvalidLabel()
651+
{
652+
// 0xC3 starts a 2-byte UTF-8 sequence but the continuation byte is missing.
653+
var br = new BytesRef(new byte[] { 0xC3 });
654+
Assert.AreEqual("Invalid UTF-8 [c3]", GetDebuggerDisplay(br));
655+
}
656+
657+
[Test]
658+
[LuceneNetSpecific]
659+
public static void Test_DebuggerDisplay_EmbeddedFFFD_IsNotMistakenForInvalid()
660+
{
661+
// U+FFFD encoded as legitimate UTF-8 (EF BF BD) must round-trip, not be reported as invalid.
662+
var br = new BytesRef("\uFFFD");
663+
Assert.AreEqual("\uFFFD [ef bf bd]", GetDebuggerDisplay(br));
664+
}
665+
666+
[Test]
667+
[LuceneNetSpecific]
668+
public static void Test_DebuggerDisplay_RespectsOffsetAndLength()
669+
{
670+
var bytes = new[] { (byte)'a', (byte)'b', (byte)'c', (byte)'d' };
671+
var br = new BytesRef(bytes, 1, 2); // "bc"
672+
Assert.AreEqual("bc [62 63]", GetDebuggerDisplay(br));
673+
}
674+
675+
#endregion
615676
}
616677
}

src/Lucene.Net.Tests/Util/TestUnicodeUtil.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Lucene.Net.Attributes;
44
using NUnit.Framework;
55
using System;
6+
using System.Text;
67
using Assert = Lucene.Net.TestFramework.Assert;
78

89
namespace Lucene.Net.Util
@@ -339,7 +340,7 @@ public void TestUTF8toUTF16Exception(byte[] invalidUtf8, bool shouldThrow)
339340

340341
if (shouldThrow)
341342
{
342-
Assert.Throws<FormatException>(() => UnicodeUtil.UTF8toUTF16(invalidUtf8, scratch));
343+
Assert.Throws<DecoderFallbackException>(() => UnicodeUtil.UTF8toUTF16(invalidUtf8, scratch));
343344
}
344345
else
345346
{
@@ -376,6 +377,25 @@ public void TestUTF8toUTF16WithFallback(byte[] utf8, string expected)
376377
Assert.AreEqual(expected, scratch.ToString());
377378
}
378379

380+
[Test]
381+
[LuceneNetSpecific]
382+
[TestCase(new byte[] { 0x63, 0x61, 0xc3 }, "ca\ufffd")] // ca�, start of 2-byte sequence
383+
[TestCase(new byte[] { 0x63, 0x61, 0xe3 }, "ca\ufffd")] // ca�, start of 3-byte sequence
384+
[TestCase(new byte[] { 0x63, 0x61, 0xf3 }, "ca\ufffd")] // ca�, start of 4-byte sequence
385+
[TestCase(new byte[] { 0x63, 0x61, 0xc3, 0xb1, 0x6f, 0x6e }, "cañon")]
386+
public void TestUTF8toUTF16WithFallback_ByteArrayOverload(byte[] utf8, string expected)
387+
{
388+
// Regression: the 4-arg byte[] overload must delegate to the fallback implementation,
389+
// not the throwing one. Exercise it via a non-zero offset to confirm the arguments flow through.
390+
var scratch = new CharsRef();
391+
var padded = new byte[utf8.Length + 2];
392+
Array.Copy(utf8, 0, padded, 2, utf8.Length);
393+
394+
UnicodeUtil.UTF8toUTF16WithFallback(padded, 2, utf8.Length, scratch);
395+
396+
Assert.AreEqual(expected, scratch.ToString());
397+
}
398+
379399
[Test]
380400
[LuceneNetSpecific]
381401
[Repeat(100)]

src/Lucene.Net/Util/BytesRef.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ namespace Lucene.Net.Util
4545
[Serializable]
4646
#endif
4747
// LUCENENET specific: Not implementing ICloneable per Microsoft's recommendation
48-
[DebuggerDisplay("{ToString()} {Utf8ToString()}")]
48+
[DebuggerDisplay("{DebuggerDisplay,nq}")]
4949
public sealed class BytesRef : IComparable<BytesRef>, IComparable, IEquatable<BytesRef> // LUCENENET specific - implemented IComparable for FieldComparator, IEquatable<BytesRef>
5050
{
5151
/// <summary>
@@ -317,6 +317,11 @@ public bool TryUtf8ToString([NotNullWhen(true)] out string? result)
317317
}
318318
#nullable restore
319319

320+
// LUCENENET specific: "Invalid UTF-8" disambiguates a decode failure from a legitimate U+FFFD in the data.
321+
[SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Referenced by DebuggerDisplay attribute")]
322+
private string DebuggerDisplay
323+
=> $"{(TryUtf8ToString(out var s) ? s : "Invalid UTF-8")} {ToString()}";
324+
320325
/// <summary>
321326
/// Returns hex encoded bytes, eg [0x6c 0x75 0x63 0x65 0x6e 0x65] </summary>
322327
public override string ToString()

src/Lucene.Net/Util/UnicodeUtil.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -975,7 +975,7 @@ public static string ToHexString(string s)
975975
/// it doesn't provide enough space to hold the worst case of each byte becoming a UTF-16 codepoint.
976976
/// <para/>
977977
/// NOTE: Full characters are read, even if this reads past the length passed (and
978-
/// can result in an <see cref="FormatException"/> if invalid UTF-8 is passed).
978+
/// can result in a <see cref="DecoderFallbackException"/> if invalid UTF-8 is passed).
979979
/// Explicit checks for valid UTF-8 are not performed.
980980
/// </summary>
981981
/// <seealso cref="UTF8toUTF16(ReadOnlySpan{byte}, CharsRef)"/>
@@ -990,7 +990,7 @@ public static void UTF8toUTF16(byte[] utf8, int offset, int length, CharsRef cha
990990
/// it doesn't provide enough space to hold the worst case of each byte becoming a UTF-16 codepoint.
991991
/// <para/>
992992
/// NOTE: Full characters are read, even if this reads past the length passed (and
993-
/// can result in an <see cref="FormatException"/> if invalid UTF-8 is passed).
993+
/// can result in a <see cref="DecoderFallbackException"/> if invalid UTF-8 is passed).
994994
/// Explicit checks for valid UTF-8 are not performed.
995995
/// </summary>
996996
/// <remarks>
@@ -1015,15 +1015,15 @@ public static void UTF8toUTF16(ReadOnlySpan<byte> utf8, CharsRef chars)
10151015
{
10161016
if (utf8.Length <= i)
10171017
{
1018-
throw new FormatException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
1018+
throw new DecoderFallbackException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
10191019
}
10201020
@out[out_offset++] = (char)(((b & 0x1f) << 6) + (utf8[i++] & 0x3f));
10211021
}
10221022
else if (b < 0xf0)
10231023
{
10241024
if (utf8.Length <= i + 1)
10251025
{
1026-
throw new FormatException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
1026+
throw new DecoderFallbackException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
10271027
}
10281028
@out[out_offset++] = (char)(((b & 0xf) << 12) + ((utf8[i] & 0x3f) << 6) + (utf8[i + 1] & 0x3f));
10291029
i += 2;
@@ -1032,7 +1032,7 @@ public static void UTF8toUTF16(ReadOnlySpan<byte> utf8, CharsRef chars)
10321032
{
10331033
if (utf8.Length <= i + 2)
10341034
{
1035-
throw new FormatException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
1035+
throw new DecoderFallbackException($"Invalid UTF-8 starting at [{b:x2}] at offset {i - 1}");
10361036
}
10371037
if (Debugging.AssertsEnabled) Debugging.Assert(b < 0xf8, "b = 0x{0:x}", b);
10381038
int ch = ((b & 0x7) << 18) + ((utf8[i] & 0x3f) << 12) + ((utf8[i + 1] & 0x3f) << 6) + (utf8[i + 2] & 0x3f);
@@ -1065,7 +1065,7 @@ public static void UTF8toUTF16(ReadOnlySpan<byte> utf8, CharsRef chars)
10651065
// TODO: broken if chars.offset != 0
10661066
public static void UTF8toUTF16WithFallback(byte[] utf8, int offset, int length, CharsRef chars)
10671067
{
1068-
UTF8toUTF16(utf8.AsSpan(offset, length), chars);
1068+
UTF8toUTF16WithFallback(utf8.AsSpan(offset, length), chars);
10691069
}
10701070

10711071
/// <summary>

0 commit comments

Comments
 (0)