From 4ad241f60b952dee5bb04d2de88d69ba44b33718 Mon Sep 17 00:00:00 2001 From: valentinbreiz Date: Sat, 3 Oct 2026 11:20:41 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Port=20the=20HTTP=20client=20to?= =?UTF-8?q?=20Cosmos=20Gen3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cosmos.Network.Http 2.0.0 is a plain net10.0 library over System.Net.Sockets, with no Cosmos reference, like Cosmos.Network.Ftp: a Gen3 kernel runs it on its network stack through the socket and DNS plugs, and a desktop runs it as is. new HttpRequest(url).Send() returns the response whole: status, headers and body, error statuses included, with EnsureSuccessStatusCode and GetString on top. Requests set their method, body, headers, timeout and redirect limit. Bodies end at their Content-Length, their last chunk or the end of the connection, so a server that keeps it open is not waited for. 1xx responses are skipped, redirects are followed (a 303 turns into a GET), and https:// is refused: there is no TLS. It works around the Gen3 socket plugs: host names are resolved before connecting, only the byte[] receive overloads are used, the socket is closed outside finally, and the waiting is done by Socket.Poll, never Thread.Sleep, so a request may run on the kernel's main loop. Responses come without content coding, which drops the Zlib.Portable dependency. --- .gitignore | 3 +- Cosmos.Network.Http.slnx | 5 + CosmosHttp.csproj | 42 --- source/Client/HttpRequest.cs | 287 ----------------- source/Client/HttpResponse.cs | 154 --------- source/Compress.cs | 105 ------ source/HttpPacket.cs | 84 ----- src/Cosmos.Network.Http/ChunkedBody.cs | 150 +++++++++ .../Cosmos.Network.Http.csproj | 44 +++ src/Cosmos.Network.Http/HttpConnection.cs | 177 +++++++++++ src/Cosmos.Network.Http/HttpException.cs | 28 ++ src/Cosmos.Network.Http/HttpRequest.cs | 262 +++++++++++++++ src/Cosmos.Network.Http/HttpResponse.cs | 142 +++++++++ src/Cosmos.Network.Http/HttpUrl.cs | 249 +++++++++++++++ src/Cosmos.Network.Http/ResponseReader.cs | 298 ++++++++++++++++++ 15 files changed, 1357 insertions(+), 673 deletions(-) create mode 100644 Cosmos.Network.Http.slnx delete mode 100644 CosmosHttp.csproj delete mode 100644 source/Client/HttpRequest.cs delete mode 100644 source/Client/HttpResponse.cs delete mode 100644 source/Compress.cs delete mode 100644 source/HttpPacket.cs create mode 100644 src/Cosmos.Network.Http/ChunkedBody.cs create mode 100644 src/Cosmos.Network.Http/Cosmos.Network.Http.csproj create mode 100644 src/Cosmos.Network.Http/HttpConnection.cs create mode 100644 src/Cosmos.Network.Http/HttpException.cs create mode 100644 src/Cosmos.Network.Http/HttpRequest.cs create mode 100644 src/Cosmos.Network.Http/HttpResponse.cs create mode 100644 src/Cosmos.Network.Http/HttpUrl.cs create mode 100644 src/Cosmos.Network.Http/ResponseReader.cs diff --git a/.gitignore b/.gitignore index f677870..4c9a8fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin obj -.vs \ No newline at end of file +.vs +artifacts diff --git a/Cosmos.Network.Http.slnx b/Cosmos.Network.Http.slnx new file mode 100644 index 0000000..7e95d0d --- /dev/null +++ b/Cosmos.Network.Http.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/CosmosHttp.csproj b/CosmosHttp.csproj deleted file mode 100644 index 11360ea..0000000 --- a/CosmosHttp.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - - net6.0 - True - Cosmos - Cosmos - HTTP Client for CosmosOS. - 1.0.4 - - - - CosmosHttpClient - icon.png - LICENSE.txt - https://github.com/CosmosOS/CosmosHttp - https://github.com/CosmosOS/CosmosHttp - README.md - - - - - - - - - - - - - - - True - \ - - - True - \ - - - - \ No newline at end of file diff --git a/source/Client/HttpRequest.cs b/source/Client/HttpRequest.cs deleted file mode 100644 index 56f57f3..0000000 --- a/source/Client/HttpRequest.cs +++ /dev/null @@ -1,287 +0,0 @@ -/* -* PROJECT: CosmosHttp Development -* CONTENT: Http Request class (Heavily inspered by https://github.com/2881099/TcpClientHttpRequest) -* PROGRAMMERS: Valentin Charbonnier -*/ - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace CosmosHttp.Client -{ - public class HttpRequest : HttpPacket - { - private TcpClient _client; - private string _remote; - private string _path; - private int _timeout = 20000; - private NetworkStream _stream; - private HttpResponse _response; - - public string Path - { - get - { - return _path; - } - set - { - _path = value; - } - } - - public HttpResponse Response - { - get { return _response; } - } - - public HttpRequest() - { - _headers.Add("Connection", "Keep-Alive"); - _headers.Add("Accept", "*/*"); - _headers.Add("User-Agent", "CosmosHttp Client (CosmosOS)"); - _headers.Add("Accept-Language", "en-us"); - _headers.Add("Accept-Encoding", "gzip, deflate"); - } - - public void Close() - { - if (_client != null) - { - if (_stream != null) - { - _stream.Close(); - } - _client.Close(); - _client = null; - } - } - - public void Send() - { - Send(string.Empty); - } - - public virtual void Send(string data) - { - Send(data, 0); - } - - private void Send(string data, int redirections) - { - _data = data; - - _headers.Remove("Content-Length"); - if (!string.IsNullOrEmpty(data) && string.Compare(_method, "post", true) == 0) - { - _headers["Content-Length"] = string.Concat(Encoding.ASCII.GetBytes(data).Length); - if (string.IsNullOrEmpty(_headers["Content-Type"])) - { - _headers["Content-Type"] = "application/x-www-form-urlencoded; charset=" + _charset; - } - else if (_headers["Content-Type"].IndexOf("multipart/form-data") == -1) - { - if (_headers["Content-Type"].IndexOf("application/x-www-form-urlencoded") == -1) - { - _headers["Content-Type"] += "; application/x-www-form-urlencoded"; - } - if (_headers["Content-Type"].IndexOf("charset=") == -1) - { - _headers["Content-Type"] += "; charset=" + _charset; - } - } - data += "\r\n\r\n"; - } - _headers["Host"] = _domain; - - string http = _method + " " + _path + " HTTP/1.1\r\n"; - foreach (string head in _headers.Keys) - { - http += head + ": " + _headers[head] + "\r\n"; - } - - http += "\r\n" + data; - _head = http; - byte[] request = Encoding.ASCII.GetBytes(http); - if (_client == null || _remote == null) - { - _remote = _ip; - this.Close(); - _client = new TcpClient(_ip, 80); - } - try - { - _stream = getStream(); - _stream.Write(request, 0, request.Length); - } - catch - { - this.Close(); - _client = new TcpClient(_ip, 80); - _stream = getStream(); - _stream.Write(request, 0, request.Length); - } - receive(_stream, redirections, _ip); - } - - protected void receive(Stream stream, int redirections, string action) - { - // stream.ReadTimeout = _timeout; TO PLUG - _response = null; - byte[] bytes = new byte[1024]; - int bytesRead = 0; - byte[] headBuffer = null; - byte[] bodyBuffer = null; - Exception exception = null; - - while (true) - { - int idx = -1; - try - { - bytesRead = stream.Read(bytes, 0, bytes.Length); - if (bytesRead == 0) - { - if (headBuffer == null || headBuffer.Length == 0) - { - throw new Exception("headBuffer is empty and no more data to read"); - } - break; - } - } - catch (Exception e) - { - exception = e; - break; - } - - if (_response == null) - { - // Add the newly read bytes to the head buffer - int oldLength = headBuffer != null ? headBuffer.Length : 0; - byte[] newHeadBuffer = new byte[oldLength + bytesRead]; - if (headBuffer != null) - { - Array.Copy(headBuffer, 0, newHeadBuffer, 0, oldLength); - } - Array.Copy(bytes, 0, newHeadBuffer, oldLength, bytesRead); - headBuffer = newHeadBuffer; - - // Check for the header delimiter - idx = Utils.findBytes(headBuffer, new byte[] { 13, 10, 13, 10 }, 0); - if (idx != -1) - { - // Create the response with the header - byte[] header = new byte[idx]; - Array.Copy(headBuffer, 0, header, 0, idx); - _response = new HttpResponse(this, header); - _response.Received += headBuffer.Length - idx - 4; - - // Transfer remaining bytes to the body buffer - int bodyLength = headBuffer.Length - idx - 4; - bodyBuffer = new byte[bodyLength]; - Array.Copy(headBuffer, idx + 4, bodyBuffer, 0, bodyLength); - } - } - else - { - _response.Received += bytesRead; - // Add the newly read bytes to the body buffer - int oldLength = bodyBuffer != null ? bodyBuffer.Length : 0; - byte[] newBodyBuffer = new byte[oldLength + bytesRead]; - if (bodyBuffer != null) - { - Array.Copy(bodyBuffer, 0, newBodyBuffer, 0, oldLength); - } - Array.Copy(bytes, 0, newBodyBuffer, oldLength, bytesRead); - bodyBuffer = newBodyBuffer; - } - - if (_response != null) - { - if (_response.ContentLength >= 0) - { - if (_response.ContentLength <= bodyBuffer.Length) - { - break; - } - } - } - } - - if (_response == null) - { - this.closeTcp(); - - // Construct the request headers string - List sb = new List(); - sb.Add(_method.ToUpper() + " " + _ip + " HTTP/1.1"); - foreach (string header in _headers.Keys) - { - sb.Add(header + ": " + _headers[header]); - } - - // Throw a WebException with the appropriate message - if (exception == null) - { - throw new WebException("WebException " + string.Join("\r\n", sb.ToArray())); - } - else - { - throw new WebException(exception.Message + "\r\n" + string.Join("\r\n", sb.ToArray()), exception); - } - } - - _response.SetStream(bodyBuffer); - - this.closeTcp(); - } - - protected bool closeTcp() - { - this.Close(); - return false; - } - - protected NetworkStream getStream() - { - return _client.GetStream(); - } - } - - public class Utils - { - public static int findBytes(byte[] source, byte[] find, int startIndex) - { - if (find == null) return -1; - if (find.Length == 0) return -1; - if (source == null) return -1; - if (source.Length == 0) return -1; - if (startIndex < 0) startIndex = 0; - int idx = -1, idx2 = startIndex - 1; - do - { - idx2 = idx = Array.FindIndex(source, Math.Min(idx2 + 1, source.Length), delegate (byte b) { - return b == find[0]; - }); - if (idx2 != -1) - { - for (int a = 1; a < find.Length; a++) - { - if (++idx2 >= source.Length || source[idx2] != find[a]) - { - idx = -1; - break; - } - } - if (idx != -1) break; - } - } while (idx2 != -1); - return idx; - } - } -} \ No newline at end of file diff --git a/source/Client/HttpResponse.cs b/source/Client/HttpResponse.cs deleted file mode 100644 index ab4b99e..0000000 --- a/source/Client/HttpResponse.cs +++ /dev/null @@ -1,154 +0,0 @@ -/* -* PROJECT: CosmosHttp Development -* CONTENT: Http Response class (Heavily inspered by https://github.com/2881099/TcpClientHttpRequest) -* PROGRAMMERS: Valentin Charbonnier -*/ - -using System; -using System.Net; -using System.Text; - -namespace CosmosHttp.Client -{ - public class HttpResponse : HttpPacket - { - private int _received = 0; - private HttpStatusCode _statusCode; - private int _contentLength = -1; - private string _contentType; - private string _server; - private string _content; - private string _contentEncoding = string.Empty; - private byte[] _stream = new byte[] { }; - - public int Received - { - get { return _received; } - internal set { _received = value; } - } - - public string TransferEncoding - { - get { return _headers["Transfer-Encoding"]; } - set { _headers["Transfer-Encoding"] = value; } - } - - public int ContentLength - { - get { return _contentLength; } - } - - public string Content - { - get - { - if (_content == null) - { - _content = Encoding.ASCII.GetString(_stream); - } - return _content; - } - } - - public HttpResponse(HttpRequest ie, byte[] headBytes) - { - _ip = ie.IP; - _method = ie.Method; - _charset = ie.Charset; - string head = Encoding.ASCII.GetString(headBytes); - _head = head = head.Trim(); - int idx = head.IndexOf(' '); - if (idx != -1) - { - head = head.Substring(idx + 1); - } - idx = head.IndexOf(' '); - if (idx != -1) - { - _statusCode = (HttpStatusCode)int.Parse(head.Remove(idx)); - head = head.Substring(idx + 1); - } - idx = head.IndexOf("\r\n"); - if (idx != -1) - { - head = head.Substring(idx + 2); - } - - string[] heads = head.Split(new string[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries); - foreach (string h in heads) - { - string[] nv = h.Split(new char[] { ':' }, 2); - if (nv.Length == 2) - { - string n = nv[0].Trim(); - string v = nv[1].Trim(); - - // Handle specific headers and their unique cases - switch (n.ToLower()) - { - case "content-length": - if (!int.TryParse(v, out _contentLength)) _contentLength = -1; - break; - case "content-type": - _contentType = v; - idx = v.IndexOf("charset=", StringComparison.OrdinalIgnoreCase); - if (idx != -1) - { - string charset = v.Substring(idx + 8).Split(';')[0].Trim(); - if (string.Compare(_charset, charset, StringComparison.OrdinalIgnoreCase) != 0) - { - try - { - Encoding testEncode = Encoding.GetEncoding(charset); - _charset = charset; - } - catch (Exception ex) - { - Cosmos.HAL.Global.debugger.Send("Ex: " + ex.ToString()); - } - } - } - break; - case "server": - _server = v; - break; - case "content-encoding": - _contentEncoding = v; - break; - // Add more specific headers as needed - default: - if (_headers.ContainsKey(n)) - { - // Append or replace based on your requirement - _headers[n] = v; - } - else - { - _headers.Add(n, v); - } - break; - } - } - } - } - - public void SetStream(byte[] bodyBytes) - { - _stream = bodyBytes; - _contentLength = bodyBytes.Length; - } - - public byte[] GetStream() - { - switch (_contentEncoding.ToLower()) - { - case "gzip": - return GZip.Decompress(_stream); - case "deflate": - return Deflate.Decompress(_stream); - default: - return _stream; - } - } - } -} \ No newline at end of file diff --git a/source/Compress.cs b/source/Compress.cs deleted file mode 100644 index 6b6158b..0000000 --- a/source/Compress.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace CosmosHttp -{ - public static class GZip - { - public static byte[] Decompress(Stream stream) - { - try - { - stream.Position = 0; - using (MemoryStream ms = new MemoryStream()) - { - using (Ionic.Zlib.GZipStream gzip = new Ionic.Zlib.GZipStream(stream, Ionic.Zlib.CompressionMode.Decompress)) - { - byte[] data = new byte[1024]; - int size = 0; - while ((size = gzip.Read(data, 0, data.Length)) > 0) - { - ms.Write(data, 0, size); - } - } - return ms.ToArray(); - } - } - catch { return (stream as MemoryStream).ToArray(); }; - } - public static byte[] Decompress(byte[] bt) - { - return Decompress(new MemoryStream(bt)); - } - public static byte[] Compress(string text) - { - return Compress(Encoding.UTF8.GetBytes(text)); - } - public static byte[] Compress(byte[] bt) - { - return Compress(bt, 0, bt.Length); - } - public static byte[] Compress(byte[] bt, int startIndex, int length) - { - using (MemoryStream ms = new MemoryStream()) - { - using (Ionic.Zlib.GZipStream gzip = new Ionic.Zlib.GZipStream(ms, Ionic.Zlib.CompressionMode.Compress)) - { - gzip.Write(bt, startIndex, length); - } - return ms.ToArray(); - } - } - } - - public static class Deflate - { - public static byte[] Decompress(Stream stream) - { - try - { - stream.Position = 0; - using (MemoryStream ms = new MemoryStream()) - { - using (Ionic.Zlib.DeflateStream def = new Ionic.Zlib.DeflateStream(stream, Ionic.Zlib.CompressionMode.Decompress)) - { - byte[] data = new byte[1024]; - int size = 0; - while ((size = def.Read(data, 0, data.Length)) > 0) - { - ms.Write(data, 0, size); - } - } - return ms.ToArray(); - } - } - catch { return (stream as MemoryStream).ToArray(); }; - } - public static byte[] Decompress(byte[] bt) - { - return Decompress(new MemoryStream(bt)); - } - public static byte[] Compress(string text) - { - return Compress(Encoding.UTF8.GetBytes(text)); - } - public static byte[] Compress(byte[] bt) - { - return Compress(bt, 0, bt.Length); - } - public static byte[] Compress(byte[] bt, int startIndex, int length) - { - using (MemoryStream ms = new MemoryStream()) - { - using (Ionic.Zlib.DeflateStream def = new Ionic.Zlib.DeflateStream(ms, Ionic.Zlib.CompressionMode.Compress)) - { - def.Write(bt, startIndex, length); - } - return ms.ToArray(); - } - } - } -} diff --git a/source/HttpPacket.cs b/source/HttpPacket.cs deleted file mode 100644 index f0b958f..0000000 --- a/source/HttpPacket.cs +++ /dev/null @@ -1,84 +0,0 @@ -/* -* PROJECT: CosmosHttp Development -* CONTENT: Base Http packet class (Heavily inspered by https://github.com/2881099/TcpClientHttpRequest) -* PROGRAMMERS: Valentin Charbonnier -*/ - -using System; -using System.Collections.Generic; - -namespace CosmosHttp -{ - public class HttpPacket : IDisposable - { - internal string _domain; - internal string _ip; - internal string _method = "GET"; - internal string _charset = "us-ascii"; - internal string _data; - internal string _head; - internal Dictionary _headers; - - public HttpPacket() - { - _headers = new Dictionary(); - } - - public string Method - { - get - { - return _method; - } - set - { - _method = value.ToUpper(); - } - } - - public string Domain - { - get - { - return _domain; - } - set - { - _domain = value; - } - } - - public string IP - { - get - { - return _ip; - } - set - { - _ip = value; - } - } - - public string Charset - { - get - { - return _charset; - } - set - { - _charset = value; - } - } - - public Dictionary Headers - { - get { return _headers; } - } - - public void Dispose() - { - } - } -} diff --git a/src/Cosmos.Network.Http/ChunkedBody.cs b/src/Cosmos.Network.Http/ChunkedBody.cs new file mode 100644 index 0000000..d5fb09b --- /dev/null +++ b/src/Cosmos.Network.Http/ChunkedBody.cs @@ -0,0 +1,150 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System.IO; + +namespace Cosmos.Network.Http; + +/// +/// The chunked transfer coding (RFC 9112, section 7.1): chunks, each after +/// its size in hexadecimal, up to a chunk of size 0 and a trailer section. +/// Chunk extensions and trailers carry nothing a client needs and are +/// skipped. +/// +internal static class ChunkedBody +{ + /// + /// Whether the chunked body that starts at + /// has arrived whole: its last chunk and the trailer section that ends it. + /// moves past each whole chunk, so the next + /// call resumes there rather than walking the body again. + /// + /// A chunk size is malformed. + public static bool FindEnd(byte[] data, int length, ref int position) + { + while (true) + { + int lineEnd = IndexOfLineEnd(data, position, length); + if (lineEnd < 0) + { + return false; + } + + int size = ParseSize(data, position, lineEnd); + if (size == 0) + { + // The trailer section: field lines up to an empty one. + int line = lineEnd + 2; + while (true) + { + int end = IndexOfLineEnd(data, line, length); + if (end < 0) + { + return false; + } + + if (end == line) + { + return true; + } + + line = end + 2; + } + } + + long next = (long)lineEnd + 2 + size + 2; + if (next > length) + { + return false; + } + + position = (int)next; + } + } + + /// The data of the chunked body that starts at , chunk sizes, extensions and trailers left out. + /// The body ends before its last chunk, or a chunk size is malformed. + public static byte[] Decode(byte[] data, int start, int length) + { + MemoryStream body = new(); + int position = start; + while (true) + { + int lineEnd = IndexOfLineEnd(data, position, length); + if (lineEnd < 0) + { + throw Truncated(); + } + + int size = ParseSize(data, position, lineEnd); + if (size == 0) + { + return body.ToArray(); + } + + int chunkStart = lineEnd + 2; + if ((long)chunkStart + size > length) + { + throw Truncated(); + } + + body.Write(data, chunkStart, size); + position = chunkStart + size + 2; + } + } + + /// The size on a chunk line, ignoring the extensions after it. + private static int ParseSize(byte[] data, int start, int end) + { + int size = 0; + int digits = 0; + int i = start; + for (; i < end; i++) + { + int digit = HexValue(data[i]); + if (digit < 0) + { + break; + } + + if (size > (int.MaxValue >> 4)) + { + throw new HttpException("The response has a chunk larger than 2 GB."); + } + + size = (size << 4) | digit; + digits++; + } + + // What follows the digits, if anything, is whitespace or a ";name=value" extension. + if (digits == 0 || (i < end && data[i] is not ((byte)';' or (byte)' ' or (byte)'\t'))) + { + throw new HttpException("The response has a malformed chunk size."); + } + + return size; + } + + private static int HexValue(byte c) => c switch + { + >= (byte)'0' and <= (byte)'9' => c - '0', + >= (byte)'a' and <= (byte)'f' => c - 'a' + 10, + >= (byte)'A' and <= (byte)'F' => c - 'A' + 10, + _ => -1, + }; + + /// The index of the next CRLF from , or -1. + private static int IndexOfLineEnd(byte[] data, int start, int length) + { + for (int i = start; i + 1 < length; i++) + { + if (data[i] == '\r' && data[i + 1] == '\n') + { + return i; + } + } + + return -1; + } + + private static HttpException Truncated() => new("The server closed the connection in the middle of a chunked body."); +} diff --git a/src/Cosmos.Network.Http/Cosmos.Network.Http.csproj b/src/Cosmos.Network.Http/Cosmos.Network.Http.csproj new file mode 100644 index 0000000..c766746 --- /dev/null +++ b/src/Cosmos.Network.Http/Cosmos.Network.Http.csproj @@ -0,0 +1,44 @@ + + + + + net10.0 + latest + enable + disable + true + true + Cosmos.Network.Http + + + + Cosmos.Network.Http + 2.0.0 + Cosmos.Network.Http + Cosmos + Cosmos + HTTP client for Cosmos Gen3 kernels. + cosmos;http;client;kernel + icon.png + LICENSE.txt + README.md + https://github.com/CosmosOS/Cosmos.Network.Http + https://github.com/CosmosOS/Cosmos.Network.Http + + + + + + + + + + + + + diff --git a/src/Cosmos.Network.Http/HttpConnection.cs b/src/Cosmos.Network.Http/HttpConnection.cs new file mode 100644 index 0000000..4333766 --- /dev/null +++ b/src/Cosmos.Network.Http/HttpConnection.cs @@ -0,0 +1,177 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Cosmos.Network.Http; + +/// +/// One request and its response, over a connection of their own: requests +/// ask the server to close it once it has answered. +/// +/// +/// Written for the Cosmos socket plugs, in a way a desktop runs as well: +/// +/// Host names are resolved with before connecting: +/// the plugged host name overloads of Connect do not resolve. +/// Bytes come in through the byte[] Receive overload, for what +/// reports: the Span overloads lose what they +/// receive, and a Receive with nothing waiting returns 0 on an open +/// connection. +/// The waiting is done by , +/// never by Thread.Sleep. Poll blocks for its timeout where sockets +/// block, and returns at once on a Cosmos kernel: a request may run on any +/// thread there, the kernel's main loop included, which must never +/// block. +/// The socket is closed after the try, not in a finally: +/// a Cosmos kernel skips finally blocks while an exception +/// unwinds. +/// +/// +internal static class HttpConnection +{ + /// The most taken from the socket at a time, in bytes. + private const int ReceiveChunkSize = 16 * 1024; + + /// How long a Poll waits for data where it can wait, in microseconds. + private const int PollIntervalUs = 10_000; + + /// Sends to and reads the response. + /// Where to connect. + /// The request, head and body. + /// Whether the request is a HEAD one, whose response has no body. + /// How long the server may stay silent, in milliseconds. + /// The host could not be resolved or reached, or the response did not come whole. + public static HttpResponse Exchange(HttpUrl url, byte[] request, bool isHead, int timeout) + { + IPAddress address = Resolve(url.Host); + + Socket socket = new(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + HttpResponse? response = null; + Exception? failure = null; + bool connected = false; + try + { + socket.Connect(address, url.Port); + connected = true; + Send(socket, request); + response = Receive(socket, url, isHead, timeout); + } + catch (Exception exception) + { + failure = exception; + } + + try + { + socket.Close(); + } + catch (Exception) + { + // Nothing left to do with a connection that would not close. + } + + if (failure is HttpException) + { + throw failure; + } + + if (failure is not null) + { + // Not narrowed to SocketException: the Cosmos socket plugs report + // a refused or dropped connection as a bare Exception. + string message = connected + ? $"The connection to {url.Authority} failed: {failure.Message}" + : $"Could not connect to {url.Authority}: {failure.Message}"; + throw new HttpException(message, failure); + } + + return response!; + } + + private static IPAddress Resolve(string host) + { + if (IPAddress.TryParse(host, out IPAddress? literal)) + { + return literal; + } + + IPAddress[] addresses; + try + { + addresses = Dns.GetHostAddresses(host); + } + catch (Exception exception) + { + throw new HttpException($"Could not resolve {host}: {exception.Message}", exception); + } + + // An IPv4 address first: a Cosmos kernel has nothing else to connect to. + foreach (IPAddress address in addresses) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + return address; + } + } + + if (addresses.Length == 0) + { + throw new HttpException($"Could not resolve {host}: it has no address."); + } + + return addresses[0]; + } + + private static void Send(Socket socket, byte[] request) + { + int sent = 0; + while (sent < request.Length) + { + int count = socket.Send(request, sent, request.Length - sent, SocketFlags.None); + if (count <= 0) + { + throw new HttpException("The server closed the connection before the request was sent."); + } + + sent += count; + } + } + + private static HttpResponse Receive(Socket socket, HttpUrl url, bool isHead, int timeout) + { + ResponseReader reader = new(isHead); + byte[] buffer = new byte[ReceiveChunkSize]; + long lastReceived = Stopwatch.GetTimestamp(); + + while (!reader.IsComplete) + { + int available = socket.Available; + if (available > 0) + { + int read = socket.Receive(buffer, 0, Math.Min(available, buffer.Length), SocketFlags.None); + if (read > 0) + { + reader.Append(buffer, read); + lastReceived = Stopwatch.GetTimestamp(); + continue; + } + } + + // Readable with nothing to read: the server closed its end. + if (socket.Poll(PollIntervalUs, SelectMode.SelectRead) && socket.Available == 0) + { + break; + } + + if (Stopwatch.GetElapsedTime(lastReceived).TotalMilliseconds > timeout) + { + throw new HttpException($"{url.Authority} sent nothing for {timeout} ms."); + } + } + + return reader.ToResponse(url.ToString()); + } +} diff --git a/src/Cosmos.Network.Http/HttpException.cs b/src/Cosmos.Network.Http/HttpException.cs new file mode 100644 index 0000000..6308677 --- /dev/null +++ b/src/Cosmos.Network.Http/HttpException.cs @@ -0,0 +1,28 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; + +namespace Cosmos.Network.Http; + +/// +/// A request that did not get a usable response: the host could not be +/// resolved or reached, the server went silent or sent something that is not +/// HTTP, a redirect led nowhere, or +/// found an error status. +/// +public sealed class HttpException : Exception +{ + /// The status of the response that failed, or 0 when there was no response. + public int StatusCode { get; } + + /// Creates an exception, with the status of the response that failed if there was one. + public HttpException(string message, int statusCode = 0) : base(message) + { + StatusCode = statusCode; + } + + /// Creates an exception for a failure underneath HTTP, a socket error for instance. + public HttpException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/src/Cosmos.Network.Http/HttpRequest.cs b/src/Cosmos.Network.Http/HttpRequest.cs new file mode 100644 index 0000000..10cb50b --- /dev/null +++ b/src/Cosmos.Network.Http/HttpRequest.cs @@ -0,0 +1,262 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Cosmos.Network.Http; + +/// +/// An HTTP/1.1 request to an http:// URL. runs it on the +/// calling thread, follows redirects, and returns the response whole. +/// +/// +/// Each request opens a connection of its own and asks the server to close +/// it once it has answered. There is no TLS, so no https://. Responses are +/// asked for without content coding (Accept-Encoding: identity): the +/// body comes as the server stores it. +/// +public sealed class HttpRequest +{ + /// How long the server may stay silent by default, in milliseconds. + public const int DefaultTimeout = 15_000; + + /// How many redirects are followed by default. + public const int DefaultMaxRedirects = 5; + + /// The User-Agent sent unless names one. + public const string DefaultUserAgent = "Cosmos.Network.Http/2.0"; + + /// + /// Headers the request sets itself: Connection tells where a response + /// without a length ends, Content-Length frames the body. + /// + private static readonly string[] s_reservedHeaders = ["Connection", "Content-Length", "Transfer-Encoding"]; + + private readonly HttpUrl _url; + private readonly string _method = "GET"; + private readonly int _timeout = DefaultTimeout; + private readonly int _maxRedirects = DefaultMaxRedirects; + + /// Creates a request; sends it. + /// An http:// URL. One without a scheme is taken as an http:// one, as wget takes it. + /// is empty. + /// is not an http:// URL (https:// needs TLS, which there is none of), or it carries credentials. + /// names no host, or a port that is not a TCP port. + public HttpRequest(string url) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url); + _url = HttpUrl.Parse(url); + } + + /// The URL the request goes to, written out in full. + public string Url => _url.ToString(); + + /// The method, GET unless set: HEAD, POST, PUT, DELETE and so on. + public string Method + { + get => _method; + init + { + ArgumentException.ThrowIfNullOrEmpty(value); + if (!IsToken(value)) + { + throw new ArgumentException($"'{value}' is not an HTTP method.", nameof(value)); + } + + _method = value; + } + } + + /// The body sent after the head, if any; a POST, PUT or PATCH without one sends an empty body. + public byte[]? Body { get; init; } + + /// + /// Headers sent with the request, looked up case-insensitively. They + /// replace the ones the request sends by default: Host, User-Agent, + /// Accept and Accept-Encoding. Connection, Content-Length and + /// Transfer-Encoding are the request's own, and cannot be set. + /// + public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// How long the server may stay silent while it answers before the request fails, in milliseconds. + public int Timeout + { + get => _timeout; + init + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _timeout = value; + } + } + + /// + /// How many redirects (301, 302, 303, 307, 308) are followed. With 0, + /// returns the redirect itself. + /// + public int MaxRedirects + { + get => _maxRedirects; + init + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + _maxRedirects = value; + } + } + + /// Receives a line for every response and redirect, when set. + public Action? Log { get; init; } + + /// + /// Sends the request on the calling thread, follows redirects, and + /// returns the response once it has arrived whole. Any status is + /// returned, error ones included. + /// + /// + /// A 303, and a 301 or 302 to a POST, turn the request into a GET without + /// a body, as browsers do. The other redirects send it again as it was. + /// + /// sets a header the request sets itself, or holds a name or a value that cannot be sent. + /// The host could not be resolved or reached, the server went silent for longer than , the response did not come whole or is not HTTP, or the redirects went on for longer than or to a URL that cannot be followed. + public HttpResponse Send() + { + CheckHeaders(); + + HttpUrl url = _url; + string method = _method; + byte[]? body = Body; + + for (int redirects = 0; ; redirects++) + { + HttpResponse response = HttpConnection.Exchange(url, BuildRequest(url, method, body), method == "HEAD", _timeout); + Log?.Invoke($"{method} {url} {response.StatusCode} {response.ReasonPhrase} ({response.Content.Length} bytes)"); + + string? location = response.GetHeader("Location"); + if (!IsRedirect(response.StatusCode) || location is null || _maxRedirects == 0) + { + return response; + } + + if (redirects == _maxRedirects) + { + throw new HttpException($"{_url} redirected more than {_maxRedirects} times.", response.StatusCode); + } + + HttpUrl next; + try + { + next = url.Resolve(location); + } + catch (Exception exception) + { + // NotSupportedException (https://) or FormatException. + throw new HttpException($"{url} redirected to a URL that cannot be followed: {exception.Message}", response.StatusCode); + } + + if ((response.StatusCode == 303 && method != "HEAD") || (response.StatusCode is 301 or 302 && method == "POST")) + { + method = "GET"; + body = null; + } + + Log?.Invoke($"Redirected to {next}"); + url = next; + } + } + + private void CheckHeaders() + { + foreach (KeyValuePair header in Headers) + { + foreach (string reserved in s_reservedHeaders) + { + if (header.Key.Equals(reserved, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"The {reserved} header is set by the request itself."); + } + } + + if (!IsToken(header.Key)) + { + throw new InvalidOperationException($"'{header.Key}' is not a header name."); + } + + if (header.Value is null || header.Value.AsSpan().IndexOfAny('\r', '\n', '\0') >= 0) + { + throw new InvalidOperationException($"The value of the {header.Key} header cannot be sent: it is null or breaks the line."); + } + } + } + + private byte[] BuildRequest(HttpUrl url, string method, byte[]? body) + { + StringBuilder head = new(); + head.Append(method).Append(' ').Append(url.Target).Append(" HTTP/1.1\r\n"); + AppendHeader(head, "Host", url.Authority); + AppendHeader(head, "User-Agent", DefaultUserAgent); + AppendHeader(head, "Accept", "*/*"); + // Nothing gzip or deflate would have to undo: a Cosmos kernel has no System.IO.Compression. + AppendHeader(head, "Accept-Encoding", "identity"); + head.Append("Connection: close\r\n"); + + if (body is not null || method is "POST" or "PUT" or "PATCH") + { + head.Append("Content-Length: ").Append(body is null ? 0 : body.Length).Append("\r\n"); + } + + foreach (KeyValuePair header in Headers) + { + if (!IsDefaultHeader(header.Key)) + { + head.Append(header.Key).Append(": ").Append(header.Value).Append("\r\n"); + } + } + + head.Append("\r\n"); + + byte[] headBytes = Encoding.UTF8.GetBytes(head.ToString()); + if (body is null || body.Length == 0) + { + return headBytes; + } + + byte[] request = new byte[headBytes.Length + body.Length]; + Buffer.BlockCopy(headBytes, 0, request, 0, headBytes.Length); + Buffer.BlockCopy(body, 0, request, headBytes.Length, body.Length); + return request; + } + + /// Appends a header the request sends by default, with the value gives it if any. + private void AppendHeader(StringBuilder head, string name, string defaultValue) + { + string value = Headers.TryGetValue(name, out string? set) ? set : defaultValue; + head.Append(name).Append(": ").Append(value).Append("\r\n"); + } + + private static bool IsDefaultHeader(string name) => + name.Equals("Host", StringComparison.OrdinalIgnoreCase) + || name.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) + || name.Equals("Accept", StringComparison.OrdinalIgnoreCase) + || name.Equals("Accept-Encoding", StringComparison.OrdinalIgnoreCase); + + private static bool IsRedirect(int statusCode) => statusCode is 301 or 302 or 303 or 307 or 308; + + /// Whether is a token (RFC 9110, section 5.6.2), as methods and header names are. + private static bool IsToken(string value) + { + if (value.Length == 0) + { + return false; + } + + foreach (char c in value) + { + if (!char.IsAsciiLetterOrDigit(c) && "!#$%&'*+-.^_`|~".IndexOf(c) < 0) + { + return false; + } + } + + return true; + } +} diff --git a/src/Cosmos.Network.Http/HttpResponse.cs b/src/Cosmos.Network.Http/HttpResponse.cs new file mode 100644 index 0000000..849f742 --- /dev/null +++ b/src/Cosmos.Network.Http/HttpResponse.cs @@ -0,0 +1,142 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Cosmos.Network.Http; + +/// +/// What a server answered: the status, the headers and the whole body. +/// returns any status, error ones included; +/// turns those into an exception. +/// +public sealed class HttpResponse +{ + /// The URL that answered: the one requested, or where its redirects led. + public string Url { get; } + + /// The HTTP version the server answered with, such as HTTP/1.1. + public string Version { get; } + + /// The status code, such as 200 or 404. + public int StatusCode { get; } + + /// The text after the status code, such as OK; it may be empty. + public string ReasonPhrase { get; } + + /// + /// The headers, looked up case-insensitively. A header the server sent + /// more than once holds its values joined by ", ". + /// + public IReadOnlyDictionary Headers { get; } + + /// + /// The body, with any chunked transfer coding removed. It is empty for a + /// HEAD request and for a 204 or 304. + /// + public byte[] Content { get; } + + /// Whether the status is a 2xx one. + public bool IsSuccessStatusCode => StatusCode is >= 200 and <= 299; + + /// The Content-Type header, or null when the server sent none. + public string? ContentType => GetHeader("Content-Type"); + + internal HttpResponse(string url, string version, int statusCode, string reasonPhrase, Dictionary headers, byte[] content) + { + Url = url; + Version = version; + StatusCode = statusCode; + ReasonPhrase = reasonPhrase; + Headers = headers; + Content = content; + } + + /// A header's value, or null when the server did not send it. + public string? GetHeader(string name) => Headers.TryGetValue(name, out string? value) ? value : null; + + /// + /// The body as text, decoded with the charset of the Content-Type header: + /// UTF-8, US-ASCII, ISO-8859-1 or UTF-16. Any other charset, or none, is + /// read as UTF-8. A byte order mark is not part of the text. + /// + public string GetString() + { + byte[] content = Content; + if (content.Length >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF) + { + return Encoding.UTF8.GetString(content, 3, content.Length - 3); + } + + return GetEncoding(GetCharset(ContentType)).GetString(content); + } + + /// Throws unless the status is a 2xx one. + /// This response, so the call can be chained. + /// The status is not a 2xx one; the exception carries it. + public HttpResponse EnsureSuccessStatusCode() + { + if (!IsSuccessStatusCode) + { + string status = ReasonPhrase.Length == 0 ? $"{StatusCode}" : $"{StatusCode} {ReasonPhrase}"; + throw new HttpException($"{Url} answered {status}.", StatusCode); + } + + return this; + } + + private static string? GetCharset(string? contentType) + { + if (contentType is null) + { + return null; + } + + foreach (string parameter in contentType.Split(';')) + { + string trimmed = parameter.Trim(); + if (trimmed.StartsWith("charset=", StringComparison.OrdinalIgnoreCase)) + { + return trimmed.Substring("charset=".Length).Trim('"', ' '); + } + } + + return null; + } + + /// + /// Only the encodings every .NET runtime has built in: a Cosmos kernel has + /// no code page provider for to + /// find the others in. + /// + private static Encoding GetEncoding(string? charset) + { + if (charset is null) + { + return Encoding.UTF8; + } + + if (charset.Equals("us-ascii", StringComparison.OrdinalIgnoreCase) || charset.Equals("ascii", StringComparison.OrdinalIgnoreCase)) + { + return Encoding.ASCII; + } + + if (charset.Equals("iso-8859-1", StringComparison.OrdinalIgnoreCase) || charset.Equals("latin1", StringComparison.OrdinalIgnoreCase)) + { + return Encoding.Latin1; + } + + if (charset.Equals("utf-16", StringComparison.OrdinalIgnoreCase) || charset.Equals("utf-16le", StringComparison.OrdinalIgnoreCase)) + { + return Encoding.Unicode; + } + + if (charset.Equals("utf-16be", StringComparison.OrdinalIgnoreCase)) + { + return Encoding.BigEndianUnicode; + } + + return Encoding.UTF8; + } +} diff --git a/src/Cosmos.Network.Http/HttpUrl.cs b/src/Cosmos.Network.Http/HttpUrl.cs new file mode 100644 index 0000000..ca725d3 --- /dev/null +++ b/src/Cosmos.Network.Http/HttpUrl.cs @@ -0,0 +1,249 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Globalization; +using System.Text; + +namespace Cosmos.Network.Http; + +/// +/// An http:// URL taken apart into what a request needs: the host and port to +/// connect to, and the target its request line names. The fragment is dropped, +/// as it never leaves the client. +/// +internal sealed class HttpUrl +{ + /// The port of an http:// URL that names none. + public const int DefaultPort = 80; + + private const string HexDigits = "0123456789ABCDEF"; + + /// The host name or address, an IPv6 literal without its brackets. + public string Host { get; } + + /// The TCP port to connect to. + public int Port { get; } + + /// The path and query the request line names: at least /, percent-encoded where it has to be. + public string Target { get; } + + /// The host, and the port when it is not 80: what the Host header names. + public string Authority { get; } + + private HttpUrl(string host, int port, string target) + { + Host = host; + Port = port; + Target = target; + + string name = host.Contains(':') ? "[" + host + "]" : host; + Authority = port == DefaultPort ? name : $"{name}:{port}"; + } + + public override string ToString() => "http://" + Authority + Target; + + /// + /// Takes apart an absolute URL. One without a scheme is an http:// URL, as + /// wget takes it. + /// + /// The URL is not an http:// one (there is no TLS, so not an https:// one either), or it carries credentials. + /// The URL names no host, or a port that is not a TCP port. + public static HttpUrl Parse(string url) + { + string rest = url.Trim(); + + int fragment = rest.IndexOf('#'); + if (fragment >= 0) + { + rest = rest.Substring(0, fragment); + } + + int schemeLength = SchemeLength(rest); + if (schemeLength > 0) + { + string scheme = rest.Substring(0, schemeLength); + if (scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException($"{url} needs TLS, which is not supported: use http://."); + } + + if (!scheme.Equals("http", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException($"{url} is not an http:// URL."); + } + + rest = rest.Substring(schemeLength + 3); + } + + int authorityEnd = rest.IndexOfAny(['/', '?']); + string authority = authorityEnd < 0 ? rest : rest.Substring(0, authorityEnd); + string target = authorityEnd < 0 ? "/" : rest.Substring(authorityEnd); + + if (authority.Contains('@')) + { + throw new NotSupportedException($"{url} carries credentials, which are not supported."); + } + + string host; + string? portText = null; + if (authority.StartsWith('[')) + { + int close = authority.IndexOf(']'); + if (close < 0) + { + throw new FormatException($"{url} has an IPv6 address with no closing bracket."); + } + + host = authority.Substring(1, close - 1); + string afterHost = authority.Substring(close + 1); + if (afterHost.Length > 0) + { + if (afterHost[0] != ':') + { + throw new FormatException($"{url} has something other than a port after its IPv6 address."); + } + + portText = afterHost.Substring(1); + } + } + else + { + int colon = authority.IndexOf(':'); + host = colon < 0 ? authority : authority.Substring(0, colon); + portText = colon < 0 ? null : authority.Substring(colon + 1); + } + + if (host.Length == 0) + { + throw new FormatException($"{url} names no host."); + } + + // An empty port ("host:/") is the default one (RFC 3986, section 3.2.3). + int port = DefaultPort; + if (!string.IsNullOrEmpty(portText) + && (!int.TryParse(portText, NumberStyles.None, CultureInfo.InvariantCulture, out port) || port < 1 || port > 65535)) + { + throw new FormatException($"{url} names port {portText}, which is not a TCP port."); + } + + if (target.StartsWith('?')) + { + target = "/" + target; + } + + return new HttpUrl(host, port, EncodeTarget(target)); + } + + /// + /// Resolves the Location of a redirect against this URL: an absolute URL, + /// a URL without a scheme (//host/path), an absolute path, a query, + /// or a path relative to this URL's directory. + /// + /// The location is an absolute URL does not support. + /// The location is an absolute URL cannot take apart. + public HttpUrl Resolve(string location) + { + string reference = location.Trim(); + + if (SchemeLength(reference) > 0) + { + return Parse(reference); + } + + if (reference.StartsWith("//", StringComparison.Ordinal)) + { + return Parse("http:" + reference); + } + + int fragment = reference.IndexOf('#'); + if (fragment >= 0) + { + reference = reference.Substring(0, fragment); + } + + if (reference.Length == 0) + { + return this; + } + + string path = Target; + int query = path.IndexOf('?'); + if (query >= 0) + { + path = path.Substring(0, query); + } + + string target = reference[0] switch + { + '/' => reference, + '?' => path + reference, + // The target always starts with '/', so there is a directory to resolve against. + _ => path.Substring(0, path.LastIndexOf('/') + 1) + reference, + }; + + return new HttpUrl(Host, Port, EncodeTarget(target)); + } + + /// + /// The length of the scheme starts with, when it + /// starts with scheme:// (RFC 3986, section 3.1); otherwise 0. + /// + private static int SchemeLength(string url) + { + int end = url.IndexOf("://", StringComparison.Ordinal); + if (end <= 0) + { + return 0; + } + + for (int i = 0; i < end; i++) + { + char c = url[i]; + bool valid = char.IsAsciiLetter(c) || (i > 0 && (char.IsAsciiDigit(c) || c is '+' or '-' or '.')); + if (!valid) + { + return 0; + } + } + + return end; + } + + /// + /// Percent-encodes what a request line cannot carry as is: spaces, control + /// characters and anything beyond ASCII, as UTF-8. What is already + /// percent-encoded is left alone. + /// + private static string EncodeTarget(string target) + { + bool clean = true; + foreach (char c in target) + { + if (c <= ' ' || c > '~') + { + clean = false; + break; + } + } + + if (clean) + { + return target; + } + + StringBuilder encoded = new(target.Length + 16); + foreach (byte b in Encoding.UTF8.GetBytes(target)) + { + if (b <= ' ' || b > '~') + { + encoded.Append('%').Append(HexDigits[b >> 4]).Append(HexDigits[b & 0xF]); + } + else + { + encoded.Append((char)b); + } + } + + return encoded.ToString(); + } +} diff --git a/src/Cosmos.Network.Http/ResponseReader.cs b/src/Cosmos.Network.Http/ResponseReader.cs new file mode 100644 index 0000000..ef6f566 --- /dev/null +++ b/src/Cosmos.Network.Http/ResponseReader.cs @@ -0,0 +1,298 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace Cosmos.Network.Http; + +/// +/// Takes a response apart as its bytes arrive, and tells when it has arrived +/// whole (RFC 9112, section 6.3), so the connection need not be read to its +/// end: a response with a Content-Length or a chunked body is complete +/// before the server closes. One without either ends where the connection +/// does. +/// +internal sealed class ResponseReader +{ + /// The longest response head taken, in bytes. + internal const int MaxHeadLength = 64 * 1024; + + /// The largest Content-Length the receive buffer is sized for up front, in bytes. + private const int MaxPreallocatedLength = 64 * 1024 * 1024; + + private enum Framing + { + /// No body: a response to HEAD, a 1xx, a 204 or a 304. + None, + + /// As many bytes as Content-Length says. + Length, + + /// The chunked transfer coding. + Chunked, + + /// Everything up to the end of the connection. + UntilClose, + } + + private readonly MemoryStream _received = new(); + private readonly bool _isHead; + + private Head? _head; + private Framing _framing; + private long _contentLength; + + /// Where the head being looked for starts: past any 1xx interim response. + private int _headStart; + + /// Where the search for the end of that head resumes. + private int _scanFrom; + + private int _bodyStart; + + /// Where the search for the end of a chunked body resumes. + private int _chunkScan; + + /// Whether the request was a HEAD one, whose response has no body whatever its headers say. + public ResponseReader(bool isHead) + { + _isHead = isHead; + } + + /// Whether the whole response has arrived. + public bool IsComplete { get; private set; } + + /// Takes the next bytes of the response. + /// The bytes are not an HTTP response. + public void Append(byte[] buffer, int count) + { + _received.Write(buffer, 0, count); + + if (_head is null && !TryReadHead()) + { + return; + } + + IsComplete = _framing switch + { + Framing.None => true, + Framing.Length => _received.Length - _bodyStart >= _contentLength, + Framing.Chunked => ChunkedBody.FindEnd(_received.GetBuffer(), (int)_received.Length, ref _chunkScan), + _ => false, + }; + } + + /// + /// The response, once it is complete or the server has closed the + /// connection, which completes a response that runs until it does. + /// + /// The URL that answered. + /// The connection closed before the response was whole. + public HttpResponse ToResponse(string url) + { + if (_head is null) + { + throw new HttpException(_received.Length == _headStart + ? "The server closed the connection without answering." + : "The server closed the connection in the middle of the response head."); + } + + byte[] data = _received.GetBuffer(); + int received = (int)_received.Length - _bodyStart; + byte[] content; + switch (_framing) + { + case Framing.None: + content = []; + break; + + case Framing.Length: + if (received < _contentLength) + { + throw new HttpException($"The server closed the connection after {received} of {_contentLength} bytes.", _head.StatusCode); + } + + content = Slice(data, _bodyStart, (int)_contentLength); + break; + + case Framing.Chunked: + content = ChunkedBody.Decode(data, _bodyStart, (int)_received.Length); + break; + + default: + content = Slice(data, _bodyStart, received); + break; + } + + return new HttpResponse(url, _head.Version, _head.StatusCode, _head.ReasonPhrase, _head.Headers, content); + } + + /// Looks for the end of the head, and takes the head apart once it arrived. + /// Whether the head of the final response arrived. + private bool TryReadHead() + { + byte[] data = _received.GetBuffer(); + int length = (int)_received.Length; + + while (true) + { + int end = IndexOfHeadEnd(data, _scanFrom, length); + if (end < 0) + { + if (length - _headStart > MaxHeadLength) + { + throw new HttpException($"The response head is longer than {MaxHeadLength} bytes."); + } + + // The blank line may straddle what arrives next. + _scanFrom = Math.Max(_headStart, length - 3); + return false; + } + + Head head = ParseHead(data, _headStart, end - _headStart); + if (head.StatusCode is >= 100 and <= 199 && head.StatusCode != 101) + { + // An interim response, such as 100 Continue: the final one follows. + _headStart = _scanFrom = end; + continue; + } + + _head = head; + _bodyStart = _chunkScan = end; + ChooseFraming(head); + return true; + } + } + + private void ChooseFraming(Head head) + { + if (_isHead || head.StatusCode is 204 or 304 or (>= 100 and <= 199)) + { + _framing = Framing.None; + return; + } + + // Transfer-Encoding wins over Content-Length. A coding other than + // chunked last leaves nothing to tell the end of the body by but the + // end of the connection. + if (head.Headers.TryGetValue("Transfer-Encoding", out string? transferEncoding)) + { + _framing = transferEncoding.Trim().EndsWith("chunked", StringComparison.OrdinalIgnoreCase) ? Framing.Chunked : Framing.UntilClose; + return; + } + + if (head.Headers.TryGetValue("Content-Length", out string? contentLength)) + { + _framing = Framing.Length; + _contentLength = ParseContentLength(contentLength); + if (_contentLength <= MaxPreallocatedLength) + { + _received.Capacity = Math.Max(_received.Capacity, _bodyStart + (int)_contentLength); + } + + return; + } + + _framing = Framing.UntilClose; + } + + /// + /// A Content-Length, which a server that sent it more than once joined + /// with commas: taken if every copy agrees (RFC 9110, section 8.6). + /// + private static long ParseContentLength(string value) + { + long length = -1; + foreach (string part in value.Split(',')) + { + if (!long.TryParse(part.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long parsed) + || parsed > int.MaxValue + || (length >= 0 && parsed != length)) + { + throw new HttpException($"The response has an invalid Content-Length: {value}."); + } + + length = parsed; + } + + return length; + } + + /// Takes apart a status line and the header lines after it (RFC 9112, sections 4 and 5). + private static Head ParseHead(byte[] data, int start, int length) + { + // Headers are ASCII but for the odd value, which UTF-8 reads as well as anything. + string[] lines = Encoding.UTF8.GetString(data, start, length).Split("\r\n"); + string statusLine = lines[0]; + + // HTTP-version SP 3DIGIT [SP reason-phrase] + int space = statusLine.IndexOf(' '); + int statusCode = 0; + if (!statusLine.StartsWith("HTTP/", StringComparison.Ordinal) + || space < 0 + || statusLine.Length < space + 4 + || (statusLine.Length > space + 4 && statusLine[space + 4] != ' ') + || !int.TryParse(statusLine.Substring(space + 1, 3), NumberStyles.None, CultureInfo.InvariantCulture, out statusCode)) + { + string shown = statusLine.Length > 64 ? statusLine.Substring(0, 64) + "..." : statusLine; + throw new HttpException($"The server answered with something that is not HTTP: {shown}"); + } + + string version = statusLine.Substring(0, space); + string reasonPhrase = statusLine.Length > space + 5 ? statusLine.Substring(space + 5) : string.Empty; + + Dictionary headers = new(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i]; + int colon = line.IndexOf(':'); + + // Lines with no name, and the obsolete continuations that start with whitespace, hold nothing to keep. + if (colon <= 0 || line[0] is ' ' or '\t') + { + continue; + } + + string name = line.Substring(0, colon).Trim(); + string value = line.Substring(colon + 1).Trim(); + headers[name] = headers.TryGetValue(name, out string? previous) ? previous + ", " + value : value; + } + + return new Head(version, statusCode, reasonPhrase, headers); + } + + /// The index just past the blank line that ends a head, or -1. + private static int IndexOfHeadEnd(byte[] data, int start, int length) + { + for (int i = start; i + 3 < length; i++) + { + if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') + { + return i + 4; + } + } + + return -1; + } + + private static byte[] Slice(byte[] data, int start, int length) + { + byte[] slice = new byte[length]; + Buffer.BlockCopy(data, start, slice, 0, length); + return slice; + } + + private sealed class Head(string version, int statusCode, string reasonPhrase, Dictionary headers) + { + public string Version { get; } = version; + + public int StatusCode { get; } = statusCode; + + public string ReasonPhrase { get; } = reasonPhrase; + + public Dictionary Headers { get; } = headers; + } +} From c75c34b517614971417d369b45827c7414c987c4 Mon Sep 17 00:00:00 2001 From: valentinbreiz Date: Sat, 3 Oct 2026 11:20:48 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=85=20Test=20the=20client=20over=20lo?= =?UTF-8?q?opback=20and=20byte=20by=20byte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request tests send to a loopback server that answers with bytes the test writes out, and check both directions: the request line and headers, bodies framed by length, chunks or the end of the connection, servers that keep the connection open, HEAD, POST, redirects and their limit, error statuses, timeouts, truncated bodies, refused connections and unknown hosts. The response reader is fed every split of a response a connection could deliver, and URL parsing and redirect resolution are unit-tested on their own. --- Cosmos.Network.Http.slnx | 3 + .../Cosmos.Network.Http.Tests.csproj | 21 + .../HttpRequestTests.cs | 390 ++++++++++++++++++ .../Cosmos.Network.Http.Tests/HttpUrlTests.cs | 95 +++++ .../ResponseReaderTests.cs | 243 +++++++++++ tests/Cosmos.Network.Http.Tests/TestServer.cs | 215 ++++++++++ 6 files changed, 967 insertions(+) create mode 100644 tests/Cosmos.Network.Http.Tests/Cosmos.Network.Http.Tests.csproj create mode 100644 tests/Cosmos.Network.Http.Tests/HttpRequestTests.cs create mode 100644 tests/Cosmos.Network.Http.Tests/HttpUrlTests.cs create mode 100644 tests/Cosmos.Network.Http.Tests/ResponseReaderTests.cs create mode 100644 tests/Cosmos.Network.Http.Tests/TestServer.cs diff --git a/Cosmos.Network.Http.slnx b/Cosmos.Network.Http.slnx index 7e95d0d..6005ea2 100644 --- a/Cosmos.Network.Http.slnx +++ b/Cosmos.Network.Http.slnx @@ -2,4 +2,7 @@ + + + diff --git a/tests/Cosmos.Network.Http.Tests/Cosmos.Network.Http.Tests.csproj b/tests/Cosmos.Network.Http.Tests/Cosmos.Network.Http.Tests.csproj new file mode 100644 index 0000000..4193a30 --- /dev/null +++ b/tests/Cosmos.Network.Http.Tests/Cosmos.Network.Http.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + latest + enable + disable + false + + + + + + + + + + + + + diff --git a/tests/Cosmos.Network.Http.Tests/HttpRequestTests.cs b/tests/Cosmos.Network.Http.Tests/HttpRequestTests.cs new file mode 100644 index 0000000..8330b48 --- /dev/null +++ b/tests/Cosmos.Network.Http.Tests/HttpRequestTests.cs @@ -0,0 +1,390 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using NUnit.Framework; + +namespace Cosmos.Network.Http.Tests; + +/// +/// Sends requests to a loopback server that answers with bytes the tests +/// write out, and checks what went over the wire both ways. +/// +[TestFixture] +public class HttpRequestTests +{ + // Short, so a client that waits for a close it should not need fails fast. + private const int Timeout = 2000; + + [Test] + public void Get_SendsAMinimalRequestAndReturnsTheBody() + { + using TestServer server = new(); + server.Then("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 5\r\n\r\nhello"); + + HttpResponse response = new HttpRequest(server.Url("/page?x=1")) { Timeout = Timeout }.Send(); + + Assert.That(response.StatusCode, Is.EqualTo(200)); + Assert.That(response.GetString(), Is.EqualTo("hello")); + Assert.That(response.Url, Is.EqualTo(server.Url("/page?x=1"))); + + TestRequest request = server.Requests.Single(); + Assert.That(request.Head, Does.StartWith("GET /page?x=1 HTTP/1.1\r\n")); + Assert.That(request.Headers["Host"], Is.EqualTo($"127.0.0.1:{server.Port}")); + Assert.That(request.Headers["Connection"], Is.EqualTo("close")); + Assert.That(request.Headers["Accept-Encoding"], Is.EqualTo("identity")); + Assert.That(request.Headers["User-Agent"], Is.EqualTo(HttpRequest.DefaultUserAgent)); + Assert.That(request.Headers.ContainsKey("Content-Length"), Is.False); + } + + [Test] + public void ContentLength_DoesNotWaitForTheServerToClose() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.Write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); + connection.WaitForClientClose(); + }); + + HttpResponse response = new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send(); + + Assert.That(response.GetString(), Is.EqualTo("ok")); + } + + [Test] + public void Chunked_DoesNotWaitForTheServerToClose() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.WriteSlowly("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n"); + connection.WaitForClientClose(); + }); + + HttpResponse response = new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send(); + + Assert.That(response.GetString(), Is.EqualTo("abcde")); + } + + [Test] + public void NoLength_ReadsUntilTheServerCloses() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.WriteSlowly("HTTP/1.0 200 OK\r\n\r\n"); + connection.Write(new string('x', 100_000)); + }); + + HttpResponse response = new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send(); + + Assert.That(response.Content, Has.Length.EqualTo(100_000)); + } + + [Test] + public void LargeBody_ArrivesWhole() + { + byte[] body = new byte[3 * 1024 * 1024]; + new Random(42).NextBytes(body); + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.Write($"HTTP/1.1 200 OK\r\nContent-Length: {body.Length}\r\n\r\n"); + connection.Write(body); + }); + + HttpResponse response = new HttpRequest(server.Url("/big.bin")) { Timeout = Timeout }.Send(); + + Assert.That(response.Content, Is.EqualTo(body)); + } + + [Test] + public void Head_ReturnsNoBody() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.Write("HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n"); + connection.WaitForClientClose(); + }); + + HttpResponse response = new HttpRequest(server.Url("/")) { Method = "HEAD", Timeout = Timeout }.Send(); + + Assert.That(response.Content, Is.Empty); + Assert.That(response.GetHeader("Content-Length"), Is.EqualTo("1234")); + Assert.That(server.Requests.Single().Method, Is.EqualTo("HEAD")); + } + + [Test] + public void Post_SendsTheBodyWithItsLength() + { + using TestServer server = new(); + server.Then("HTTP/1.1 201 Created\r\nContent-Length: 0\r\n\r\n"); + + HttpResponse response = new HttpRequest(server.Url("/api")) + { + Method = "POST", + Body = Encoding.UTF8.GetBytes("{\"a\":1}"), + Headers = { ["Content-Type"] = "application/json" }, + Timeout = Timeout, + }.Send(); + + Assert.That(response.StatusCode, Is.EqualTo(201)); + TestRequest request = server.Requests.Single(); + Assert.That(request.Method, Is.EqualTo("POST")); + Assert.That(request.Headers["Content-Length"], Is.EqualTo("7")); + Assert.That(request.Headers["Content-Type"], Is.EqualTo("application/json")); + Assert.That(Encoding.UTF8.GetString(request.Body), Is.EqualTo("{\"a\":1}")); + } + + [Test] + public void PostWithoutBody_SendsAZeroLength() + { + using TestServer server = new(); + server.Then("HTTP/1.1 204 No Content\r\n\r\n"); + + new HttpRequest(server.Url("/")) { Method = "POST", Timeout = Timeout }.Send(); + + Assert.That(server.Requests.Single().Headers["Content-Length"], Is.EqualTo("0")); + } + + [Test] + public void Headers_ReplaceTheDefaultsAndAddToThem() + { + using TestServer server = new(); + server.Then("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + + new HttpRequest(server.Url("/")) + { + Headers = + { + ["host"] = "virtual.example", + ["User-Agent"] = "AuraOS/1.0", + ["X-Extra"] = "yes", + }, + Timeout = Timeout, + }.Send(); + + TestRequest request = server.Requests.Single(); + Assert.That(request.Headers["Host"], Is.EqualTo("virtual.example")); + Assert.That(request.Headers["User-Agent"], Is.EqualTo("AuraOS/1.0")); + Assert.That(request.Headers["X-Extra"], Is.EqualTo("yes")); + Assert.That(request.Head.Split("\r\n").Count(line => line.StartsWith("Host:", StringComparison.OrdinalIgnoreCase)), Is.EqualTo(1)); + } + + [TestCase("Connection")] + [TestCase("content-length")] + [TestCase("Transfer-Encoding")] + public void Headers_TheRequestSetsItselfAreRefused(string name) + { + HttpRequest request = new("http://127.0.0.1:1/") { Headers = { [name] = "x" } }; + + Assert.Throws(() => request.Send()); + } + + [Test] + public void Headers_ThatBreakTheLineAreRefused() + { + HttpRequest request = new("http://127.0.0.1:1/") { Headers = { ["X-Evil"] = "a\r\nInjected: yes" } }; + + Assert.Throws(() => request.Send()); + } + + [Test] + public void ErrorStatus_IsReturned_AndEnsureSuccessThrows() + { + using TestServer server = new(); + server.Then("HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nnot found"); + + HttpResponse response = new HttpRequest(server.Url("/missing")) { Timeout = Timeout }.Send(); + + Assert.That(response.StatusCode, Is.EqualTo(404)); + Assert.That(response.GetString(), Is.EqualTo("not found")); + HttpException exception = Assert.Throws(() => response.EnsureSuccessStatusCode())!; + Assert.That(exception.StatusCode, Is.EqualTo(404)); + Assert.That(exception.Message, Does.Contain("404 Not Found")); + } + + [Test] + public void Redirects_AreFollowed() + { + using TestServer server = new(); + server.Then("HTTP/1.1 301 Moved Permanently\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n") + .Then($"HTTP/1.1 302 Found\r\nLocation: {server.Url("/last")}\r\nContent-Length: 0\r\n\r\n") + .Then("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndone"); + List log = []; + + HttpResponse response = new HttpRequest(server.Url("/first")) { Timeout = Timeout, Log = log.Add }.Send(); + + Assert.That(response.GetString(), Is.EqualTo("done")); + Assert.That(response.Url, Is.EqualTo(server.Url("/last"))); + Assert.That(server.Requests.Select(request => request.Target), Is.EqualTo(new[] { "/first", "/next", "/last" })); + Assert.That(log, Has.Count.EqualTo(5)); + } + + [Test] + public void SeeOther_TurnsAPostIntoAGet() + { + using TestServer server = new(); + server.Then("HTTP/1.1 303 See Other\r\nLocation: /result\r\nContent-Length: 0\r\n\r\n") + .Then("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + + new HttpRequest(server.Url("/form")) { Method = "POST", Body = [1, 2, 3], Timeout = Timeout }.Send(); + + TestRequest second = server.Requests.Last(); + Assert.That(second.Method, Is.EqualTo("GET")); + Assert.That(second.Body, Is.Empty); + Assert.That(second.Headers.ContainsKey("Content-Length"), Is.False); + } + + [Test] + public void TemporaryRedirect_KeepsThePost() + { + using TestServer server = new(); + server.Then("HTTP/1.1 307 Temporary Redirect\r\nLocation: /elsewhere\r\nContent-Length: 0\r\n\r\n") + .Then("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + + new HttpRequest(server.Url("/form")) { Method = "POST", Body = [1, 2, 3], Timeout = Timeout }.Send(); + + TestRequest second = server.Requests.Last(); + Assert.That(second.Method, Is.EqualTo("POST")); + Assert.That(second.Body, Is.EqualTo(new byte[] { 1, 2, 3 })); + } + + [Test] + public void Redirects_StopAtTheLimit() + { + using TestServer server = new(); + for (int i = 0; i < 3; i++) + { + server.Then("HTTP/1.1 302 Found\r\nLocation: /again\r\nContent-Length: 0\r\n\r\n"); + } + + HttpException exception = Assert.Throws(() => new HttpRequest(server.Url("/")) { MaxRedirects = 2, Timeout = Timeout }.Send())!; + Assert.That(exception.StatusCode, Is.EqualTo(302)); + } + + [Test] + public void Redirects_AreReturnedWithoutLimit() + { + using TestServer server = new(); + server.Then("HTTP/1.1 301 Moved Permanently\r\nLocation: /next\r\nContent-Length: 0\r\n\r\n"); + + HttpResponse response = new HttpRequest(server.Url("/")) { MaxRedirects = 0, Timeout = Timeout }.Send(); + + Assert.That(response.StatusCode, Is.EqualTo(301)); + Assert.That(response.GetHeader("Location"), Is.EqualTo("/next")); + } + + [Test] + public void RedirectToHttps_Throws() + { + using TestServer server = new(); + server.Then("HTTP/1.1 301 Moved Permanently\r\nLocation: https://secure.example/\r\nContent-Length: 0\r\n\r\n"); + + HttpException exception = Assert.Throws(() => new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send())!; + Assert.That(exception.StatusCode, Is.EqualTo(301)); + Assert.That(exception.Message, Does.Contain("TLS")); + } + + [Test] + public void SilentServer_TimesOut() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.Hang(); + }); + + HttpException exception = Assert.Throws(() => new HttpRequest(server.Url("/")) { Timeout = 300 }.Send())!; + Assert.That(exception.Message, Does.Contain("sent nothing")); + } + + [Test] + public void TruncatedBody_Throws() + { + using TestServer server = new(); + server.Then("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"); + + HttpException exception = Assert.Throws(() => new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send())!; + Assert.That(exception.Message, Does.Contain("3 of 10")); + } + + [Test] + public void ClosedWithoutAnswer_Throws() + { + using TestServer server = new(); + server.Then(connection => connection.ReadRequest()); + + HttpException exception = Assert.Throws(() => new HttpRequest(server.Url("/")) { Timeout = Timeout }.Send())!; + Assert.That(exception.Message, Does.Contain("without answering")); + } + + [Test] + public void RefusedConnection_Throws() + { + // A port nothing listens on: bound, then released. + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + HttpException exception = Assert.Throws(() => new HttpRequest($"http://127.0.0.1:{port}/") { Timeout = Timeout }.Send())!; + Assert.That(exception.Message, Does.StartWith($"Could not connect to 127.0.0.1:{port}")); + } + + [Test] + public void UnknownHost_Throws() + { + HttpException exception = Assert.Throws(() => new HttpRequest("http://no-such-host.invalid/") { Timeout = Timeout }.Send())!; + Assert.That(exception.Message, Does.StartWith("Could not resolve no-such-host.invalid")); + } + + [Test] + public void GetString_UsesTheCharsetAndSkipsTheByteOrderMark() + { + using TestServer server = new(); + server.Then(connection => + { + connection.ReadRequest(); + connection.Write("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=\"ISO-8859-1\"\r\nContent-Length: 1\r\n\r\n"); + connection.Write([0xE9]); + }).Then(connection => + { + connection.ReadRequest(); + connection.Write("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"); + connection.Write([0xEF, 0xBB, 0xBF, (byte)'{']); + }); + + Assert.That(new HttpRequest(server.Url("/latin1")) { Timeout = Timeout }.Send().GetString(), Is.EqualTo("é")); + Assert.That(new HttpRequest(server.Url("/bom")) { Timeout = Timeout }.Send().GetString(), Is.EqualTo("{")); + } + + [TestCase("GET POST")] + [TestCase("G\r\nET")] + [TestCase("")] + public void Method_MustBeAToken(string method) + { + Assert.Throws(() => _ = new HttpRequest("http://host/") { Method = method }); + } + + [Test] + public void Settings_AreValidated() + { + Assert.Throws(() => _ = new HttpRequest("http://host/") { Timeout = 0 }); + Assert.Throws(() => _ = new HttpRequest("http://host/") { MaxRedirects = -1 }); + Assert.Throws(() => _ = new HttpRequest(" ")); + Assert.Throws(() => _ = new HttpRequest("https://host/")); + } +} diff --git a/tests/Cosmos.Network.Http.Tests/HttpUrlTests.cs b/tests/Cosmos.Network.Http.Tests/HttpUrlTests.cs new file mode 100644 index 0000000..7dd46f5 --- /dev/null +++ b/tests/Cosmos.Network.Http.Tests/HttpUrlTests.cs @@ -0,0 +1,95 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using NUnit.Framework; + +namespace Cosmos.Network.Http.Tests; + +[TestFixture] +public class HttpUrlTests +{ + [TestCase("http://httpforever.com/", "httpforever.com", 80, "/")] + [TestCase("http://httpforever.com", "httpforever.com", 80, "/")] + [TestCase("HTTP://Example.com/a/b.html", "Example.com", 80, "/a/b.html")] + [TestCase("httpforever.com/page", "httpforever.com", 80, "/page")] + [TestCase("http://10.0.2.2:8080/x?y=1", "10.0.2.2", 8080, "/x?y=1")] + [TestCase("http://host?q=1", "host", 80, "/?q=1")] + [TestCase("http://host/a#section", "host", 80, "/a")] + [TestCase("http://host:/a", "host", 80, "/a")] + [TestCase(" http://host/a ", "host", 80, "/a")] + [TestCase("http://[::1]:8000/", "::1", 8000, "/")] + [TestCase("host/search?u=http://other/", "host", 80, "/search?u=http://other/")] + public void Parse_TakesTheUrlApart(string url, string host, int port, string target) + { + HttpUrl parsed = HttpUrl.Parse(url); + + Assert.Multiple(() => + { + Assert.That(parsed.Host, Is.EqualTo(host)); + Assert.That(parsed.Port, Is.EqualTo(port)); + Assert.That(parsed.Target, Is.EqualTo(target)); + }); + } + + [TestCase("http://host/", "host")] + [TestCase("http://host:8080/", "host:8080")] + [TestCase("http://[::1]:8080/", "[::1]:8080")] + public void Authority_LeavesOutTheDefaultPort(string url, string authority) + { + Assert.That(HttpUrl.Parse(url).Authority, Is.EqualTo(authority)); + } + + [Test] + public void Parse_PercentEncodesWhatARequestLineCannotCarry() + { + Assert.That(HttpUrl.Parse("http://host/a file/é?x=a b").Target, Is.EqualTo("/a%20file/%C3%A9?x=a%20b")); + Assert.That(HttpUrl.Parse("http://host/a%20b").Target, Is.EqualTo("/a%20b")); + } + + [Test] + public void Parse_RefusesHttps() + { + NotSupportedException exception = Assert.Throws(() => HttpUrl.Parse("https://host/"))!; + Assert.That(exception.Message, Does.Contain("TLS")); + } + + [TestCase("ftp://host/")] + [TestCase("http://user:pass@host/")] + public void Parse_RefusesWhatItDoesNotSupport(string url) + { + Assert.Throws(() => HttpUrl.Parse(url)); + } + + [TestCase("http://")] + [TestCase("http:///path")] + [TestCase("http://host:0/")] + [TestCase("http://host:65536/")] + [TestCase("http://host:http/")] + [TestCase("http://[::1/")] + [TestCase("http://[::1]x/")] + public void Parse_RefusesMalformedUrls(string url) + { + Assert.Throws(() => HttpUrl.Parse(url)); + } + + [TestCase("http://other:81/x", "http://other:81/x")] + [TestCase("//other/x", "http://other/x")] + [TestCase("/x/y", "http://host:8080/x/y")] + [TestCase("?page=2", "http://host:8080/dir/file?page=2")] + [TestCase("other", "http://host:8080/dir/other")] + [TestCase("sub/other?a=1", "http://host:8080/dir/sub/other?a=1")] + [TestCase("#top", "http://host:8080/dir/file?q=1")] + [TestCase("/a b", "http://host:8080/a%20b")] + public void Resolve_FollowsEveryKindOfLocation(string location, string expected) + { + HttpUrl url = HttpUrl.Parse("http://host:8080/dir/file?q=1"); + + Assert.That(url.Resolve(location).ToString(), Is.EqualTo(expected)); + } + + [Test] + public void Resolve_RefusesHttps() + { + Assert.Throws(() => HttpUrl.Parse("http://host/").Resolve("https://host/")); + } +} diff --git a/tests/Cosmos.Network.Http.Tests/ResponseReaderTests.cs b/tests/Cosmos.Network.Http.Tests/ResponseReaderTests.cs new file mode 100644 index 0000000..9c7f6c0 --- /dev/null +++ b/tests/Cosmos.Network.Http.Tests/ResponseReaderTests.cs @@ -0,0 +1,243 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System.Text; +using NUnit.Framework; + +namespace Cosmos.Network.Http.Tests; + +/// +/// Feeds responses to the reader in every split a connection could deliver +/// them in, and checks it tells where they end. +/// +[TestFixture] +public class ResponseReaderTests +{ + private const string Url = "http://host/"; + + [Test] + public void ContentLength_CompletesWithoutTheConnectionClosing() + { + const string response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\n\r\nhello"; + + foreach (ResponseReader reader in FeedInEverySplit(response)) + { + Assert.That(reader.IsComplete, Is.True); + HttpResponse parsed = reader.ToResponse(Url); + Assert.That(parsed.StatusCode, Is.EqualTo(200)); + Assert.That(parsed.ReasonPhrase, Is.EqualTo("OK")); + Assert.That(parsed.Version, Is.EqualTo("HTTP/1.1")); + Assert.That(parsed.ContentType, Is.EqualTo("text/plain")); + Assert.That(Encoding.ASCII.GetString(parsed.Content), Is.EqualTo("hello")); + } + } + + [Test] + public void ContentLength_IsNotCompleteBeforeTheLastByte() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhell"); + + Assert.That(reader.IsComplete, Is.False); + HttpException exception = Assert.Throws(() => reader.ToResponse(Url))!; + Assert.That(exception.Message, Does.Contain("4 of 5")); + } + + [Test] + public void ContentLength_IgnoresBytesPastTheBody() + { + HttpResponse response = Feed("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhello").ToResponse(Url); + + Assert.That(Encoding.ASCII.GetString(response.Content), Is.EqualTo("he")); + } + + [Test] + public void Chunked_CompletesAtTheLastChunkAndTrailers() + { + const string response = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + + "5;name=value\r\nhello\r\n" + + "1\r\n,\r\n" + + "6 \r\n world\r\n" + + "0\r\nExpires: never\r\n\r\n"; + + foreach (ResponseReader reader in FeedInEverySplit(response)) + { + Assert.That(reader.IsComplete, Is.True); + Assert.That(Encoding.ASCII.GetString(reader.ToResponse(Url).Content), Is.EqualTo("hello, world")); + } + } + + [Test] + public void Chunked_IsNotCompleteBeforeTheBlankLineAfterTheLastChunk() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n"); + + Assert.That(reader.IsComplete, Is.False); + } + + [Test] + public void Chunked_TruncatedBodyThrows() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nA\r\nabc"); + + Assert.Throws(() => reader.ToResponse(Url)); + } + + [TestCase("zz")] + [TestCase("")] + [TestCase("5x")] + public void Chunked_MalformedSizeThrows(string size) + { + Assert.Throws(() => Feed($"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{size}\r\nhello\r\n0\r\n\r\n")); + } + + [Test] + public void TransferEncoding_WinsOverContentLength() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nContent-Length: 100\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n0\r\n\r\n"); + + Assert.That(reader.IsComplete, Is.True); + Assert.That(Encoding.ASCII.GetString(reader.ToResponse(Url).Content), Is.EqualTo("ok")); + } + + [Test] + public void NoLength_RunsUntilTheConnectionCloses() + { + ResponseReader reader = Feed("HTTP/1.0 200 OK\r\n\r\neverything until the end"); + + Assert.That(reader.IsComplete, Is.False); + Assert.That(Encoding.ASCII.GetString(reader.ToResponse(Url).Content), Is.EqualTo("everything until the end")); + } + + [TestCase(204)] + [TestCase(304)] + public void StatusesWithoutBody_CompleteAtTheHead(int status) + { + ResponseReader reader = Feed($"HTTP/1.1 {status} Whatever\r\nContent-Length: 10\r\n\r\n"); + + Assert.That(reader.IsComplete, Is.True); + Assert.That(reader.ToResponse(Url).Content, Is.Empty); + } + + [Test] + public void HeadRequest_CompletesAtTheHead() + { + ResponseReader reader = new(isHead: true); + byte[] bytes = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n"); + reader.Append(bytes, bytes.Length); + + Assert.That(reader.IsComplete, Is.True); + HttpResponse response = reader.ToResponse(Url); + Assert.That(response.Content, Is.Empty); + Assert.That(response.GetHeader("content-length"), Is.EqualTo("1234")); + } + + [Test] + public void InterimResponses_AreSkipped() + { + const string response = "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 103 Early Hints\r\nLink: \r\n\r\n" + + "HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\nok"; + + foreach (ResponseReader reader in FeedInEverySplit(response)) + { + HttpResponse parsed = reader.ToResponse(Url); + Assert.That(parsed.StatusCode, Is.EqualTo(201)); + Assert.That(parsed.GetHeader("Link"), Is.Null); + Assert.That(Encoding.ASCII.GetString(parsed.Content), Is.EqualTo("ok")); + } + } + + [Test] + public void Headers_AreCaseInsensitiveAndRepeatsAreJoined() + { + HttpResponse response = Feed("HTTP/1.1 200 OK\r\nX-Thing: a\r\nx-thing: b\r\nSpaced : value \r\nContent-Length: 0\r\n\r\n").ToResponse(Url); + + Assert.That(response.GetHeader("X-THING"), Is.EqualTo("a, b")); + Assert.That(response.Headers["spaced"], Is.EqualTo("value")); + } + + [Test] + public void StatusLine_WithoutReasonPhrase() + { + HttpResponse response = Feed("HTTP/1.1 404\r\nContent-Length: 0\r\n\r\n").ToResponse(Url); + + Assert.That(response.StatusCode, Is.EqualTo(404)); + Assert.That(response.ReasonPhrase, Is.Empty); + Assert.That(response.IsSuccessStatusCode, Is.False); + } + + [TestCase("SSH-2.0-OpenSSH_9.6\r\n\r\n")] + [TestCase("HTTP/1.1 2000 OK\r\n\r\n")] + [TestCase("HTTP/1.1 abc OK\r\n\r\n")] + public void NotHttp_Throws(string response) + { + Assert.Throws(() => Feed(response)); + } + + [TestCase("abc")] + [TestCase("5, 6")] + [TestCase("-1")] + public void InvalidContentLength_Throws(string length) + { + Assert.Throws(() => Feed($"HTTP/1.1 200 OK\r\nContent-Length: {length}\r\n\r\n")); + } + + [Test] + public void RepeatedContentLength_IsTakenWhenTheCopiesAgree() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Length: 2\r\n\r\nok"); + + Assert.That(reader.IsComplete, Is.True); + } + + [Test] + public void OversizedHead_Throws() + { + string response = "HTTP/1.1 200 OK\r\nX-Big: " + new string('a', ResponseReader.MaxHeadLength) + "\r\n"; + + Assert.Throws(() => Feed(response)); + } + + [Test] + public void NoAnswer_Throws() + { + HttpException exception = Assert.Throws(() => new ResponseReader(isHead: false).ToResponse(Url))!; + Assert.That(exception.Message, Does.Contain("without answering")); + } + + [Test] + public void PartialHead_Throws() + { + ResponseReader reader = Feed("HTTP/1.1 200 OK\r\nContent-"); + + HttpException exception = Assert.Throws(() => reader.ToResponse(Url))!; + Assert.That(exception.Message, Does.Contain("middle of the response head")); + } + + private static ResponseReader Feed(string response) + { + ResponseReader reader = new(isHead: false); + byte[] bytes = Encoding.ASCII.GetBytes(response); + reader.Append(bytes, bytes.Length); + return reader; + } + + /// The reader after the response arrived in two parts, for every place it can be split, and then a byte at a time. + private static System.Collections.Generic.IEnumerable FeedInEverySplit(string response) + { + byte[] bytes = Encoding.ASCII.GetBytes(response); + for (int split = 0; split <= bytes.Length; split++) + { + ResponseReader reader = new(isHead: false); + reader.Append(bytes[..split], split); + reader.Append(bytes[split..], bytes.Length - split); + yield return reader; + } + + ResponseReader slow = new(isHead: false); + foreach (byte b in bytes) + { + slow.Append([b], 1); + } + + yield return slow; + } +} diff --git a/tests/Cosmos.Network.Http.Tests/TestServer.cs b/tests/Cosmos.Network.Http.Tests/TestServer.cs new file mode 100644 index 0000000..e3111ab --- /dev/null +++ b/tests/Cosmos.Network.Http.Tests/TestServer.cs @@ -0,0 +1,215 @@ +// This code is licensed under the BSD 3-Clause license (see LICENSE.txt for details) + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; + +namespace Cosmos.Network.Http.Tests; + +/// +/// A loopback server that answers each connection with the next handler a +/// test queued, so a test writes the response bytes exactly as a server +/// would send them. +/// +internal sealed class TestServer : IDisposable +{ + private readonly TcpListener _listener; + private readonly Thread _thread; + private readonly ConcurrentQueue> _handlers = new(); + private volatile bool _disposed; + + public TestServer() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + _thread = new Thread(Serve) { IsBackground = true }; + _thread.Start(); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// The requests received, in order. + public ConcurrentQueue Requests { get; } = new(); + + /// The first failure of a handler, rethrown by so the test sees it. + private Exception? _failure; + + public string Url(string target) => $"http://127.0.0.1:{Port}{target}"; + + /// Queues the handler for the next connection. + public TestServer Then(Action handler) + { + _handlers.Enqueue(handler); + return this; + } + + /// Queues a handler that reads the request and sends , then closes. + public TestServer Then(string response) => Then(connection => + { + connection.ReadRequest(); + connection.Write(response); + }); + + public void Dispose() + { + _disposed = true; + _listener.Stop(); + _thread.Join(5000); + if (_failure is not null) + { + throw new InvalidOperationException("A test server handler failed.", _failure); + } + } + + private void Serve() + { + while (!_disposed) + { + TcpClient client; + try + { + client = _listener.AcceptTcpClient(); + } + catch (SocketException) + { + return; + } + + using (client) + { + if (!_handlers.TryDequeue(out Action? handler)) + { + continue; + } + + try + { + handler(new TestConnection(this, client)); + } + catch (Exception exception) when (exception is IOException or SocketException) + { + // The client went away, which some tests make it do. + } + catch (Exception exception) + { + _failure ??= exception; + } + } + } + } + + internal void Record(TestRequest request) => Requests.Enqueue(request); +} + +/// A request as the test server received it. +internal sealed record TestRequest(string Method, string Target, Dictionary Headers, byte[] Body, string Head); + +/// One connection to the test server. +internal sealed class TestConnection +{ + private readonly TestServer _server; + private readonly NetworkStream _stream; + private readonly MemoryStream _pending = new(); + + public TestConnection(TestServer server, TcpClient client) + { + _server = server; + _stream = client.GetStream(); + _stream.ReadTimeout = 10_000; + } + + /// Reads a request head and the body its Content-Length announces. + public TestRequest ReadRequest() + { + byte[] buffer = new byte[4096]; + int headEnd; + while ((headEnd = IndexOfHeadEnd(_pending.GetBuffer(), (int)_pending.Length)) < 0) + { + int read = _stream.Read(buffer, 0, buffer.Length); + if (read == 0) + { + throw new IOException("The client closed before sending a whole head."); + } + + _pending.Write(buffer, 0, read); + } + + string head = Encoding.UTF8.GetString(_pending.GetBuffer(), 0, headEnd); + string[] lines = head.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + string[] requestLine = lines[0].Split(' '); + Dictionary headers = new(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + int colon = lines[i].IndexOf(':'); + headers[lines[i].Substring(0, colon).Trim()] = lines[i].Substring(colon + 1).Trim(); + } + + int length = headers.TryGetValue("Content-Length", out string? value) ? int.Parse(value) : 0; + while (_pending.Length < headEnd + length) + { + int read = _stream.Read(buffer, 0, buffer.Length); + if (read == 0) + { + throw new IOException("The client closed before sending a whole body."); + } + + _pending.Write(buffer, 0, read); + } + + byte[] body = new byte[length]; + Buffer.BlockCopy(_pending.GetBuffer(), headEnd, body, 0, length); + + TestRequest request = new(requestLine[0], requestLine[1], headers, body, head); + _server.Record(request); + return request; + } + + public void Write(string text) => Write(Encoding.UTF8.GetBytes(text)); + + public void Write(byte[] data) + { + _stream.Write(data, 0, data.Length); + _stream.Flush(); + } + + /// Writes a byte at a time, so the client sees every split there is. + public void WriteSlowly(string text) + { + foreach (byte b in Encoding.UTF8.GetBytes(text)) + { + _stream.WriteByte(b); + _stream.Flush(); + Thread.Sleep(1); + } + } + + /// Keeps the connection open until the client closes it, as a server that ignores Connection: close would. + public void WaitForClientClose() + { + byte[] buffer = new byte[256]; + while (_stream.Read(buffer, 0, buffer.Length) > 0) + { + } + } + + /// Stays silent until the client gives up. + public void Hang() => WaitForClientClose(); + + private static int IndexOfHeadEnd(byte[] data, int length) + { + for (int i = 0; i + 3 < length; i++) + { + if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') + { + return i + 4; + } + } + + return -1; + } +} From f4422c61eb2bd7877c11f7795694169715b3abf6 Mon Sep 17 00:00:00 2001 From: valentinbreiz Date: Sat, 3 Oct 2026 11:20:48 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=91=B7=20Publish=20to=20nuget.org=20t?= =?UTF-8?q?hrough=20Trusted=20Publishing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build, test and pack on every push and pull request. A v tag also publishes that version, trading GitHub's OIDC token for a short-lived nuget.org key instead of storing one. --- .github/workflows/build.yml | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..de06626 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,72 @@ +# Builds, tests and packs on every push and pull request. A tag named +# v (e.g. v2.0.1) also publishes that version to nuget.org through +# Trusted Publishing: no API key is stored, the publish job trades GitHub's +# OIDC token for a short-lived nuget.org key. +# +# The nuget.org Trusted Publishing policy matches on repository owner, +# repository name, this file's name (build.yml) and, if it names one, the +# `production` environment: renaming this file breaks publishing. + +name: Build + +on: + push: + branches: [main, gen3] + tags: ['v*'] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Test + run: dotnet test Cosmos.Network.Http.slnx -c Release + + # On a release tag the tag names the version; otherwise the .csproj does. + - name: Pack + run: | + args=() + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + args+=("-p:Version=${GITHUB_REF_NAME#v}") + fi + dotnet pack src/Cosmos.Network.Http/Cosmos.Network.Http.csproj -c Release -o artifacts "${args[@]}" + + - uses: actions/upload-artifact@v7 + with: + name: nupkg + path: artifacts/*.nupkg + + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: production + permissions: + id-token: write # lets NuGet/login request GitHub's OIDC token + steps: + - uses: actions/download-artifact@v8 + with: + name: nupkg + path: artifacts + + - name: NuGet login + id: login + uses: NuGet/login@v1 + with: + # The user who CREATED the policy, not the CosmosOS profile that owns + # it. nuget.org looks the policy up by creator and answers a wrong + # guess with "No matching trust policy owned by user 'CosmosOS' was + # found" (HTTP 401). Public, so not a secret. + user: valentinbreiz + + - name: Push to nuget.org + run: dotnet nuget push "artifacts/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json From da1787f500018978d80575b728e837da94d8df17 Mon Sep 17 00:00:00 2001 From: valentinbreiz Date: Sat, 3 Oct 2026 11:20:48 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=9D=20Document=20the=20Gen3=20clie?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 94 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0da87f0..690526e 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,90 @@ -

CosmosHTTP Client [WIP]

+

CosmosHTTP Client 🚀

- - Version + + Version - + License: BSD Clause 3 License

-> CosmosHTTP is a HTTP client made in C# for the Cosmos operating system construction kit. GET and PUT are currently supported. - -### Todo -See [this issue](https://github.com/CosmosOS/CosmosHttp/issues/1) for todo list. +> CosmosHTTP is an HTTP/1.1 client made in C# for the Cosmos operating system construction kit. ## Usage -### Installation - -Install the Nuget Package from [Nuget](https://www.nuget.org/packages/CosmosHttp/): +Add the package to your kernel .csproj: -```PM -Install-Package CosmosHttp -Version 1.0.4 +```xml + + + ``` -```PM -dotnet add PROJECT package CosmosHttp --version 1.0.4 -``` +The kernel needs networking (`CosmosEnableNetwork`, on by default), an IP configuration (DHCP or static) and, for host names, a DNS server. `Send()` runs the request on the calling thread and returns the response once it has arrived whole: + +```csharp +using System; +using System.IO; +using Cosmos.Network.Http; -Or add these lines to your Cosmos kernel .csproj: +HttpResponse response = new HttpRequest("http://httpforever.com/").Send(); +Console.WriteLine($"{response.StatusCode} {response.ReasonPhrase}, {response.Content.Length} bytes"); +File.WriteAllBytes("/0/index.html", response.Content); ``` - - - + +`Send()` returns error statuses too; `EnsureSuccessStatusCode()` turns them into an `HttpException`, and `GetString()` decodes the body with the charset of its Content-Type: + +```csharp +string json = new HttpRequest("http://example.com/data.json").Send().EnsureSuccessStatusCode().GetString(); ``` -### Examples +A request can set its method, body, headers, timeout and how many redirects it follows: + +```csharp +HttpResponse response = new HttpRequest("http://example.com/api") +{ + Method = "POST", + Body = Encoding.UTF8.GetBytes("{\"name\":\"cosmos\"}"), + Headers = { ["Content-Type"] = "application/json" }, + Timeout = 10_000, // how long the server may stay silent, in milliseconds (15 s by default) + MaxRedirects = 0, // return redirects instead of following them (5 by default) + // Optional: one line per response and redirect. + Log = message => Cosmos.Kernel.System.Diagnostics.Log.WriteString(message + "\n"), +}.Send(); +``` -```CS -using CosmosHttp.Client; +The Host header comes from the URL. Setting it in `Headers` reaches a virtual host by IP address: -HttpRequest request = new(); -request.IP = "34.223.124.45"; -request.Domain = "neverssl.com"; //very useful for subdomains on same IP -request.Path = "/"; -request.Method = "GET"; -request.Send(); -Console.WriteLine(request.Response.Content); // or to get bytes Encoding.ASCII.getString(request.Response.GetStream()) +```csharp +new HttpRequest("http://34.223.124.45/") { Headers = { ["Host"] = "neverssl.com" } }.Send(); ``` -Here is a basic wget command implementation using CosmosHttp: [github.com/aura-systems/Aura-Operating-System](https://github.com/aura-systems/Aura-Operating-System/blob/master/SRC/Aura_OS/System/Interpreter/Commands/Network/Wget.cs#L63). +### Limits + +- `http://` only: there is no TLS, so `https://` URLs, and redirects to them, throw. +- Each request opens a connection of its own, and the server closes it once it has answered. +- Responses come without content coding (`Accept-Encoding: identity`): a Cosmos kernel has no gzip to undo. +- The whole body is held in memory. + +### Threads + +`Send()` never waits in `Thread.Sleep`: it waits in `Socket.Poll`, which returns at once on a Cosmos kernel. So it runs on the kernel's main loop, which must never block, as well as on a thread of its own. ## Authors 👤 **[@valentinbreiz](https://github.com/valentinbreiz)** -👤 **[@2881099](https://github.com/2881099)** +👤 **[@2881099](https://github.com/2881099)** (the first version was inspired by [TcpClientHttpRequest](https://github.com/2881099/TcpClientHttpRequest)) ## 🤝 Contributing -Contributions, issues and feature requests are welcome! Feel free to check [issues page](https://github.com/CosmosOS/CosmosHttp/issues). +Contributions, issues and feature requests are welcome! -## Show your support - -Give a ⭐️ if this project helped you! +Feel free to check [issues page](https://github.com/CosmosOS/Cosmos.Network.Http/issues). ## 📝 License -Copyright © 2023 [CosmosOS](https://github.com/CosmosOS). This project is [BSD Clause 3](https://github.com/CosmosOS/CosmosHttp/blob/main/LICENSE.txt) licensed. +Copyright © 2023-2026 [CosmosOS](https://github.com/CosmosOS). + +This project is [BSD Clause 3](https://github.com/CosmosOS/Cosmos.Network.Http/blob/main/LICENSE.txt) licensed. From 6894c1de76cdbe3d947724ef3e916f1c619de07a Mon Sep 17 00:00:00 2001 From: valentinbreiz Date: Sat, 3 Oct 2026 11:20:48 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=8D=B1=20Use=20the=20Cosmos=20logo=20?= =?UTF-8?q?as=20the=20package=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/icon.png | Bin 73586 -> 9346 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/resources/icon.png b/resources/icon.png index 391927b0aae6d41e2a867fec89f7973c7adcaf12..def41133dc33b7bee4a4c4a4076a6396147957cc 100644 GIT binary patch literal 9346 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 ZKkA-+<}!-+)I{Ky#<3GeGnB2b{U6F~Xu$vg