using System.Text; using Xunit; namespace RadicadeServer.Tests; public class ImageSignatureRequestTests { private static byte[] Metadata(params string[] literals) { using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); writer.Write(0xFAB11BAFu); writer.Write(27); writer.Write(24); writer.Write(literals.Length * 8); writer.Write(24 + literals.Length * 8); writer.Write(literals.Sum(s => Encoding.UTF8.GetByteCount(s))); int index = 0; foreach (var literal in literals) { int size = Encoding.UTF8.GetByteCount(literal); writer.Write(size); writer.Write(index); index += size; } foreach (var literal in literals) writer.Write(Encoding.UTF8.GetBytes(literal)); return stream.ToArray(); } [Theory] [InlineData(".rec.net")] [InlineData(".radicade.internal")] public void ClientMetadataControlsHeaderEvenOnWrongRoute(string tail) { var key = ImageSignatureRequest.DetectKeyId(Metadata("https://img.rec.net", tail)); Assert.Equal("KEY:RSA:p1" + tail, key); var url = NameserverRedirect.Rewrite("https://img.rec.net/photo?width=64&sig=p1", "https://private.example"); url = ImageSignatureRequest.AddKeyId(url, "https://private.example", key); Assert.Contains("width=64&sig=p1&rcsig=" + Uri.EscapeDataString(key), url); var header = RadicadeServer.Images.ImageAPI.SignatureFor(key, "bare", RadicadeServer.Images.ImageAPI.StockKeyId); Assert.StartsWith("key-id=" + key + "; data=", header); Assert.Equal(url, ImageSignatureRequest.AddKeyId(url, "https://private.example", key)); } // Rec Room's own metadata does not use the plain v27 header: the four literal-table // fields are shuffled into other 32-bit slots, so the reader discovers the layout. // This writes the same shape (the 20230831 build puts table offset at 0x74, table // size at 0x24, data offset at 0xA0, data size at 0x3C) at 0x100-aligned offsets. private static byte[] ShuffledMetadata(params string[] literals) { // Real metadata files carry tens of thousands of literals; the reader refuses // tables that are too small to be one, so the fixture is padded past that floor. var padded = literals.Concat(Enumerable.Range(0, 40).Select(i => $"filler literal {i}")).ToArray(); int tableOffset = 0x100; int tableSize = padded.Length * 8; int dataOffset = tableOffset + tableSize; int dataSize = padded.Sum(s => Encoding.UTF8.GetByteCount(s)); var file = new byte[dataOffset + dataSize]; BitConverter.GetBytes(0x2EBE0u).CopyTo(file, 0); BitConverter.GetBytes(tableSize).CopyTo(file, 0x24); BitConverter.GetBytes(dataSize).CopyTo(file, 0x3C); BitConverter.GetBytes(tableOffset).CopyTo(file, 0x74); BitConverter.GetBytes(dataOffset).CopyTo(file, 0xA0); int index = 0; for (int i = 0; i < padded.Length; i++) { int size = Encoding.UTF8.GetByteCount(padded[i]); BitConverter.GetBytes(size).CopyTo(file, tableOffset + 8 * i); BitConverter.GetBytes(index).CopyTo(file, tableOffset + 8 * i + 4); Encoding.UTF8.GetBytes(padded[i]).CopyTo(file, dataOffset + index); index += size; } return file; } [Theory] [InlineData(".rec.net")] [InlineData(".radicade.internal")] public void ShuffledHeaderMetadataIsDiscoveredStructurally(string tail) { var metadata = ShuffledMetadata("https://img.rec.net", tail, "another literal"); Assert.Equal("KEY:RSA:p1" + tail, ImageSignatureRequest.DetectKeyIdFromGameMetadata(metadata)); Assert.Equal("KEY:RSA:p1" + tail, ImageSignatureRequest.DetectKeyIdFromGameMetadata(metadata)); // A plain v27 file keeps working through the same entry point. Assert.Equal("KEY:RSA:p1" + tail, ImageSignatureRequest.DetectKeyIdFromGameMetadata(Metadata(tail))); } [Fact] public void ShuffledHeaderAmbiguityAndGarbageStayUndecided() { Assert.Null(ImageSignatureRequest.DetectKeyIdFromGameMetadata( ShuffledMetadata(".rec.net", ".radicade.internal", "x"))); Assert.Null(ImageSignatureRequest.DetectKeyIdFromGameMetadata( ShuffledMetadata("https://img.rec.net", "no key tail here"))); Assert.Null(ImageSignatureRequest.DetectKeyIdFromGameMetadata(new byte[512])); var random = new byte[4096]; new Random(7).NextBytes(random); Assert.Null(ImageSignatureRequest.DetectKeyIdFromGameMetadata(random)); } [Fact] public void InvalidOrAmbiguousMetadataDoesNotGuess() { Assert.Null(ImageSignatureRequest.DetectKeyId(new byte[2])); Assert.Null(ImageSignatureRequest.DetectKeyId(Metadata(".rec.net", ".radicade.internal"))); Assert.Null(ImageSignatureRequest.DetectKeyId(Metadata("https://img.rec.net"))); var invalid = Metadata(".rec.net"); Array.Copy(BitConverter.GetBytes(int.MaxValue), 0, invalid, 12, 4); Assert.Null(ImageSignatureRequest.DetectKeyId(invalid)); } [Theory] [InlineData("https://other.example/images/photo")] [InlineData("https://private.example.evil/images/photo")] [InlineData("https://private.example/api/photo")] [InlineData("http://private.example/images/photo")] public void OnlyConfiguredServerImagesReceiveHint(string url) => Assert.Equal(url, ImageSignatureRequest.AddKeyId(url, "https://private.example", "KEY:RSA:p1.rec.net")); [Fact] public void PatchedRouteAndServerBasePathKeepParametersAndReplaceStaleHint() { var url = ImageSignatureRequest.AddKeyId("https://private.example/base/images/img/photo?rcsig=old&cropSquare=1#f", "https://private.example/base/", "KEY:RSA:p1.radicade.internal"); Assert.Equal("https://private.example/base/images/img/photo?cropSquare=1&rcsig=KEY%3ARSA%3Ap1.radicade.internal#f", url); } }