diff --git a/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj b/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj new file mode 100644 index 00000000000..ae8638c92c4 --- /dev/null +++ b/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj @@ -0,0 +1,24 @@ + + + + false + true + + + + net8.0 + SpacetimeDB + + + + + + + + + + + + + + diff --git a/crates/bindings-csharp/BSATN.Runtime.Tests/Tests.cs b/crates/bindings-csharp/BSATN.Runtime.Tests/Tests.cs new file mode 100644 index 00000000000..8a49b9a690e --- /dev/null +++ b/crates/bindings-csharp/BSATN.Runtime.Tests/Tests.cs @@ -0,0 +1,142 @@ +namespace SpacetimeDB; + +using CsCheck; +using Xunit; + +public static class BSATNRuntimeTests +{ + [Fact] + public static void AddressRoundtrips() + { + var str = "00112233445566778899AABBCCDDEEFF"; + var addr = Address.FromHexString(str); + + Assert.NotNull(addr); + Assert.Equal(addr.ToString(), str); + + var bytes = Convert.FromHexString(str); + + var addr2 = Address.FromBigEndian(bytes); + Assert.Equal(addr2, addr); + + Array.Reverse(bytes); + var addr3 = Address.From(bytes); + Assert.Equal(addr3, addr); + + var memoryStream = new MemoryStream(); + var bsatn = new Address.BSATN(); + using (var writer = new BinaryWriter(memoryStream)) + { + if (addr is { } addrNotNull) + { + bsatn.Write(writer, addrNotNull); + } + else + { + Assert.Fail("Impossible"); + } + } + + var littleEndianBytes = memoryStream.ToArray(); + var reader = new BinaryReader(new MemoryStream(littleEndianBytes)); + var addr4 = bsatn.Read(reader); + Assert.Equal(addr4, addr); + + // Note: From = FromLittleEndian + var addr5 = Address.From(littleEndianBytes); + Assert.Equal(addr5, addr); + } + + static readonly Gen genHex = Gen.String[Gen.Char["0123456789abcdef"], 0, 128]; + + [Fact] + public static void AddressLengthCheck() + { + genHex.Sample(s => + { + if (s.Length == 32) + { + return; + } + Assert.ThrowsAny(() => Address.FromHexString(s)); + }); + Gen.Byte.Array[0, 64] + .Sample(arr => + { + if (arr.Length == 16) + { + return; + } + Assert.ThrowsAny(() => Address.FromBigEndian(arr)); + Assert.ThrowsAny(() => Address.From(arr)); + }); + } + + [Fact] + public static void IdentityRoundtrips() + { + var str = "00112233445566778899AABBCCDDEEFF00112233445566778899AABBCCDDEEFF"; + var ident = Identity.FromHexString(str); + + Assert.Equal(ident.ToString(), str); + + // We can't use this in the implementation because it isn't available + // in Unity's .NET. But we can use it in tests. + var bytes = Convert.FromHexString(str); + + var ident2 = Identity.FromBigEndian(bytes); + Assert.Equal(ident2, ident); + + Array.Reverse(bytes); + var ident3 = Identity.From(bytes); + Assert.Equal(ident3, ident); + + var memoryStream = new MemoryStream(); + var bsatn = new Identity.BSATN(); + using (var writer = new BinaryWriter(memoryStream)) + { + bsatn.Write(writer, ident); + } + + var littleEndianBytes = memoryStream.ToArray(); + var reader = new BinaryReader(new MemoryStream(littleEndianBytes)); + var ident4 = bsatn.Read(reader); + Assert.Equal(ident4, ident); + + // Note: From = FromLittleEndian + var ident5 = Identity.From(littleEndianBytes); + Assert.Equal(ident5, ident); + } + + [Fact] + public static void IdentityLengthCheck() + { + genHex.Sample(s => + { + if (s.Length == 64) + { + return; + } + Assert.ThrowsAny(() => Identity.FromHexString(s)); + }); + Gen.Byte.Array[0, 64] + .Sample(arr => + { + if (arr.Length == 32) + { + return; + } + Assert.ThrowsAny(() => Identity.FromBigEndian(arr)); + Assert.ThrowsAny(() => Identity.From(arr)); + }); + } + + [Fact] + public static void NonHexStrings() + { + // n.b. 32 chars long + Assert.ThrowsAny( + () => Address.FromHexString("these are not hex characters....") + ); + } +} diff --git a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs index ee4ca33c999..ed60d29fe16 100644 --- a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs +++ b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs @@ -8,11 +8,144 @@ namespace SpacetimeDB; internal static class Util { - // Same as `Convert.ToHexString`, but that method is not available in .NET Standard - // which we need to target for Unity support. - public static string ToHex(T val) - where T : struct => - BitConverter.ToString(MemoryMarshal.AsBytes([val]).ToArray()).Replace("-", ""); + /// + /// Convert this object to a BIG-ENDIAN hex string. + /// + /// Big endian is almost always the correct convention here. It puts the most significant bytes + /// of the number at the lowest indexes of the resulting string; assuming the string is printed + /// with low indexes to the left, this will result in the correct hex number being displayed. + /// + /// (This might be wrong if the string is printed after, say, a unicode right-to-left marker. + /// But, well, what can you do.) + /// + /// Similar to `Convert.ToHexString`, but that method is not available in .NET Standard + /// which we need to target for Unity support. + /// + /// + /// + /// + public static string ToHexBigEndian(T val) + where T : struct => BitConverter.ToString(AsBytesBigEndian(val).ToArray()).Replace("-", ""); + + /// + /// Read a value of type T from the passed span, which is assumed to be in little-endian format. + /// The behavior of this method is independent of the endianness of the host, unlike MemoryMarshal.Read. + /// + /// + /// + /// + public static T ReadLittleEndian(ReadOnlySpan source) + where T : struct => Read(source, !BitConverter.IsLittleEndian); + + /// + /// Read a value of type T from the passed span, which is assumed to be in big-endian format. + /// The behavior of this method is independent of the endianness of the host, unlike MemoryMarshal.Read. + /// + /// + /// + /// + public static T ReadBigEndian(ReadOnlySpan source) + where T : struct => Read(source, BitConverter.IsLittleEndian); + + /// + /// Convert the passed byte array to a value of type T, optionally reversing it before performing the conversion. + /// If the input is not reversed, it is treated as having the native endianness of the host system. + /// (The endianness of the host system can be checked via System.BitConverter.IsLittleEndian.) + /// + /// + /// + /// + /// + static T Read(ReadOnlySpan source, bool reverse) + where T : struct + { + Debug.Assert( + source.Length == Marshal.SizeOf(), + $"Error while reading ${typeof(T).FullName}: expected source span to be {Marshal.SizeOf()} bytes long, but was {source.Length} bytes." + ); + + var result = MemoryMarshal.Read(source); + + if (reverse) + { + var resultSpan = MemoryMarshal.CreateSpan(ref result, 1); + MemoryMarshal.AsBytes(resultSpan).Reverse(); + } + + return result; + } + + /// + /// Convert the passed T to a little-endian byte array. + /// The behavior of this method is independent of the endianness of the host, unlike MemoryMarshal.Read. + /// + /// + /// + /// + public static byte[] AsBytesLittleEndian(T source) + where T : struct => AsBytes(source, !BitConverter.IsLittleEndian); + + /// + /// Convert the passed T to a big-endian byte array. + /// The behavior of this method is independent of the endianness of the host, unlike MemoryMarshal.Read. + /// + /// + /// + /// + public static byte[] AsBytesBigEndian(T source) + where T : struct => AsBytes(source, BitConverter.IsLittleEndian); + + /// + /// Convert the passed T to a byte array, and optionally reverse the array before returning it. + /// If the output is not reversed, it will have the native endianness of the host system. + /// (The endianness of the host system can be checked via System.BitConverter.IsLittleEndian.) + /// + /// + /// + /// + /// + static byte[] AsBytes(T source, bool reverse) + where T : struct + { + var result = MemoryMarshal.AsBytes([source]).ToArray(); + if (reverse) + { + Array.Reverse(result, 0, result.Length); + } + return result; + } + + /// + /// Convert a hex string to a byte array. + /// + /// + /// + public static byte[] StringToByteArray(string hex) + { + Debug.Assert( + hex.Length % 2 == 0, + $"Expected input string (\"{hex}\") to be of even length" + ); + + var NumberChars = hex.Length; + var bytes = new byte[NumberChars / 2]; + for (var i = 0; i < NumberChars; i += 2) + { + bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); + } + return bytes; + } + + /// + /// Read a value from a "big-endian" hex string. + /// All hex strings we expect to encounter are big-endian (store most significant bytes + /// at low indexes) so this should always be used. + /// + /// + /// + /// + public static T ReadFromBigEndianHexString(string hex) + where T : struct => ReadBigEndian(StringToByteArray(hex)); } public readonly partial struct Unit @@ -35,10 +168,48 @@ public readonly record struct Address internal Address(U128 v) => value = v; + /// + /// Create an Address from a LITTLE-ENDIAN byte array. + /// + /// If you are parsing an Address from a string, you probably want FromHexString instead, + /// or, failing that, FromBigEndian. + /// + /// Returns null if the resulting address is the default. + /// + /// public static Address? From(byte[] bytes) { - Debug.Assert(bytes.Length == 16); - var addr = new Address(MemoryMarshal.Read(bytes)); + var addr = new Address(Util.ReadLittleEndian(bytes)); + return addr == default ? null : addr; + } + + /// + /// Create an Address from a BIG-ENDIAN byte array. + /// + /// This method is the correct choice if you have converted the bytes of a hexadecimal-formatted Address + /// to a byte array in the following way: + /// + /// "0xb0b1b2..." + /// -> + /// [0xb0, 0xb1, 0xb2, ...] + /// + /// Returns null if the resulting address is the default. + /// + /// + public static Address? FromBigEndian(byte[] bytes) + { + var addr = new Address(Util.ReadBigEndian(bytes)); + return addr == default ? null : addr; + } + + /// + /// Create an Address from a hex string. + /// + /// + /// + public static Address? FromHexString(string hex) + { + var addr = new Address(Util.ReadFromBigEndianHexString(hex)); return addr == default ? null : addr; } @@ -62,7 +233,7 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) => new AlgebraicType.Product([new("__address__", new AlgebraicType.U128(default))]); } - public override string ToString() => Util.ToHex(value); + public override string ToString() => Util.ToHexBigEndian(value); } public readonly record struct Identity @@ -71,14 +242,51 @@ public readonly record struct Identity internal Identity(U256 val) => value = val; + /// + /// Create an Identity from a LITTLE-ENDIAN byte array. + /// + /// If you are parsing an Identity from a string, you probably want FromHexString instead, + /// or, failing that, FromBigEndian. + /// + /// public Identity(byte[] bytes) { - Debug.Assert(bytes.Length == 32); - value = MemoryMarshal.Read(bytes); + value = Util.ReadLittleEndian(bytes); } + /// + /// Create an Identity from a LITTLE-ENDIAN byte array. + /// + /// If you are parsing an Identity from a string, you probably want FromHexString instead, + /// or, failing that, FromBigEndian. + /// + /// public static Identity From(byte[] bytes) => new(bytes); + /// + /// Create an Identity from a BIG-ENDIAN byte array. + /// + /// This method is the correct choice if you have converted the bytes of a hexadecimal-formatted `Identity` + /// to a byte array in the following way: + /// + /// "0xb0b1b2..." + /// -> + /// [0xb0, 0xb1, 0xb2, ...] + /// + /// + public static Identity FromBigEndian(byte[] bytes) + { + return new Identity(Util.ReadBigEndian(bytes)); + } + + /// + /// Create an Identity from a hex string. + /// + /// + /// + public static Identity FromHexString(string hex) => + new Identity(Util.ReadFromBigEndianHexString(hex)); + public readonly struct BSATN : IReadWrite { public Identity Read(BinaryReader reader) => new(new SpacetimeDB.BSATN.U256().Read(reader)); @@ -91,7 +299,7 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) => } // This must be explicitly forwarded to base, otherwise record will generate a new implementation. - public override string ToString() => Util.ToHex(value); + public override string ToString() => Util.ToHexBigEndian(value); } // [SpacetimeDB.Type] - we have custom representation of time in microseconds, so implementing BSATN manually diff --git a/crates/bindings-csharp/SpacetimeSharpSATS.sln b/crates/bindings-csharp/SpacetimeSharpSATS.sln index 4e49b92f77b..737bb0981c2 100644 --- a/crates/bindings-csharp/SpacetimeSharpSATS.sln +++ b/crates/bindings-csharp/SpacetimeSharpSATS.sln @@ -28,6 +28,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sdk-tests", "sdk-tests", "{ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "benchmarks-cs", "..\..\modules\benchmarks-cs\benchmarks-cs.csproj", "{50E1AAE1-C42C-4C2F-B708-5190B0362165}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BSATN.Runtime.Tests", "BSATN.Runtime.Tests\BSATN.Runtime.Tests.csproj", "{FCF18E21-FB59-4A4D-A9ED-B85D2874E536}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -62,6 +64,10 @@ Global {FDACD960-168E-44F9-B036-2E29EA391BE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {50E1AAE1-C42C-4C2F-B708-5190B0362165}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {50E1AAE1-C42C-4C2F-B708-5190B0362165}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCF18E21-FB59-4A4D-A9ED-B85D2874E536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCF18E21-FB59-4A4D-A9ED-B85D2874E536}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCF18E21-FB59-4A4D-A9ED-B85D2874E536}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCF18E21-FB59-4A4D-A9ED-B85D2874E536}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE