diff --git a/src/DataFormat.cs b/src/DataFormat.cs
index 211c6af..e785f43 100644
--- a/src/DataFormat.cs
+++ b/src/DataFormat.cs
@@ -7,8 +7,19 @@
namespace Neliva
{
///
- /// Provides support to encode and decode data in hex and base32.
+ /// Provides support to encode and decode data, including values,
+ /// in hexadecimal and base32 formats.
///
+ ///
+ ///
+ /// Encoding methods produce lowercase output. Decoding and validation methods
+ /// accept both lowercase and uppercase input.
+ ///
+ ///
+ /// Base32 uses the 0123456789abcdefghjkmnpqrstvwxyz alphabet (the
+ /// i, l, o, and u letters are omitted) and is produced without padding.
+ ///
+ ///
public static class DataFormat
{
///
@@ -31,7 +42,7 @@ public static class DataFormat
///
/// The map to decode bytes from hex characters.
///
- private static ReadOnlySpan HexMap => new byte[MC]
+ private static ReadOnlySpan HexMap => new byte[]
{
MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC,
MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC,
@@ -46,7 +57,7 @@ public static class DataFormat
///
/// The map to decode bytes from base32 0123456789abcdefghjkmnpqrstvwxyz characters.
///
- private static ReadOnlySpan Base32Map => new byte[MC]
+ private static ReadOnlySpan Base32Map => new byte[]
{
MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC,
MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC, MC,
@@ -82,7 +93,7 @@ public static unsafe string ToHex(ReadOnlySpan value)
if (length > (int.MaxValue / 2))
{
- throw new ArgumentOutOfRangeException(nameof(value), "Input is too large to be processed.");
+ throw new ArgumentOutOfRangeException(nameof(value), "The input is too large to be processed.");
}
fixed (byte* bytesPtr = value)
@@ -128,7 +139,7 @@ public static unsafe string ToBase32(ReadOnlySpan value)
if (length > (int)(((long)int.MaxValue * 5) / 8))
{
- throw new ArgumentOutOfRangeException(nameof(value), "Input is too large to be processed.");
+ throw new ArgumentOutOfRangeException(nameof(value), "The input is too large to be processed.");
}
fixed (byte* bytesPtr = value)
@@ -249,6 +260,12 @@ private static void FromHex(ReadOnlySpan value, Span destination)
///
/// The contains a non-base32 character.
///
+ ///
+ /// OR
+ ///
+ ///
+ /// The contains non-zero trailing bits.
+ ///
///
public static byte[] FromBase32(ReadOnlySpan value)
{
@@ -298,6 +315,12 @@ private static void FromBase32(ReadOnlySpan value, Span destination)
bitsLeft -= 8;
}
}
+
+ // Enforce canonical encoding: the trailing (padding) bits must be zero.
+ if ((buffer & ((1 << bitsLeft) - 1)) != 0)
+ {
+ throw new FormatException("The input is not a valid base32 string as it contains non-zero trailing bits.");
+ }
}
///
@@ -314,7 +337,7 @@ public static string ToHexGuid(Guid value)
{
Span guidBytes = stackalloc byte[16];
- WriteGuidBigEndian(value, guidBytes);
+ TryWriteGuidBigEndian(value, guidBytes);
return ToHex(guidBytes);
}
@@ -334,7 +357,7 @@ public static string ToBase32Guid(Guid value)
{
Span guidBytes = stackalloc byte[16];
- WriteGuidBigEndian(value, guidBytes);
+ TryWriteGuidBigEndian(value, guidBytes);
return ToBase32(guidBytes);
}
@@ -392,6 +415,12 @@ public static Guid FromHexGuid(ReadOnlySpan value)
///
/// The contains a non-base32 character.
///
+ ///
+ /// OR
+ ///
+ ///
+ /// The contains non-zero trailing bits.
+ ///
///
public static Guid FromBase32Guid(ReadOnlySpan value)
{
@@ -415,8 +444,8 @@ public static Guid FromBase32Guid(ReadOnlySpan value)
/// The span to verify.
///
///
- /// true if the span has
- /// an even number of hexadecimal digits; otherwise, false.
+ /// true if the span has an even length and
+ /// every character is a valid hexadecimal digit; otherwise, false.
///
///
/// This method returns true for an empty span.
@@ -450,8 +479,8 @@ public static bool IsHex(ReadOnlySpan value)
/// The span to verify.
///
///
- /// true if the span has
- /// correct number of base32 digits; otherwise, false.
+ /// true if the span has a valid length, contains only
+ /// valid base32 digits, and has zero trailing bits; otherwise, false.
///
///
/// This method returns true for an empty span.
@@ -483,6 +512,13 @@ public static bool IsBase32(ReadOnlySpan value)
}
}
+ int trailingBits = (5 * (length % 8)) % 8;
+
+ if (trailingBits != 0 && (Base32Map[value[length - 1]] & ((1 << trailingBits) - 1)) != 0)
+ {
+ return false;
+ }
+
return true;
}
@@ -492,12 +528,10 @@ private static Guid ReadGuidBigEndian(ReadOnlySpan value)
return new Guid(value, true);
}
- private static void WriteGuidBigEndian(Guid value, Span destination)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool TryWriteGuidBigEndian(Guid value, Span destination)
{
- if (!value.TryWriteBytes(destination, true, out _))
- {
- throw new ArgumentOutOfRangeException(nameof(destination));
- }
+ return value.TryWriteBytes(destination, true, out _);
}
}
}
\ No newline at end of file
diff --git a/tests/DataFormatTests.cs b/tests/DataFormatTests.cs
index cad01a6..2620b5e 100644
--- a/tests/DataFormatTests.cs
+++ b/tests/DataFormatTests.cs
@@ -131,6 +131,23 @@ public void FromHexInvalidInputCharFail(string invalidCharInHex)
Assert.Equal("The input is not a valid hex string as it contains a non-hex character.", ex.Message);
}
+ // Boundary: char 127 must map to invalid via the table; char 128 (== MC) and above must
+ // short-circuit on the ch >= MC guard before indexing the 128-entry map (no out-of-range read).
+ [Theory]
+ [InlineData("\u007f0")] // 127 at c1 -> table miss
+ [InlineData("0\u007f")] // 127 at c2 -> table miss
+ [InlineData("\u00800")] // 128 at c1 -> ch >= MC guard
+ [InlineData("0\u0080")] // 128 at c2 -> ch >= MC guard
+ [InlineData("\uffff0")] // max char at c1 -> ch >= MC guard
+ [InlineData("0\uffff")] // max char at c2 -> ch >= MC guard
+ public void FromHexCharMapBoundaryFails(string input)
+ {
+ Assert.False(DataFormat.IsHex(input));
+
+ var ex = Assert.Throws(() => DataFormat.FromHex(input));
+ Assert.Equal("The input is not a valid hex string as it contains a non-hex character.", ex.Message);
+ }
+
[Theory]
[InlineData(int.MaxValue)]
[InlineData(int.MaxValue / 2 + 1)]
@@ -188,6 +205,57 @@ public void FromHexGuidInvalidInputCharFail(string invalidCharInHex)
var ex = Assert.Throws(() => DataFormat.FromHexGuid(invalidCharInHex));
Assert.Equal("The input is not a valid hex string as it contains a non-hex character.", ex.Message);
}
+
+ // --- Limits & bounds: mixed-case decoding and exhaustive round trips ---
+
+ [Theory]
+ [InlineData("0aB3Cd", new byte[] { 0x0A, 0xB3, 0xCD })]
+ [InlineData("DeAdBeEf", new byte[] { 0xDE, 0xAD, 0xBE, 0xEF })]
+ public void FromHexMixedCaseDecodesCorrectly(string mixed, byte[] expected)
+ {
+ Assert.True(DataFormat.IsHex(mixed));
+ Assert.Equal(expected, DataFormat.FromHex(mixed));
+ }
+
+ [Fact]
+ public void HexRoundTripAllLengthsPass()
+ {
+ for (int length = 0; length <= 64; length++)
+ {
+ var data = new byte[length];
+ for (int i = 0; i < length; i++)
+ {
+ data[i] = (byte)((i * 31) + (length * 7));
+ }
+
+ string hex = DataFormat.ToHex(data);
+
+ Assert.Equal(length * 2, hex.Length);
+ Assert.True(DataFormat.IsHex(hex));
+ Assert.Equal(data, DataFormat.FromHex(hex));
+ }
+ }
+
+ [Fact]
+ public void HexGuidRoundTripExhaustivePass()
+ {
+ for (int i = 0; i < 256; i++)
+ {
+ var bytes = new byte[16];
+ for (int b = 0; b < 16; b++)
+ {
+ bytes[b] = (byte)(i + (b * 17));
+ }
+
+ var expected = new Guid(bytes);
+
+ string hex = DataFormat.ToHexGuid(expected);
+
+ Assert.Equal(32, hex.Length);
+ Assert.True(DataFormat.IsHex(hex));
+ Assert.Equal(expected, DataFormat.FromHexGuid(hex));
+ }
+ }
}
[ExcludeFromCodeCoverage]
@@ -325,6 +393,23 @@ public void FromBase32InvalidInputCharFail(string invalidCharInBase32)
Assert.Equal("The input is not a valid base32 string as it contains a non-base32 character.", ex.Message);
}
+ // Boundary: char 127 must map to invalid via the table; char 128 (== MC) and above must
+ // short-circuit on the ch >= MC guard before indexing the 128-entry map (no out-of-range read).
+ [Theory]
+ [InlineData("\u007f0")] // 127 at first char -> table miss
+ [InlineData("0\u007f")] // 127 at second char -> table miss
+ [InlineData("\u00800")] // 128 at first char -> ch >= MC guard
+ [InlineData("0\u0080")] // 128 at second char -> ch >= MC guard
+ [InlineData("\uffff0")] // max char at first char -> ch >= MC guard
+ [InlineData("0\uffff")] // max char at second char -> ch >= MC guard
+ public void FromBase32CharMapBoundaryFails(string input)
+ {
+ Assert.False(DataFormat.IsBase32(input));
+
+ var ex = Assert.Throws(() => DataFormat.FromBase32(input));
+ Assert.Equal("The input is not a valid base32 string as it contains a non-base32 character.", ex.Message);
+ }
+
[Theory]
[InlineData(int.MaxValue)]
[InlineData(((int)(((long)int.MaxValue * 5) / 8)) + 1)]
@@ -461,5 +546,167 @@ public void IsHexValidReturnsTrue(string value)
{
Assert.True(DataFormat.IsHex(value));
}
+
+ // --- Limits & bounds: mixed/upper case, non-canonical trailing bits, exhaustive round trips ---
+
+ [Theory]
+ [InlineData("3G7wZ02V9s7Wk52AfAj597BwYw", "3g7wz02v9s7wk52afaj597bwyw")]
+ [InlineData("0123456789ABCDEFGHJKMNPQRSTVWXYZ", "0123456789abcdefghjkmnpqrstvwxyz")]
+ public void FromBase32MixedCaseDecodesAsLowercase(string mixed, string lower)
+ {
+ Assert.True(DataFormat.IsBase32(mixed));
+ Assert.Equal(DataFormat.FromBase32(lower), DataFormat.FromBase32(mixed));
+ }
+
+ // The encoder always emits zero trailing bits; the decoder rejects any input whose
+ // final character carries non-zero padding bits (non-canonical encoding).
+ [Theory]
+ [InlineData("10", new byte[] { 0x08 })]
+ [InlineData("zw", new byte[] { 0xFF })]
+ [InlineData("0g", new byte[] { 0x04 })]
+ [InlineData("0000g", new byte[] { 0x00, 0x00, 0x08 })]
+ public void FromBase32CanonicalTrailingBitsDecodes(string input, byte[] expected)
+ {
+ Assert.True(DataFormat.IsBase32(input));
+
+ var decoded = DataFormat.FromBase32(input);
+
+ Assert.Equal(expected, decoded);
+ Assert.Equal(input, DataFormat.ToBase32(decoded));
+ }
+
+ [Theory]
+ [InlineData("11")]
+ [InlineData("12")]
+ [InlineData("13")]
+ [InlineData("zz")]
+ [InlineData("zx")]
+ [InlineData("zy")]
+ [InlineData("0001")]
+ [InlineData("000h")]
+ [InlineData("00001")]
+ [InlineData("0000001")]
+ public void FromBase32NonZeroTrailingBitsThrows(string input)
+ {
+ Assert.False(DataFormat.IsBase32(input));
+
+ var ex = Assert.Throws(() => DataFormat.FromBase32(input));
+ Assert.Equal("The input is not a valid base32 string as it contains non-zero trailing bits.", ex.Message);
+ }
+
+ // Exhaustive: for every length that has trailing bits, sweep all 32 possible final
+ // characters and assert only canonical (zero low-bit) endings decode; the rest throw.
+ [Theory]
+ [InlineData(2)]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(7)]
+ [InlineData(10)]
+ [InlineData(12)]
+ [InlineData(13)]
+ [InlineData(15)]
+ public void FromBase32TrailingBitsAllFinalCharVariations(int length)
+ {
+ const string alphabet = "0123456789abcdefghjkmnpqrstvwxyz";
+ int trailingBits = (5 * (length % 8)) % 8;
+ int mask = (1 << trailingBits) - 1;
+
+ string prefix = new string('0', length - 1);
+
+ for (int v = 0; v < 32; v++)
+ {
+ string s = prefix + alphabet[v];
+ bool canonical = (v & mask) == 0;
+
+ Assert.Equal(canonical, DataFormat.IsBase32(s));
+
+ if (canonical)
+ {
+ var decoded = DataFormat.FromBase32(s);
+ Assert.Equal(s, DataFormat.ToBase32(decoded));
+ }
+ else
+ {
+ var ex = Assert.Throws(() => DataFormat.FromBase32(s));
+ Assert.Equal("The input is not a valid base32 string as it contains non-zero trailing bits.", ex.Message);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("3G7WZ02V9S7WK52AFAJ597BWYW", "1c0fcf80-5b4e-4fc9-944a-7aa4549d7cf7")]
+ [InlineData("ZZZZZZZZZZZZZZZZZZZZZZZZZW", "ffffffff-ffff-ffff-ffff-ffffffffffff")]
+ public void FromBase32GuidUppercaseDecodesPass(string upperBase32Guid, string guidStr)
+ {
+ var expected = Guid.Parse(guidStr);
+
+ Assert.Equal(expected, DataFormat.FromBase32Guid(upperBase32Guid));
+ }
+
+ [Fact]
+ public void FromBase32GuidCanonicalTrailingBitsDecodes()
+ {
+ var allOnes = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ string canonical = new string('z', 25) + "w";
+
+ Assert.True(DataFormat.IsBase32(canonical));
+ Assert.Equal(allOnes, DataFormat.FromBase32Guid(canonical));
+ }
+
+ // A 26-character base32 GUID has 2 trailing bits, so any final character whose
+ // low 2 bits are set is non-canonical and must be rejected.
+ [Theory]
+ [InlineData('z')]
+ [InlineData('x')]
+ [InlineData('y')]
+ public void FromBase32GuidNonZeroTrailingBitsThrows(char lastChar)
+ {
+ string nonCanonical = new string('z', 25) + lastChar;
+
+ Assert.False(DataFormat.IsBase32(nonCanonical));
+
+ var ex = Assert.Throws(() => DataFormat.FromBase32Guid(nonCanonical));
+ Assert.Equal("The input is not a valid base32 string as it contains non-zero trailing bits.", ex.Message);
+ }
+
+ [Fact]
+ public void Base32RoundTripAllLengthsPass()
+ {
+ for (int length = 0; length <= 64; length++)
+ {
+ var data = new byte[length];
+ for (int i = 0; i < length; i++)
+ {
+ data[i] = (byte)((i * 31) + (length * 7));
+ }
+
+ string b32 = DataFormat.ToBase32(data);
+
+ Assert.True(DataFormat.IsBase32(b32));
+ Assert.Equal(data, DataFormat.FromBase32(b32));
+ }
+ }
+
+ [Fact]
+ public void Base32GuidRoundTripExhaustivePass()
+ {
+ for (int i = 0; i < 256; i++)
+ {
+ var bytes = new byte[16];
+ for (int b = 0; b < 16; b++)
+ {
+ bytes[b] = (byte)(i + (b * 17));
+ }
+
+ var expected = new Guid(bytes);
+
+ string b32 = DataFormat.ToBase32Guid(expected);
+
+ Assert.Equal(26, b32.Length);
+ Assert.True(DataFormat.IsBase32(b32));
+ Assert.Equal(expected, DataFormat.FromBase32Guid(b32));
+ }
+ }
}
}