diff --git a/Hamakaze/Hamakaze.csproj b/Hamakaze/Hamakaze.csproj index f208d30..dbc1517 100644 --- a/Hamakaze/Hamakaze.csproj +++ b/Hamakaze/Hamakaze.csproj @@ -1,7 +1,7 @@ - net5.0 + net6.0 diff --git a/Hamakaze/Headers/HttpConnectionHeader.cs b/Hamakaze/Headers/HttpConnectionHeader.cs index 50b1318..ff406ec 100644 --- a/Hamakaze/Headers/HttpConnectionHeader.cs +++ b/Hamakaze/Headers/HttpConnectionHeader.cs @@ -9,9 +9,10 @@ namespace Hamakaze.Headers { public const string CLOSE = @"close"; public const string KEEP_ALIVE = @"keep-alive"; + public const string UPGRADE = @"upgrade"; public HttpConnectionHeader(string mode) { - Value = mode ?? throw new ArgumentNullException(nameof(mode)); + Value = (mode ?? throw new ArgumentNullException(nameof(mode))).ToLowerInvariant(); } } } diff --git a/Hamakaze/HttpClient.cs b/Hamakaze/HttpClient.cs index be009b5..5d694a9 100644 --- a/Hamakaze/HttpClient.cs +++ b/Hamakaze/HttpClient.cs @@ -1,14 +1,22 @@ using Hamakaze.Headers; +using Hamakaze.WebSocket; using System; using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; namespace Hamakaze { public class HttpClient : IDisposable { public const string PRODUCT_STRING = @"HMKZ"; public const string VERSION_MAJOR = @"1"; - public const string VERSION_MINOR = @"0"; + public const string VERSION_MINOR = @"1"; public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR; + private const string WS_GUID = @"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + private const string WS_PROTO = @"websocket"; + private const int WS_RNG = 16; + private static HttpClient InstanceValue { get; set; } public static HttpClient Instance { get { @@ -47,7 +55,8 @@ namespace Hamakaze { request.UserAgent = DefaultUserAgent; if(!request.HasHeader(HttpAcceptEncodingHeader.NAME)) request.AcceptedEncodings = AcceptedEncodings; - request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE; + if(!request.HasHeader(HttpConnectionHeader.NAME)) + request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE; HttpTask task = new(Connections, request, disposeRequest, disposeResponse); @@ -85,6 +94,131 @@ namespace Hamakaze { RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse)); } + public void CreateWsClient( + string url, + Action onOpen, + Action onMessage, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) => CreateWsConnection( + url, + conn => onOpen(new WsClient(conn, onMessage, onError)), + onError, + protocols, + onResponse, + disposeRequest, + disposeResponse + ); + + public void CreateWsClient( + HttpRequestMessage request, + Action onOpen, + Action onMessage, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) => CreateWsConnection( + request, + conn => onOpen(new WsClient(conn, onMessage, onError)), + onError, + protocols, + onResponse, + disposeRequest, + disposeResponse + ); + + public void CreateWsConnection( + string url, + Action onOpen, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) => CreateWsConnection( + new HttpRequestMessage(@"GET", url), + onOpen, + onError, + protocols, + onResponse, + disposeRequest, + disposeResponse + ); + + public void CreateWsConnection( + HttpRequestMessage request, + Action onOpen, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) { + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(WS_RNG)); + + request.Connection = HttpConnectionHeader.UPGRADE; + request.SetHeader(@"Cache-Control", @"no-cache"); + request.SetHeader(@"Upgrade", WS_PROTO); + request.SetHeader(@"Sec-WebSocket-Key", key); + request.SetHeader(@"Sec-WebSocket-Version", @"13"); + + if(protocols?.Any() == true) + request.SetHeader(@"Sec-WebSocket-Protocol", string.Join(@", ", protocols)); + + SendRequest( + request, + (t, res) => { + try { + onResponse?.Invoke(res); + + if(res.ProtocolVersion.CompareTo(@"1.1") < 0) + throw new HttpUpgradeProtocolVersionException(@"1.1", res.ProtocolVersion); + + if(res.StatusCode != 101) + throw new HttpUpgradeUnexpectedStatusException(res.StatusCode); + + if(res.Connection != HttpConnectionHeader.UPGRADE) + throw new HttpUpgradeUnexpectedHeaderException( + @"Connection", + HttpConnectionHeader.UPGRADE, + res.Connection + ); + + string hUpgrade = res.GetHeaderLine(@"Upgrade"); + if(hUpgrade != WS_PROTO) + throw new HttpUpgradeUnexpectedHeaderException(@"Upgrade", WS_PROTO, hUpgrade); + + string serverHashStr = res.GetHeaderLine(@"Sec-WebSocket-Accept"); + byte[] expectHash = SHA1.HashData(Encoding.ASCII.GetBytes(key + WS_GUID)); + + if(string.IsNullOrWhiteSpace(serverHashStr)) + throw new HttpUpgradeUnexpectedHeaderException( + @"Sec-WebSocket-Accept", + Convert.ToBase64String(expectHash), + serverHashStr + ); + + byte[] givenHash = Convert.FromBase64String(serverHashStr.Trim()); + + if(!expectHash.SequenceEqual(givenHash)) + throw new HttpUpgradeInvalidHashException(Convert.ToBase64String(expectHash), serverHashStr); + + onOpen(t.Connection.ToWebSocket()); + } catch(Exception ex) { + onError(ex); + } + }, + (t, ex) => onError(ex), + disposeRequest: disposeRequest, + disposeResponse: disposeResponse + ); + } + public static void Send( HttpRequestMessage request, Action onComplete = null, @@ -95,9 +229,57 @@ namespace Hamakaze { Action onStateChange = null, bool disposeRequest = true, bool disposeResponse = true - ) { - Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse); - } + ) => Instance.SendRequest( + request, + onComplete, + onError, + onCancel, + onDownloadProgress, + onUploadProgress, + onStateChange, + disposeRequest, + disposeResponse + ); + + public static void Connect( + string url, + Action onOpen, + Action onMessage, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) => Instance.CreateWsClient( + url, + onOpen, + onMessage, + onError, + protocols, + onResponse, + disposeRequest, + disposeResponse + ); + + public static void Connect( + HttpRequestMessage request, + Action onOpen, + Action onMessage, + Action onError, + IEnumerable protocols = null, + Action onResponse = null, + bool disposeRequest = true, + bool disposeResponse = true + ) => Instance.CreateWsClient( + request, + onOpen, + onMessage, + onError, + protocols, + onResponse, + disposeRequest, + disposeResponse + ); private bool IsDisposed; ~HttpClient() diff --git a/Hamakaze/HttpConnection.cs b/Hamakaze/HttpConnection.cs index 8bb90d0..c7eda62 100644 --- a/Hamakaze/HttpConnection.cs +++ b/Hamakaze/HttpConnection.cs @@ -4,27 +4,29 @@ using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; -using System.Threading; +using Hamakaze.WebSocket; namespace Hamakaze { public class HttpConnection : IDisposable { public IPEndPoint EndPoint { get; } public Stream Stream { get; } - public Socket Socket { get; } - public NetworkStream NetworkStream { get; } - public SslStream SslStream { get; } + private Socket Socket { get; } + + private NetworkStream NetworkStream { get; } + private SslStream SslStream { get; } public string Host { get; } public bool IsSecure { get; } - public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle; + public bool HasTimedOut => MaxRequests < 1 || (DateTimeOffset.Now - LastOperation) > MaxIdle; - public int MaxRequests { get; set; } = -1; + public int? MaxRequests { get; set; } = null; public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue; public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now; public bool InUse { get; private set; } + public bool HasUpgraded { get; private set; } public HttpConnection(string host, IPEndPoint endPoint, bool secure) { Host = host ?? throw new ArgumentNullException(nameof(host)); @@ -45,25 +47,41 @@ namespace Hamakaze { if(IsSecure) { SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null); Stream = SslStream; - SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true); + SslStream.AuthenticateAsClient( + Host, + null, + SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, + true + ); } else Stream = NetworkStream; } public void MarkUsed() { LastOperation = DateTimeOffset.Now; - if(MaxRequests > 0) + if(MaxRequests != null) --MaxRequests; } public bool Acquire() { - return !InUse && (InUse = true); + return !HasUpgraded && !InUse && (InUse = true); } public void Release() { InUse = false; } + public WsConnection ToWebSocket() { + if(HasUpgraded) + throw new HttpConnectionAlreadyUpgradedException(); + HasUpgraded = true; + + NetworkStream.ReadTimeout = -1; + SslStream.ReadTimeout = -1; + + return new WsConnection(Stream); + } + private bool IsDisposed; ~HttpConnection() => DoDispose(); @@ -75,7 +93,9 @@ namespace Hamakaze { if(IsDisposed) return; IsDisposed = true; - Stream.Dispose(); + + if(!HasUpgraded) + Stream.Dispose(); } } } diff --git a/Hamakaze/HttpException.cs b/Hamakaze/HttpException.cs index d6e0bce..726b9ab 100644 --- a/Hamakaze/HttpException.cs +++ b/Hamakaze/HttpException.cs @@ -5,6 +5,32 @@ namespace Hamakaze { public HttpException(string message) : base(message) { } } + public class HttpUpgradeException : HttpException { + public HttpUpgradeException(string message) : base(message) { } + } + public class HttpUpgradeProtocolVersionException : HttpUpgradeException { + public HttpUpgradeProtocolVersionException(string expectedVersion, string givenVersion) + : base($@"Server HTTP version ({givenVersion}) is lower than what is expected {expectedVersion}.") { } + } + public class HttpUpgradeUnexpectedStatusException : HttpUpgradeException { + public HttpUpgradeUnexpectedStatusException(int statusCode) : base($@"Expected HTTP status code 101, got {statusCode}.") { } + } + public class HttpUpgradeUnexpectedHeaderException : HttpUpgradeException { + public HttpUpgradeUnexpectedHeaderException(string header, string expected, string given) + : base($@"Unexpected {header} header value ""{given}"", expected ""{expected}"".") { } + } + public class HttpUpgradeInvalidHashException : HttpUpgradeException { + public HttpUpgradeInvalidHashException(string expected, string given) + : base($@"Server sent invalid hash ""{given}"", expected ""{expected}"".") { } + } + + public class HttpConnectionException : HttpException { + public HttpConnectionException(string message) : base(message) { } + } + public class HttpConnectionAlreadyUpgradedException : HttpConnectionException { + public HttpConnectionAlreadyUpgradedException() : base(@"This connection has already been upgraded.") { } + } + public class HttpConnectionManagerException : HttpException { public HttpConnectionManagerException(string message) : base(message) { } } @@ -12,6 +38,13 @@ namespace Hamakaze { public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { } } + public class HttpRequestMessageException : HttpException { + public HttpRequestMessageException(string message) : base(message) { } + } + public class HttpRequestMessageStreamException : HttpRequestMessageException { + public HttpRequestMessageStreamException() : base(@"Provided Stream is not writable.") { } + } + public class HttpTaskException : HttpException { public HttpTaskException(string message) : base(message) { } } diff --git a/Hamakaze/HttpRequestMessage.cs b/Hamakaze/HttpRequestMessage.cs index 6ce3ce3..ba01492 100644 --- a/Hamakaze/HttpRequestMessage.cs +++ b/Hamakaze/HttpRequestMessage.cs @@ -92,7 +92,8 @@ namespace Hamakaze { public HttpRequestMessage(string method, Uri uri) { Method = method ?? throw new ArgumentNullException(nameof(method)); RequestTarget = uri.PathAndQuery; - IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase); + IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase) + || uri.Scheme.Equals(@"wss", StringComparison.InvariantCultureIgnoreCase); Host = uri.Host; ushort defaultPort = (IsSecure ? HTTPS : HTTP); Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port; @@ -157,6 +158,9 @@ namespace Hamakaze { } public void WriteTo(Stream stream, Action onProgress = null) { + if(!stream.CanWrite) + throw new HttpRequestMessageStreamException(); + using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) { sw.NewLine = "\r\n"; sw.Write(Method); diff --git a/Hamakaze/HttpResponseMessage.cs b/Hamakaze/HttpResponseMessage.cs index c041e93..a7bfb95 100644 --- a/Hamakaze/HttpResponseMessage.cs +++ b/Hamakaze/HttpResponseMessage.cs @@ -124,10 +124,10 @@ namespace Hamakaze { using MemoryStream ms = new(); int byt; ushort lastTwo = 0; - for(; ; ) { + for(;;) { byt = stream.ReadByte(); if(byt == -1 && ms.Length == 0) - return null; + throw new IOException(@"readLine: There is no data."); ms.WriteByte((byte)byt); @@ -151,7 +151,7 @@ namespace Hamakaze { if(line == null) throw new IOException(@"Failed to read initial HTTP header."); if(!line.StartsWith(@"HTTP/")) - throw new IOException(@"Response is not a valid HTTP message."); + throw new IOException($@"Response is not a valid HTTP message: {line}."); string[] parts = line[5..].Split(' ', 3); if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode)) throw new IOException(@"Invalid HTTP status code format."); @@ -238,11 +238,11 @@ namespace Hamakaze { readBuffer(chunkLength); readLine(); } + readLine(); } else if(contentLength != 0) { body = new MemoryStream(); readBuffer(contentLength); - readLine(); } if(body != null) diff --git a/Hamakaze/HttpTask.cs b/Hamakaze/HttpTask.cs index ddcd212..5bd604f 100644 --- a/Hamakaze/HttpTask.cs +++ b/Hamakaze/HttpTask.cs @@ -1,7 +1,6 @@ using Hamakaze.Headers; using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net; @@ -25,7 +24,7 @@ namespace Hamakaze { private HttpConnectionManager Connections { get; } private IEnumerable Addresses { get; set; } - private HttpConnection Connection { get; set; } + public HttpConnection Connection { get; private set; } public bool DisposeRequest { get; set; } public bool DisposeResponse { get; set; } @@ -70,102 +69,90 @@ namespace Hamakaze { if(IsCancelled) return false; - switch(State) { - case TaskState.Initial: - State = TaskState.Lookup; - OnStateChange?.Invoke(this, State); - DoLookup(); - break; - case TaskState.Lookup: - State = TaskState.Request; - OnStateChange?.Invoke(this, State); - DoRequest(); - break; - case TaskState.Request: - State = TaskState.Response; - OnStateChange?.Invoke(this, State); - DoResponse(); - break; - case TaskState.Response: - State = TaskState.Finished; - OnStateChange?.Invoke(this, State); - OnComplete?.Invoke(this, Response); - if(DisposeResponse) - Response?.Dispose(); - if(DisposeRequest) - Request?.Dispose(); - return false; - default: - Error(new HttpTaskInvalidStateException()); - return false; + try { + switch(State) { + case TaskState.Initial: + State = TaskState.Lookup; + OnStateChange?.Invoke(this, State); + DoLookup(); + break; + case TaskState.Lookup: + State = TaskState.Request; + OnStateChange?.Invoke(this, State); + DoRequest(); + break; + case TaskState.Request: + State = TaskState.Response; + OnStateChange?.Invoke(this, State); + DoResponse(); + break; + case TaskState.Response: + State = TaskState.Finished; + OnStateChange?.Invoke(this, State); + OnComplete?.Invoke(this, Response); + if(DisposeResponse) + Response?.Dispose(); + if(DisposeRequest) + Request?.Dispose(); + return false; + default: + throw new HttpTaskInvalidStateException(); + } + } catch(Exception ex) { + Error(ex); + return false; } return true; } private void DoLookup() { - try { - Addresses = Dns.GetHostAddresses(Request.Host); - } catch(Exception ex) { - Error(ex); - return; - } + Addresses = Dns.GetHostAddresses(Request.Host); if(!Addresses.Any()) - Error(new HttpTaskNoAddressesException()); + throw new HttpTaskNoAddressesException(); } private void DoRequest() { - Exception exception = null; + Queue addresses = new(Addresses); - try { - foreach(IPAddress addr in Addresses) { - int tries = 0; - IPEndPoint endPoint = new(addr, Request.Port); + while(addresses.TryDequeue(out IPAddress addr)) { + int tries = 0; + IPEndPoint endPoint = new(addr, Request.Port); - exception = null; + Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + + retry: + ++tries; + try { + Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t)); + break; + } catch(HttpRequestMessageStreamException) { + Connection.Dispose(); Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); - retry: - ++tries; - try { - Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t)); - break; - } catch(IOException ex) { - Connection.Dispose(); - Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure); + if(tries < 2) + goto retry; - if(tries < 2) - goto retry; - - exception = ex; - continue; - } finally { - Connection.MarkUsed(); - } + if(!addresses.Any()) + throw; + } finally { + Connection.MarkUsed(); } - } catch(Exception ex) { - Error(ex); } - if(exception != null) - Error(exception); - else if(Connection == null) - Error(new HttpTaskNoConnectionException()); + if(Connection == null) + throw new HttpTaskNoConnectionException(); } private void DoResponse() { - try { - Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t)); - } catch(Exception ex) { - Error(ex); - return; - } + Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t)); - if(Response.Connection == HttpConnectionHeader.CLOSE) + if(Response.Connection == HttpConnectionHeader.CLOSE + || Response.ProtocolVersion.CompareTo(@"1.1") < 0) Connection.Dispose(); if(Response == null) - Error(new HttpTaskRequestFailedException()); + throw new HttpTaskRequestFailedException(); HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast().FirstOrDefault(); if(hkah != null) { diff --git a/Hamakaze/WebSocket/IHasBinaryData.cs b/Hamakaze/WebSocket/IHasBinaryData.cs new file mode 100644 index 0000000..bd994b7 --- /dev/null +++ b/Hamakaze/WebSocket/IHasBinaryData.cs @@ -0,0 +1,5 @@ +namespace Hamakaze.WebSocket { + public interface IHasBinaryData { + byte[] Data { get; } + } +} diff --git a/Hamakaze/WebSocket/WsBinaryMessage.cs b/Hamakaze/WebSocket/WsBinaryMessage.cs new file mode 100644 index 0000000..a0be483 --- /dev/null +++ b/Hamakaze/WebSocket/WsBinaryMessage.cs @@ -0,0 +1,11 @@ +using System; + +namespace Hamakaze.WebSocket { + public class WsBinaryMessage : WsMessage, IHasBinaryData { + public byte[] Data { get; } + + public WsBinaryMessage(byte[] data) { + Data = data ?? Array.Empty(); + } + } +} diff --git a/Hamakaze/WebSocket/WsBufferedSend.cs b/Hamakaze/WebSocket/WsBufferedSend.cs new file mode 100644 index 0000000..9c628be --- /dev/null +++ b/Hamakaze/WebSocket/WsBufferedSend.cs @@ -0,0 +1,36 @@ +using System; + +namespace Hamakaze.WebSocket { + public class WsBufferedSend : IDisposable { + private WsConnection Connection { get; } + + internal WsBufferedSend(WsConnection conn) { + Connection = conn ?? throw new ArgumentNullException(nameof(conn)); + } + + public void SendPart(ReadOnlySpan data) + => Connection.WriteFrame(WsOpcode.DataBinary, data, false); + + public void SendFinalPart(ReadOnlySpan data) + => Connection.WriteFrame(WsOpcode.DataBinary, data, true); + + private bool IsDisposed; + + ~WsBufferedSend() { + DoDispose(); + } + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + Connection.EndBufferedSend(); + } + } +} diff --git a/Hamakaze/WebSocket/WsClient.cs b/Hamakaze/WebSocket/WsClient.cs new file mode 100644 index 0000000..d626c71 --- /dev/null +++ b/Hamakaze/WebSocket/WsClient.cs @@ -0,0 +1,306 @@ +using System; +using System.Threading; + +// todo: sending errors as fake close messages + +namespace Hamakaze.WebSocket { + public class WsClient : IDisposable { + public WsConnection Connection { get; } + public bool IsRunning { get; private set; } = true; + + private Thread ReadThread { get; } + private Action MessageHandler { get; } + private Action ExceptionHandler { get; } + + private Mutex SendLock { get; } + private const int TIMEOUT = 60000; + + public WsClient( + WsConnection connection, + Action messageHandler, + Action exceptionHandler + ) { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + MessageHandler = messageHandler ?? throw new ArgumentNullException(nameof(messageHandler)); + ExceptionHandler = exceptionHandler ?? throw new ArgumentNullException(nameof(exceptionHandler)); + + SendLock = new(); + + ReadThread = new(ReadThreadBody) { IsBackground = true }; + ReadThread.Start(); + } + + private void ReadThreadBody() { + try { + while(IsRunning) + MessageHandler(Connection.Receive()); + } catch(Exception ex) { + IsRunning = false; + ExceptionHandler(ex); + } + } + + public void Send(string text) { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Send(text); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Send(object obj) { + if(obj == null) + throw new ArgumentNullException(nameof(obj)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Send(obj.ToString()); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Send(ReadOnlySpan data) { + if(data == null) + throw new ArgumentNullException(nameof(data)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Send(data); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Send(byte[] buffer, int offset, int count) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Send(buffer.AsSpan(offset, count)); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Send(Action handler) { + if(handler == null) + throw new ArgumentNullException(nameof(handler)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + using(WsBufferedSend bs = Connection.BeginBufferedSend()) + handler(bs); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Ping() { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Ping(); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Ping(ReadOnlySpan data) { + if(data == null) + throw new ArgumentNullException(nameof(data)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Ping(data); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Ping(byte[] buffer, int offset, int length) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Ping(buffer.AsSpan(offset, length)); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Pong() { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Pong(); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Pong(ReadOnlySpan data) { + if(data == null) + throw new ArgumentNullException(nameof(data)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Pong(data); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Pong(byte[] buffer, int offset, int length) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Pong(buffer.AsSpan(offset, length)); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close() { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(WsCloseReason.NormalClosure); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void CloseEmpty() { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.CloseEmpty(); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(WsCloseReason opcode) { + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(opcode); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(string reason) { + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(WsCloseReason.NormalClosure, reason); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(WsCloseReason opcode, string reason) { + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(opcode, reason); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(ReadOnlySpan data) { + if(data == null) + throw new ArgumentNullException(nameof(data)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(data); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(byte[] buffer, int offset, int length) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(buffer.AsSpan(offset, length)); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(WsCloseReason opcode, ReadOnlySpan data) { + if(data == null) + throw new ArgumentNullException(nameof(data)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(opcode, data); + } finally { + SendLock.ReleaseMutex(); + } + } + + public void Close(WsCloseReason code, byte[] buffer, int offset, int length) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + try { + if(!SendLock.WaitOne(TIMEOUT)) + throw new WsClientMutexFailedException(); + Connection.Close(code, buffer.AsSpan(offset, length)); + } finally { + SendLock.ReleaseMutex(); + } + } + + private bool IsDisposed; + + ~WsClient() { + DoDispose(); + } + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + SendLock.Dispose(); + Connection.Dispose(); + } + } +} diff --git a/Hamakaze/WebSocket/WsCloseMessage.cs b/Hamakaze/WebSocket/WsCloseMessage.cs new file mode 100644 index 0000000..9989046 --- /dev/null +++ b/Hamakaze/WebSocket/WsCloseMessage.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; + +namespace Hamakaze.WebSocket { + public class WsCloseMessage : WsMessage, IHasBinaryData { + public WsCloseReason Reason { get; } + public string ReasonPhrase { get; } + public byte[] Data { get; } + + public WsCloseMessage(WsCloseReason reason) { + Reason = reason; + ReasonPhrase = string.Empty; + Data = Array.Empty(); + } + + public WsCloseMessage(byte[] data) { + if(data == null) { + Reason = WsCloseReason.NoStatus; + ReasonPhrase = string.Empty; + Data = Array.Empty(); + } else { + Reason = (WsCloseReason)WsUtils.ToU16(data); + Data = data; + + if(data.Length > 2) + try { + ReasonPhrase = Encoding.UTF8.GetString(data, 2, data.Length - 2); + } catch { + ReasonPhrase = string.Empty; + } + else + ReasonPhrase = string.Empty; + } + } + } +} diff --git a/Hamakaze/WebSocket/WsCloseReason.cs b/Hamakaze/WebSocket/WsCloseReason.cs new file mode 100644 index 0000000..5b6e5a0 --- /dev/null +++ b/Hamakaze/WebSocket/WsCloseReason.cs @@ -0,0 +1,16 @@ +namespace Hamakaze.WebSocket { + public enum WsCloseReason : ushort { + NormalClosure = 1000, + GoingAway = 1001, + ProtocolError = 1002, + InvalidData = 1003, + NoStatus = 1005, // virtual -> no data in close frame + AbnormalClosure = 1006, // virtual -> connection dropped + MalformedData = 1007, + PolicyViolation = 1008, + FrameTooLarge = 1009, + MissingExtension = 1010, + UnexpectedCondition = 1011, + TlsHandshakeFailed = 1015, // virtual -> obvious + } +} diff --git a/Hamakaze/WebSocket/WsConnection.cs b/Hamakaze/WebSocket/WsConnection.cs new file mode 100644 index 0000000..a1f42c3 --- /dev/null +++ b/Hamakaze/WebSocket/WsConnection.cs @@ -0,0 +1,395 @@ +using System; +using System.IO; +using System.Net.Security; +using System.Security.Cryptography; +using System.Text; + +namespace Hamakaze.WebSocket { + public class WsConnection : IDisposable { + public Stream Stream { get; } + + public bool IsSecure { get; } + public bool IsClosed { get; private set; } + + private const byte MASK_FLAG = 0x80; + private const int MASK_SIZE = 4; + + private WsOpcode FragmentType = 0; + private MemoryStream FragmentStream; + + private WsBufferedSend BufferedSend; + + public WsConnection(Stream stream) { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + IsSecure = stream is SslStream; + } + + private static byte[] GenerateMask() { + return RandomNumberGenerator.GetBytes(MASK_SIZE); + } + + private void StrictRead(byte[] buffer, int offset, int length) { + int read = Stream.Read(buffer, offset, length); + if(read < length) + throw new Exception(@"Was unable to read the requested amount of data."); + } + + private (WsOpcode opcode, int length, bool isFinal, byte[] mask) ReadFrameHeader() { + byte[] buffer = new byte[8]; + StrictRead(buffer, 0, 2); + + WsOpcode opcode = (WsOpcode)(buffer[0] & 0x0F); + bool isFinal = (buffer[0] & (byte)WsOpcode.FlagFinal) > 0; + + if(opcode >= WsOpcode.CtrlClose && !isFinal) + throw new WsInvalidOpcodeException((WsOpcode)buffer[0]); + + bool isControl = (opcode & WsOpcode.CtrlClose) > 0; + + if(isControl && !isFinal) + throw new WsInvalidControlFrameException(@"fragmented"); + + bool isMasked = (buffer[1] & MASK_FLAG) > 0; + + // this may look stupid and you'd be correct but it's better than the stack of casts + // i'd otherwise have to do otherwise because c# converts everything back to int32 + buffer[1] &= 0x7F; + long length = buffer[1]; + + if(length == 126) { + StrictRead(buffer, 0, 2); + length = WsUtils.ToU16(buffer); + } else if(length == 127) { + StrictRead(buffer, 0, 8); + length = WsUtils.ToI64(buffer); + } + + if(isControl && length > 125) + throw new WsInvalidControlFrameException(@"too large"); + + // should there be a sanity check on the length of frames? + // i seriously don't understand the rationale behind both + // having a framing system but then also supporting frame lengths + // of 2^63, feels like 2^16 per frame would be a fine max. + // UPDATE: decided to put the max at 2^32-1 + // it's still more than you should ever need for a single frame + // and it makes working with the number within a .NET context + // less of a bother. + if(length < 0 || length > int.MaxValue) + throw new WsInvalidFrameSizeException(length); + + byte[] mask = null; + + if(isMasked) { + StrictRead(buffer, 0, MASK_SIZE); + mask = buffer; + } + + return (opcode, (int)length, isFinal, mask); + } + + private int ReadFrameBody(byte[] target, int length, byte[] mask, int offset = 0) { + if(target == null) + throw new ArgumentNullException(nameof(target)); + + bool isMasked = mask != null; + + int read; + const int bufferSize = 0x1000; + int take = length > bufferSize ? bufferSize : (int)length; + + while(length > 0) { + read = Stream.Read(target, offset, take); + + if(isMasked) + for(int i = 0; i < read; ++i) { + int o = offset + i; + target[o] ^= mask[o % MASK_SIZE]; + } + + length -= read; + offset += read; + + if(take > length) + take = (int)length; + } + + return offset; + } + + private WsMessage ReadFrame() { + (WsOpcode opcode, int length, bool isFinal, byte[] mask) = ReadFrameHeader(); + + if(opcode is not WsOpcode.DataContinue + and not WsOpcode.DataBinary + and not WsOpcode.DataText + and not WsOpcode.CtrlClose + and not WsOpcode.CtrlPing + and not WsOpcode.CtrlPong) + throw new WsUnsupportedOpcodeException(opcode); + + bool hasBody = length > 0; + bool isContinue = opcode == WsOpcode.DataContinue; + bool canFragment = (opcode & WsOpcode.CtrlClose) == 0; + + byte[] body = length < 1 ? null : new byte[length]; + + if(hasBody) { + ReadFrameBody(body, length, mask); + + if(canFragment) { + if(isContinue) { + if(FragmentType == 0) + throw new WsUnexpectedContinueException(); + + opcode = FragmentType; + + FragmentStream ??= new(); + FragmentStream.Write(body, 0, length); + } else { + if(FragmentType != 0) + throw new WsUnexpectedDataException(); + + if(!isFinal) { + FragmentType = opcode; + FragmentStream = new(); + FragmentStream.Write(body, 0, length); + } + } + } + } + + WsMessage msg; + + if(isFinal) { + if(canFragment && isContinue) { + FragmentType = 0; + + body = FragmentStream.ToArray(); + FragmentStream.Dispose(); + FragmentStream = null; + } + + msg = opcode switch { + WsOpcode.DataText => new WsTextMessage(body), + WsOpcode.DataBinary => new WsBinaryMessage(body), + + WsOpcode.CtrlClose => new WsCloseMessage(body), + WsOpcode.CtrlPing => new WsPingMessage(body), + WsOpcode.CtrlPong => new WsPongMessage(body), + + // fallback, if we end up here something is very fucked + _ => throw new WsUnsupportedOpcodeException(opcode), + }; + } else msg = null; + + return msg; + } + + public WsMessage Receive() { + WsMessage msg; + while((msg = ReadFrame()) == null); + return msg; + } + + private void WriteFrameHeader(WsOpcode opcode, int length, bool isFinal, byte[] mask = null) { + if(length < 0 || length > int.MaxValue) + throw new WsInvalidFrameSizeException(length); + + bool shouldMask = mask != null; + + if(isFinal) + opcode |= WsOpcode.FlagFinal; + + Stream.WriteByte((byte)opcode); + + byte bLen1 = 0; + if(shouldMask) + bLen1 |= MASK_FLAG; + + byte[] bLenBuff = WsUtils.FromI64(length); + if(length < 126) { + Stream.WriteByte((byte)(bLen1 | bLenBuff[7])); + } else if(length <= ushort.MaxValue) { + Stream.WriteByte((byte)(bLen1 | 126)); + Stream.Write(bLenBuff, 6, 2); + } else { + Stream.WriteByte((byte)(bLen1 | 127)); + Stream.Write(bLenBuff, 0, 8); + } + + if(shouldMask) + Stream.Write(mask, 0, MASK_SIZE); + Stream.Flush(); + } + + private int WriteFrameBody(ReadOnlySpan body, byte[] mask = null, int offset = 0) { + if(body == null) + throw new ArgumentNullException(nameof(body)); + + if(mask != null) { + byte[] masked = new byte[body.Length]; + + for(int i = 0; i < body.Length; ++i) + masked[i] = (byte)(body[i] ^ mask[offset++ % MASK_SIZE]); + + body = masked; + } + + Stream.Write(body); + Stream.Flush(); + + return offset; + } + + internal void WriteFrame(WsOpcode opcode, ReadOnlySpan body, bool isFinal) { + if(body == null) + throw new ArgumentNullException(nameof(body)); + + byte[] mask = GenerateMask(); + WriteFrameHeader(opcode, body.Length, isFinal, mask); + if(body.Length > 0) + WriteFrameBody(body, mask); + } + + private void WriteData(WsOpcode opcode, ReadOnlySpan body) { + if(body == null) + throw new ArgumentNullException(nameof(body)); + if(BufferedSend != null) + throw new WsBufferedSendInSessionException(); + + if(body.Length > ushort.MaxValue) { + WriteFrame(opcode, body.Slice(0, ushort.MaxValue), false); + body = body.Slice(ushort.MaxValue); + + while(body.Length > ushort.MaxValue) { + WriteFrame(WsOpcode.DataContinue, body.Slice(0, ushort.MaxValue), false); + body = body.Slice(ushort.MaxValue); + } + + WriteFrame(WsOpcode.DataContinue, body, true); + } else + WriteFrame(opcode, body, true); + } + + public void Send(string text) + => WriteData(WsOpcode.DataText, Encoding.UTF8.GetBytes(text)); + + public void Send(ReadOnlySpan buffer) + => WriteData(WsOpcode.DataBinary, buffer); + + public WsBufferedSend BeginBufferedSend() { + if(BufferedSend != null) + throw new WsBufferedSendAlreadyActiveException(); + return BufferedSend = new(this); + } + + // this method should only be called from within WsBufferedSend.Dispose + internal void EndBufferedSend() { + BufferedSend = null; + } + + private void WriteControl(WsOpcode opcode) + => WriteFrameHeader(opcode, 0, true, GenerateMask()); + + private void WriteControl(WsOpcode opcode, ReadOnlySpan buffer) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + if(buffer.Length > 125) + throw new ArgumentException(@"Data may not be more than 125 bytes.", nameof(buffer)); + + byte[] mask = GenerateMask(); + WriteFrameHeader(opcode, buffer.Length, true, mask); + WriteFrameBody(buffer, mask); + } + + public void Ping() + => WriteControl(WsOpcode.CtrlPing); + + public void Ping(ReadOnlySpan buffer) + => WriteControl(WsOpcode.CtrlPing, buffer); + + public void Pong() + => WriteControl(WsOpcode.CtrlPong); + + public void Pong(ReadOnlySpan buffer) + => WriteControl(WsOpcode.CtrlPong, buffer); + + public void CloseEmpty() { + if(IsClosed) + return; + IsClosed = true; + + WriteControl(WsOpcode.CtrlClose); + } + + public void Close(ReadOnlySpan buffer) { + if(buffer == null) + throw new ArgumentNullException(nameof(buffer)); + + if(IsClosed) + return; + IsClosed = true; + + WriteControl(WsOpcode.CtrlClose, buffer); + } + + public void Close(WsCloseReason code) + => Close(WsUtils.FromU16((ushort)code)); + + public void Close(WsCloseReason code, ReadOnlySpan reason) { + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + if(reason.Length > 123) + throw new ArgumentException(@"Reason may not be more than 123 bytes.", nameof(reason)); + + if(IsClosed) + return; + IsClosed = true; + + byte[] mask = GenerateMask(); + WriteFrameHeader(WsOpcode.CtrlClose, 2 + reason.Length, true, mask); + WriteFrameBody(WsUtils.FromU16((ushort)code), mask); + WriteFrameBody(reason, mask, 2); + } + + public void Close(WsCloseReason code, string reason) { + if(reason == null) + throw new ArgumentNullException(nameof(reason)); + + int length = Encoding.UTF8.GetByteCount(reason); + if(length > 123) + throw new ArgumentException(@"Reason string may not exceed 123 bytes in length.", nameof(reason)); + + if(IsClosed) + return; + IsClosed = true; + + byte[] mask = GenerateMask(); + WriteFrameHeader(WsOpcode.CtrlClose, 2 + reason.Length, true, mask); + WriteFrameBody(WsUtils.FromU16((ushort)code), mask); + WriteFrameBody(Encoding.UTF8.GetBytes(reason), mask, 2); + } + + private bool IsDisposed; + + ~WsConnection() { + DoDispose(); + } + + public void Dispose() { + DoDispose(); + GC.SuppressFinalize(this); + } + + private void DoDispose() { + if(IsDisposed) + return; + IsDisposed = true; + + BufferedSend?.Dispose(); + FragmentStream?.Dispose(); + Stream.Dispose(); + } + } +} diff --git a/Hamakaze/WebSocket/WsException.cs b/Hamakaze/WebSocket/WsException.cs new file mode 100644 index 0000000..fc17bf6 --- /dev/null +++ b/Hamakaze/WebSocket/WsException.cs @@ -0,0 +1,41 @@ +namespace Hamakaze.WebSocket { + public class WsException : HttpException { + public WsException(string message) : base(message) { } + } + + public class WsInvalidOpcodeException : WsException { + public WsInvalidOpcodeException(WsOpcode opcode) : base($@"An invalid WebSocket opcode was encountered: {opcode}.") { } + } + + public class WsUnsupportedOpcodeException : WsException { + public WsUnsupportedOpcodeException(WsOpcode opcode) : base($@"An unsupported WebSocket opcode was encountered: {opcode}.") { } + } + + public class WsInvalidFrameSizeException : WsException { + public WsInvalidFrameSizeException(long size) : base($@"WebSocket frame size is too large: {size} bytes.") { } + } + + public class WsUnexpectedContinueException : WsException { + public WsUnexpectedContinueException() : base(@"A WebSocket continue frame was issued but there is nothing to continue.") { } + } + + public class WsUnexpectedDataException : WsException { + public WsUnexpectedDataException() : base(@"A WebSocket data frame was issued while a fragmented frame is being constructed.") { } + } + + public class WsInvalidControlFrameException : WsException { + public WsInvalidControlFrameException(string variant) : base($@"An invalid WebSocket control frame was encountered: {variant}") { } + } + + public class WsClientMutexFailedException : WsException { + public WsClientMutexFailedException() : base(@"Failed to acquire send mutex.") { } + } + + public class WsBufferedSendAlreadyActiveException : WsException { + public WsBufferedSendAlreadyActiveException() : base(@"A buffered websocket send is already in session.") { } + } + + public class WsBufferedSendInSessionException : WsException { + public WsBufferedSendInSessionException() : base(@"Cannot send data while a buffered send is in session.") { } + } +} diff --git a/Hamakaze/WebSocket/WsMessage.cs b/Hamakaze/WebSocket/WsMessage.cs new file mode 100644 index 0000000..ebb9344 --- /dev/null +++ b/Hamakaze/WebSocket/WsMessage.cs @@ -0,0 +1,5 @@ +namespace Hamakaze.WebSocket { + public abstract class WsMessage { + // nothing, lol + } +} diff --git a/Hamakaze/WebSocket/WsOpcode.cs b/Hamakaze/WebSocket/WsOpcode.cs new file mode 100644 index 0000000..4491160 --- /dev/null +++ b/Hamakaze/WebSocket/WsOpcode.cs @@ -0,0 +1,13 @@ +namespace Hamakaze.WebSocket { + public enum WsOpcode : byte { + DataContinue = 0x00, + DataText = 0x01, + DataBinary = 0x02, + + CtrlClose = 0x08, + CtrlPing = 0x09, + CtrlPong = 0x0A, + + FlagFinal = 0x80, + } +} diff --git a/Hamakaze/WebSocket/WsPingMessage.cs b/Hamakaze/WebSocket/WsPingMessage.cs new file mode 100644 index 0000000..15548d1 --- /dev/null +++ b/Hamakaze/WebSocket/WsPingMessage.cs @@ -0,0 +1,11 @@ +using System; + +namespace Hamakaze.WebSocket { + public class WsPingMessage : WsMessage, IHasBinaryData { + public byte[] Data { get; } + + public WsPingMessage(byte[] data) { + Data = data ?? Array.Empty(); + } + } +} diff --git a/Hamakaze/WebSocket/WsPongMessage.cs b/Hamakaze/WebSocket/WsPongMessage.cs new file mode 100644 index 0000000..96218e7 --- /dev/null +++ b/Hamakaze/WebSocket/WsPongMessage.cs @@ -0,0 +1,11 @@ +using System; + +namespace Hamakaze.WebSocket { + public class WsPongMessage : WsMessage, IHasBinaryData { + public byte[] Data { get; } + + public WsPongMessage(byte[] data) { + Data = data ?? Array.Empty(); + } + } +} diff --git a/Hamakaze/WebSocket/WsTextMessage.cs b/Hamakaze/WebSocket/WsTextMessage.cs new file mode 100644 index 0000000..13b050d --- /dev/null +++ b/Hamakaze/WebSocket/WsTextMessage.cs @@ -0,0 +1,20 @@ +using System.Text; + +namespace Hamakaze.WebSocket { + public class WsTextMessage : WsMessage { + public string Text { get; } + + public WsTextMessage(byte[] data) { + if(data?.Length > 0) + Text = Encoding.UTF8.GetString(data); + else + Text = string.Empty; + } + + public static implicit operator string(WsTextMessage msg) => msg.Text; + + public override string ToString() { + return Text; + } + } +} diff --git a/Hamakaze/WebSocket/WsUtils.cs b/Hamakaze/WebSocket/WsUtils.cs new file mode 100644 index 0000000..ce2b319 --- /dev/null +++ b/Hamakaze/WebSocket/WsUtils.cs @@ -0,0 +1,38 @@ +using System; + +namespace Hamakaze.WebSocket { + internal static class WsUtils { + public static byte[] FromU16(ushort num) { + byte[] buff = BitConverter.GetBytes(num); + if(BitConverter.IsLittleEndian) + Array.Reverse(buff); + return buff; + } + + public static ushort ToU16(ReadOnlySpan buffer) { + if(BitConverter.IsLittleEndian) + buffer = new byte[2] { + buffer[1], buffer[0], + }; + + return BitConverter.ToUInt16(buffer); + } + + public static byte[] FromI64(long num) { + byte[] buff = BitConverter.GetBytes(num); + if(BitConverter.IsLittleEndian) + Array.Reverse(buff); + return buff; + } + + public static long ToI64(ReadOnlySpan buffer) { + if(BitConverter.IsLittleEndian) + buffer = new byte[8] { + buffer[7], buffer[6], buffer[5], buffer[4], + buffer[3], buffer[2], buffer[1], buffer[0], + }; + + return BitConverter.ToInt64(buffer); + } + } +}