Public Access
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen. Changing the font size refits the grid and reports the new column count, so the far end is told it has fewer columns. That round trip is the feature. The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the page asks and the host decides — a signed step over a new client opcode, answered with a size over a new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size set by either is the size both remember. Stored in settings.json beside the cache rather than in it, and that is not laziness about a migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so out loud, because the next person to add a preference is the one who needs to read it. Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text must never be the thing that is off-screen. The desktop gets the three chords every terminal emulator has, answered by the page while a terminal has focus and by the window when it does not, plus a row in preferences that shows the current value and names the chords rather than replacing them. Someone whose terminal is too small to read is not in a position to go looking. Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every column of it is still a column the remote is being told exists; above thirty-two a phone in portrait has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses that do nothing, which on a terminal reads as the application having stopped responding. The preferences screen's header comment claimed none of the design's terminal settings could be saved, and listed the three things that were missing to make one work. All three now exist, so it says which one is real and why the other five still are not. Verified with the protocol suite — including that the step byte round-trips signed, since read unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most dramatic available version of "larger" — a data-plane test that the chord is heard with no session registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works, and a size chosen in one shell is there in a second one over the same profile directory. Layout suite and both heads build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
557 lines
20 KiB
C#
557 lines
20 KiB
C#
using System.Buffers.Text;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Net.WebSockets;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace DodoSSH.Client.Terminal;
|
|
|
|
/// <summary>
|
|
/// Serves the renderer page and carries terminal frames, over one loopback socket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The data plane is a socket rather than the WebView's JavaScript bridge. The bridge is UI-thread
|
|
/// bound string evaluation with no backpressure signal, which at terminal throughput means thousands
|
|
/// of script evaluations per second on the thread that also has to paint. A socket gives ordering,
|
|
/// binary payloads and flow control for free.
|
|
/// </para>
|
|
/// <para>
|
|
/// The page is served from the same listener as the socket, which is what makes the <c>Origin</c>
|
|
/// header predictable — it is always <c>http://127.0.0.1:{port}</c>. Loading the page through a
|
|
/// WebView virtual-host mapping instead would produce a different origin on each backend and give
|
|
/// nothing to validate against.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What the token and origin check actually defend against.</b> Not a hostile process running as
|
|
/// the same user: that process can already read this one's memory, so nothing here is a boundary
|
|
/// against it. They defend against a web page in the user's browser, which can and does attempt
|
|
/// WebSocket connections to loopback ports, and against a second copy of the application
|
|
/// accidentally attaching to the wrong terminal. Both are real; neither is stopped by the socket
|
|
/// being loopback alone.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|
{
|
|
/// <summary>Subprotocol the renderer must request.</summary>
|
|
public const string SubProtocol = "dodossh.terminal.v1";
|
|
|
|
/// <summary>Path the renderer page is served from.</summary>
|
|
public const string PagePath = "/terminal";
|
|
|
|
/// <summary>Path the WebSocket upgrade is accepted on.</summary>
|
|
public const string SocketPath = "/socket";
|
|
|
|
/// <summary>Placeholder in the page that is replaced with the connection token.</summary>
|
|
public const string TokenPlaceholder = "__DODOSSH_TOKEN__";
|
|
|
|
/// <summary>Placeholder in the page that is replaced with the socket URL.</summary>
|
|
public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__";
|
|
|
|
/// <summary>RFC 6455 §1.3: the fixed GUID mixed into the handshake response.</summary>
|
|
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
|
private const int MaximumRequestBytes = 16 * 1024;
|
|
private const int ReceiveBufferBytes = 64 * 1024;
|
|
|
|
private readonly TcpListener listener;
|
|
private readonly ITerminalAssetProvider assets;
|
|
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
|
|
private readonly Lock pumpGate = new();
|
|
private readonly SemaphoreSlim sendGate = new(1, 1);
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
private readonly TaskCompletionSource rendererAttached =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private WebSocket? socket;
|
|
private int accepted;
|
|
private int disposed;
|
|
|
|
/// <param name="assets">Where the renderer's files come from.</param>
|
|
public TerminalDataPlane(ITerminalAssetProvider assets)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(assets);
|
|
|
|
this.assets = assets;
|
|
|
|
Token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
|
|
|
listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
|
|
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
}
|
|
|
|
/// <summary>The port the OS assigned.</summary>
|
|
public int Port { get; }
|
|
|
|
/// <summary>The single-use connection token embedded in the served page.</summary>
|
|
public string Token { get; }
|
|
|
|
/// <summary>Where the WebView should navigate.</summary>
|
|
public Uri PageUrl => new(
|
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"),
|
|
UriKind.Absolute);
|
|
|
|
/// <summary>Completes once the renderer has attached its socket.</summary>
|
|
public Task RendererAttached => rendererAttached.Task;
|
|
|
|
/// <summary>Raised when the page asks for a different font size.</summary>
|
|
/// <remarks>
|
|
/// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
|
|
/// has to marshal. <see cref="TerminalWorkspace"/> forwards it as it arrives and leaves that to the
|
|
/// shell, which is where the thread affinity is known.
|
|
/// </remarks>
|
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
|
|
|
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
|
|
public void Register(uint sessionId, TerminalSessionPump pump)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pump);
|
|
|
|
lock (pumpGate)
|
|
{
|
|
pumps[sessionId] = pump;
|
|
}
|
|
}
|
|
|
|
/// <summary>Forgets a session that has ended.</summary>
|
|
public void Unregister(uint sessionId)
|
|
{
|
|
lock (pumpGate)
|
|
{
|
|
pumps.Remove(sessionId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Accepts connections until disposed.</summary>
|
|
public async Task RunAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
|
cancellationToken,
|
|
lifetime.Token);
|
|
|
|
while (!linked.Token.IsCancellationRequested)
|
|
{
|
|
TcpClient client;
|
|
try
|
|
{
|
|
client = await listener.AcceptTcpClientAsync(linked.Token).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
|
|
// lives for the whole session, so handling connections in sequence would leave the accept
|
|
// loop parked inside the receive loop and every later request unanswered — the page's
|
|
// script and stylesheet among them. Concurrency needs no coordination here because the
|
|
// single-attach guard is an interlocked exchange.
|
|
_ = HandleConnectionAsync(client, linked.Token);
|
|
}
|
|
}
|
|
|
|
private async Task HandleConnectionAsync(TcpClient client, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await HandleAsync(client, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception)
|
|
when (exception is IOException or SocketException or WebSocketException
|
|
or OperationCanceledException or ObjectDisposedException)
|
|
{
|
|
// A renderer that went away mid-handshake, or a shutdown in progress. Ordinary.
|
|
}
|
|
finally
|
|
{
|
|
client.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
|
{
|
|
var current = socket;
|
|
if (current is null || current.State != WebSocketState.Open)
|
|
{
|
|
// Dropped rather than queued. A terminal whose renderer has gone has nothing to catch up
|
|
// on, and buffering for one that may never return is the unbounded growth the credit
|
|
// window exists to prevent.
|
|
return;
|
|
}
|
|
|
|
// WebSocket forbids concurrent sends, and every session shares this one socket.
|
|
await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
await current
|
|
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
sendGate.Release();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref disposed, 1) == 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
|
rendererAttached.TrySetCanceled();
|
|
|
|
listener.Dispose();
|
|
socket?.Dispose();
|
|
sendGate.Dispose();
|
|
lifetime.Dispose();
|
|
}
|
|
|
|
private async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
|
|
{
|
|
var stream = client.GetStream();
|
|
await using var streamScope = stream.ConfigureAwait(false);
|
|
|
|
var request = await ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false);
|
|
if (request is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(request.Path, SocketPath, StringComparison.Ordinal))
|
|
{
|
|
await UpgradeAsync(stream, request, cancellationToken).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
await ServeAssetAsync(stream, request.Path, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task ServeAssetAsync(Stream stream, string path, CancellationToken cancellationToken)
|
|
{
|
|
var asset = assets.Find(path);
|
|
|
|
if (asset is null)
|
|
{
|
|
await WriteResponseAsync(stream, "404 Not Found", "text/plain", "Not found"u8.ToArray(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var content = asset.Content;
|
|
|
|
if (string.Equals(path, PagePath, StringComparison.Ordinal))
|
|
{
|
|
// The token and socket URL are substituted at serve time rather than being written into
|
|
// the file, so the token never touches disk and never appears in a URL that could reach a
|
|
// log or a browser history.
|
|
var text = Encoding.UTF8.GetString(content)
|
|
.Replace(TokenPlaceholder, Token, StringComparison.Ordinal)
|
|
.Replace(
|
|
SocketUrlPlaceholder,
|
|
string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{Port}{SocketPath}"),
|
|
StringComparison.Ordinal);
|
|
|
|
content = Encoding.UTF8.GetBytes(text);
|
|
}
|
|
|
|
await WriteResponseAsync(stream, "200 OK", asset.ContentType, content, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task UpgradeAsync(
|
|
Stream stream,
|
|
HttpRequestLine request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!IsAcceptableUpgrade(request))
|
|
{
|
|
await WriteResponseAsync(
|
|
stream, "403 Forbidden", "text/plain", "Rejected"u8.ToArray(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref accepted, 1) == 1)
|
|
{
|
|
// One renderer, one socket. A second attach would be either a bug or something else on the
|
|
// machine having found the port.
|
|
await WriteResponseAsync(
|
|
stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
|
|
var accept = ComputeHandshakeAccept(key);
|
|
|
|
var handshake =
|
|
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
+ "Upgrade: websocket\r\n"
|
|
+ "Connection: Upgrade\r\n"
|
|
+ "Sec-WebSocket-Accept: " + accept + "\r\n"
|
|
+ "Sec-WebSocket-Protocol: " + SubProtocol + "\r\n\r\n";
|
|
|
|
await stream.WriteAsync(Encoding.ASCII.GetBytes(handshake), cancellationToken).ConfigureAwait(false);
|
|
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// The handshake is ours; the framing is the BCL's. Hand-rolling masking, fragmentation and
|
|
// control frames would be a great deal of code for no gain.
|
|
using var webSocket = WebSocket.CreateFromStream(
|
|
stream,
|
|
new WebSocketCreationOptions
|
|
{
|
|
IsServer = true,
|
|
SubProtocol = SubProtocol,
|
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
|
});
|
|
|
|
socket = webSocket;
|
|
rendererAttached.TrySetResult();
|
|
|
|
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The origin is checked because a page in the user's browser can attempt a WebSocket connection to
|
|
/// a loopback port, and the token because that page could otherwise simply guess the path. Neither
|
|
/// defends against a process running as this user; that one can read our memory regardless.
|
|
/// </remarks>
|
|
private bool IsAcceptableUpgrade(HttpRequestLine request)
|
|
{
|
|
var origin = request.Headers.GetValueOrDefault("origin");
|
|
var expectedOrigin = string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}");
|
|
|
|
if (!string.Equals(origin, expectedOrigin, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (request.Headers.GetValueOrDefault("sec-websocket-key") is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var protocols = request.Headers.GetValueOrDefault("sec-websocket-protocol") ?? string.Empty;
|
|
|
|
var offered = protocols
|
|
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
if (!offered.Contains(SubProtocol, StringComparer.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Carried as a subprotocol rather than a query parameter, which keeps it out of anything that
|
|
// logs URLs.
|
|
var presented = offered.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal));
|
|
|
|
return presented is not null
|
|
&& CryptographicOperations.FixedTimeEquals(
|
|
Encoding.ASCII.GetBytes(presented["token.".Length..]),
|
|
Encoding.ASCII.GetBytes(Token));
|
|
}
|
|
|
|
private async Task ReceiveLoopAsync(WebSocket webSocket, CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[ReceiveBufferBytes];
|
|
|
|
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
|
{
|
|
var result = await webSocket
|
|
.ReceiveAsync(buffer.AsMemory(), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!result.EndOfMessage)
|
|
{
|
|
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
|
|
// so this is either a bug or something hostile; dropping is the safe response.
|
|
continue;
|
|
}
|
|
|
|
Dispatch(buffer.AsSpan(0, result.Count));
|
|
}
|
|
}
|
|
|
|
private void Dispatch(ReadOnlySpan<byte> frame)
|
|
{
|
|
if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Answered before the session lookup, because it is the one frame that is not about a session. The
|
|
// page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just
|
|
// ended is still a request to make the text bigger.
|
|
if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep)
|
|
{
|
|
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
|
|
{
|
|
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
TerminalSessionPump? pump;
|
|
lock (pumpGate)
|
|
{
|
|
pump = pumps.GetValueOrDefault(sessionId);
|
|
}
|
|
|
|
if (pump is null)
|
|
{
|
|
// A frame for a session that has already ended. Ordinary during teardown.
|
|
return;
|
|
}
|
|
|
|
switch ((TerminalClientOpcode)opcode)
|
|
{
|
|
case TerminalClientOpcode.Input:
|
|
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
|
|
// sharing this socket.
|
|
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
|
|
break;
|
|
|
|
case TerminalClientOpcode.Acknowledge:
|
|
if (TerminalFrame.TryReadAcknowledgement(payload, out var rendered))
|
|
{
|
|
pump.Acknowledge(rendered);
|
|
}
|
|
|
|
break;
|
|
|
|
case TerminalClientOpcode.Resize:
|
|
if (TerminalFrame.TryReadResize(payload, out var size))
|
|
{
|
|
pump.Resize(size);
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
// Unknown opcode from a newer page than this host. Ignored rather than fatal.
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// SHA-1 is mandated by the specification and is not doing security work here. The value proves
|
|
/// only that the server understood the WebSocket handshake rather than being a plain HTTP server
|
|
/// that echoed the request; it authenticates nothing and protects no data. Substituting SHA-256
|
|
/// would simply make the handshake fail with every client in existence.
|
|
/// </para>
|
|
/// <para>
|
|
/// The alternative that avoids SHA-1 in our own code is
|
|
/// <c>HttpListener.AcceptWebSocketAsync</c>, which throws <c>PlatformNotSupportedException</c>
|
|
/// off Windows — so it would trade a documented suppression for a platform restriction.
|
|
/// </para>
|
|
/// </remarks>
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
|
"Security",
|
|
"CA5350:Do Not Use Weak Cryptographic Algorithms",
|
|
Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
|
"ApiDesign",
|
|
"RS0030:Do not use banned APIs",
|
|
Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
|
|
private static string ComputeHandshakeAccept(string key) =>
|
|
Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + HandshakeGuid)));
|
|
|
|
private static async Task WriteResponseAsync(
|
|
Stream stream,
|
|
string status,
|
|
string contentType,
|
|
byte[] body,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var header =
|
|
"HTTP/1.1 " + status + "\r\n"
|
|
+ "Content-Type: " + contentType + "\r\n"
|
|
+ "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"
|
|
|
|
// The page carries a connection token, so it must never be cached anywhere.
|
|
+ "Cache-Control: no-store\r\n"
|
|
+ "Connection: close\r\n\r\n";
|
|
|
|
await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
|
|
await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
|
|
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<HttpRequestLine?> ReadRequestAsync(
|
|
Stream stream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var buffer = new byte[MaximumRequestBytes];
|
|
var count = 0;
|
|
|
|
while (count < buffer.Length)
|
|
{
|
|
var read = await stream
|
|
.ReadAsync(buffer.AsMemory(count), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (read == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
count += read;
|
|
|
|
if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
var lines = Encoding.ASCII.GetString(buffer, 0, count).Split("\r\n");
|
|
var parts = lines[0].Split(' ');
|
|
|
|
if (parts.Length < 2 || !string.Equals(parts[0], "GET", StringComparison.Ordinal))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var line in lines.Skip(1))
|
|
{
|
|
if (line.Length == 0)
|
|
{
|
|
break;
|
|
}
|
|
|
|
var separator = line.IndexOf(':', StringComparison.Ordinal);
|
|
if (separator > 0)
|
|
{
|
|
headers[line[..separator].Trim()] = line[(separator + 1)..].Trim();
|
|
}
|
|
}
|
|
|
|
var path = parts[1];
|
|
var query = path.IndexOf('?', StringComparison.Ordinal);
|
|
|
|
return new HttpRequestLine(query < 0 ? path : path[..query], headers);
|
|
}
|
|
|
|
private sealed record HttpRequestLine(string Path, IReadOnlyDictionary<string, string> Headers);
|
|
}
|