using System.Globalization; using System.Text; using System.Text.RegularExpressions; using Renci.SshNet; using Renci.SshNet.Common; namespace DodoSSH.Client.Ssh.Tests; /// /// The M1 client gate: does a PTY resize actually reach the remote? /// /// /// /// The plan flagged window-change on ShellStream as unverified, and it decides the /// terminal's whole design — a terminal that cannot resize is unusable, and the fallback is /// dropping to IChannelSession and driving the channel requests directly. SSH.NET 2025.1.0 /// does expose ChangeWindowSize, but a method existing is not the remote observing it, so /// these read the size back from the server rather than asserting the call did not throw. /// /// /// Kept as a permanent regression suite rather than deleted after the spike. An SSH.NET upgrade /// that silently stopped sending the request would otherwise ship, and the symptom — wrapped /// output only after the user resizes the window — is easy to blame on the terminal emulator. /// /// [Collection(SshCollection.Name)] public sealed class PtyAndResizeSpikeTests(SshServerFixture fixture) { private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(20); /// /// Matches busybox stty size output: rows then columns. /// /// /// Surrounding horizontal whitespace is tolerated, and the caller normalises CR to LF first. /// A PTY emits CRLF, so an anchored pattern that does not account for the CR fails on output /// that is visibly correct — which reads as "the resize did not work" and sends you looking in /// entirely the wrong place. /// private static readonly Regex SizeLine = new( @"^[ \t]*(?[0-9]{1,5})[ \t]+(?[0-9]{1,5})[ \t]*$", RegexOptions.Multiline | RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); [Fact] public async Task PasswordAuthentication_Connects() { using var client = CreatePasswordClient(); await client.ConnectAsync(TestContext.Current.CancellationToken); client.IsConnected.ShouldBeTrue(); } [Fact] public async Task PublicKeyAuthentication_Connects() { // PKCS#1 PEM ("BEGIN RSA PRIVATE KEY"), which SSH.NET has read for years. The vault stores // key material in its own format, so this is only about SSH.NET accepting a standard PEM. var pem = fixture.ClientKey.ExportRSAPrivateKeyPem(); using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(pem)); using var privateKey = new PrivateKeyFile(keyStream); using var client = new SshClient( new ConnectionInfo( fixture.Host, fixture.Port, SshServerFixture.Username, new PrivateKeyAuthenticationMethod(SshServerFixture.Username, privateKey))); await client.ConnectAsync(TestContext.Current.CancellationToken); client.IsConnected.ShouldBeTrue(); } [Fact] public async Task TheHostKey_ArrivesAsARawBlobWeCanFingerprint() { // Known-hosts entries are a synced vault entity, so the fingerprint we store has to be // computed from the wire blob rather than read off a library property. This confirms the // blob is exposed at all, and that our formatting matches OpenSSH's shape. using var client = CreatePasswordClient(); byte[]? blob = null; string? algorithm = null; client.HostKeyReceived += (_, e) => { blob = e.HostKey; algorithm = e.HostKeyName; }; await client.ConnectAsync(TestContext.Current.CancellationToken); blob.ShouldNotBeNull(); blob.Length.ShouldBeGreaterThan(0); algorithm.ShouldNotBeNullOrEmpty(); var fingerprint = SshHostKeyFingerprint.Format(blob); fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix); fingerprint.ShouldNotEndWith("="); // 32 bytes of SHA-256 render as 43 unpadded base64 characters. fingerprint.Length.ShouldBe(SshHostKeyFingerprint.Prefix.Length + 43); } [Fact] public async Task RefusingTheHostKey_AbortsTheConnection() { // The mismatch path has to be a hard block, not a warning. If CanTrust were advisory the // TOFU dialog would be decoration. using var client = CreatePasswordClient(); client.HostKeyReceived += (_, e) => e.CanTrust = false; await Should.ThrowAsync( async () => await client.ConnectAsync(TestContext.Current.CancellationToken)); client.IsConnected.ShouldBeFalse(); } [Fact] public async Task APtyShell_ReportsTheSizeItWasCreatedWith() { using var client = CreatePasswordClient(); await client.ConnectAsync(TestContext.Current.CancellationToken); using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); var (rows, columns) = await ReadSizeAsync(shell); rows.ShouldBe(24); columns.ShouldBe(80); } [Fact] public async Task ChangeWindowSize_IsObservedByTheRemote() { // The gate. Reads the size back from the server after resizing, so this fails if // ChangeWindowSize silently does nothing. using var client = CreatePasswordClient(); await client.ConnectAsync(TestContext.Current.CancellationToken); using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); var initial = await ReadSizeAsync(shell); initial.ShouldBe((24, 80)); shell.ChangeWindowSize(columns: 132, rows: 43, width: 1320, height: 1075); var resized = await ReadSizeAsync(shell); resized.ShouldBe( (43, 132), "The remote did not observe the window-change request. ShellStream.ChangeWindowSize is " + "not usable, and the terminal must drive IChannelSession directly instead."); } [Fact] public async Task ChangeWindowSize_CanBeCalledRepeatedly() { // A user dragging a window edge produces a stream of these. If only the first took effect, // the terminal would settle on whatever size the drag started at. using var client = CreatePasswordClient(); await client.ConnectAsync(TestContext.Current.CancellationToken); using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); await ReadSizeAsync(shell); foreach (var (columns, rows) in new[] { (100u, 30u), (120u, 40u), (90u, 25u) }) { shell.ChangeWindowSize(columns, rows, columns * 10, rows * 25); var observed = await ReadSizeAsync(shell); observed.ShouldBe(((int)rows, (int)columns)); } } // ---- Helpers ---- private SshClient CreatePasswordClient() => new(fixture.Host, fixture.Port, SshServerFixture.Username, SshServerFixture.Password); /// Asks the remote what size it thinks the terminal is. /// /// The marker is written split — "MAR""KER" — so the PTY's echo of the command line /// does not contain it. Without that the read completes on the echo and never sees the output, /// which looks exactly like a resize that did not take effect. /// private static async Task<(int Rows, int Columns)> ReadSizeAsync(ShellStream shell) { shell.WriteLine("""stty size; echo "MAR""KER" """); var output = (await ReadUntilAsync(shell, "MARKER")).Replace('\r', '\n'); var matches = SizeLine.Matches(output); matches.Count.ShouldBeGreaterThan(0, $"No 'rows cols' line in output:\n{output}"); // The last match, not the first. A read can begin with output still buffered from the // previous command, including its size line — taking the first match would report the size // from before the resize and turn a working resize into a failing test. var match = matches[^1]; return ( int.Parse(match.Groups["rows"].Value, CultureInfo.InvariantCulture), int.Parse(match.Groups["columns"].Value, CultureInfo.InvariantCulture)); } /// /// Raced against a timeout rather than cancelled. ShellStream does not override /// ReadAsync, so the base implementation runs the blocking read on a pool thread and /// cannot observe a token once it has started — passing one would produce a test that hangs /// instead of failing. The abandoned read dies with the process. /// private static async Task ReadUntilAsync(ShellStream shell, string marker) { var accumulated = new StringBuilder(); var deadline = TimeProvider.System.GetUtcNow() + ReadTimeout; while (TimeProvider.System.GetUtcNow() < deadline) { var buffer = new byte[4096]; var read = shell.ReadAsync(buffer.AsMemory()).AsTask(); var remaining = deadline - TimeProvider.System.GetUtcNow(); if (remaining <= TimeSpan.Zero || await Task.WhenAny(read, Task.Delay(remaining)) != read) { break; } var count = await read; if (count == 0) { break; } accumulated.Append(Encoding.UTF8.GetString(buffer, 0, count)); if (accumulated.ToString().Contains(marker, StringComparison.Ordinal)) { return accumulated.ToString(); } } throw new TimeoutException( $"Did not see '{marker}' within {ReadTimeout}. Output so far:\n{accumulated}"); } }