using System; using System.Buffers; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using JetBrains.Annotations; using NetSerializer; using Newtonsoft.Json.Linq; using Robust.Shared.ContentPack; using Robust.Shared.Interfaces.Log; using Robust.Shared.Interfaces.Network; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Utility; using YamlDotNet.RepresentationModel; namespace Robust.Shared.Serialization { /// /// Serializer which manages a mapping of pre-loaded strings to constant /// values, for message compression. The mapping is shared between the /// server and client. /// /// /// Strings are long and expensive to send over the wire, and lots of /// strings involved in messages are sent repeatedly between the server /// and client - such as filenames, icon states, constant strings, etc. /// /// To compress these strings, we use a constant string mapping, decided /// by the server when it starts up, that associates strings with a /// fixed value. The mapping is shared with clients when they connect. /// /// When sending these strings over the wire, the serializer can then /// send the constant value instead - and at the other end, the /// serializer can use the same mapping to recover the original string. /// public class RobustMappedStringSerializer : IStaticTypeSerializer, IRobustMappedStringSerializer { private INetManager? _net; private readonly Lazy _lazyLogSzr = new Lazy(() => Logger.GetSawmill("szr")); private ISawmill LogSzr => _lazyLogSzr.Value; private readonly HashSet _incompleteHandshakes = new HashSet(); /// /// Starts the handshake from the server end of the given channel, /// sending a . /// /// The network channel to perform the handshake over. /// /// Locks the string mapping if this is the first time the server is /// performing the handshake. /// /// /// public async Task Handshake(INetChannel channel) { var net = channel.NetPeer; if (net.IsClient) { return; } if (!LockMappedStrings) { LockMappedStrings = true; LogSzr.Debug($"Locked in at {_mappedStrings.Count} mapped strings."); } _incompleteHandshakes.Add(channel); var message = net.CreateNetMessage(); message.Hash = MappedStringsHash; net.ServerSendMessage(message, channel); while (_incompleteHandshakes.Contains(channel)) { await Task.Delay(1); } LogSzr.Debug($"Completed handshake with {channel.RemoteEndPoint.Address}."); } /// /// Performs the setup so that the serializer can perform the string- /// exchange protocol. /// /// /// The string-exchange protocol is started by the server when the /// client first connects. The server sends the client a hash of the /// string mapping; the client checks that hash against any local /// caches; and if necessary, the client requests a new copy of the /// mapping from the server. /// /// Uncached flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | /// /// /// Cached flow: /// Client | Server /// | <-------------- Hash | /// | Dont Need Strings -> | /// /// /// Verification failure flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | /// | <----------- Strings | /// + Hash Failed | /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | /// /// /// NOTE: Verification failure flow is currently not implemented. /// /// /// The to perform the protocol steps over. /// /// /// /// /// /// /// /// public void NetworkInitialize(INetManager net) { _net = net; net.RegisterNetMessage( nameof(MsgRobustMappedStringsSerializerServerHandshake), msg => HandleServerHandshake(net, msg)); net.RegisterNetMessage( nameof(MsgRobustMappedStringsSerializerClientHandshake), msg => HandleClientHandshake(net, msg)); net.RegisterNetMessage( nameof(MsgRobustMappedStringsSerializerStrings), msg => HandleStringsMessage(net, msg)); } /// /// Handles the reception, verification of a strings package /// and subsequent mapping of strings and initiator of /// receipt response. /// /// Uncached flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | <- you are here on client /// /// Verification failure flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | /// | <----------- Strings | /// + Hash Failed | <- you are here on client /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | <- you are here on client /// /// /// NOTE: Verification failure flow is currently not implemented. /// /// /// Unable to verify strings package by hash. /// private void HandleStringsMessage(INetManager net, MsgRobustMappedStringsSerializerStrings msgRobustMappedStringsSerializer) { if (net.IsServer) { LogSzr.Error("Received strings from client."); return; } LockMappedStrings = false; ClearStrings(); DebugTools.Assert(msgRobustMappedStringsSerializer.Package != null, "msg.Package != null"); LoadStrings(new MemoryStream(msgRobustMappedStringsSerializer.Package!, false)); var checkHash = CalculateHash(msgRobustMappedStringsSerializer.Package!); if (!checkHash.SequenceEqual(ServerHash)) { // TODO: retry sending MsgClientHandshake with NeedsStrings = false throw new InvalidOperationException("Unable to verify strings package by hash." + $"\n{ConvertToBase64Url(checkHash)} vs. {ConvertToBase64Url(ServerHash)}"); } _stringMapHash = ServerHash; LockMappedStrings = true; LogSzr.Debug($"Locked in at {_mappedStrings.Count} mapped strings."); WriteStringCache(); // ok we're good now var channel = msgRobustMappedStringsSerializer.MsgChannel; OnClientCompleteHandshake(net, channel); } /// /// Interpret a client's handshake, either sending a package /// of strings or completing the handshake. /// /// Uncached flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | <- you are here on server /// | <----------- Strings | /// | Dont Need Strings -> | <- you are here on server /// /// /// Cached flow: /// Client | Server /// | <-------------- Hash | /// | Dont Need Strings -> | <- you are here on server /// /// /// Verification failure flow: /// Client | Server /// | <-------------- Hash | /// | Need Strings ------> | <- you are here on server /// | <----------- Strings | /// + Hash Failed | /// | Need Strings ------> | <- you are here on server /// | <----------- Strings | /// | Dont Need Strings -> | /// /// /// NOTE: Verification failure flow is currently not implemented. /// /// private void HandleClientHandshake(INetManager net, MsgRobustMappedStringsSerializerClientHandshake msgRobustMappedStringsSerializer) { if (net.IsClient) { LogSzr.Error("Received client handshake on client."); return; } LogSzr.Debug($"Received handshake from {msgRobustMappedStringsSerializer.MsgChannel.RemoteEndPoint.Address}."); if (!msgRobustMappedStringsSerializer.NeedsStrings) { LogSzr.Debug($"Completing handshake with {msgRobustMappedStringsSerializer.MsgChannel.RemoteEndPoint.Address}."); _incompleteHandshakes.Remove(msgRobustMappedStringsSerializer.MsgChannel); return; } // TODO: count and limit number of requests to send strings during handshake var strings = msgRobustMappedStringsSerializer.MsgChannel.NetPeer.CreateNetMessage(); using (var ms = new MemoryStream()) { WriteStringPackage(ms); ms.Position = 0; strings.Package = ms.ToArray(); LogSzr.Debug($"Sending {ms.Length} bytes sized mapped strings package to {msgRobustMappedStringsSerializer.MsgChannel.RemoteEndPoint.Address}."); } msgRobustMappedStringsSerializer.MsgChannel.SendMessage(strings); } /// /// Interpret a server's handshake, either requesting a package /// of strings or completing the handshake. /// /// Uncached flow: /// Client | Server /// | <-------------- Hash | <- you are here on client /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | /// /// /// Cached flow: /// Client | Server /// | <-------------- Hash | <- you are here on client /// | Dont Need Strings -> | /// /// /// Verification failure flow: /// Client | Server /// | <-------------- Hash | <- you are here on client /// | Need Strings ------> | /// | <----------- Strings | /// + Hash Failed | /// | Need Strings ------> | /// | <----------- Strings | /// | Dont Need Strings -> | /// /// /// NOTE: Verification failure flow is currently not implemented. /// /// Mapped strings are locked. /// private void HandleServerHandshake(INetManager net, MsgRobustMappedStringsSerializerServerHandshake msgRobustMappedStringsSerializer) { if (net.IsServer) { LogSzr.Error("Received server handshake on server."); return; } ServerHash = msgRobustMappedStringsSerializer.Hash; LockMappedStrings = false; if (LockMappedStrings) { throw new InvalidOperationException("Mapped strings are locked."); } ClearStrings(); var hashStr = ConvertToBase64Url(Convert.ToBase64String(msgRobustMappedStringsSerializer.Hash!)); LogSzr.Debug($"Received server handshake with hash {hashStr}."); var fileName = CacheForHash(hashStr); if (!File.Exists(fileName)) { LogSzr.Debug($"No string cache for {hashStr}."); var handshake = net.CreateNetMessage(); LogSzr.Debug("Asking server to send mapped strings."); handshake.NeedsStrings = true; msgRobustMappedStringsSerializer.MsgChannel.SendMessage(handshake); } else { LogSzr.Debug($"We had a cached string map that matches {hashStr}."); using var file = File.OpenRead(fileName); var added = LoadStrings(file); _stringMapHash = msgRobustMappedStringsSerializer.Hash!; LogSzr.Debug($"Read {added} strings from cache {hashStr}."); LockMappedStrings = true; LogSzr.Debug($"Locked in at {_mappedStrings.Count} mapped strings."); // ok we're good now var channel = msgRobustMappedStringsSerializer.MsgChannel; OnClientCompleteHandshake(net, channel); } } /// /// Inform the server that the client has a complete copy of the /// mapping, and alert other code that the handshake is over. /// /// /// private void OnClientCompleteHandshake(INetManager net, INetChannel channel) { LogSzr.Debug("Letting server know we're good to go."); var handshake = net.CreateNetMessage(); handshake.NeedsStrings = false; channel.SendMessage(handshake); if (ClientHandshakeComplete == null) { LogSzr.Warning("There's no handler attached to ClientHandshakeComplete."); } ClientHandshakeComplete?.Invoke(); } /// /// Gets the cache file associated with the given hash. /// /// The hash to look up the cache for. /// /// The filename where the cache for the given hash would be. The /// file itself may or may not exist. If it does not exist, no cache /// was made for the given hash. /// private string CacheForHash(string hashStr) => PathHelpers.ExecutableRelativeFile($"strings-{hashStr}"); /// /// Saves the string cache to a file based on it's hash. /// private void WriteStringCache() { var hashStr = Convert.ToBase64String(MappedStringsHash); hashStr = ConvertToBase64Url(hashStr); var fileName = CacheForHash(hashStr); using var file = File.OpenWrite(fileName); WriteStringPackage(file); LogSzr.Debug($"Wrote string cache {hashStr}."); } private byte[]? _mappedStringsPackage; private byte[] MappedStringsPackage => LockMappedStrings ? _mappedStringsPackage ??= WriteStringPackage() : throw new InvalidOperationException("Mapped strings must be locked."); /// /// Writes strings to a package and converts to an array of bytes. /// /// /// This is invoked by accessing for the first time. /// private byte[] WriteStringPackage() { using var ms = new MemoryStream(); WriteStringPackage(ms); return ms.ToArray(); } /// /// Writes a strings package to a stream. /// /// A writable stream. /// Overly long string in strings package. public void WriteStringPackage(Stream stream) { // ReSharper disable once SuggestVarOrType_Elsewhere Span buf = stackalloc byte[MaxMappedStringSize]; var sw = Stopwatch.StartNew(); var enc = Encoding.UTF8.GetEncoder(); using (var zs = new DeflateStream(stream, CompressionLevel.Optimal, true)) { var bytesWritten = WriteCompressedUnsignedInt(zs, (uint) MappedStrings.Count); foreach (var str in MappedStrings) { if (str.Length >= MaxMappedStringSize) { throw new NotImplementedException("Overly long string in strings package."); } var l = enc.GetBytes(str, buf, true); if (l >= MaxMappedStringSize) { throw new NotImplementedException("Overly long string in strings package."); } bytesWritten += WriteCompressedUnsignedInt(zs, (uint) l); zs.Write(buf.Slice(0,l)); bytesWritten += l; enc.Reset(); } zs.Write(BitConverter.GetBytes(bytesWritten)); zs.Flush(); } LogSzr.Debug($"Wrote {MappedStrings.Count} strings to package in {sw.ElapsedMilliseconds}ms."); } /// /// Loads a strings package from a stream. /// /// /// Uses to extract strings and adds them to the mapping. /// /// A readable stream. /// The number of strings loaded. /// Mapped strings are locked, will not load. /// Did not read all bytes in package! private int LoadStrings(Stream stream) { if (LockMappedStrings) { throw new InvalidOperationException("Mapped strings are locked, will not load."); } var started = MappedStrings.Count; foreach (var str in ReadStringPackage(stream)) { _stringMapping[str] = _mappedStrings.Count; _mappedStrings.Add(str); } if (stream.CanSeek && stream.CanRead) { if (stream.Position != stream.Length) { throw new InvalidDataException("Did not read all bytes in package!"); } } var added = MappedStrings.Count - started; return added; } /// /// Reads the contents of a strings package. /// /// /// Does not add strings to the current mapping. /// /// A readable stream. /// Strings from within the package. /// Could not read the full length of string #N. private IEnumerable ReadStringPackage(Stream stream) { var buf = ArrayPool.Shared.Rent(65536); var sw = Stopwatch.StartNew(); using var zs = new DeflateStream(stream, CompressionMode.Decompress); var c = ReadCompressedUnsignedInt(zs, out var x); var bytesRead = x; for (var i = 0; i < c; ++i) { var l = (int) ReadCompressedUnsignedInt(zs, out x); bytesRead += x; var y = zs.Read(buf, 0, l); if (y != l) { throw new InvalidDataException($"Could not read the full length of string #{i}."); } bytesRead += y; var str = Encoding.UTF8.GetString(buf, 0, l); yield return str; } zs.Read(buf, 0, 4); var checkBytesRead = BitConverter.ToInt32(buf, 0); if (checkBytesRead != bytesRead) { throw new InvalidDataException("Could not verify package was read correctly."); } LogSzr.Debug($"Read package of {c} strings in {sw.ElapsedMilliseconds}ms."); } /// /// Converts a byte array such as a hash to a Base64 representation that is URL safe. /// /// /// A base64url string form of the byte array. private string ConvertToBase64Url(byte[]? data) => data == null ? "" : ConvertToBase64Url(Convert.ToBase64String(data)); /// /// Converts a a Base64 string to one that is URL safe. /// /// A base64url formed string. private string ConvertToBase64Url(string b64Str) { if (b64Str is null) { throw new ArgumentNullException(nameof(b64Str)); } var cut = b64Str[^1] == '=' ? b64Str[^2] == '=' ? 2 : 1 : 0; b64Str = new StringBuilder(b64Str).Replace('+', '-').Replace('/', '_').ToString(0, b64Str.Length - cut); return b64Str; } /// /// Converts a URL-safe Base64 string into a byte array. /// /// A base64url formed string. /// The represented byte array. public byte[] ConvertFromBase64Url(string s) { var l = s.Length % 3; var sb = new StringBuilder(s); sb.Replace('-', '+').Replace('_', '/'); for (var i = 0; i < l; ++i) { sb.Append('='); } s = sb.ToString(); return Convert.FromBase64String(s); } public byte[]? ServerHash; private readonly List _mappedStrings = new List(); private readonly Dictionary _stringMapping = new Dictionary(); public IReadOnlyList MappedStrings => new ReadOnlyCollection(_mappedStrings); /// /// Whether the string mapping is decided, and cannot be changed. /// /// /// /// While false, strings can be added to the mapping, but /// it cannot be saved to a cache. /// /// /// While true, the mapping cannot be modified, but can be /// shared between the server and client and saved to a cache. /// /// public bool LockMappedStrings { get; set; } private readonly Regex _rxSymbolSplitter = new Regex( @"(?<=[^\s\W])(?=[A-Z]) # Match for split at start of new capital letter |(?<=[^0-9\s\W])(?=[0-9]) # Match for split before spans of numbers |(?<=[A-Za-z0-9])(?=_) # Match for a split before an underscore |(?=[.\\\/,#$?!@|&*()^`""'`~[\]{}:;\-]) # Match for a split after symbols |(?<=[.\\\/,#$?!@|&*()^`""'`~[\]{}:;\-]) # Match for a split before symbols too", RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace ); /// /// Add a string to the constant mapping. /// /// /// If the string has multiple detectable subcomponents, such as a /// filepath, it may result in more than one string being added to /// the mapping. As string parts are commonly sent as subsets or /// scoped names, this increases the likelyhood of a successful /// string mapping. /// /// /// true if the string was added to the mapping for the first /// time, false otherwise. /// /// /// Thrown if the string is not normalized (). /// public bool AddString(string str) { if (LockMappedStrings) { if (_net!.IsClient) { //LogSzr.Debug("On client and mapped strings are locked, will not add."); return false; } //throw new InvalidOperationException("Mapped strings are locked, will not add."); LogSzr.Debug("On server and mapped strings are locked, will not add."); return false; } if (String.IsNullOrEmpty(str)) { return false; } if (!str.IsNormalized()) { throw new InvalidOperationException("Only normalized strings may be added."); } if (_stringMapping.ContainsKey(str)) { return false; } if (str.Length >= MaxMappedStringSize) return false; if (str.Length <= MinMappedStringSize) return false; str = str.Trim(); if (str.Length <= MinMappedStringSize) return false; str = str.Replace(Environment.NewLine, "\n"); if (str.Length <= MinMappedStringSize) return false; var symTrimmedStr = str.Trim(TrimmableSymbolChars); if (symTrimmedStr != str) { AddString(symTrimmedStr); } if (str.Contains('/')) { var parts = str.Split('/', StringSplitOptions.RemoveEmptyEntries); for (var i = 0; i < parts.Length; ++i) { for (var l = 1; l <= parts.Length - i; ++l) { var subStr = String.Join('/', parts.Skip(i).Take(l)); if (_stringMapping.TryAdd(subStr, _mappedStrings.Count)) { _mappedStrings.Add(subStr); } if (!subStr.Contains('.')) { continue; } var subParts = subStr.Split('.', StringSplitOptions.RemoveEmptyEntries); for (var si = 0; si < subParts.Length; ++si) { for (var sl = 1; sl <= subParts.Length - si; ++sl) { var subSubStr = String.Join('.', subParts.Skip(si).Take(sl)); // ReSharper disable once InvertIf if (_stringMapping.TryAdd(subSubStr, _mappedStrings.Count)) { _mappedStrings.Add(subSubStr); } } } } } } else if (str.Contains("_")) { foreach (var substr in str.Split("_")) { AddString(substr); } } else if (str.Contains(" ")) { foreach (var substr in str.Split(" ")) { if (substr == str) continue; AddString(substr); } } else { var parts = _rxSymbolSplitter.Split(str); foreach (var substr in parts) { if (substr == str) continue; AddString(substr); } for (var si = 0; si < parts.Length; ++si) { for (var sl = 1; sl <= parts.Length - si; ++sl) { var subSubStr = String.Concat(parts.Skip(si).Take(sl)); if (_stringMapping.TryAdd(subSubStr, _mappedStrings.Count)) { _mappedStrings.Add(subSubStr); } } } } if (_stringMapping.TryAdd(str, _mappedStrings.Count)) { _mappedStrings.Add(str); } _stringMapHash = null; _mappedStringsPackage = null; return true; } /// /// Add the constant strings from an to the /// mapping. /// /// The assembly from which to collect constant strings. [MethodImpl(MethodImplOptions.Synchronized)] public unsafe void AddStrings(Assembly asm) { if (LockMappedStrings) { if (_net!.IsClient) { //LogSzr.Debug("On client and mapped strings are locked, will not add."); return; } //throw new InvalidOperationException("Mapped strings are locked, will not add ."); LogSzr.Debug("On server and mapped strings are locked, will not add."); return; } var started = MappedStrings.Count; var sw = Stopwatch.StartNew(); if (asm.TryGetRawMetadata(out var blob, out var len)) { var reader = new MetadataReader(blob, len); var usrStrHandle = default(UserStringHandle); do { var userStr = reader.GetUserString(usrStrHandle); if (userStr != "") { AddString(String.Intern(userStr.Normalize())); } usrStrHandle = reader.GetNextHandle(usrStrHandle); } while (usrStrHandle != default); var strHandle = default(StringHandle); do { var str = reader.GetString(strHandle); if (str != "") { AddString(String.Intern(str.Normalize())); } strHandle = reader.GetNextHandle(strHandle); } while (strHandle != default); } var added = MappedStrings.Count - started; LogSzr.Debug($"Mapping {added} strings from {asm.GetName().Name} took {sw.ElapsedMilliseconds}ms."); } /// /// Add strings from the given to the mapping. /// /// /// Strings are taken from YAML anchors, tags, and leaf nodes. /// /// The YAML to collect strings from. /// The stream name. Only used for logging. [MethodImpl(MethodImplOptions.Synchronized)] public void AddStrings(YamlStream yaml, string name) { if (LockMappedStrings) { if (_net!.IsClient) { //LogSzr.Debug("On client and mapped strings are locked, will not add."); return; } //throw new InvalidOperationException("Mapped strings are locked, will not add."); LogSzr.Debug("On server and mapped strings are locked, will not add."); return; } var started = MappedStrings.Count; var sw = Stopwatch.StartNew(); foreach (var doc in yaml) { foreach (var node in doc.AllNodes) { var a = node.Anchor; if (!String.IsNullOrEmpty(a)) { AddString(a); } var t = node.Tag; if (!String.IsNullOrEmpty(t)) { AddString(t); } switch (node) { case YamlScalarNode scalar: { var v = scalar.Value; if (String.IsNullOrEmpty(v)) { continue; } AddString(v); break; } } } } var added = MappedStrings.Count - started; LogSzr.Debug($"Mapping {added} strings from {name} took {sw.ElapsedMilliseconds}ms."); } /// /// Add strings from the given to the mapping. /// /// /// Strings are taken from JSON property names and string nodes. /// /// The JSON to collect strings from. /// The stream name. Only used for logging. public void AddStrings(JObject obj, string name) { if (LockMappedStrings) { if (_net!.IsClient) { //LogSzr.Debug("On client and mapped strings are locked, will not add."); return; } //throw new InvalidOperationException("Mapped strings are locked, will not add."); LogSzr.Debug("On server and mapped strings are locked, will not add."); return; } var started = MappedStrings.Count; var sw = Stopwatch.StartNew(); foreach (var node in obj.DescendantsAndSelf()) { switch (node) { case JValue value: { if (value.Type != JTokenType.String) { continue; } var v = value.Value?.ToString(); if (String.IsNullOrEmpty(v)) { continue; } AddString(v); break; } case JProperty prop: { var propName = prop.Name; if (String.IsNullOrEmpty(propName)) { continue; } AddString(propName); break; } } } var added = MappedStrings.Count - started; LogSzr.Debug($"Mapping {added} strings from {name} took {sw.ElapsedMilliseconds}ms."); } /// /// Remove all strings from the mapping, completely resetting it. /// /// /// Thrown if the mapping is locked. /// public void ClearStrings() { if (LockMappedStrings) { throw new InvalidOperationException("Mapped strings are locked, will not clear."); } _mappedStrings.Clear(); _stringMapping.Clear(); _stringMapHash = null; } /// /// Add strings from the given enumeration to the mapping. /// /// The strings to add. /// The source provider of the strings to be logged. [MethodImpl(MethodImplOptions.Synchronized)] public void AddStrings(IEnumerable strings, string providerName) { if (LockMappedStrings) { if (_net!.IsClient) { //LogSzr.Debug("On client and mapped strings are locked, will not add."); return; } //throw new InvalidOperationException("Mapped strings are locked, will not add."); LogSzr.Debug("On server and mapped strings are locked, will not add."); return; } var started = MappedStrings.Count; foreach (var str in strings) { AddString(str); } var added = MappedStrings.Count - started; LogSzr.Debug($"Mapping {added} strings from {providerName}."); } private byte[]? _stringMapHash; /// /// The hash of the string mapping. /// /// /// Thrown if the mapping is not locked. /// public byte[] MappedStringsHash => _stringMapHash ??= CalculateMappedStringsHash(); private byte[] CalculateMappedStringsHash() { if (!LockMappedStrings) { throw new InvalidOperationException("String table should be locked before attempting to retrieve hash."); } var sw = Stopwatch.StartNew(); var hash = CalculateHash(MappedStringsPackage); LogSzr.Debug($"Hashing {MappedStrings.Count} strings took {sw.ElapsedMilliseconds}ms."); LogSzr.Debug($"Size: {MappedStringsPackage.Length} bytes, Hash: {ConvertToBase64Url(hash)}"); return hash; } /// /// Creates a SHA512 hash of the given array of bytes. /// /// An array of bytes to be hashed. /// A 512-bit (64-byte) hash result as an array of bytes. /// private byte[] CalculateHash(byte[] data) { if (data is null) { throw new ArgumentNullException(nameof(data)); } using var hasher = SHA512.Create(); var hash = hasher.ComputeHash(data); return hash; } /// /// Implements . /// Specifies that this implementation handles strings. /// public bool Handles(Type type) => type == typeof(string); /// /// Implements . /// public IEnumerable GetSubtypes(Type type) => Type.EmptyTypes; /// /// Implements . /// /// public MethodInfo GetStaticWriter(Type type) => WriteMappedStringMethodInfo; /// /// Implements . /// /// public MethodInfo GetStaticReader(Type type) => ReadMappedStringMethodInfo; private delegate void WriteStringDelegate(Stream stream, string? value); private delegate void ReadStringDelegate(Stream stream, out string? value); private static readonly MethodInfo WriteMappedStringMethodInfo = ((WriteStringDelegate) StaticWriteMappedString).Method; private static readonly MethodInfo ReadMappedStringMethodInfo = ((ReadStringDelegate) StaticReadMappedString).Method; private static readonly char[] TrimmableSymbolChars = { '.', '\\', '/', ',', '#', '$', '?', '!', '@', '|', '&', '*', '(', ')', '^', '`', '"', '\'', '`', '~', '[', ']', '{', '}', ':', ';', '-' }; /// /// The shortest a string can be in order to be inserted in the mapping. /// /// /// Strings below a certain length aren't worth compressing. /// private const int MinMappedStringSize = 3; /// /// The longest a string can be in order to be inserted in the mapping. /// private const int MaxMappedStringSize = 420; /// /// The special value corresponding to a null string in the /// encoding. /// private const int MappedNull = 0; /// /// The special value corresponding to a string which was not mapped. /// This is followed by the bytes of the unmapped string. /// private const int UnmappedString = 1; /// /// The first non-special value, used for encoding mapped strings. /// /// /// Since previous values are taken by and /// , this value is used to encode /// mapped strings at an offset - in the encoding, a value /// >= FirstMappedIndexStart represents the string with /// mapping of that value - FirstMappedIndexStart. /// private const int FirstMappedIndexStart = 2; /// /// Write the encoding of the given string to the stream. /// Static form of for use by . /// /// The stream to write to. /// The (possibly null) string to write. public static void StaticWriteMappedString(Stream stream, string? value) { var mss = IoCManager.Resolve(); mss.WriteMappedString(stream, value); } /// /// Write the encoding of the given string to the stream. /// /// The stream to write to. /// The (possibly null) string to write. public void WriteMappedString(Stream stream, string? value) { if (!LockMappedStrings) { LogSzr.Warning("Performing unlocked string mapping."); } if (value == null) { WriteCompressedUnsignedInt(stream, MappedNull); return; } if (_stringMapping.TryGetValue(value, out var mapping)) { #if DEBUG if (mapping >= _mappedStrings.Count || mapping < 0) { throw new InvalidOperationException("A string mapping outside of the mapped string table was encountered."); } #endif WriteCompressedUnsignedInt(stream, (uint) mapping + FirstMappedIndexStart); //Logger.DebugS("szr", $"Encoded mapped string: {value}"); return; } // indicate not mapped WriteCompressedUnsignedInt(stream, UnmappedString); var buf = Encoding.UTF8.GetBytes(value); //Logger.DebugS("szr", $"Encoded unmapped string: {value}"); WriteCompressedUnsignedInt(stream, (uint) buf.Length); stream.Write(buf); } /// /// Try to read a string from the given stream. /// Static form of for use by . /// /// The stream to read from. /// The (possibly null) string read. /// /// Thrown if the mapping is not locked. /// public static void StaticReadMappedString(Stream stream, out string? value) { var mss = IoCManager.Resolve(); mss.ReadMappedString(stream, out value); } /// /// Try to read a string from the given stream. /// /// The stream to read from. /// The (possibly null) string read. /// /// Thrown if the mapping is not locked. /// public void ReadMappedString(Stream stream, out string? value) { if (!LockMappedStrings) { throw new InvalidOperationException("Not performing unlocked string mapping."); } var mapIndex = ReadCompressedUnsignedInt(stream, out _); if (mapIndex == MappedNull) { value = null; return; } if (mapIndex == UnmappedString) { // not mapped var length = checked((int)ReadCompressedUnsignedInt(stream, out _)); // ReSharper disable once SuggestVarOrType_Elsewhere Span buf = stackalloc byte[length]; stream.Read(buf); value = Encoding.UTF8.GetString(buf); //Logger.DebugS("szr", $"Decoded unmapped string: {value}"); return; } value = _mappedStrings[(int) mapIndex - FirstMappedIndexStart]; //Logger.DebugS("szr", $"Decoded mapped string: {value}"); } // TODO: move the below methods to some stream helpers class #if ROBUST_SERIALIZER_DISABLE_COMPRESSED_UINTS public static int WriteCompressedUnsignedInt(Stream stream, uint value) { WriteUnsignedInt(stream, value); return 4; } public static uint ReadCompressedUnsignedInt(Stream stream, out int byteCount) { byteCount = 4; return ReadUnsignedInt(stream); } #else public static int WriteCompressedUnsignedInt(Stream stream, uint value) { var length = 1; while (value >= 0x80) { stream.WriteByte((byte) (0x80 | value)); value >>= 7; ++length; } stream.WriteByte((byte) value); return length; } public static uint ReadCompressedUnsignedInt(Stream stream, out int byteCount) { byteCount = 0; var value = 0u; var shift = 0; while (stream.CanRead) { var current = stream.ReadByte(); ++byteCount; if (current == -1) { throw new EndOfStreamException(); } value |= (0x7Fu & (byte) current) << shift; shift += 7; if ((0x80 & current) == 0) { return value; } } throw new EndOfStreamException(); } #endif [UsedImplicitly] public static unsafe void WriteUnsignedInt(Stream stream, uint value) { var bytes = MemoryMarshal.AsBytes(new ReadOnlySpan(&value, 1)); stream.Write(bytes); } [UsedImplicitly] public static unsafe uint ReadUnsignedInt(Stream stream) { uint value; var bytes = MemoryMarshal.AsBytes(new Span(&value, 1)); stream.Read(bytes); return value; } /// /// See . /// public event Action? ClientHandshakeComplete; } }