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 readonly bool blockReads; 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. /// /// /// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either /// end of the "produces bytes" and "hit end of stream" spectrum /// covers. then blocks until cancelled, which is what a real idle SSH channel's /// read does. Exists for tests that need a session whose Run stays live without a background /// read loop racing the test for control of the pump's credit window — see the reattach tests in /// TerminalWorkspaceTests. /// internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false) { remaining = bytesToProduce; this.blockReads = blockReads; } /// 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++; if (blockReads) { // Never completes on its own. The only way out is the same way a real blocked read ends: the // token being cancelled, which is what disposing the pump does. await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); } 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; } } /// /// Hands out s, so a workspace can be driven with no network. /// /// /// Exists for the session-lifetime tests. Everything else in this suite works on a pump directly; the /// workspace is the layer that decides when a session is over, and that decision is what needs a /// connection whose shell can be made to end on cue. /// internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false) : ISshConnectionFactory { /// Connections handed out, in order. internal List Connections { get; } = []; /// public Task ConnectAsync( SshConnectionRequest request, IProgress? progress, CancellationToken cancellationToken) { // The real factory's own order, so that a test watching this fake is watching the same sequence a // real handshake produces. It cannot report CheckingHostKey — there is no key exchange here to // produce a key — and inventing one would make this the only place that phase came from. progress?.Report(SshConnectionPhase.Reaching); progress?.Report(SshConnectionPhase.Authenticating); var connection = new FakeConnection(request, bytesPerShell, blockShellReads); Connections.Add(connection); return Task.FromResult(connection); } } /// A connection that opens fake shells and records its own disposal. internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false) : ISshConnection { /// public bool IsConnected { get; private set; } = true; /// public HostKeyPresentation HostKey { get; } = new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake"); /// public string Cipher { get; } = "aes256-gcm@openssh.com"; /// The shell this connection opened, if it opened one. internal FakeShellSession? Shell { get; private set; } /// Whether the connection was disposed, which is what closing a session must do. internal bool IsDisposed { get; private set; } /// public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) { Shell = new FakeShellSession(bytesPerShell, blockShellReads); return Task.FromResult(Shell); } /// public ValueTask DisposeAsync() { IsDisposed = true; IsConnected = false; 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); } /// An that runs its callback on the thread that reported. /// /// System.Progress<T> would post to a captured synchronisation context, or to the thread pool /// when there is none — which is what a test has here — so a list it appended to would be asserted on before /// it had been written. This is the same reason the shell does not use it either; see /// VaultViewModel.ReporterFor. /// internal sealed class DelegateProgress(Action report) : IProgress { public void Report(T value) => report(value); }