using System.Text; using XFESystem.Browser; using WebResponse = XFESystem.Browser.WebResponse; #if !XFE_HTTP_PROTOCOL_HOST_TEST using System.Diagnostics; using System.Net; using System.Net.Sockets; using AddressFamily = System.Net.Sockets.AddressFamily; using Cosmos.Kernel.System.Network; using Cosmos.Kernel.System.Timers; #endif namespace XFESystem.Kernel; #if !XFE_HTTP_PROTOCOL_HOST_TEST /// /// Real Cosmos socket transport for bounded IPv4 HTTP/1.1. HTTPS is refused: /// the pinned runtime has no validated native TLS implementation. /// public sealed class NativeWebTransport : IWebTransport, IWebTransportCapabilities { public bool IsNative => true; public bool SupportsHttp => true; public bool SupportsHttps => false; // Cosmos 3.0.94 ConnectTcp has an internal fixed 5000 ms wait. Never enter // it when the operation's remaining budget cannot cover that wait. private const int ConnectBudgetMilliseconds = 5_100; private string? fault; public string? Fault => fault; public WebResponse Get(Uri address, BrowserLimits limits) { ArgumentNullException.ThrowIfNull(address); ArgumentNullException.ThrowIfNull(limits); ValidateAddress(address); if (fault is not null) throw new InvalidOperationException("Native HTTP transport stopped after a socket failure: " + fault); if (!NetworkManager.IsEnabled || NetworkManager.DeviceCount == 0 || !NetworkManager.Ready || !NetworkManager.LinkUp || NetworkManager.Primary.IPConfig is null) throw new InvalidOperationException("No configured native IPv4 network adapter is available."); if (!TimerManager.IsInitialized || Stopwatch.Frequency <= 0) throw new InvalidOperationException("Native HTTP requires a reliable monotonic clock and timer."); if (limits.NetworkTimeout < TimeSpan.FromMilliseconds(ConnectBudgetMilliseconds) || limits.NetworkTimeout > TimeSpan.FromMinutes(2)) throw new NotSupportedException("This native socket profile requires a network timeout between 5.1 and 120 seconds."); if (limits.MaximumHtmlCharacters is < 1 or > 8_388_608 || limits.MaximumRedirects is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(limits)); var watch = Stopwatch.StartNew(); var target = address; for (var redirects = 0; ; redirects++) { ValidateAddress(target); var remote = Resolve(target.IdnHost, watch, limits.NetworkTimeout); if (Remaining(watch, limits.NetworkTimeout) < ConnectBudgetMilliseconds) throw new TimeoutException("The remaining HTTP deadline cannot cover the native TCP connect wait."); var request = BuildRequest(target); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var connected = false; NativeHttpResponse response; try { // 3.0.94 reaps SYN_SENT on Close(0), so a failed connection // can be retried without the 3.0.88 permanent circuit break. socket.Connect(remote, target.Port); connected = true; CheckDeadline(watch, limits.NetworkTimeout); var sent = 0; while (sent < request.Length) { CheckDeadline(watch, limits.NetworkTimeout); var count = socket.Send(request, sent, Math.Min(512, request.Length - sent), SocketFlags.None); if (count <= 0) throw new IOException("The native TCP send did not make progress."); sent += count; } response = NativeHttpProtocol.ReadResponse((buffer, offset, count) => Receive(socket, buffer, offset, count, watch, limits.NetworkTimeout), checked(limits.MaximumHtmlCharacters * 4)); // The request explicitly asks the server to close. Give its // FIN a bounded grace interval; do not accumulate unbounded // detached FIN_WAIT state when a peer ignores that request. var closeStart = watch.ElapsedMilliseconds; while (socket.Connected && watch.ElapsedMilliseconds - closeStart < 100 && Remaining(watch, limits.NetworkTimeout) > 0) TimerManager.Wait(1); if (socket.Connected) fault = "The peer did not close its HTTP connection; this runtime cannot reap detached TCP state safely. Restart the network runtime before another request."; // Cosmos 3.0.94 does not run finally clauses during exception // unwinding. Release on both explicit paths instead. socket.Close(0); } catch { if (connected && socket.Connected) fault ??= "The HTTP exchange failed while TCP remained open. Restart the network runtime before retrying."; // Use the explicit zero-linger overload; Dispose/Close() use // the upstream five-second close wait and native handles. try { socket.Close(0); } catch (Exception cleanupError) { fault ??= "Native TCP cleanup failed: " + cleanupError.Message; } throw; } if (response.StatusCode is 301 or 302 or 303 or 307 or 308) { if (redirects >= limits.MaximumRedirects) throw new InvalidOperationException("The HTTP redirect limit was reached."); if (string.IsNullOrWhiteSpace(response.Location) || !Uri.TryCreate(target, response.Location, out var next)) throw new InvalidOperationException("The HTTP redirect has no valid Location header."); ValidateAddress(next); if (fault is not null) throw new InvalidOperationException(fault); target = next; continue; } var body = NativeHttpProtocol.DecodeBody(response); if (body.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The decoded native HTTP body exceeds its character limit."); return new(target, response.StatusCode, response.ContentType, body, false, response.ContentSecurityPolicy); } } private static IPAddress Resolve(string host, Stopwatch watch, TimeSpan timeout) { if (TryIPv4(host, out var bytes)) return new IPAddress(bytes); if (DnsConfig.Nameservers.Count == 0) throw new InvalidOperationException("No native DNS server is configured."); if (DnsConfig.Nameservers[0] is not Address4) throw new NotSupportedException("This native browser transport resolves DNS over IPv4 only."); var milliseconds = Math.Min(5_000, Remaining(watch, timeout) - ConnectBudgetMilliseconds); milliseconds = milliseconds / 10 * 10; if (milliseconds <= 0) throw new TimeoutException("The HTTP deadline cannot cover DNS and TCP connect."); var resolver = new DnsClient(); try { resolver.Connect(DnsConfig.Nameservers[0]); resolver.SendQuery(host); var address = resolver.Receive(milliseconds); var result = address is Address4 ? new IPAddress(address.ToBytes().ToArray()) : throw new InvalidOperationException("Native DNS did not return an IPv4 address before its deadline."); resolver.Close(); return result; } catch { resolver.Close(); throw; } } private static int Receive(Socket socket, byte[] buffer, int offset, int count, Stopwatch watch, TimeSpan timeout) { while (true) { CheckDeadline(watch, timeout); var available = socket.Available; if (available > 0) { socket.ReceiveTimeout = Math.Max(1, Remaining(watch, timeout)); // Avoid the extra temporary copy allocated by the upstream // Span overload; this adapter owns a reusable byte[] buffer. return socket.Receive(buffer, offset, Math.Min(count, available), SocketFlags.None); } if (!socket.Connected) { // Recheck in case the final data and FIN arrived between the // first Available read and the connection-state read. if (socket.Available > 0) continue; return 0; } // Poll outside Receive so the whole-operation deadline is checked // between waits; 3.0.94 also supplies the receive timeout above. TimerManager.Wait(1); } } private static byte[] BuildRequest(Uri address) { var host = address.IsDefaultPort ? address.IdnHost : address.IdnHost + ":" + address.Port; var path = string.IsNullOrEmpty(address.PathAndQuery) ? "/" : address.PathAndQuery; var request = "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: XFESystem/0.3-CSharp\r\nAccept: text/html,text/plain,text/markdown\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n"; if (request.Length > 4_096 || request.Any(character => character > 127 || character == '\0')) throw new NotSupportedException("The native HTTP request exceeds its ASCII header limit."); return Encoding.ASCII.GetBytes(request); } private static void ValidateAddress(Uri address) { if (!address.IsAbsoluteUri || !string.IsNullOrEmpty(address.UserInfo) || address.AbsoluteUri.Length > 2_048) throw new NotSupportedException("Native HTTP requires an absolute URL without embedded credentials."); if (address.Scheme == "https") throw new NotSupportedException("HTTPS is unavailable: this native runtime has no verified TLS implementation. Certificate validation cannot be bypassed."); if (address.Scheme != "http" || address.HostNameType is not (UriHostNameType.IPv4 or UriHostNameType.Dns)) throw new NotSupportedException("The native browser currently supports IPv4 HTTP URLs only."); if (address.AbsoluteUri.Any(char.IsControl)) throw new ArgumentException("The URL contains control characters."); } private static bool TryIPv4(string host, out byte[] bytes) { bytes = new byte[4]; var fragments = host.Split('.'); if (fragments.Length != 4) return false; for (var index = 0; index < 4; index++) { if (fragments[index].Length is 0 or > 3) return false; var value = 0; foreach (var character in fragments[index]) { if (character is < '0' or > '9') return false; value = value * 10 + character - '0'; } if (value > 255) return false; bytes[index] = (byte)value; } return true; } private static int Remaining(Stopwatch watch, TimeSpan timeout) => (int)Math.Max(0, timeout.TotalMilliseconds - watch.Elapsed.TotalMilliseconds); private static void CheckDeadline(Stopwatch watch, TimeSpan timeout) { if (Remaining(watch, timeout) <= 0) throw new TimeoutException("The native HTTP operation timed out."); } } #endif internal sealed record NativeHttpResponse(int StatusCode, string ContentType, byte[] Body, string? Location, string? ContentSecurityPolicy); /// Bounded HTTP framing shared verbatim by the native adapter and host protocol tests. internal static class NativeHttpProtocol { private const int MaximumHeaderBytes = 16_384; private const int MaximumLineBytes = 4_096; public static NativeHttpResponse ReadResponse(Func receive, int maximumBodyBytes) { ArgumentNullException.ThrowIfNull(receive); ArgumentOutOfRangeException.ThrowIfLessThan(maximumBodyBytes, 1); ArgumentOutOfRangeException.ThrowIfGreaterThan(maximumBodyBytes, 33_554_432); var reader = new WireReader(receive); Dictionary headers; var totalHeaderBytes = 0; var interim = 0; int status; do { var statusLine = reader.Line(ref totalHeaderBytes); if (statusLine.Length < 12 || !(statusLine.StartsWith("HTTP/1.1 ", StringComparison.Ordinal) || statusLine.StartsWith("HTTP/1.0 ", StringComparison.Ordinal)) || statusLine[9] is < '1' or > '5' || statusLine[10] is < '0' or > '9' || statusLine[11] is < '0' or > '9' || statusLine.Length > 12 && statusLine[12] != ' ') throw new IOException("The HTTP status line is invalid."); status = (statusLine[9] - '0') * 100 + (statusLine[10] - '0') * 10 + statusLine[11] - '0'; headers = ReadHeaders(reader, ref totalHeaderBytes); if (status == 101) throw new NotSupportedException("HTTP protocol upgrades are not supported."); if (status < 200 && ++interim > 4) throw new IOException("Too many interim HTTP responses."); } while (status < 200); headers.TryGetValue("Content-Type", out var contentType); headers.TryGetValue("Location", out var location); headers.TryGetValue("Content-Security-Policy", out var csp); if (headers.TryGetValue("Content-Encoding", out var contentEncoding) && !contentEncoding.Equals("identity", StringComparison.OrdinalIgnoreCase)) throw new NotSupportedException("Compressed native HTTP bodies are not supported."); var hasLength = headers.TryGetValue("Content-Length", out var lengthText); var hasTransfer = headers.TryGetValue("Transfer-Encoding", out var transfer); if (hasLength && hasTransfer) throw new IOException("Ambiguous HTTP Content-Length and Transfer-Encoding."); if (hasTransfer && !transfer!.Equals("chunked", StringComparison.OrdinalIgnoreCase)) throw new NotSupportedException("Only a single chunked HTTP transfer coding is supported."); var body = new ByteBody(maximumBodyBytes); if (status is 204 or 304 or 301 or 302 or 303 or 307 or 308) return new(status, contentType ?? "text/plain; charset=utf-8", [], location, csp); if (hasTransfer) { var trailerBytes = 0; while (true) { var lineBytes = 0; var chunkLine = reader.Line(ref lineBytes); var semicolon = chunkLine.IndexOf(';'); var size = ParseUnsigned(semicolon < 0 ? chunkLine : chunkLine[..semicolon], 16, maximumBodyBytes); if (size == 0) { var trailers = ReadHeaders(reader, ref trailerBytes); if (trailers.ContainsKey("Content-Length") || trailers.ContainsKey("Transfer-Encoding") || trailers.ContainsKey("Host")) throw new IOException("HTTP trailers contain forbidden framing fields."); break; } body.ReadExactly(reader, size); if (reader.Byte() != '\r' || reader.Byte() != '\n') throw new IOException("A chunk has no CRLF terminator."); } } else if (hasLength) { var size = ParseUnsigned(lengthText!, 10, maximumBodyBytes); body.ReadExactly(reader, size); } else { var buffer = new byte[4_096]; int count; while ((count = reader.Read(buffer, 0, buffer.Length)) != 0) body.Append(buffer, count); } return new(status, contentType ?? "text/plain; charset=utf-8", body.ToArray(), location, csp); } public static string DecodeBody(NativeHttpResponse response) { var charset = "utf-8"; foreach (var parameter in response.ContentType.Split(';').Skip(1)) { var pair = parameter.Trim().Split('=', 2); if (pair.Length == 2 && pair[0].Equals("charset", StringComparison.OrdinalIgnoreCase)) charset = pair[1].Trim().Trim('"', '\'').ToLowerInvariant(); } var encoding = charset switch { "utf-8" or "utf8" => Encoding.UTF8, "us-ascii" or "ascii" => Encoding.ASCII, "iso-8859-1" or "latin1" => Encoding.Latin1, _ => throw new NotSupportedException("The native HTTP charset is not supported: " + charset) }; return encoding.GetString(response.Body); } private static Dictionary ReadHeaders(WireReader reader, ref int totalBytes) { var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); while (true) { var line = reader.Line(ref totalBytes); if (line.Length == 0) return headers; if (headers.Count == 100) throw new IOException("The HTTP header count limit was reached."); var colon = line.IndexOf(':'); if (colon <= 0 || line[..colon].Any(character => !IsToken(character))) throw new IOException("The HTTP header name is invalid."); var name = line[..colon]; var value = line[(colon + 1)..].Trim(' ', '\t'); if (headers.TryGetValue(name, out var previous)) { if (name.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) || name.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || name.Equals("Location", StringComparison.OrdinalIgnoreCase)) throw new IOException("Duplicate HTTP framing or redirect header."); headers[name] = previous + "; " + value; } else headers.Add(name, value); } } private static bool IsToken(char value) => value is >= '0' and <= '9' or >= 'A' and <= 'Z' or >= 'a' and <= 'z' || "!#$%&'*+-.^_`|~".Contains(value); private static int ParseUnsigned(string text, int radix, int maximum) { if (text.Length is 0 or > 16) throw new IOException("The HTTP body length is invalid."); var result = 0; foreach (var character in text) { var digit = character is >= '0' and <= '9' ? character - '0' : character is >= 'a' and <= 'f' ? character - 'a' + 10 : character is >= 'A' and <= 'F' ? character - 'A' + 10 : -1; if (digit < 0 || digit >= radix) throw new IOException("The HTTP body length is invalid."); if (result > (maximum - digit) / radix || digit > maximum) throw new BrowserLimitException("The native HTTP body exceeds its byte limit."); result = result * radix + digit; } return result; } private sealed class WireReader(Func receive) { private readonly byte[] buffer = new byte[4_096]; private int position; private int length; public int Byte() { if (position == length) { length = receive(buffer, 0, buffer.Length); position = 0; if (length < 0 || length > buffer.Length) throw new IOException("Invalid native TCP byte count."); if (length == 0) return -1; } return buffer[position++]; } public int Read(byte[] target, int offset, int count) { if (position != length) { var copied = Math.Min(count, length - position); Array.Copy(buffer, position, target, offset, copied); position += copied; return copied; } var read = receive(target, offset, count); if (read < 0 || read > count) throw new IOException("Invalid native TCP byte count."); return read; } public string Line(ref int totalBytes) { var line = new StringBuilder(); while (true) { var value = Byte(); if (value < 0) throw new IOException("The HTTP headers ended unexpectedly."); if (++totalBytes > MaximumHeaderBytes) throw new IOException("The HTTP header byte limit was reached."); if (value == '\r') { if (Byte() != '\n') throw new IOException("HTTP lines require CRLF."); if (++totalBytes > MaximumHeaderBytes) throw new IOException("The HTTP header byte limit was reached."); return line.ToString(); } if (value != '\t' && value < 32 || value > 126) throw new IOException("HTTP headers contain invalid control or non-ASCII bytes."); if (line.Length == MaximumLineBytes) throw new IOException("The HTTP line length limit was reached."); line.Append((char)value); } } } private sealed class ByteBody(int maximum) { private byte[] bytes = new byte[Math.Min(maximum, 4_096)]; private int length; public void ReadExactly(WireReader reader, int count) { EnsureCapacity(count); var end = length + count; while (length < end) { var read = reader.Read(bytes, length, end - length); if (read == 0) throw new IOException("The HTTP body ended before its declared length."); length += read; } } public void Append(byte[] buffer, int count) { EnsureCapacity(count); Array.Copy(buffer, 0, bytes, length, count); length += count; } private void EnsureCapacity(int count) { if (count > maximum - length) throw new BrowserLimitException("The native HTTP body exceeds its byte limit."); if (bytes.Length >= length + count) return; var expanded = new byte[Math.Min(maximum, Math.Max(length + count, bytes.Length * 2))]; Array.Copy(bytes, expanded, length); bytes = expanded; } public byte[] ToArray() { var result = new byte[length]; Array.Copy(bytes, result, length); return result; } } }