diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 503db9e..46956a9 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -18,12 +18,14 @@ + + diff --git a/src/DodoSSH.Client.Ssh/HostKeyTrust.cs b/src/DodoSSH.Client.Ssh/HostKeyTrust.cs new file mode 100644 index 0000000..f52c126 --- /dev/null +++ b/src/DodoSSH.Client.Ssh/HostKeyTrust.cs @@ -0,0 +1,114 @@ +namespace DodoSSH.Client.Ssh; + +/// A host key as the server presented it during the handshake. +/// Host as dialled. +/// Port as dialled. +/// Key algorithm, e.g. ssh-ed25519. +/// OpenSSH-style fingerprint, from . +public sealed record HostKeyPresentation(string Host, int Port, string Algorithm, string Fingerprint); + +/// +/// The trusted host keys a user has accumulated. +/// +/// +/// Known hosts live in the end-to-end encrypted vault as a synced entity, not in a local file. Trust +/// then follows the user to every device, and the server cannot tamper with it — which matters, +/// because a server that could silently drop a pin could downgrade every connection to first-use. +/// +public interface IKnownHostStore +{ + /// Returns the pinned fingerprint for a host and key algorithm, if there is one. + /// + /// Keyed on algorithm as well as host, because a server legitimately offers several host keys and + /// which one is negotiated can change between connections. Pinning only one and rejecting the + /// others would make a normal server look hostile. + /// + ValueTask FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken); + + /// Records a host key as trusted. + ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken); +} + +/// +/// The host has never been seen, so there is nothing to compare against. +/// +/// +/// A distinct exception rather than a prompt inside the handshake, and that is a deliberate design +/// choice. SSH.NET raises host key verification as a synchronous event, so consulting the user from +/// inside it would mean blocking the handshake thread on a UI round trip — sync-over-async, and a +/// deadlock the first time the prompt needs the UI thread. Failing the connection and letting the +/// caller prompt keeps everything asynchronous, at the cost of a second TCP connection the first +/// time a host is used. +/// +public sealed class SshHostKeyUnknownException(HostKeyPresentation presentation) + : Exception($"The host key for {presentation.Host}:{presentation.Port} is not trusted yet.") +{ + /// The key the server offered, to show the user before they trust it. + public HostKeyPresentation Presentation { get; } = presentation; +} + +/// +/// The host presented a different key from the one pinned for it. +/// +/// +/// This must stay a hard block with no "continue anyway" in the connect path. A dialog offering to +/// proceed is how users are trained to click through the one warning that actually indicates an +/// interception. A legitimate key change — a rebuilt server — is handled by explicitly removing the +/// pin in the host's settings, which is a deliberate act performed away from the moment of +/// connecting. +/// +public sealed class SshHostKeyMismatchException(HostKeyPresentation presentation, string pinnedFingerprint) + : Exception( + $"The host key for {presentation.Host}:{presentation.Port} has changed. " + + $"Pinned {pinnedFingerprint}, but the server offered {presentation.Fingerprint}.") +{ + /// The key the server offered. + public HostKeyPresentation Presentation { get; } = presentation; + + /// The key previously trusted for this host. + public string PinnedFingerprint { get; } = pinnedFingerprint; +} + +/// +/// A known-host store held in memory. +/// +/// +/// Stands in until the encrypted local cache lands. Trust is lost when the process exits, so a user +/// is asked about every host on every launch — noisy, but the noise is the correct failure mode for a +/// placeholder: it cannot be mistaken for working persistence. +/// +public sealed class InMemoryKnownHostStore : IKnownHostStore +{ + private readonly Dictionary pins = new(StringComparer.Ordinal); + private readonly Lock gate = new(); + + /// + public ValueTask FindAsync( + string host, + int port, + string algorithm, + CancellationToken cancellationToken) + { + lock (gate) + { + return ValueTask.FromResult(pins.GetValueOrDefault(Key(host, port, algorithm))); + } + } + + /// + public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(presentation); + + lock (gate) + { + pins[Key(presentation.Host, presentation.Port, presentation.Algorithm)] = + presentation.Fingerprint; + } + + return ValueTask.CompletedTask; + } + + private static string Key(string host, int port, string algorithm) => + $"{host}:{port}/{algorithm}"; +} diff --git a/src/DodoSSH.Client.Ssh/SshConnection.cs b/src/DodoSSH.Client.Ssh/SshConnection.cs new file mode 100644 index 0000000..4a8bd1a --- /dev/null +++ b/src/DodoSSH.Client.Ssh/SshConnection.cs @@ -0,0 +1,82 @@ +namespace DodoSSH.Client.Ssh; + +/// How to authenticate to a host. +/// +/// Always decrypted from the vault immediately before use and never persisted outside it. Because +/// SSH terminates on the client, using a credential requires its plaintext here — which is exactly +/// why the Connect permission is a UI hint and not an enforceable boundary. See ADR 0001. +/// +public abstract record SshCredential; + +/// Password authentication, and keyboard-interactive where the server prefers it. +public sealed record SshPasswordCredential(string Password) : SshCredential; + +/// Public-key authentication. +/// The private key in PEM form, as stored in the vault. +/// Passphrase protecting the key, when it has one. +public sealed record SshPrivateKeyCredential(byte[] PrivateKeyPem, string? Passphrase) : SshCredential; + +/// Everything needed to reach one host. +/// Hostname or address. +/// Port. +/// Remote account. +/// How to authenticate. +/// How long to wait for the transport and handshake. +public sealed record SshConnectionRequest( + string Host, + int Port, + string Username, + SshCredential Credential, + TimeSpan? ConnectTimeout = null); + +/// An interactive shell over a pseudo-terminal. +public interface ISshShellSession : IAsyncDisposable +{ + /// Whether the channel is still usable. + bool IsOpen { get; } + + /// Reads whatever output is available, blocking until at least one byte arrives. + /// Bytes read, or 0 once the remote closes the channel. + ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken); + + /// Sends keystrokes to the remote. + ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken); + + /// + /// Tells the remote the terminal has been resized. + /// + /// + /// Verified to reach the remote against a real sshd; see PtyAndResizeSpikeTests. Sizes + /// that are not are dropped rather than forwarded. + /// + void Resize(TerminalSize size); +} + +/// An authenticated connection to one host. +public interface ISshConnection : IAsyncDisposable +{ + /// Whether the transport is still up. + bool IsConnected { get; } + + /// The host key that was accepted for this connection. + HostKeyPresentation HostKey { get; } + + /// Opens an interactive shell with a pseudo-terminal. + Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken); +} + +/// Opens connections, enforcing host key trust before authenticating. +public interface ISshConnectionFactory +{ + /// + /// Connects and authenticates. + /// + /// + /// The host has no pinned key. The caller must show the fingerprint, and only on explicit + /// confirmation record it via and retry. + /// + /// + /// The presented key differs from the pin. There is no retry path: this is a hard block. + /// + Task ConnectAsync(SshConnectionRequest request, CancellationToken cancellationToken); +} diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs new file mode 100644 index 0000000..40c51d8 --- /dev/null +++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs @@ -0,0 +1,248 @@ +using System.Text; +using Renci.SshNet; +using Renci.SshNet.Common; + +namespace DodoSSH.Client.Ssh; + +/// +/// Opens SSH connections with SSH.NET, checking host key trust during the handshake. +/// +/// +/// The pinned fingerprint is looked up before connecting, so the comparison inside SSH.NET's +/// synchronous HostKeyReceived event is a pure equality check with no I/O and no chance of +/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an +/// exception the caller resolves asynchronously. +/// +public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory +{ + private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15); + + /// + public async Task ConnectAsync( + SshConnectionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var client = new SshClient(BuildConnectionInfo(request)); + var gate = new HostKeyGate(knownHosts, request, cancellationToken); + + client.HostKeyReceived += gate.OnHostKeyReceived; + + try + { + await client.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is SshConnectionException or SshAuthenticationException) + { + client.Dispose(); + + // Translate a refusal we caused ourselves into something the caller can act on. Without + // this the user sees "connection lost" for what is really "do you trust this key?". + throw gate.TranslateFailure() ?? exception; + } + catch + { + client.Dispose(); + throw; + } + + return new SshNetConnection(client, gate.Presented!); + } + + /// + /// Decides host key trust during the handshake, and remembers enough to explain a refusal. + /// + /// + /// Separate from the connect method because the decision is the security-relevant part and reads + /// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch, + /// unpinned refuses as unknown. There is no fourth branch, and there is no prompt. + /// + private sealed class HostKeyGate( + IKnownHostStore knownHosts, + SshConnectionRequest request, + CancellationToken cancellationToken) + { + /// What the server offered, once the handshake has reached that point. + public HostKeyPresentation? Presented { get; private set; } + + private string? pinned; + private bool mismatch; + + public void OnHostKeyReceived(object? sender, HostKeyEventArgs e) + { + var presentation = new HostKeyPresentation( + request.Host, + request.Port, + e.HostKeyName, + SshHostKeyFingerprint.Format(e.HostKey)); + + Presented = presentation; + + // Looked up here rather than before connecting, because the negotiated algorithm is only + // known now and a server may choose a different one than it did last time. + // + // This is the one place the design cannot stay asynchronous: SSH.NET raises host key + // verification synchronously. It is a local store read rather than a UI round trip, and + // making the store synchronous instead would rule out a vault-backed implementation. + pinned = knownHosts + .FindAsync(request.Host, request.Port, e.HostKeyName, cancellationToken) + .AsTask() + .GetAwaiter() + .GetResult(); + + if (pinned is null) + { + // Refused, not prompted. The caller decides, off the handshake thread. + e.CanTrust = false; + return; + } + + var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint); + mismatch = !matches; + e.CanTrust = matches; + } + + /// The specific exception for a refusal this gate caused, or null if it did not. + public Exception? TranslateFailure() + { + if (Presented is not { } presentation) + { + return null; + } + + if (mismatch && pinned is { } pin) + { + return new SshHostKeyMismatchException(presentation, pin); + } + + return pinned is null ? new SshHostKeyUnknownException(presentation) : null; + } + } + + /// + /// The known-host lookup inside the synchronous event is the one place this design cannot avoid + /// blocking. It is a local store read rather than a UI round trip, and the alternative — making + /// the store synchronous — would rule out the encrypted vault-backed implementation entirely. + /// + private static ConnectionInfo BuildConnectionInfo(SshConnectionRequest request) + { + AuthenticationMethod method = request.Credential switch + { + SshPasswordCredential password => + new PasswordAuthenticationMethod(request.Username, password.Password), + + SshPrivateKeyCredential key => new PrivateKeyAuthenticationMethod( + request.Username, + CreatePrivateKeyFile(key)), + + _ => throw new NotSupportedException( + $"Credential type {request.Credential.GetType().Name} is not supported."), + }; + + return new ConnectionInfo(request.Host, request.Port, request.Username, method) + { + Timeout = request.ConnectTimeout ?? DefaultConnectTimeout, + }; + } + + private static PrivateKeyFile CreatePrivateKeyFile(SshPrivateKeyCredential credential) + { + using var stream = new MemoryStream(credential.PrivateKeyPem, writable: false); + + return credential.Passphrase is null + ? new PrivateKeyFile(stream) + : new PrivateKeyFile(stream, credential.Passphrase); + } +} + +/// An SSH.NET-backed connection. +internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hostKey) : ISshConnection +{ + /// + public bool IsConnected => client.IsConnected; + + /// + public HostKeyPresentation HostKey { get; } = hostKey; + + /// + public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var effective = size.IsUsable ? size : TerminalSize.Default; + + // 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here + // only delays the first byte reaching the screen. + var shell = client.CreateShellStream( + "xterm-256color", + effective.Columns, + effective.Rows, + effective.PixelWidth, + effective.PixelHeight, + 4096); + + return Task.FromResult(new SshNetShellSession(shell)); + } + + /// + public ValueTask DisposeAsync() + { + client.Dispose(); + return ValueTask.CompletedTask; + } +} + +/// An SSH.NET-backed shell session. +internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession +{ + /// + public bool IsOpen => shell.CanRead; + + /// + /// + /// ShellStream does not override ReadAsync, so the base + /// implementation runs the blocking read on a thread-pool thread. Every idle session therefore + /// parks one thread; see docs/platform-flags.md. + /// + public ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) => + shell.ReadAsync(buffer, cancellationToken); + + /// + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => + shell.WriteAsync(data, cancellationToken); + + /// + public void Resize(TerminalSize size) + { + if (!size.IsUsable) + { + // A collapsed pane or a minimised window produces these. Forwarding one leaves the + // remote's idea of the terminal nonsensical until the next resize arrives. + return; + } + + shell.ChangeWindowSize(size.Columns, size.Rows, size.PixelWidth, size.PixelHeight); + } + + /// + public async ValueTask DisposeAsync() + { + await shell.DisposeAsync().ConfigureAwait(false); + } +} + +/// Convenience helpers over a shell session. +public static class SshShellSessionExtensions +{ + /// Writes UTF-8 text to the remote. + public static ValueTask WriteTextAsync( + this ISshShellSession session, + string text, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + + return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken); + } +} diff --git a/src/DodoSSH.Client.Ssh/TerminalSize.cs b/src/DodoSSH.Client.Ssh/TerminalSize.cs new file mode 100644 index 0000000..e43367c --- /dev/null +++ b/src/DodoSSH.Client.Ssh/TerminalSize.cs @@ -0,0 +1,35 @@ +using System.Runtime.InteropServices; + +namespace DodoSSH.Client.Ssh; + +/// +/// A pseudo-terminal's dimensions. +/// +/// +/// Both the character grid and the pixel extent, because the SSH pty-req and +/// window-change requests carry both. Pixel dimensions are what let a remote program draw +/// sixel graphics or size an image correctly; sending zeroes is legal and tells the remote there is +/// no pixel information, which is not the same as telling it the terminal is zero pixels wide. +/// +/// Character columns. +/// Character rows. +/// Width in pixels, or 0 when unknown. +/// Height in pixels, or 0 when unknown. +[StructLayout(LayoutKind.Auto)] +public readonly record struct TerminalSize( + ushort Columns, + ushort Rows, + ushort PixelWidth = 0, + ushort PixelHeight = 0) +{ + /// The conventional default, for a session opened before the UI has measured itself. + public static TerminalSize Default => new(80, 24); + + /// Whether the size is usable as a terminal. + /// + /// A zero dimension is worth rejecting rather than forwarding: a resize to 0×0 arrives naturally + /// when a pane is collapsed or a window minimised, and passing it on makes the remote's idea of + /// the terminal nonsensical until the next resize. + /// + public bool IsUsable => Columns > 0 && Rows > 0; +} diff --git a/src/DodoSSH.Client.Terminal/CreditWindow.cs b/src/DodoSSH.Client.Terminal/CreditWindow.cs new file mode 100644 index 0000000..70a2970 --- /dev/null +++ b/src/DodoSSH.Client.Terminal/CreditWindow.cs @@ -0,0 +1,152 @@ +namespace DodoSSH.Client.Terminal; + +/// +/// Credit-based flow control over one terminal session's output. +/// +/// +/// +/// This is what makes yes survivable. A terminal emulator renders at 60 Hz at best, while a +/// remote process can produce output as fast as the network allows. Without a limit the difference +/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the +/// application's memory grows until it dies. +/// +/// +/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte +/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When +/// credit reaches zero the pump stops reading the SSH channel. That closes SSH's own receive +/// window, which makes the remote sshd block on write, which propagates the backpressure all +/// the way to the process producing the output. Nothing buffers without bound because nothing is read +/// that cannot be delivered. +/// +/// +/// The window has to be large enough that a normal burst never stalls and small enough to bound +/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's +/// in-flight cost at a quarter of a megabyte. +/// +/// +public sealed class CreditWindow +{ + /// Default window size: 256 KiB. + public const int DefaultWindowBytes = 256 * 1024; + + private readonly Lock gate = new(); + private readonly int windowBytes; + + /// Signalled whenever credit becomes available. + private TaskCompletionSource available = CreateSignal(); + + private int outstanding; + + /// How many unrendered bytes the renderer may be behind by. + public CreditWindow(int windowBytes = DefaultWindowBytes) + { + ArgumentOutOfRangeException.ThrowIfLessThan(windowBytes, 1); + + this.windowBytes = windowBytes; + } + + /// Bytes sent but not yet reported as rendered. + public int Outstanding + { + get + { + lock (gate) + { + return outstanding; + } + } + } + + /// Bytes that may be sent right now. + public int Available + { + get + { + lock (gate) + { + return windowBytes - outstanding; + } + } + } + + /// + /// Reserves up to bytes of credit, returning how many were granted. + /// + /// + /// A partial grant rather than all-or-nothing. Refusing to send 40 KiB because only 30 KiB of + /// credit remains would stall a session that could have made progress, and the caller has to + /// handle short writes regardless. + /// + /// Bytes reserved, which is zero when the window is full. + public int TryReserve(int wanted) + { + ArgumentOutOfRangeException.ThrowIfLessThan(wanted, 0); + + lock (gate) + { + var granted = Math.Min(wanted, windowBytes - outstanding); + outstanding += granted; + + return granted; + } + } + + /// + /// Returns credit for bytes the renderer has reported rendering. + /// + /// + /// Clamped rather than trusted. The acknowledgement crosses a process boundary into JavaScript, so + /// a buggy or tampered page could acknowledge more than it was ever sent; letting that drive + /// outstanding negative would hand it an unbounded window and reintroduce exactly the + /// failure this class exists to prevent. + /// + public void Return(int rendered) + { + ArgumentOutOfRangeException.ThrowIfLessThan(rendered, 0); + + lock (gate) + { + outstanding -= Math.Min(rendered, outstanding); + + // Released inside the lock so a waiter cannot miss the transition, and completed + // asynchronously so a continuation cannot run while the lock is held. + available.TrySetResult(); + available = CreateSignal(); + } + } + + /// Waits until at least one byte of credit is available. + public async ValueTask WaitForCreditAsync(CancellationToken cancellationToken) + { + while (true) + { + Task signal; + + lock (gate) + { + if (windowBytes - outstanding > 0) + { + return; + } + + signal = available.Task; + } + + await signal.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// Discards all outstanding credit, for a session being torn down. + public void Reset() + { + lock (gate) + { + outstanding = 0; + available.TrySetResult(); + available = CreateSignal(); + } + } + + private static TaskCompletionSource CreateSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj b/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj new file mode 100644 index 0000000..5c6cd27 --- /dev/null +++ b/src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Terminal/TerminalAssets.cs b/src/DodoSSH.Client.Terminal/TerminalAssets.cs new file mode 100644 index 0000000..eecbee9 --- /dev/null +++ b/src/DodoSSH.Client.Terminal/TerminalAssets.cs @@ -0,0 +1,31 @@ +namespace DodoSSH.Client.Terminal; + +/// One file the renderer needs. +/// MIME type, including a charset for text. +/// The bytes to serve. +public sealed record TerminalAsset(string ContentType, byte[] Content); + +/// +/// Supplies the renderer's HTML, JavaScript and CSS. +/// +/// +/// An abstraction so the data plane does not depend on Avalonia's resource system, which keeps the +/// whole transport testable with in-memory assets. The application implements this over +/// AvaloniaResource; tests hand over a dictionary. +/// +public interface ITerminalAssetProvider +{ + /// + /// Returns the asset for a request path, or null if there is none. + /// + /// Absolute request path, beginning with a slash. + TerminalAsset? Find(string path); +} + +/// Assets held in a dictionary. +public sealed class InMemoryTerminalAssetProvider(IReadOnlyDictionary assets) + : ITerminalAssetProvider +{ + /// + public TerminalAsset? Find(string path) => assets.GetValueOrDefault(path); +} diff --git a/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs new file mode 100644 index 0000000..241c194 --- /dev/null +++ b/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs @@ -0,0 +1,535 @@ +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; + +/// +/// Serves the renderer page and carries terminal frames, over one loopback socket. +/// +/// +/// +/// 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. +/// +/// +/// The page is served from the same listener as the socket, which is what makes the Origin +/// header predictable — it is always http://127.0.0.1:{port}. Loading the page through a +/// WebView virtual-host mapping instead would produce a different origin on each backend and give +/// nothing to validate against. +/// +/// +/// What the token and origin check actually defend against. 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. +/// +/// +public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable +{ + /// Subprotocol the renderer must request. + public const string SubProtocol = "dodossh.terminal.v1"; + + /// Path the renderer page is served from. + public const string PagePath = "/terminal"; + + /// Path the WebSocket upgrade is accepted on. + public const string SocketPath = "/socket"; + + /// Placeholder in the page that is replaced with the connection token. + public const string TokenPlaceholder = "__DODOSSH_TOKEN__"; + + /// Placeholder in the page that is replaced with the socket URL. + public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__"; + + /// RFC 6455 §1.3: the fixed GUID mixed into the handshake response. + 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 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; + + /// Where the renderer's files come from. + 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; + } + + /// The port the OS assigned. + public int Port { get; } + + /// The single-use connection token embedded in the served page. + public string Token { get; } + + /// Where the WebView should navigate. + public Uri PageUrl => new( + string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"), + UriKind.Absolute); + + /// Completes once the renderer has attached its socket. + public Task RendererAttached => rendererAttached.Task; + + /// Registers a session so inbound frames can be routed to it. + public void Register(uint sessionId, TerminalSessionPump pump) + { + ArgumentNullException.ThrowIfNull(pump); + + lock (pumpGate) + { + pumps[sessionId] = pump; + } + } + + /// Forgets a session that has ended. + public void Unregister(uint sessionId) + { + lock (pumpGate) + { + pumps.Remove(sessionId); + } + } + + /// Accepts connections until disposed. + 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(); + } + } + + /// + public async ValueTask SendAsync(ReadOnlyMemory 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(); + } + } + + /// + 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); + } + + /// + /// 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. + /// + 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 frame) + { + if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload)) + { + 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; + } + } + + /// + /// Computes the Sec-WebSocket-Accept value RFC 6455 §4.2.2 requires. + /// + /// + /// + /// 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. + /// + /// + /// The alternative that avoids SHA-1 in our own code is + /// HttpListener.AcceptWebSocketAsync, which throws PlatformNotSupportedException + /// off Windows — so it would trade a documented suppression for a platform restriction. + /// + /// + [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 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(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 Headers); +} diff --git a/src/DodoSSH.Client.Terminal/TerminalFrame.cs b/src/DodoSSH.Client.Terminal/TerminalFrame.cs new file mode 100644 index 0000000..5606f51 --- /dev/null +++ b/src/DodoSSH.Client.Terminal/TerminalFrame.cs @@ -0,0 +1,181 @@ +using System.Buffers.Binary; + +namespace DodoSSH.Client.Terminal; + +/// Frames the host sends to the renderer. +public enum TerminalServerOpcode : byte +{ + /// Not a legal value. + Unspecified = 0, + + /// Terminal output. Payload is raw bytes for term.write. + Output = 1, + + /// A session has been created; the renderer should attach a terminal to it. + SessionOpened = 2, + + /// A session has ended. Payload is a UTF-8 reason for the user. + SessionClosed = 3, +} + +/// Frames the renderer sends to the host. +public enum TerminalClientOpcode : byte +{ + /// Not a legal value. + Unspecified = 0, + + /// Keystrokes. Payload is raw bytes for the remote. + Input = 1, + + /// + /// Bytes actually rendered. Payload is a big-endian , returning credit. + /// + Acknowledge = 2, + + /// The terminal was resized. Payload is four big-endian values. + Resize = 3, +} + +/// +/// The wire format between the host process and the renderer page. +/// +/// +/// +/// Binary frames over a loopback WebSocket, not the WebView's JavaScript bridge. The official bridge +/// is UI-thread-bound string evaluation: at 10 MB/s in 4 KiB chunks that is roughly 2,500 script +/// evaluations per second on the thread that also has to paint, with base64's 33% overhead on top and +/// — decisively — no backpressure signal at all. A socket gives flow control for free. +/// +/// +/// Every frame carries a session id because one WebView hosts every terminal. A WebView2 instance is +/// a separate browser process, so one per tab would mean twenty renderer processes and hundreds of +/// megabytes for a normal working set of tabs. +/// +/// +/// Fixed 5-byte header, big-endian, no length prefix: WebSocket already delimits messages, so adding +/// our own length would be a second source of truth about where a frame ends. +/// +/// +public static class TerminalFrame +{ + /// Opcode plus session id. + public const int HeaderLength = 1 + sizeof(uint); + + /// Writes a frame into and returns its length. + public static int Write( + Span destination, + byte opcode, + uint sessionId, + ReadOnlySpan payload) + { + var total = HeaderLength + payload.Length; + + if (destination.Length < total) + { + throw new ArgumentException( + $"Need {total} bytes for the frame, got {destination.Length}.", + nameof(destination)); + } + + destination[0] = opcode; + BinaryPrimitives.WriteUInt32BigEndian(destination[1..], sessionId); + payload.CopyTo(destination[HeaderLength..]); + + return total; + } + + /// Allocates and writes a frame. + public static byte[] Create(byte opcode, uint sessionId, ReadOnlySpan payload) + { + var frame = new byte[HeaderLength + payload.Length]; + Write(frame, opcode, sessionId, payload); + + return frame; + } + + /// + /// Reads a frame's header and payload. + /// + /// + /// Returns false rather than throwing on anything malformed. These frames arrive from a WebView + /// page — a different process running code we shipped but do not control at runtime — so a bad + /// frame is untrusted input to be dropped, not an exceptional condition. + /// + public static bool TryRead( + ReadOnlySpan frame, + out byte opcode, + out uint sessionId, + out ReadOnlySpan payload) + { + opcode = 0; + sessionId = 0; + payload = default; + + if (frame.Length < HeaderLength) + { + return false; + } + + opcode = frame[0]; + sessionId = BinaryPrimitives.ReadUInt32BigEndian(frame[1..]); + payload = frame[HeaderLength..]; + + return true; + } + + /// Reads an payload. + public static bool TryReadAcknowledgement(ReadOnlySpan payload, out uint rendered) + { + rendered = 0; + + if (payload.Length != sizeof(uint)) + { + return false; + } + + rendered = BinaryPrimitives.ReadUInt32BigEndian(payload); + + return true; + } + + /// Reads a payload. + public static bool TryReadResize(ReadOnlySpan payload, out DodoSSH.Client.Ssh.TerminalSize size) + { + size = default; + + if (payload.Length != sizeof(ushort) * 4) + { + return false; + } + + size = new DodoSSH.Client.Ssh.TerminalSize( + BinaryPrimitives.ReadUInt16BigEndian(payload), + BinaryPrimitives.ReadUInt16BigEndian(payload[2..]), + BinaryPrimitives.ReadUInt16BigEndian(payload[4..]), + BinaryPrimitives.ReadUInt16BigEndian(payload[6..])); + + return true; + } + + /// Writes a payload. Used by tests and tooling. + public static byte[] CreateResizePayload(DodoSSH.Client.Ssh.TerminalSize size) + { + var payload = new byte[sizeof(ushort) * 4]; + + BinaryPrimitives.WriteUInt16BigEndian(payload, size.Columns); + BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(2), size.Rows); + BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(4), size.PixelWidth); + BinaryPrimitives.WriteUInt16BigEndian(payload.AsSpan(6), size.PixelHeight); + + return payload; + } + + /// Writes an payload. + public static byte[] CreateAcknowledgementPayload(uint rendered) + { + var payload = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32BigEndian(payload, rendered); + + return payload; + } +} diff --git a/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs new file mode 100644 index 0000000..4b4db8e --- /dev/null +++ b/src/DodoSSH.Client.Terminal/TerminalSessionPump.cs @@ -0,0 +1,308 @@ +using System.Buffers; +using System.Text; +using System.Threading.Channels; +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Terminal; + +/// Where terminal frames are sent. +/// +/// Implementations must serialise sends: a WebSocket does not permit concurrent writes, and the pump +/// deliberately does not know whether its transport is a socket, a test double or something else. +/// +public interface ITerminalTransport +{ + /// Sends one binary frame. + ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken); +} + +/// Tuning for one session's output path. +public sealed class TerminalPumpOptions +{ + /// How many bytes to read from the channel at once. + public int ReadBufferBytes { get; init; } = 32 * 1024; + + /// + /// How long to accumulate output before sending it. + /// + /// + /// xterm cannot render faster than the display refreshes, so flushing more often than once a frame + /// is work whose result is overwritten before anyone sees it. 16 ms is one frame at 60 Hz, and the + /// added latency on an echoed keystroke is below the threshold of perception. + /// + public TimeSpan FlushInterval { get; init; } = TimeSpan.FromMilliseconds(16); + + /// How far behind the renderer may fall, in bytes. + public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes; +} + +/// +/// Moves bytes between one SSH shell channel and the renderer, under flow control. +/// +/// +/// +/// Credit is reserved before reading, never after. That ordering is the whole design: because the pump +/// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit +/// window rather than by how fast the remote can talk. Reserving after reading would leave an +/// unbounded queue between the socket and the screen, which is the failure mode this exists to +/// prevent. +/// +/// +/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote +/// sshd blocks on write, and the process producing output blocks in turn — backpressure all the +/// way to the source, with no custom protocol. +/// +/// +public sealed class TerminalSessionPump : IAsyncDisposable +{ + private readonly uint sessionId; + private readonly ISshShellSession session; + private readonly ITerminalTransport transport; + private readonly TimeProvider clock; + private readonly TerminalPumpOptions options; + private readonly Channel pending = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); + + private readonly CancellationTokenSource lifetime = new(); + private int disposed; + + /// Identifies this session in every frame. + /// The shell channel. + /// Where frames go. + /// Time source, so the flush interval is testable. + /// Tuning, or null for the defaults. + public TerminalSessionPump( + uint sessionId, + ISshShellSession session, + ITerminalTransport transport, + TimeProvider clock, + TerminalPumpOptions? options = null) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(clock); + + this.sessionId = sessionId; + this.session = session; + this.transport = transport; + this.clock = clock; + this.options = options ?? new TerminalPumpOptions(); + + Credits = new CreditWindow(this.options.WindowBytes); + } + + /// This session's flow-control window. + public CreditWindow Credits { get; } + + /// Total bytes read from the remote, for the throughput harness. + public long BytesRead { get; private set; } + + /// Total frames sent to the renderer, for the throughput harness. + public long FramesSent { get; private set; } + + /// + /// Runs until the remote closes the channel or the token is cancelled. + /// + /// + /// The read and flush loops are separate tasks because a read blocks until bytes arrive: combining + /// them would mean output sitting unflushed until the next byte happened to show up, so a prompt + /// would appear only after the user pressed a key. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + lifetime.Token); + + await SendAsync(TerminalServerOpcode.SessionOpened, default, linked.Token).ConfigureAwait(false); + + var reader = ReadLoopAsync(linked.Token); + var flusher = FlushLoopAsync(linked.Token); + + string reason; + try + { + await reader.ConfigureAwait(false); + reason = "The remote closed the session."; + } + catch (OperationCanceledException) + { + reason = "The session was closed."; + } + catch (Exception exception) + { + reason = exception.Message; + } + + // Stop the flusher, but only after draining what the reader already produced — the last thing + // a remote writes is often the most important, and dropping it makes a clean exit look like a + // crash. + pending.Writer.TryComplete(); + + try + { + await flusher.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Cancelled during shutdown; the drain below is best-effort anyway. + } + + await SendAsync( + TerminalServerOpcode.SessionClosed, + Encoding.UTF8.GetBytes(reason), + CancellationToken.None) + .ConfigureAwait(false); + } + + /// Forwards keystrokes to the remote. + public ValueTask WriteInputAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => + session.WriteAsync(data, cancellationToken); + + /// Tells the remote the terminal was resized. + public void Resize(TerminalSize size) => session.Resize(size); + + /// Returns credit for bytes the renderer reported rendering. + public void Acknowledge(uint rendered) => + Credits.Return(rendered > int.MaxValue ? int.MaxValue : (int)rendered); + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref disposed, 1) == 1) + { + return; + } + + await lifetime.CancelAsync().ConfigureAwait(false); + + // Unblocks anything waiting on credit that will now never be acknowledged. + Credits.Reset(); + + pending.Writer.TryComplete(); + lifetime.Dispose(); + + await session.DisposeAsync().ConfigureAwait(false); + } + + private async Task ReadLoopAsync(CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(options.ReadBufferBytes); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false); + + var granted = Credits.TryReserve(options.ReadBufferBytes); + if (granted == 0) + { + continue; + } + + int read; + try + { + read = await session + .ReadAsync(buffer.AsMemory(0, granted), cancellationToken) + .ConfigureAwait(false); + } + catch + { + Credits.Return(granted); + throw; + } + + // Hand back what was reserved but not used, so a short read does not permanently + // shrink the window. + Credits.Return(granted - read); + + if (read == 0) + { + return; + } + + BytesRead += read; + await pending.Writer.WriteAsync(buffer[..read], cancellationToken).ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task FlushLoopAsync(CancellationToken cancellationToken) + { + var segments = new List(); + + while (await pending.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (!pending.Reader.TryRead(out var first)) + { + continue; + } + + segments.Clear(); + segments.Add(first); + + // Coalesce for one frame's worth of time, then send everything at once. Whatever the + // remote produced in that window becomes a single write to the terminal. + await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false); + + while (pending.Reader.TryRead(out var more)) + { + segments.Add(more); + } + + await SendOutputAsync(segments, cancellationToken).ConfigureAwait(false); + } + + // The channel completed. Anything the reader wrote before finishing still has to go out. + segments.Clear(); + while (pending.Reader.TryRead(out var trailing)) + { + segments.Add(trailing); + } + + if (segments.Count > 0) + { + await SendOutputAsync(segments, CancellationToken.None).ConfigureAwait(false); + } + } + + private async ValueTask SendOutputAsync(List segments, CancellationToken cancellationToken) + { + var total = 0; + foreach (var segment in segments) + { + total += segment.Length; + } + + var frame = new byte[TerminalFrame.HeaderLength + total]; + TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default); + + var offset = TerminalFrame.HeaderLength; + foreach (var segment in segments) + { + segment.CopyTo(frame, offset); + offset += segment.Length; + } + + FramesSent++; + await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask SendAsync( + TerminalServerOpcode opcode, + ReadOnlyMemory payload, + CancellationToken cancellationToken) + { + FramesSent++; + + await transport + .SendAsync(TerminalFrame.Create((byte)opcode, sessionId, payload.Span), cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/DodoSSH.Client.Terminal/packages.lock.json b/src/DodoSSH.Client.Terminal/packages.lock.json new file mode 100644 index 0000000..716d82c --- /dev/null +++ b/src/DodoSSH.Client.Terminal/packages.lock.json @@ -0,0 +1,54 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "dodossh.client.ssh": { + "type": "Project", + "dependencies": { + "SSH.NET": "[2025.1.0, )" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "SSH.NET": { + "type": "CentralTransitive", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs b/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs new file mode 100644 index 0000000..aff5685 --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/CreditWindowTests.cs @@ -0,0 +1,181 @@ +namespace DodoSSH.Client.Terminal.Tests; + +/// +/// The flow-control accounting. +/// +/// +/// Worth testing on its own, ahead of the pump, because everything else about the terminal depends on +/// this arithmetic being right. An off-by-one that leaks credit shows up as a session that stalls +/// after several minutes of heavy output — a symptom nobody would trace back to here. +/// +public sealed class CreditWindowTests +{ + [Fact] + public void AFreshWindow_OffersItsWholeSize() + { + var window = new CreditWindow(1024); + + window.Available.ShouldBe(1024); + window.Outstanding.ShouldBe(0); + } + + [Fact] + public void Reserving_ReducesWhatIsAvailable() + { + var window = new CreditWindow(1024); + + window.TryReserve(400).ShouldBe(400); + + window.Outstanding.ShouldBe(400); + window.Available.ShouldBe(624); + } + + [Fact] + public void ReservingMoreThanRemains_GrantsAPartialAmount() + { + // All-or-nothing would stall a session that could have made progress with what was left, and + // the caller has to cope with a short read regardless. + var window = new CreditWindow(1024); + window.TryReserve(900); + + window.TryReserve(400).ShouldBe(124); + window.Available.ShouldBe(0); + } + + [Fact] + public void AFullWindow_GrantsNothing() + { + var window = new CreditWindow(1024); + window.TryReserve(1024); + + window.TryReserve(1).ShouldBe(0); + } + + [Fact] + public void Returning_RestoresCredit() + { + var window = new CreditWindow(1024); + window.TryReserve(1024); + + window.Return(512); + + window.Available.ShouldBe(512); + window.Outstanding.ShouldBe(512); + } + + [Fact] + public void ReturningMoreThanWasReserved_IsClamped() + { + // The acknowledgement crosses into JavaScript, so a buggy or tampered page can claim to have + // rendered more than it was sent. Letting that drive outstanding negative would hand it an + // unbounded window, which is precisely what this class exists to prevent. + var window = new CreditWindow(1024); + window.TryReserve(100); + + window.Return(100_000); + + window.Outstanding.ShouldBe(0); + window.Available.ShouldBe(1024); + } + + [Fact] + public void ReturningWithNothingOutstanding_ChangesNothing() + { + var window = new CreditWindow(1024); + + window.Return(500); + + window.Available.ShouldBe(1024); + } + + [Fact] + public async Task WaitingWithCreditAvailable_ReturnsImmediately() + { + var window = new CreditWindow(1024); + + await window.WaitForCreditAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task WaitingOnAFullWindow_BlocksUntilCreditIsReturned() + { + var window = new CreditWindow(1024); + window.TryReserve(1024); + + var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask(); + + wait.IsCompleted.ShouldBeFalse("A full window must not let a reader proceed."); + + window.Return(1); + + await wait; + } + + [Fact] + public async Task Reset_UnblocksAWaiter() + { + // A session being torn down must not leave its reader parked forever on credit that will + // never be acknowledged. + var window = new CreditWindow(1024); + window.TryReserve(1024); + + var wait = window.WaitForCreditAsync(TestContext.Current.CancellationToken).AsTask(); + wait.IsCompleted.ShouldBeFalse(); + + window.Reset(); + + await wait; + window.Outstanding.ShouldBe(0); + } + + [Fact] + public async Task Waiting_ObservesCancellation() + { + var window = new CreditWindow(1024); + window.TryReserve(1024); + + using var cts = new CancellationTokenSource(); + var wait = window.WaitForCreditAsync(cts.Token).AsTask(); + + await cts.CancelAsync(); + + await Should.ThrowAsync(async () => await wait); + } + + [Fact] + public async Task ConcurrentReservesAndReturns_KeepTheAccountingConsistent() + { + // The pump reserves on one task and returns on another, so the arithmetic has to hold under + // real contention rather than only in a single-threaded walkthrough. + const int Window = 64 * 1024; + var window = new CreditWindow(Window); + + var reserved = 0; + var returned = 0; + + var workers = Enumerable.Range(0, 8).Select(_ => Task.Run(() => + { + for (var i = 0; i < 2_000; i++) + { + var granted = window.TryReserve(97); + Interlocked.Add(ref reserved, granted); + + window.Return(granted); + Interlocked.Add(ref returned, granted); + } + }, TestContext.Current.CancellationToken)); + + await Task.WhenAll(workers); + + reserved.ShouldBe(returned); + window.Outstanding.ShouldBe(0); + window.Available.ShouldBe(Window); + } + + [Fact] + public void AWindowOfZeroOrLess_IsRejected() + { + Should.Throw(() => new CreditWindow(0)); + Should.Throw(() => new CreditWindow(-1)); + } +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj b/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj new file mode 100644 index 0000000..44b9525 --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs new file mode 100644 index 0000000..14589fc --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/FakeShellSession.cs @@ -0,0 +1,167 @@ +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Terminal.Tests; + +/// A shell session that produces output on demand, for exercising the pump. +internal sealed class FakeShellSession : ISshShellSession +{ + private readonly List written = []; + private readonly Lock gate = new(); + + private long remaining; + private byte pattern; + + /// + /// How many bytes to emit before reporting end of stream. for an + /// endless producer, which is what a runaway remote process looks like — those sessions are ended + /// by disposing the pump rather than by running out of data. + /// + internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce; + + /// + public bool IsOpen { get; private set; } = true; + + /// Reads issued against this session, to detect a pump that kept reading. + public int ReadCount { get; private set; } + + /// Bytes the pump wrote toward the remote. + public byte[] Written + { + get + { + lock (gate) + { + return [.. written]; + } + } + } + + /// The last size the pump forwarded, or null if it forwarded none. + public TerminalSize? LastResize { get; private set; } + + /// How many resizes were forwarded, so a dropped one is observable. + public int ResizeCount { get; private set; } + + /// + /// + /// Returns 0 once the configured budget is spent, which is what a remote closing the channel looks + /// like. Not synchronous: a fake that never yields would let the pump's read loop monopolise the + /// thread and hide any ordering problem between reading and flushing. + /// + public async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken) + { + ReadCount++; + + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + if (remaining <= 0) + { + return 0; + } + + var count = (int)Math.Min(buffer.Length, remaining); + buffer.Span[..count].Fill(unchecked(pattern++)); + remaining -= count; + + return count; + } + + /// + public ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + lock (gate) + { + written.AddRange(data.ToArray()); + } + + return ValueTask.CompletedTask; + } + + /// + public void Resize(TerminalSize size) + { + // Mirrors the real session: an unusable size is dropped rather than forwarded. + if (!size.IsUsable) + { + return; + } + + ResizeCount++; + LastResize = size; + } + + /// + public ValueTask DisposeAsync() + { + IsOpen = false; + remaining = 0; + + return ValueTask.CompletedTask; + } +} + +/// Records frames, and can acknowledge them to keep credit flowing. +internal sealed class RecordingTransport : ITerminalTransport +{ + private readonly List frames = []; + private readonly Lock gate = new(); + + /// Set to acknowledge every output frame immediately, as a keeping-up renderer would. + internal TerminalSessionPump? AutoAcknowledge { get; set; } + + /// Frames sent so far. + internal IReadOnlyList Frames + { + get + { + lock (gate) + { + return [.. frames]; + } + } + } + + /// + public ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken) + { + var copy = frame.ToArray(); + + lock (gate) + { + frames.Add(copy); + } + + if (AutoAcknowledge is { } pump + && TerminalFrame.TryRead(copy, out var opcode, out _, out var payload) + && opcode == (byte)TerminalServerOpcode.Output) + { + pump.Acknowledge((uint)payload.Length); + } + + return ValueTask.CompletedTask; + } + + /// Concatenated payloads of every output frame. + internal byte[] OutputBytes() + { + var output = new List(); + + foreach (var frame in Frames) + { + if (TerminalFrame.TryRead(frame, out var opcode, out _, out var payload) + && opcode == (byte)TerminalServerOpcode.Output) + { + output.AddRange(payload); + } + } + + return [.. output]; + } + + /// Counts frames of one opcode. + internal int CountOf(TerminalServerOpcode opcode) => + Frames.Count(frame => + TerminalFrame.TryRead(frame, out var actual, out _, out _) + && actual == (byte)opcode); +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs new file mode 100644 index 0000000..8a0dc92 --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalDataPlaneTests.cs @@ -0,0 +1,389 @@ +using System.Globalization; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Terminal.Tests; + +/// +/// The loopback transport end to end, with a real WebSocket client. +/// +/// +/// Uses against the real listener rather than a stubbed transport, +/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that +/// no real client accepts would pass any test that skipped it. +/// +public sealed class TerminalDataPlaneTests : IAsyncDisposable +{ + private const uint SessionId = 3; + + private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes( + $""); + + private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider( + new Dictionary(StringComparer.Ordinal) + { + [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate), + ["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()), + })); + + private readonly CancellationTokenSource lifetime = new(); + private Task? server; + + /// + public async ValueTask DisposeAsync() + { + await lifetime.CancelAsync(); + await plane.DisposeAsync(); + + if (server is not null) + { + try + { + await server; + } + catch (OperationCanceledException) + { + // Expected: the accept loop is stopped by cancelling it. + } + } + + lifetime.Dispose(); + } + + // ---- Serving the page ---- + + [Fact] + public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted() + { + // Substituted at serve time rather than written into the file, so the token never touches disk + // and never appears in a URL that a log or a browser history could keep. + Start(); + + using var client = new HttpClient(); + var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken); + + body.ShouldContain(plane.Token); + body.ShouldContain( + string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket")); + + body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder); + body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder); + } + + [Fact] + public async Task ThePage_IsNotCached() + { + // It carries a connection token. + Start(); + + using var client = new HttpClient(); + using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken); + + response.Headers.CacheControl!.NoStore.ShouldBeTrue(); + } + + [Fact] + public async Task AnUnknownPath_Is404() + { + Start(); + + using var client = new HttpClient(); + using var response = await client.GetAsync( + new Uri($"http://127.0.0.1:{plane.Port}/nope"), + TestContext.Current.CancellationToken); + + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + // ---- Attaching ---- + + [Fact] + public async Task TheRenderer_Attaches() + { + Start(); + + using var socket = await ConnectAsync(); + + socket.State.ShouldBe(WebSocketState.Open); + socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol); + + await plane.RendererAttached; + } + + [Fact] + public async Task AConnectionWithoutTheToken_IsRejected() + { + Start(); + + await Should.ThrowAsync(async () => + await ConnectAsync(token: null)); + } + + [Fact] + public async Task AConnectionWithTheWrongToken_IsRejected() + { + Start(); + + await Should.ThrowAsync(async () => + await ConnectAsync(token: "not-the-token")); + } + + [Fact] + public async Task AConnectionFromAnotherOrigin_IsRejected() + { + // The threat this actually addresses: a page open in the user's browser can attempt WebSocket + // connections to loopback ports, and would otherwise reach a terminal. + Start(); + + await Should.ThrowAsync(async () => + await ConnectAsync(origin: "https://evil.example")); + } + + [Fact] + public async Task ASecondRenderer_IsRejected() + { + Start(); + + using var first = await ConnectAsync(); + first.State.ShouldBe(WebSocketState.Open); + + await Should.ThrowAsync(async () => await ConnectAsync()); + } + + // ---- Frames ---- + + [Fact] + public async Task Output_ReachesTheRenderer() + { + Start(); + + using var socket = await ConnectAsync(); + + await using var session = new FakeShellSession(bytesToProduce: 128); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + var run = pump.RunAsync(TestContext.Current.CancellationToken); + + // SessionOpened first, then the output. + var opened = await ReceiveAsync(socket); + opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened); + opened.SessionId.ShouldBe(SessionId); + + var output = await ReceiveAsync(socket); + output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output); + output.Payload.Length.ShouldBe(128); + + // Acknowledge it, exactly as the page does from term.write's callback. + await SendAsync( + socket, + (byte)TerminalClientOpcode.Acknowledge, + TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length)); + + await run; + } + + [Fact] + public async Task Input_ReachesTheRemote() + { + Start(); + + using var socket = await ConnectAsync(); + + await using var session = new FakeShellSession(); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray()); + + await WaitUntilAsync(() => session.Written.Length > 0); + + Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r"); + } + + [Fact] + public async Task AResize_ReachesTheRemote() + { + Start(); + + using var socket = await ConnectAsync(); + + await using var session = new FakeShellSession(); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + await SendAsync( + socket, + (byte)TerminalClientOpcode.Resize, + TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075))); + + await WaitUntilAsync(() => session.ResizeCount > 0); + + session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075)); + } + + [Fact] + public async Task AnAcknowledgement_ReturnsCredit() + { + Start(); + + using var socket = await ConnectAsync(); + + await using var session = new FakeShellSession(); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + pump.Credits.TryReserve(1024); + pump.Credits.Outstanding.ShouldBe(1024); + + await SendAsync( + socket, + (byte)TerminalClientOpcode.Acknowledge, + TerminalFrame.CreateAcknowledgementPayload(1024)); + + await WaitUntilAsync(() => pump.Credits.Outstanding == 0); + } + + [Fact] + public async Task AFrameForAnUnregisteredSession_IsIgnored() + { + // Ordinary during teardown: the page can still have frames in flight for a session that just + // ended. Dropping them must not disturb anything else. + Start(); + + using var socket = await ConnectAsync(); + + await using var session = new FakeShellSession(); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + // A session id that is never registered. Using the registered one and relying on ordering + // would be a race: frames are dispatched on the receive loop, so the orphan can land after + // registration and legitimately be delivered. + await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999); + + await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray()); + await WaitUntilAsync(() => session.Written.Length > 0); + + Encoding.UTF8.GetString(session.Written).ShouldBe("real"); + } + + [Fact] + public async Task AMalformedFrame_IsIgnored() + { + Start(); + + using var socket = await ConnectAsync(); + + // Shorter than the header. + await socket.SendAsync( + new byte[] { 1, 2 }, + WebSocketMessageType.Binary, + endOfMessage: true, + TestContext.Current.CancellationToken); + + await using var session = new FakeShellSession(); + await using var pump = CreatePump(session); + plane.Register(SessionId, pump); + + await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray()); + await WaitUntilAsync(() => session.Written.Length > 0); + + Encoding.UTF8.GetString(session.Written).ShouldBe("still here"); + } + + // ---- Helpers ---- + + private void Start() => server = plane.RunAsync(lifetime.Token); + + private TerminalSessionPump CreatePump(FakeShellSession session) => + new( + SessionId, + session, + plane, + TimeProvider.System, + new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) }); + + private async Task ConnectAsync( + string? token = "", + string? origin = null) + { + var socket = new ClientWebSocket(); + + socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol); + + if (token is not null) + { + socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}"); + } + + socket.Options.SetRequestHeader( + "Origin", + origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}")); + + try + { + await socket.ConnectAsync( + new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"), + TestContext.Current.CancellationToken); + } + catch + { + socket.Dispose(); + throw; + } + + return socket; + } + + private static async Task SendAsync( + ClientWebSocket socket, + byte opcode, + byte[] payload, + uint sessionId = SessionId) => + await socket.SendAsync( + TerminalFrame.Create(opcode, sessionId, payload), + WebSocketMessageType.Binary, + endOfMessage: true, + TestContext.Current.CancellationToken); + + private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync( + ClientWebSocket socket) + { + var buffer = new byte[64 * 1024]; + + var result = await socket.ReceiveAsync( + buffer.AsMemory(), + TestContext.Current.CancellationToken); + + TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload) + .ShouldBeTrue(); + + return (opcode, sessionId, payload.ToArray()); + } + + /// + /// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive + /// loop, which has no completion for the caller to await. The bound keeps a genuine failure a + /// failure rather than a hang. + /// + private static async Task WaitUntilAsync(Func condition) + { + var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5); + + while (TimeProvider.System.GetUtcNow() < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(10, TestContext.Current.CancellationToken); + } + + throw new TimeoutException("The expected state was not reached within 5 seconds."); + } +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs new file mode 100644 index 0000000..a68e90f --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalFrameTests.cs @@ -0,0 +1,117 @@ +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Terminal.Tests; + +/// +/// The wire format between the host and the renderer page. +/// +/// +/// Byte order is asserted explicitly rather than only round-tripped. Both ends of this protocol are +/// ours today, but the JavaScript side reads the header with a DataView, whose default is +/// big-endian — a round-trip-only test would pass just as happily with a little-endian header that the +/// page then misreads. +/// +public sealed class TerminalFrameTests +{ + [Fact] + public void AFrame_RoundTrips() + { + var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 42, "hello"u8); + + TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload).ShouldBeTrue(); + + opcode.ShouldBe((byte)TerminalServerOpcode.Output); + sessionId.ShouldBe(42u); + payload.ToArray().ShouldBe("hello"u8.ToArray()); + } + + [Fact] + public void TheSessionId_IsBigEndian() + { + var frame = TerminalFrame.Create((byte)TerminalServerOpcode.Output, 0x01020304, []); + + frame[1].ShouldBe((byte)0x01); + frame[2].ShouldBe((byte)0x02); + frame[3].ShouldBe((byte)0x03); + frame[4].ShouldBe((byte)0x04); + } + + [Fact] + public void AnEmptyPayload_IsLegal() + { + // SessionOpened carries nothing. + var frame = TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, 1, []); + + frame.Length.ShouldBe(TerminalFrame.HeaderLength); + TerminalFrame.TryRead(frame, out _, out _, out var payload).ShouldBeTrue(); + payload.Length.ShouldBe(0); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(4)] + public void AFrameShorterThanTheHeader_IsRejected(int length) + { + // These arrive from a WebView page, so a malformed frame is untrusted input to drop rather + // than an exceptional condition. + TerminalFrame.TryRead(new byte[length], out _, out _, out _).ShouldBeFalse(); + } + + [Fact] + public void Write_RejectsATooSmallDestination() + { + Should.Throw(() => + TerminalFrame.Write(new byte[4], 1, 1, "payload"u8)); + } + + [Fact] + public void AnAcknowledgement_RoundTrips() + { + var payload = TerminalFrame.CreateAcknowledgementPayload(123_456); + + TerminalFrame.TryReadAcknowledgement(payload, out var rendered).ShouldBeTrue(); + rendered.ShouldBe(123_456u); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(5)] + public void AnAcknowledgementOfTheWrongLength_IsRejected(int length) + { + TerminalFrame.TryReadAcknowledgement(new byte[length], out _).ShouldBeFalse(); + } + + [Fact] + public void AResize_RoundTrips() + { + var size = new TerminalSize(132, 43, 1320, 1075); + var payload = TerminalFrame.CreateResizePayload(size); + + TerminalFrame.TryReadResize(payload, out var decoded).ShouldBeTrue(); + decoded.ShouldBe(size); + } + + [Theory] + [InlineData(0)] + [InlineData(7)] + [InlineData(9)] + public void AResizeOfTheWrongLength_IsRejected(int length) + { + TerminalFrame.TryReadResize(new byte[length], out _).ShouldBeFalse(); + } + + [Fact] + public void AResizePayload_OrdersColumnsBeforeRows() + { + // The page builds this by hand, and columns-before-rows is the SSH convention. Swapping them + // produces a terminal that is 24 columns by 80 rows, which looks like a rendering bug. + var payload = TerminalFrame.CreateResizePayload(new TerminalSize(80, 24)); + + payload[0].ShouldBe((byte)0); + payload[1].ShouldBe((byte)80); + payload[2].ShouldBe((byte)0); + payload[3].ShouldBe((byte)24); + } +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs b/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs new file mode 100644 index 0000000..9d24b8f --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/TerminalSessionPumpTests.cs @@ -0,0 +1,350 @@ +using System.Diagnostics; +using System.Globalization; +using DodoSSH.Client.Ssh; + +namespace DodoSSH.Client.Terminal.Tests; + +/// +/// The output path under load: coalescing, backpressure, and shutdown. +/// +/// +/// The plan calls for a throughput harness built before any UI, and this is it. Terminal throughput is +/// where a client like this usually fails — cat on a large file either freezes the UI or grows +/// memory until the process dies — and neither symptom is diagnosable once a WebView is in the picture. +/// +public sealed class TerminalSessionPumpTests +{ + private const uint SessionId = 7; + + [Fact] + public async Task Output_ReachesTheTransportTaggedWithItsSession() + { + await using var session = new FakeShellSession(bytesToProduce: 64); + var transport = new RecordingTransport(); + + await using var pump = CreatePump(session, transport); + transport.AutoAcknowledge = pump; + + await pump.RunAsync(TestContext.Current.CancellationToken); + + transport.OutputBytes().Length.ShouldBe(64); + + foreach (var frame in transport.Frames) + { + TerminalFrame.TryRead(frame, out _, out var sessionId, out _).ShouldBeTrue(); + sessionId.ShouldBe(SessionId); + } + } + + [Fact] + public async Task ASessionOpenedFrame_PrecedesAnyOutput() + { + // The renderer has to attach a terminal before bytes arrive for it, or the first screenful is + // written into nothing. + await using var session = new FakeShellSession(bytesToProduce: 16); + var transport = new RecordingTransport(); + + await using var pump = CreatePump(session, transport); + transport.AutoAcknowledge = pump; + + await pump.RunAsync(TestContext.Current.CancellationToken); + + TerminalFrame.TryRead(transport.Frames[0], out var opcode, out _, out _).ShouldBeTrue(); + opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened); + } + + [Fact] + public async Task SeveralReadsWithinOneInterval_BecomeASingleFrame() + { + // A terminal cannot render faster than the display refreshes, so sending each read separately + // is work whose result is overwritten before anyone sees it. + await using var session = new FakeShellSession(bytesToProduce: 8 * 1024); + var transport = new RecordingTransport(); + + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions + { + ReadBufferBytes = 512, + FlushInterval = TimeSpan.FromMilliseconds(400), + }); + + transport.AutoAcknowledge = pump; + + var run = pump.RunAsync(TestContext.Current.CancellationToken); + + // Long enough for one flush, short enough that a second cannot have happened. + await Task.Delay(TimeSpan.FromMilliseconds(600), TestContext.Current.CancellationToken); + + // 8 KiB arrived as sixteen 512-byte reads and left as one frame. + transport.CountOf(TerminalServerOpcode.Output).ShouldBe(1); + transport.OutputBytes().Length.ShouldBe(8 * 1024); + + await run; + } + + [Fact] + public async Task WhenCreditRunsOut_ThePumpStopsReading() + { + // The property the whole design rests on. With no acknowledgement the pump must read exactly + // the window and then stop, so nothing accumulates between the socket and the screen. Left + // unbounded, a remote running `yes` grows the client's memory until it dies. + const int Window = 4 * 1024; + + await using var session = new FakeShellSession(bytesToProduce: long.MaxValue); + var transport = new RecordingTransport(); + + // Deliberately no AutoAcknowledge: this models a renderer that has stopped keeping up. + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions + { + ReadBufferBytes = 1024, + FlushInterval = TimeSpan.FromMilliseconds(5), + WindowBytes = Window, + }); + + var run = pump.RunAsync(TestContext.Current.CancellationToken); + + await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken); + + pump.BytesRead.ShouldBe( + Window, + "The pump read past its credit window, so output accumulates without bound."); + + // Still stalled a moment later, rather than merely slow. + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + pump.BytesRead.ShouldBe(Window); + + pump.Credits.Available.ShouldBe(0); + + await pump.DisposeAsync(); + await run; + } + + [Fact] + public async Task AcknowledgingCredit_LetsThePumpResume() + { + const int Window = 4 * 1024; + + await using var session = new FakeShellSession(bytesToProduce: long.MaxValue); + var transport = new RecordingTransport(); + + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions + { + ReadBufferBytes = 1024, + FlushInterval = TimeSpan.FromMilliseconds(5), + WindowBytes = Window, + }); + + var run = pump.RunAsync(TestContext.Current.CancellationToken); + + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + pump.BytesRead.ShouldBe(Window); + + pump.Acknowledge(2048); + + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + + pump.BytesRead.ShouldBe(Window + 2048); + + await pump.DisposeAsync(); + await run; + } + + [Fact] + public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow() + { + // The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must + // not be able to talk the host into an unbounded read. + const int Window = 4 * 1024; + + await using var session = new FakeShellSession(bytesToProduce: long.MaxValue); + var transport = new RecordingTransport(); + + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions + { + ReadBufferBytes = 1024, + FlushInterval = TimeSpan.FromMilliseconds(5), + WindowBytes = Window, + }); + + var run = pump.RunAsync(TestContext.Current.CancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + + pump.Acknowledge(uint.MaxValue); + + await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); + + // One further window's worth at most, never everything the endless producer could offer. + pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2); + + await pump.DisposeAsync(); + await run; + } + + [Fact] + public async Task WhenTheRemoteCloses_ASessionClosedFrameFollowsTheLastOutput() + { + // The last thing a remote writes is often the most important — an error, an exit code — and + // dropping it makes a clean exit look like a crash. + await using var session = new FakeShellSession(bytesToProduce: 4096); + var transport = new RecordingTransport(); + + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) }); + + transport.AutoAcknowledge = pump; + + await pump.RunAsync(TestContext.Current.CancellationToken); + + transport.OutputBytes().Length.ShouldBe(4096); + + TerminalFrame.TryRead(transport.Frames[^1], out var opcode, out _, out var payload) + .ShouldBeTrue(); + + opcode.ShouldBe((byte)TerminalServerOpcode.SessionClosed); + System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Input_ReachesTheRemote() + { + await using var session = new FakeShellSession(); + var transport = new RecordingTransport(); + + await using var pump = CreatePump(session, transport); + + await pump.WriteInputAsync("ls -la\r"u8.ToArray(), TestContext.Current.CancellationToken); + + System.Text.Encoding.UTF8.GetString(session.Written).ShouldBe("ls -la\r"); + } + + [Fact] + public async Task AResize_ReachesTheRemote() + { + await using var session = new FakeShellSession(); + var transport = new RecordingTransport(); + + await using var pump = CreatePump(session, transport); + + pump.Resize(new TerminalSize(132, 43, 1320, 1075)); + + session.ResizeCount.ShouldBe(1); + session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075)); + } + + [Fact] + public async Task AZeroSizedResize_IsDropped() + { + // Arrives naturally when a pane collapses or a window minimises. Forwarding it leaves the + // remote's idea of the terminal nonsensical until the next resize. + await using var session = new FakeShellSession(); + var transport = new RecordingTransport(); + + await using var pump = CreatePump(session, transport); + + pump.Resize(new TerminalSize(0, 0)); + + session.ResizeCount.ShouldBe(0); + } + + [Fact] + public async Task ThroughputHarness_SustainsTenMegabytesPerSecondWithBoundedMemory() + { + // The plan's target: `yes` at full tilt must hold a fixed memory ceiling. Throughput is + // asserted well below what the machine manages, because a CI runner under load is slower than + // a desktop and a flaky performance test gets deleted rather than fixed. The bounded-memory + // assertion is the one that carries the real meaning. + const long Target = 32L * 1024 * 1024; + const int Window = CreditWindow.DefaultWindowBytes; + + await using var session = new FakeShellSession(bytesToProduce: Target); + var transport = new RecordingTransport(); + + await using var pump = CreatePump( + session, + transport, + new TerminalPumpOptions + { + ReadBufferBytes = 32 * 1024, + FlushInterval = TimeSpan.FromMilliseconds(1), + WindowBytes = Window, + }); + + transport.AutoAcknowledge = pump; + + using var monitor = new CancellationTokenSource(); + var sampler = SamplePeakOutstandingAsync(pump, monitor.Token); + + var stopwatch = Stopwatch.StartNew(); + await pump.RunAsync(TestContext.Current.CancellationToken); + stopwatch.Stop(); + + await monitor.CancelAsync(); + var peakOutstanding = await sampler; + + pump.BytesRead.ShouldBe(Target); + transport.OutputBytes().Length.ShouldBe((int)Target); + + var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds; + + megabytesPerSecond.ShouldBeGreaterThan( + 10, + string.Create( + CultureInfo.InvariantCulture, + $"Sustained {megabytesPerSecond:F1} MB/s, below the 10 MB/s target.")); + + // The real assertion: in-flight bytes never exceeded the window, however fast the producer ran. + peakOutstanding.ShouldBeLessThanOrEqualTo( + Window, + string.Create( + CultureInfo.InvariantCulture, + $"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window.")); + } + + /// Watches in-flight bytes for the duration of a run, returning the highest seen. + private static async Task SamplePeakOutstandingAsync( + TerminalSessionPump pump, + CancellationToken cancellationToken) + { + var peak = 0; + + while (!cancellationToken.IsCancellationRequested) + { + peak = Math.Max(peak, pump.Credits.Outstanding); + + try + { + await Task.Delay(1, cancellationToken); + } + catch (OperationCanceledException) + { + break; + } + } + + return peak; + } + + private static TerminalSessionPump CreatePump( + FakeShellSession session, + ITerminalTransport transport, + TerminalPumpOptions? options = null) => + new( + SessionId, + session, + transport, + TimeProvider.System, + options ?? new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) }); +} diff --git a/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json b/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json new file mode 100644 index 0000000..897f5d5 --- /dev/null +++ b/tests/DodoSSH.Client.Terminal.Tests/packages.lock.json @@ -0,0 +1,240 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.ssh": { + "type": "Project", + "dependencies": { + "SSH.NET": "[2025.1.0, )" + } + }, + "dodossh.client.terminal": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Ssh": "[1.0.0, )" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "SSH.NET": { + "type": "CentralTransitive", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + } + } + } +} \ No newline at end of file