Clear the SSH gate: window-change reaches the remote, and licence as MIT

Licence is MIT, set solution-wide rather than only on the packable project:
DodoSSH.Contracts is published so clients can build against it, and a
package with no licence expression is one a corporate policy scanner
rejects outright.

The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0
exposes ShellStream.ChangeWindowSize, but a method existing is not the
remote observing it, so the tests read `stty size` back from a real sshd
after resizing rather than asserting the call did not throw. Repeated
resizes each take effect too, which matters because dragging a window edge
produces a stream of them. The IChannelSession fallback is not needed.

Also verified against a real sshd: password and public-key auth, that the
host key arrives as a raw blob we can fingerprint ourselves rather than
reading SSH.NET's MD5 property, and that refusing the key via CanTrust
actually aborts the connection -- without which the TOFU dialog would be
decoration.

Kept as a permanent suite, not deleted after the spike. An upgrade that
silently stopped sending the request would present as wrapped output only
after a resize, which is easy to misattribute to the terminal emulator.

Two bugs in the test itself, both worth naming because either would have
been read as "resize does not work":

- A PTY emits CRLF, and the anchored regex rejected the CR. The output
  visibly contained `24 80` while the match failed.
- Each read can begin with output still buffered from the previous command,
  including its size line. Taking the first match would have reported the
  pre-resize size.

platform-flags.md now records window-change as resolved rather than
unverified -- a stale flag is worse than none -- plus the three real SSH.NET
limits found on the way: ShellStream does not override ReadAsync so every
idle session parks a pool thread, one connection cannot serve both
SshClient and SftpClient, and agent forwarding needs an upstream change.
This commit is contained in:
2026-07-28 16:52:09 +02:00
parent b7325b78ca
commit 885fb17bdc
15 changed files with 956 additions and 46 deletions
+7
View File
@@ -37,6 +37,13 @@
<Company>DodoTech</Company> <Company>DodoTech</Company>
<Product>DodoSSH</Product> <Product>DodoSSH</Product>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Copyright>Copyright (c) 2026 Jaap-Jan de Wit (DodoTech)</Copyright>
<!--
Set solution-wide rather than only on the packable project. DodoSSH.Contracts is
published to GitHub Packages so clients can build against it, and a package without a
licence expression is one a corporate consumer's policy scanner rejects outright.
-->
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
<ItemGroup Label="Analyzers"> <ItemGroup Label="Analyzers">
+13
View File
@@ -64,6 +64,17 @@
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" /> <PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup Label="Desktop client">
<!--
SSH.NET already covers PTY shells, all three auth methods, ed25519/RSA/ECDSA, encrypted
keys including PuTTY .ppk, SFTP, and local/remote/dynamic forwarding. The gaps are
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
pre-connected Stream — it performs its own socket connect, which is why the relay and
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
-->
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
</ItemGroup>
<ItemGroup Label="Analyzers"> <ItemGroup Label="Analyzers">
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" />
@@ -86,6 +97,8 @@
<PackageVersion Include="Shouldly" Version="4.3.0" /> <PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="NSubstitute" Version="6.0.0" /> <PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<!-- Generic container, for the OpenSSH server the SSH suite talks to. -->
<PackageVersion Include="Testcontainers" Version="4.13.0" />
<PackageVersion Include="Respawn" Version="7.0.0" /> <PackageVersion Include="Respawn" Version="7.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<!-- <!--
+2
View File
@@ -16,10 +16,12 @@
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" /> <Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" /> <Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" /> <Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" /> <Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" /> <Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" /> <Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
<Project Path="tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj" /> <Project Path="tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj" />
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jaap-Jan de Wit (DodoTech)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -100,4 +100,4 @@ off-Windows.
## Licence ## Licence
Not yet chosen. [MIT](LICENSE).
+21 -3
View File
@@ -36,9 +36,27 @@ Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the termin
`ITerminalHost` — that abstraction is what preserves the option to swap backends, and it should `ITerminalHost` — that abstraction is what preserves the option to swap backends, and it should
not be collapsed away for convenience. not be collapsed away for convenience.
**SSH.NET's `window-change` on `ShellStream` is unverified.** A terminal that cannot resize is **SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
unusable, so this gates M1's client work. Fall back to `IChannelSession` if it does not work, and `ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
prefer forking and upstreaming a fix over reflection as a long-term answer. observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and
repeated resizes each take effect. The `IChannelSession` fallback is not needed. That suite stays
in place as a regression guard, because an upgrade that silently stopped sending the request would
present as wrapped output only after a resize — easy to misattribute to the terminal emulator.
**`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs
the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as
it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent
sessions, since the fix is either an upstream change or driving `IChannelSession` directly.
**SSH.NET cannot share one connection between `SshClient` and `SftpClient`.** A shell plus SFTP to
the same host means two TCP connections, two authentications and — later — two relay sockets.
Connect SFTP lazily and reuse the cached decrypted credential so the user is not prompted twice.
**Agent forwarding is de-scoped from v1.** It needs an upstream SSH.NET change. A vault-backed
agent of our own plus ProxyJump covers the real use cases.
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an **MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The SSH layer. Avalonia-free on purpose: this is the seam that makes connection,
authentication, PTY handling and host-key trust unit-testable without a UI toolkit.
-->
<ItemGroup>
<PackageReference Include="SSH.NET" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Ssh.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
namespace DodoSSH.Client.Ssh;
/// <summary>
/// OpenSSH-style SSH host key fingerprints. See docs/crypto.md §8.
/// </summary>
/// <remarks>
/// <para>
/// A different thing from a DodoSSH identity fingerprint, and deliberately kept in the format
/// users already recognise: <c>SHA256:</c> followed by unpadded standard base64, exactly what
/// <c>ssh-keygen -lf</c> prints. A user comparing our string against the one their server
/// operator sent them must not have to wonder whether the encodings match.
/// </para>
/// <para>
/// Computed from the raw host key blob. SSH.NET exposes an MD5 <c>FingerPrint</c> property;
/// that is not used — MD5 is banned repo-wide, and no modern operator publishes an MD5 host
/// key fingerprint to compare against.
/// </para>
/// </remarks>
public static class SshHostKeyFingerprint
{
/// <summary>Prefix identifying the hash algorithm, as OpenSSH writes it.</summary>
public const string Prefix = "SHA256:";
/// <summary>
/// Formats a host key blob as <c>SHA256:&lt;base64&gt;</c>.
/// </summary>
/// <param name="hostKeyBlob">
/// The raw key blob as it arrived on the wire — the same bytes OpenSSH hashes, not a parsed
/// or re-encoded form.
/// </param>
public static string Format(ReadOnlySpan<byte> hostKeyBlob)
{
if (hostKeyBlob.IsEmpty)
{
throw new ArgumentException("A host key blob is required.", nameof(hostKeyBlob));
}
Span<byte> digest = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(hostKeyBlob, digest);
// Standard base64, not base64url, and unpadded — OpenSSH's exact rendering. Using the
// URL alphabet here would produce a string that looks right and never matches.
return Prefix + Convert.ToBase64String(digest).TrimEnd('=');
}
/// <summary>
/// Compares two fingerprints in constant time.
/// </summary>
/// <remarks>
/// A fingerprint is not a secret, so this is not about a timing oracle. It is about refusing
/// to let a mismatch be decided by ordinal string comparison somewhere that later gets
/// "optimised" into a case-insensitive or culture-aware comparison — base64 is
/// case-sensitive, and a case-insensitive match here would accept a different key.
/// </remarks>
public static bool Equal(string? left, string? right)
{
if (left is null || right is null || left.Length != right.Length)
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(left),
System.Text.Encoding.UTF8.GetBytes(right));
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"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=="
},
"SSH.NET": {
"type": "Direct",
"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"
}
},
"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"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
}
}
}
}
+23 -21
View File
@@ -1429,15 +1429,6 @@
"resolved": "1.0.5", "resolved": "1.0.5",
"contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q==" "contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q=="
}, },
"SSH.NET": {
"type": "Transitive",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Stef.Validation": { "Stef.Validation": {
"type": "Transitive", "type": "Transitive",
"resolved": "0.3.0", "resolved": "0.3.0",
@@ -1470,18 +1461,6 @@
"System.CodeDom": "6.0.0" "System.CodeDom": "6.0.0"
} }
}, },
"Testcontainers": {
"type": "Transitive",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
},
"TinyMapper.Signed": { "TinyMapper.Signed": {
"type": "Transitive", "type": "Transitive",
"resolved": "4.0.0", "resolved": "4.0.0",
@@ -1803,6 +1782,29 @@
"dependencies": { "dependencies": {
"libsodium": "[1.0.22, 1.0.23)" "libsodium": "[1.0.22, 1.0.23)"
} }
},
"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"
}
},
"Testcontainers": {
"type": "CentralTransitive",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
} }
} }
} }
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Talks to a real OpenSSH server in a container. A mocked SSH library would prove nothing
about the two things that actually decide the terminal's design: whether a PTY resize
reaches the remote, and whether host keys arrive in a form we can fingerprint.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Referenced directly as well, so spike tests can drive SSH.NET without the wrapper. -->
<PackageReference Include="SSH.NET" />
<PackageReference Include="Testcontainers" />
</ItemGroup>
</Project>
@@ -0,0 +1,249 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// The M1 client gate: does a PTY resize actually reach the remote?
/// </summary>
/// <remarks>
/// <para>
/// The plan flagged <c>window-change</c> on <c>ShellStream</c> as unverified, and it decides the
/// terminal's whole design — a terminal that cannot resize is unusable, and the fallback is
/// dropping to <c>IChannelSession</c> and driving the channel requests directly. SSH.NET 2025.1.0
/// does expose <c>ChangeWindowSize</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class PtyAndResizeSpikeTests(SshServerFixture fixture)
{
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(20);
/// <summary>
/// Matches busybox <c>stty size</c> output: rows then columns.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly Regex SizeLine = new(
@"^[ \t]*(?<rows>[0-9]{1,5})[ \t]+(?<columns>[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<SshConnectionException>(
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);
/// <summary>Asks the remote what size it thinks the terminal is.</summary>
/// <remarks>
/// The marker is written split — <c>"MAR""KER"</c> — 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.
/// </remarks>
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));
}
/// <remarks>
/// Raced against a timeout rather than cancelled. <c>ShellStream</c> does not override
/// <c>ReadAsync</c>, 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.
/// </remarks>
private static async Task<string> 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}");
}
}
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// A real OpenSSH server in a container, with password and public-key auth both enabled.
/// </summary>
/// <remarks>
/// <para>
/// One container per assembly. Everything the SSH layer needs to be right about — PTY
/// allocation, window-change requests, host key encoding, key formats — is behaviour of a real
/// sshd, and none of it can be established against a mock.
/// </para>
/// <para>
/// The image is Alpine-based, so <c>stty</c> comes from busybox. Its <c>stty size</c> prints
/// "rows cols", which is what the resize assertions read.
/// </para>
/// </remarks>
public sealed class SshServerFixture : IAsyncLifetime
{
/// <summary>The account tests authenticate as.</summary>
public const string Username = "dodo";
/// <summary>Password for password authentication.</summary>
public const string Password = "correct-horse-battery-staple";
private const int SshPort = 2222;
private IContainer? container;
/// <summary>Host port the container's sshd is published on.</summary>
public ushort Port => container!.GetMappedPublicPort(SshPort);
/// <summary>Host the container is reachable at.</summary>
public string Host => container!.Hostname;
/// <summary>An RSA key pair whose public half is authorized on the server.</summary>
public RSA ClientKey { get; } = RSA.Create(3072);
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
container = new ContainerBuilder("linuxserver/openssh-server:latest")
.WithEnvironment("PUID", "1000")
.WithEnvironment("PGID", "1000")
.WithEnvironment("USER_NAME", Username)
.WithEnvironment("USER_PASSWORD", Password)
.WithEnvironment("PASSWORD_ACCESS", "true")
.WithEnvironment("SUDO_ACCESS", "false")
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
.WithPortBinding(SshPort, assignRandomHostPort: true)
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
// key before sshd is usable, so a published port is not readiness. Both conditions are
// needed: the log line proves the authorized key was installed (a connection accepted
// before that fails public-key auth), and the port check proves sshd is actually
// accepting. The message is this image's own wording — "Server listening on" is
// OpenSSH's and never appears here, which is a wait that hangs rather than fails.
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilMessageIsLogged("Public key from env variable added")
.UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
.Build();
await container.StartAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (container is not null)
{
await container.DisposeAsync();
}
ClientKey.Dispose();
}
/// <summary>
/// Renders an RSA public key in the single-line <c>authorized_keys</c> format.
/// </summary>
/// <remarks>
/// Hand-encoded because there is no BCL helper. The SSH wire format is a sequence of
/// length-prefixed strings: the algorithm name, then the exponent, then the modulus — both
/// as signed big-endian integers, which is why a leading zero byte is prepended when the
/// high bit is set. Getting that wrong yields a key sshd silently ignores.
/// </remarks>
private static string ExportOpenSshPublicKey(RSA rsa)
{
var parameters = rsa.ExportParameters(includePrivateParameters: false);
using var blob = new MemoryStream();
WriteSshString(blob, "ssh-rsa"u8.ToArray());
WriteSshMpint(blob, parameters.Exponent!);
WriteSshMpint(blob, parameters.Modulus!);
return $"ssh-rsa {Convert.ToBase64String(blob.ToArray())} dodossh-test";
}
private static void WriteSshString(Stream destination, byte[] value)
{
Span<byte> length = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(length, (uint)value.Length);
destination.Write(length);
destination.Write(value);
}
private static void WriteSshMpint(Stream destination, byte[] value)
{
// Signed big-endian: a high bit set would otherwise read as negative.
if (value.Length > 0 && (value[0] & 0x80) != 0)
{
var padded = new byte[value.Length + 1];
value.CopyTo(padded, 1);
WriteSshString(destination, padded);
return;
}
WriteSshString(destination, value);
}
}
/// <summary>Shares one SSH server across every test class in the assembly.</summary>
[CollectionDefinition(Name)]
public sealed class SshCollection : ICollectionFixture<SshServerFixture>
{
/// <summary>Collection name.</summary>
public const string Name = "ssh";
}
@@ -0,0 +1,313 @@
{
"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"
}
},
"SSH.NET": {
"type": "Direct",
"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"
}
},
"Testcontainers": {
"type": "Direct",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
},
"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"
}
},
"Docker.DotNet.Enhanced": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3",
"Docker.DotNet.Enhanced.LegacyHttp": "4.3.3",
"Docker.DotNet.Enhanced.NPipe": "4.3.3",
"Docker.DotNet.Enhanced.NativeHttp": "4.3.3",
"Docker.DotNet.Enhanced.Unix": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.Handler.Abstractions": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"Docker.DotNet.Enhanced.LegacyHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NativeHttp": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.NPipe": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.Unix": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"Docker.DotNet.Enhanced.X509": {
"type": "Transitive",
"resolved": "4.3.3",
"contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==",
"dependencies": {
"Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3"
}
},
"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=="
},
"SharpZipLib": {
"type": "Transitive",
"resolved": "1.4.2",
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
},
"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, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
}
}
}
}
@@ -286,15 +286,6 @@
"resolved": "1.4.2", "resolved": "1.4.2",
"contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A=="
}, },
"SSH.NET": {
"type": "Transitive",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
"System.CodeDom": { "System.CodeDom": {
"type": "Transitive", "type": "Transitive",
"resolved": "6.0.0", "resolved": "6.0.0",
@@ -313,18 +304,6 @@
"System.CodeDom": "6.0.0" "System.CodeDom": "6.0.0"
} }
}, },
"Testcontainers": {
"type": "Transitive",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
},
"xunit.analyzers": { "xunit.analyzers": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.27.0", "resolved": "1.27.0",
@@ -443,6 +422,29 @@
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10", "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10" "Microsoft.Extensions.Logging": "10.0.10"
} }
},
"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"
}
},
"Testcontainers": {
"type": "CentralTransitive",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==",
"dependencies": {
"Docker.DotNet.Enhanced": "4.3.3",
"Docker.DotNet.Enhanced.X509": "4.3.3",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"SSH.NET": "2025.1.0",
"SharpZipLib": "1.4.2"
}
} }
} }
} }