Public Access
This commit is contained in:
@@ -7,6 +7,7 @@ using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
@@ -54,6 +55,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
|
||||
/// <remarks>
|
||||
/// Over a substitute factory that is never asked for a session. Every shape measured here is one the
|
||||
/// screen is in before a connection exists or after one has failed, which is deliberate: the two panes
|
||||
/// are at their widest with the local one full and the remote one carrying its explanation, and a
|
||||
/// connected pane is the same template with shorter names in it.
|
||||
/// </remarks>
|
||||
private TransfersViewModel transfers = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -82,12 +91,20 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
// every sync pass out of a suite that is only measuring rectangles.
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
transfers = new TransfersViewModel(
|
||||
Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
// Attached after seeding, so the host picker has something in it and the local pane has listed this
|
||||
// machine's home directory — which is what puts real names of real length into the row template.
|
||||
transfers.Attach(vault, knownHosts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await transfers.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
@@ -271,6 +288,73 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The transfers screen ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The widest thing in this window and the one with the least room to give: two file listings side by
|
||||
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
|
||||
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
|
||||
/// a button and a chip on one line.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
|
||||
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
|
||||
/// of real length rather than with fixtures chosen to fit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsBeforeAnythingIsConnected()
|
||||
{
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The queue is the half of this screen that only exists once something has been asked for, so a shape
|
||||
/// nothing puts a row into is a shape never laid out. Three rows, because the row template changes with
|
||||
/// the state: a running one shows a bar and a STOP, a stopped one shows RESUME and DISCARD, and a failed
|
||||
/// one carries the server's own sentence in the column the other two put a byte count in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rows are placed directly rather than driven through the queue. What is being measured is the
|
||||
/// template at each state, and running a real transfer to reach those states would put a thread-pool
|
||||
/// hand-off and a filesystem in the middle of a test about rectangles. What the queue does is measured in
|
||||
/// <c>DodoSSH.Client.Transfer.Tests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithTransfersInTheQueue()
|
||||
{
|
||||
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
|
||||
TransferState.Running, bytesPerSecond: 6_500_000);
|
||||
|
||||
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
|
||||
TransferState.Cancelled);
|
||||
|
||||
Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed,
|
||||
failure: "deploy.sh is already in that directory on the host. Rename or remove it first — "
|
||||
+ "nothing here overwrites a file that is already there.");
|
||||
|
||||
transfers.Transfers.Count.ShouldBe(3);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The trust card covers the whole screen, and it is the one thing here a user cannot get past without
|
||||
/// pressing something — so a button of its own that fell outside the window would leave the screen
|
||||
/// permanently blocked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithTheHostKeyCardShowing()
|
||||
{
|
||||
transfers.PendingHostKey = new HostKeyPresentation(
|
||||
"db.internal", 22, "ssh-ed25519", "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG");
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
// ---- The chrome ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -389,6 +473,48 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
|
||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Puts one transfer on the queue in a given state, without moving a byte.</summary>
|
||||
private void Enqueue(
|
||||
TransferDirection direction,
|
||||
string name,
|
||||
long length,
|
||||
long transferred,
|
||||
TransferState state,
|
||||
double bytesPerSecond = 0,
|
||||
string? failure = null) =>
|
||||
transfers.Transfers.Add(new TransferRowViewModel(new TransferSnapshot(
|
||||
Guid.CreateVersion7(),
|
||||
direction,
|
||||
name,
|
||||
Path.Combine(Path.GetTempPath(), name),
|
||||
SftpPath.Combine("/srv/releases", name),
|
||||
length,
|
||||
transferred,
|
||||
state,
|
||||
bytesPerSecond,
|
||||
failure)));
|
||||
|
||||
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
||||
|
||||
@@ -487,7 +487,8 @@
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
@@ -538,6 +539,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
|
||||
/// testable is everything the connect path does <em>around</em> the connection.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
|
||||
internal Exception? Failure { get; set; }
|
||||
@@ -18,6 +18,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>Requests for a file-transfer session, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
||||
/// test asserting that opening a terminal did not also open one would have nothing to look at if the two
|
||||
/// shared a list.
|
||||
/// </remarks>
|
||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -29,6 +37,81 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
? Task.FromException<ISshConnection>(failure)
|
||||
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SftpRequests.Add(request);
|
||||
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISftpSession>(failure)
|
||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A remote filesystem with one directory in it.</summary>
|
||||
/// <remarks>
|
||||
/// Enough for the shell suite, which is about what the screen does around a session rather than about
|
||||
/// moving bytes. The queue's own behaviour is covered against a fuller fake in
|
||||
/// <c>DodoSSH.Client.Transfer.Tests</c>, and the real subsystem against a container in
|
||||
/// <c>DodoSSH.Client.Ssh.Tests</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => $"/home/{request.Username}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
[
|
||||
new SftpEntry(
|
||||
"notes.txt",
|
||||
SftpPath.Combine(path, "notes.txt"),
|
||||
SftpEntryKind.File,
|
||||
12,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--"),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<SftpEntry?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
||||
|
||||
@@ -110,6 +110,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
SignInAsync,
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
@@ -293,6 +294,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -738,6 +740,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
new UnavailableDeviceKeyStore(),
|
||||
(_, _) => throw new InvalidOperationException("unreachable"),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -1966,7 +1969,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-web-01");
|
||||
await AddHostAsync(vault, "prod-web-02");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "prod-web-01");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal));
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
@@ -1999,7 +2003,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(host => host.Label == "stage-web");
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal));
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
@@ -2153,6 +2158,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
deviceKeys,
|
||||
(_, _) => throw new InvalidOperationException("The shell went to the network."),
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile);
|
||||
|
||||
await using var _ = offline.ConfigureAwait(false);
|
||||
@@ -2210,6 +2216,85 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
|
||||
// ---- File transfer ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheTransfersScreen_TakesItsHostListFromTheUnlockedVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
await AddHostAsync(shell.Vault!, "prod-db");
|
||||
|
||||
// Attached at unlock, after the vault has loaded. Before that ordering was right the picker was
|
||||
// empty until something else happened to reload it.
|
||||
shell.Transfers.Hosts.ShouldBeEmpty("the vault had no hosts when it was attached");
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> argues that
|
||||
/// locking must not destroy work — it is what somebody does when they walk away from the machine, which
|
||||
/// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have
|
||||
/// dropped the session and with it whatever was moving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect");
|
||||
|
||||
// What it does take is the host list, and it has to: those rows carry decrypted secrets and the
|
||||
// vault they came from has just been disposed.
|
||||
shell.Transfers.Hosts.ShouldBeEmpty();
|
||||
shell.Transfers.SelectedHost.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made
|
||||
/// visible: browsing a host's files authenticates again rather than reusing the terminal's connection.
|
||||
/// It is asserted rather than merely written down because the host's audit log shows a second login,
|
||||
/// and somebody will eventually be asked to explain it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(1);
|
||||
ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.SftpRequests.Count.ShouldBe(1);
|
||||
ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either");
|
||||
|
||||
// It opens on the account's home directory, which is the only path the layer knows without asking.
|
||||
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
||||
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
||||
}
|
||||
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
@@ -476,7 +476,8 @@
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
@@ -527,6 +528,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Remote paths, permission bits and byte counts — the three things the file browser renders.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not in the SSH collection, so this class needs no container. Every case here is one where the obvious
|
||||
/// implementation is wrong on Windows, at a filesystem root, or on a number that has just crossed a
|
||||
/// threshold — which is to say, one that a listing of somebody's home directory would not reveal.
|
||||
/// </remarks>
|
||||
public sealed class RemotePathTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/var/log", "syslog", "/var/log/syslog")]
|
||||
[InlineData("/", "etc", "/etc")]
|
||||
[InlineData("/home/dodo/", "notes", "/home/dodo/notes")]
|
||||
public void Combine_JoinsWithExactlyOneSeparator(string directory, string name, string expected)
|
||||
{
|
||||
// Deliberately not Path.Combine, which on Windows would yield "/var/log\syslog" — a path the remote
|
||||
// cannot resolve, failing as "no such file" somewhere the backslash is invisible.
|
||||
SftpPath.Combine(directory, name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/var/log/syslog", "/var/log")]
|
||||
[InlineData("/var/log", "/var")]
|
||||
[InlineData("/var", "/")]
|
||||
[InlineData("/", "/")]
|
||||
[InlineData("/var/log/", "/var")]
|
||||
public void Parent_StopsAtTheRoot(string path, string expected)
|
||||
{
|
||||
// The root is its own parent rather than null, which is what lets the breadcrumb's "up" be a plain
|
||||
// navigation with nothing above it to special-case.
|
||||
SftpPath.Parent(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/var/log/syslog", "syslog")]
|
||||
[InlineData("/var/log/", "log")]
|
||||
[InlineData("/", "/")]
|
||||
public void Name_IsTheLastSegment(string path, string expected)
|
||||
{
|
||||
SftpPath.Name(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trail_NamesEverySegmentWithThePathThatReachesIt()
|
||||
{
|
||||
SftpPath.Trail("/var/log/nginx").ShouldBe(
|
||||
[("var", "/var"), ("log", "/var/log"), ("nginx", "/var/log/nginx")]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trail_IsEmptyAtTheRoot()
|
||||
{
|
||||
// The root has no segment to name. The breadcrumb draws it as a leading separator, so a trail with a
|
||||
// phantom empty crumb in it would render as a button with no label.
|
||||
SftpPath.Trail("/").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixMode_RendersTheKindCharacterAndThreeTriples()
|
||||
{
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.Directory,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: true,
|
||||
groupRead: true, groupWrite: false, groupExecute: true,
|
||||
othersRead: true, othersWrite: false, othersExecute: true)
|
||||
.ShouldBe("drwxr-xr-x");
|
||||
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.File,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: false,
|
||||
groupRead: true, groupWrite: false, groupExecute: false,
|
||||
othersRead: false, othersWrite: false, othersExecute: false)
|
||||
.ShouldBe("-rw-r-----");
|
||||
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.SymbolicLink,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: true,
|
||||
groupRead: true, groupWrite: true, groupExecute: true,
|
||||
othersRead: true, othersWrite: true, othersExecute: true)
|
||||
.ShouldBe("lrwxrwxrwx");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0 B")]
|
||||
[InlineData(1023, "1023 B")]
|
||||
[InlineData(1024, "1.0 KB")]
|
||||
[InlineData(10 * 1024, "10 KB")]
|
||||
[InlineData(1536 * 1024, "1.5 MB")]
|
||||
[InlineData(5L * 1024 * 1024 * 1024, "5.0 GB")]
|
||||
public void ByteSize_KeepsOneDecimalOnlyWhileItMeansSomething(long bytes, string expected)
|
||||
{
|
||||
// The boundary at ten is the whole rule: "9.4 MB" and "512 MB" carry the same information, and
|
||||
// "512.3 MB" claims a precision the figure does not have by the time it is that large.
|
||||
ByteSize.Format(bytes).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The SFTP subsystem, against a real sshd.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything this suite is about is behaviour of a server rather than of this code: whether a listing
|
||||
/// carries the permission bits the design's <c>PERMS</c> column needs, whether opening at an offset really
|
||||
/// starts there, and whether a rename over a name that already exists fails rather than silently replacing —
|
||||
/// which the transfer queue's part-file scheme depends on. None of it can be established against a mock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One directory per test, named after it, because the container is shared with every other suite in the
|
||||
/// assembly and a test that cleaned up by emptying the home directory would take another test's fixture with
|
||||
/// it. The <em>session</em> is shared too, and that is a limit of the server rather than tidiness — see
|
||||
/// <see cref="SshServerFixture.SftpAsync"/>, which explains what opening one per test did to the rest of the
|
||||
/// assembly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(SshCollection.Name)]
|
||||
public sealed class SftpSessionTests(SshServerFixture fixture)
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task AnOpenedSession_StartsInTheAccountsHomeDirectory()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
// Absolute, because the server canonicalises it during the handshake. A relative answer would make
|
||||
// every path the browser builds relative too, and the breadcrumb trail meaningless.
|
||||
SftpPath.IsAbsolute(sftp.HomeDirectory).ShouldBeTrue(sftp.HomeDirectory);
|
||||
sftp.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AListing_CarriesKindSizeAndPermissions()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(AListing_CarriesKindSizeAndPermissions));
|
||||
var content = "the quick brown fox"u8.ToArray();
|
||||
|
||||
await WriteAsync(sftp, SftpPath.Combine(directory, "a-file"), content);
|
||||
await sftp.CreateDirectoryAsync(SftpPath.Combine(directory, "a-directory"), Token);
|
||||
|
||||
var entries = await sftp.ListAsync(directory, Token);
|
||||
|
||||
// Directories first: the order this interface promises, and what the file browser shows without
|
||||
// sorting again.
|
||||
entries.Select(entry => entry.Name).ShouldBe(["a-directory", "a-file"]);
|
||||
|
||||
var file = entries[1];
|
||||
file.Kind.ShouldBe(SftpEntryKind.File);
|
||||
file.Length.ShouldBe(content.Length);
|
||||
file.FullPath.ShouldBe(SftpPath.Combine(directory, "a-file"));
|
||||
|
||||
// The one column nothing in this repository could render before. The exact bits depend on the
|
||||
// server's umask, so what is pinned is the shape and the kind character rather than the mode.
|
||||
file.Permissions.Length.ShouldBe(10);
|
||||
file.Permissions[0].ShouldBe('-');
|
||||
file.Permissions.ShouldStartWith("-rw");
|
||||
|
||||
entries[0].Kind.ShouldBe(SftpEntryKind.Directory);
|
||||
entries[0].Permissions[0].ShouldBe('d');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AListing_DropsTheDotEntries()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(AListing_DropsTheDotEntries));
|
||||
|
||||
// Empty, which is the case where "." and ".." are the whole listing — so a browser that showed them
|
||||
// would present an empty directory as one holding two things.
|
||||
var entries = await sftp.ListAsync(directory, Token);
|
||||
|
||||
entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListingSomethingThatIsNotADirectory_FailsWithThePath()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(ListingSomethingThatIsNotADirectory_FailsWithThePath));
|
||||
|
||||
var file = SftpPath.Combine(directory, "not-a-directory");
|
||||
await WriteAsync(sftp, file, "x"u8.ToArray());
|
||||
|
||||
// The path is the whole point of the translation. SSH.NET's own exception for this carries the
|
||||
// server's message and nothing about which path was asked for, and the browser has to say which
|
||||
// row failed.
|
||||
var failure = await Should.ThrowAsync<SftpPathException>(async () =>
|
||||
await sftp.ListAsync(file, Token));
|
||||
|
||||
failure.Path.ShouldBe(file);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stat_AnswersNullForSomethingThatIsNotThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(Stat_AnswersNullForSomethingThatIsNotThere));
|
||||
|
||||
// Absent is an answer rather than a failure: the transfer queue asks this before every upload to
|
||||
// find out whether it would be overwriting something, and that question has a "no".
|
||||
(await sftp.StatAsync(SftpPath.Combine(directory, "nothing-here"), Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningAtAnOffset_ReadsFromThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(OpeningAtAnOffset_ReadsFromThere));
|
||||
var path = SftpPath.Combine(directory, "resumable");
|
||||
|
||||
await WriteAsync(sftp, path, "0123456789"u8.ToArray());
|
||||
|
||||
// The whole of resume, in one call. If the server ignored the offset this would read the file from
|
||||
// the start and a resumed download would silently duplicate its first half.
|
||||
var stream = await sftp.OpenReadAsync(path, 4, Token);
|
||||
await using var scope = stream.ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
(await reader.ReadToEndAsync(Token)).ShouldBe("456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritingAtAnOffset_LeavesWhatWasAlreadyThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(WritingAtAnOffset_LeavesWhatWasAlreadyThere));
|
||||
var path = SftpPath.Combine(directory, "appended");
|
||||
|
||||
await WriteAsync(sftp, path, "0123"u8.ToArray());
|
||||
|
||||
var stream = await sftp.OpenWriteAsync(path, 4, Token);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await stream.WriteAsync("456789"u8.ToArray(), Token);
|
||||
await stream.FlushAsync(Token);
|
||||
}
|
||||
|
||||
// The other half of resume: an upload that carried on from an offset must not have truncated the
|
||||
// bytes an earlier attempt already delivered.
|
||||
(await ReadAllAsync(sftp, path)).ShouldBe("0123456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_RefusesToReplaceSomethingThatIsAlreadyThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(Rename_RefusesToReplaceSomethingThatIsAlreadyThere));
|
||||
|
||||
var part = SftpPath.Combine(directory, "artefact.dodossh-part");
|
||||
var destination = SftpPath.Combine(directory, "artefact");
|
||||
|
||||
await WriteAsync(sftp, part, "new"u8.ToArray());
|
||||
await WriteAsync(sftp, destination, "old"u8.ToArray());
|
||||
|
||||
// The transfer queue's last step, and the assumption underneath its promise never to overwrite: it
|
||||
// checks the destination before starting, and this is what stops a file that appeared in the
|
||||
// meantime from being replaced anyway. SFTP's rename is specified not to clobber, and this is the
|
||||
// check that the server this project tests against actually behaves that way.
|
||||
await Should.ThrowAsync<SftpPathException>(async () =>
|
||||
await sftp.RenameAsync(part, destination, Token));
|
||||
|
||||
(await ReadAllAsync(sftp, destination)).ShouldBe("old");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingANonEmptyDirectory_Fails()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(DeletingANonEmptyDirectory_Fails));
|
||||
|
||||
await WriteAsync(sftp, SftpPath.Combine(directory, "occupant"), "x"u8.ToArray());
|
||||
|
||||
// Deliberate, and the reason this interface has no recursive delete: the one destructive operation
|
||||
// on the transfers screen must not be able to take a directory tree with it.
|
||||
await Should.ThrowAsync<SftpPathException>(async () => await sftp.DeleteAsync(directory, Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe()
|
||||
{
|
||||
var factory = new SshNetConnectionFactory(new InMemoryKnownHostStore());
|
||||
|
||||
// File transfer opens its own connection, so it makes its own first-contact decision. The failure
|
||||
// that matters is the one this asserts is *not* raised: a bare connection error would send the user
|
||||
// looking at the network for what is a fingerprint they have not approved.
|
||||
var refusal = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.OpenSftpAsync(Request(), Token));
|
||||
|
||||
refusal.Presentation.Host.ShouldBe(fixture.Host);
|
||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
SshServerFixture.Username,
|
||||
new SshPasswordCredential(SshServerFixture.Password));
|
||||
|
||||
/// <summary>A directory of this test's own, under the account's home.</summary>
|
||||
private static async Task<string> MakeDirectoryAsync(ISftpSession sftp, string name)
|
||||
{
|
||||
var path = SftpPath.Combine(sftp.HomeDirectory, $"sftp-{name}");
|
||||
|
||||
if (await sftp.StatAsync(path, Token) is not null)
|
||||
{
|
||||
// A previous run of the same test in a container that outlived it. Emptying it is enough:
|
||||
// nothing here creates nested directories.
|
||||
foreach (var entry in await sftp.ListAsync(path, Token))
|
||||
{
|
||||
await sftp.DeleteAsync(entry.FullPath, Token);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
await sftp.CreateDirectoryAsync(path, Token);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(ISftpSession sftp, string path, byte[] content)
|
||||
{
|
||||
var stream = await sftp.OpenWriteAsync(path, 0, Token);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await stream.WriteAsync(content, Token);
|
||||
await stream.FlushAsync(Token);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadAllAsync(ISftpSession sftp, string path)
|
||||
{
|
||||
var stream = await sftp.OpenReadAsync(path, 0, Token);
|
||||
await using var scope = stream.ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
return await reader.ReadToEndAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
private readonly SemaphoreSlim sftpGate = new(1, 1);
|
||||
|
||||
private IContainer? container;
|
||||
private ISftpSession? sftp;
|
||||
|
||||
/// <summary>Host port the container's sshd is published on.</summary>
|
||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||
@@ -68,9 +71,69 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
await container.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One file-transfer session, opened on first use and shared by every test that wants one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
|
||||
/// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
|
||||
/// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
|
||||
/// the host key — so a suite that opened its own session per test made two handshakes per test and
|
||||
/// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
|
||||
/// "the connection was closed by the remote host", a different few each run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
||||
/// named after itself. See <c>ISftpSession</c>, which is one channel and is used by one caller at a
|
||||
/// time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<ISftpSession> SftpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await sftpGate.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (sftp is not null)
|
||||
{
|
||||
return sftp;
|
||||
}
|
||||
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
Host, Port, Username, new SshPasswordCredential(Password));
|
||||
|
||||
try
|
||||
{
|
||||
// Learned by being refused, which is the only way this client learns a host key.
|
||||
return sftp = await factory.OpenSftpAsync(request, cancellationToken);
|
||||
}
|
||||
catch (SshHostKeyUnknownException unknown)
|
||||
{
|
||||
await knownHosts.TrustAsync(unknown.Presentation, cancellationToken);
|
||||
}
|
||||
|
||||
return sftp = await factory.OpenSftpAsync(request, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sftpGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (sftp is not null)
|
||||
{
|
||||
await sftp.DisposeAsync();
|
||||
}
|
||||
|
||||
sftpGate.Dispose();
|
||||
|
||||
if (container is not null)
|
||||
{
|
||||
await container.DisposeAsync();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The queue, against a real temporary directory and a fake host. Real files on the local side
|
||||
because the part-file scheme is about what is on disk when a transfer stops halfway, and a
|
||||
filesystem abstraction would let that be right in the test and wrong in the product; a fake on
|
||||
the remote side because the failures worth pinning here — a read that dies mid-file, a
|
||||
destination that appears while a transfer is queued — are ones no real server can be asked for
|
||||
on cue. What the real server does answer is in DodoSSH.Client.Ssh.Tests.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,258 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A remote filesystem in a dictionary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Here rather than a container because what this suite is about is the queue's behaviour when a transfer
|
||||
/// goes wrong halfway — a read that dies after so many bytes, a destination that appears while a transfer is
|
||||
/// queued — and neither can be asked of a real server on cue. That the real server behaves as this fake
|
||||
/// pretends is established separately, against sshd, in <c>SftpSessionTests</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSftpSession : ISftpSession
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
|
||||
private readonly Lock gate = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => "/home/dodo";
|
||||
|
||||
/// <summary>Throws once, this many bytes into the next read, and then stops doing so.</summary>
|
||||
/// <remarks>
|
||||
/// One-shot on purpose: every resume test is "it broke, then it did not", and a fake that kept failing
|
||||
/// would need turning off at exactly the point the assertion is about.
|
||||
/// </remarks>
|
||||
public int? FailReadAfter { get; set; }
|
||||
|
||||
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
|
||||
/// <remarks>
|
||||
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
|
||||
/// whether the second attempt started at the offset or at zero.
|
||||
/// </remarks>
|
||||
public List<long> ReadOffsets { get; } = [];
|
||||
|
||||
/// <summary>Puts a file on the fake host.</summary>
|
||||
public void Seed(string path, byte[] content)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
files[path] = content;
|
||||
directories.Add(SftpPath.Parent(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What is at a path, or null.</summary>
|
||||
public byte[]? Read(string path)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return files.GetValueOrDefault(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether anything is at a path.</summary>
|
||||
public bool Exists(string path)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return files.ContainsKey(path) || directories.Contains(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!directories.Contains(path))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
|
||||
IReadOnlyList<SftpEntry> entries =
|
||||
[
|
||||
.. files
|
||||
.Where(file => string.Equals(SftpPath.Parent(file.Key), path, StringComparison.Ordinal))
|
||||
.Select(file => Describe(file.Key, file.Value.Length)),
|
||||
];
|
||||
|
||||
return Task.FromResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (files.TryGetValue(path, out var content))
|
||||
{
|
||||
return Task.FromResult<SftpEntry?>(Describe(path, content.Length));
|
||||
}
|
||||
|
||||
return Task.FromResult<SftpEntry?>(
|
||||
directories.Contains(path)
|
||||
? new SftpEntry(
|
||||
SftpPath.Name(path),
|
||||
path,
|
||||
SftpEntryKind.Directory,
|
||||
0,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"drwxr-xr-x")
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
ReadOffsets.Add(offset);
|
||||
|
||||
if (!files.TryGetValue(path, out var content))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
|
||||
var failAfter = FailReadAfter;
|
||||
FailReadAfter = null;
|
||||
|
||||
return Task.FromResult<Stream>(
|
||||
new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var existing = files.GetValueOrDefault(path, []);
|
||||
|
||||
return Task.FromResult<Stream>(new CommittingStream(
|
||||
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
||||
content =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
files[path] = content;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
directories.Add(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!files.Remove(path) && !directories.Remove(path))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!files.TryGetValue(fromPath, out var content))
|
||||
{
|
||||
throw new SftpPathException(fromPath, $"{fromPath} is not there.");
|
||||
}
|
||||
|
||||
// SFTP's rename does not replace, and the queue's promise never to overwrite rests on it. A fake
|
||||
// that clobbered would make the one test about that promise pass for the wrong reason.
|
||||
if (files.ContainsKey(toPath))
|
||||
{
|
||||
throw new SftpPathException(toPath, $"{toPath} is already there.");
|
||||
}
|
||||
|
||||
files.Remove(fromPath);
|
||||
files[toPath] = content;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private static SftpEntry Describe(string path, int length) => new(
|
||||
SftpPath.Name(path),
|
||||
path,
|
||||
SftpEntryKind.File,
|
||||
length,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--");
|
||||
|
||||
/// <summary>A read that dies partway through, the way a dropped connection does.</summary>
|
||||
private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
|
||||
{
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
Guard();
|
||||
|
||||
return base.Read(buffer);
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guard();
|
||||
|
||||
return base.ReadAsync(buffer, cancellationToken);
|
||||
}
|
||||
|
||||
private void Guard()
|
||||
{
|
||||
if (failAfter is { } limit && Position >= limit)
|
||||
{
|
||||
throw new IOException("The connection dropped.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A write that lands on the fake host when it is disposed.</summary>
|
||||
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
|
||||
{
|
||||
private bool committed;
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
if (!committed)
|
||||
{
|
||||
committed = true;
|
||||
commit([.. prefix, .. ToArray()]);
|
||||
}
|
||||
|
||||
base.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The transfer queue: what ends up on disk, and what happens when a transfer stops halfway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every assertion here is about a promise the queue makes in prose — nothing is written at its final name
|
||||
/// until it is complete, a destination that already exists is refused rather than overwritten, an
|
||||
/// interrupted transfer carries on rather than starting again, and a partial file left by something else is
|
||||
/// never resumed from. Those are the four ways a file transfer can quietly deliver the wrong bytes.
|
||||
/// </remarks>
|
||||
public sealed class FileTransferQueueTests : IDisposable
|
||||
{
|
||||
private const string RemoteDirectory = "/home/dodo";
|
||||
|
||||
private readonly FakeSftpSession host = new();
|
||||
private readonly string workspace = Directory.CreateTempSubdirectory("dodossh-transfer-").FullName;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(workspace, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A handle the runtime has not released yet. The directory is under the system temporary path
|
||||
// and failing a passing test over it would be the wrong trade.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADownload_LandsAtItsFinalNameWithNoPartFileLeftBehind()
|
||||
{
|
||||
var content = Bytes(300_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
finished.Transferred.ShouldBe(content.Length);
|
||||
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
File.Exists(Local("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADownloadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
||||
{
|
||||
host.Seed(Remote("artefact.tar"), Bytes(1_000));
|
||||
await File.WriteAllTextAsync(Local("artefact.tar"), "something else entirely", Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 1_000);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
finished.Failure.ShouldNotBeNull();
|
||||
|
||||
// The whole point of the refusal: what was there is still there, unread and unreplaced.
|
||||
(await File.ReadAllTextAsync(Local("artefact.tar"), Token)).ShouldBe("something else entirely");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnInterruptedDownload_CarriesOnFromWhereItStopped()
|
||||
{
|
||||
var content = Bytes(300_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
host.FailReadAfter = 100_000;
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var broken = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
broken.State.ShouldBe(TransferState.Failed);
|
||||
broken.Transferred.ShouldBeInRange(1, content.Length - 1);
|
||||
broken.CanResume.ShouldBeTrue();
|
||||
|
||||
// The part file is what makes this a resume rather than a restart, and it is deliberately not at the
|
||||
// destination's name — nothing may look like a finished download until it is one.
|
||||
var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
|
||||
File.Exists(part).ShouldBeTrue();
|
||||
File.Exists(Local("artefact.tar")).ShouldBeFalse();
|
||||
|
||||
var partLength = new FileInfo(part).Length;
|
||||
|
||||
var resumed = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));
|
||||
|
||||
resumed.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
// Asked for the second time at the offset the part file had reached, which is the only direct
|
||||
// evidence that the bytes already moved were not moved again.
|
||||
host.ReadOffsets.ShouldBe([0, partLength]);
|
||||
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFreshDownload_WillNotResumeFromAPartFileItDidNotWrite()
|
||||
{
|
||||
var content = Bytes(200_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
|
||||
// Litter from something else entirely — an earlier run of the application, or an earlier attempt at
|
||||
// a file of the same name that has since changed. Resuming on the strength of the name matching is
|
||||
// how a corrupt artefact gets delivered with nothing reporting a failure.
|
||||
await File.WriteAllBytesAsync(
|
||||
Local("artefact.tar") + FileTransferQueue.PartSuffix, Bytes(50_000, seed: 7), Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
host.ReadOffsets.ShouldBe([0]);
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpload_LandsAtItsFinalNameOnTheHost()
|
||||
{
|
||||
var content = Bytes(150_000);
|
||||
await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
host.Read(Remote("artefact.tar")).ShouldBe(content);
|
||||
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
||||
{
|
||||
var existing = "the running deployment"u8.ToArray();
|
||||
host.Seed(Remote("artefact.tar"), existing);
|
||||
await File.WriteAllBytesAsync(Local("artefact.tar"), Bytes(1_000), Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), 1_000);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
host.Read(Remote("artefact.tar")).ShouldBe(existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransfersRunOneAtATime()
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
host.Seed(Remote($"file-{i}"), Bytes(200_000, seed: i));
|
||||
}
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var running = new HashSet<Guid>();
|
||||
var most = 0;
|
||||
var gate = new Lock();
|
||||
|
||||
queue.Changed += (_, e) =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (e.Transfer.State is TransferState.Running)
|
||||
{
|
||||
running.Add(e.Transfer.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
running.Remove(e.Transfer.Id);
|
||||
}
|
||||
|
||||
most = Math.Max(most, running.Count);
|
||||
}
|
||||
};
|
||||
|
||||
var ids = new List<Guid>();
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ids.Add(queue.Enqueue(
|
||||
TransferDirection.Download, Local($"file-{i}"), Remote($"file-{i}"), 200_000));
|
||||
}
|
||||
|
||||
await WaitForQuietAsync(queue);
|
||||
|
||||
queue.Snapshot().ShouldAllBe(transfer => transfer.State == TransferState.Completed);
|
||||
|
||||
// The claim the whole design rests on: one channel, one transfer, so the throughput on a row is the
|
||||
// throughput of the link rather than a share of it.
|
||||
lock (gate)
|
||||
{
|
||||
most.ShouldBe(1);
|
||||
}
|
||||
|
||||
ids.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingAQueuedTransfer_TakesItOutOfTheQueueWithoutStartingIt()
|
||||
{
|
||||
host.Seed(Remote("slow"), Bytes(2_000_000));
|
||||
host.Seed(Remote("never"), Bytes(1_000));
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, Local("slow"), Remote("slow"), 2_000_000);
|
||||
var second = queue.Enqueue(TransferDirection.Download, Local("never"), Remote("never"), 1_000);
|
||||
|
||||
queue.Cancel(second);
|
||||
|
||||
await WaitForQuietAsync(queue);
|
||||
|
||||
var cancelled = queue.Snapshot().Single(transfer => transfer.Id == second);
|
||||
|
||||
cancelled.State.ShouldBe(TransferState.Cancelled);
|
||||
cancelled.Transferred.ShouldBe(0);
|
||||
File.Exists(Local("never")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardingAStoppedDownload_TakesItsPartFileWithIt()
|
||||
{
|
||||
var content = Bytes(200_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
host.FailReadAfter = 60_000;
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var broken = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
|
||||
File.Exists(part).ShouldBeTrue();
|
||||
|
||||
(await queue.DiscardAsync(broken.Id, Token)).ShouldBeTrue();
|
||||
|
||||
// The row was the only thing that knew the part file existed, so removing one without the other
|
||||
// would leave bytes on disk nobody could attribute.
|
||||
queue.Snapshot().ShouldBeEmpty();
|
||||
File.Exists(part).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATransferWithNoSessionToRunOn_FailsOnItsOwnRowRatherThanSilently()
|
||||
{
|
||||
await using var queue = new FileTransferQueue(
|
||||
_ => Task.FromException<ISftpSession>(new IOException("the host is not reachable")),
|
||||
TimeProvider.System);
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 10);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
finished.Failure.ShouldBe("the host is not reachable");
|
||||
}
|
||||
|
||||
private FileTransferQueue NewQueue() =>
|
||||
new(_ => Task.FromResult<ISftpSession>(host), TimeProvider.System);
|
||||
|
||||
private string Local(string name) => Path.Combine(workspace, name);
|
||||
|
||||
private static string Remote(string name) => SftpPath.Combine(RemoteDirectory, name);
|
||||
|
||||
/// <remarks>
|
||||
/// Repeatable rather than random, so a failure can be reproduced, and not all one byte, so a resumed
|
||||
/// transfer that started from the wrong offset produces a file that differs rather than one that happens
|
||||
/// to match.
|
||||
/// </remarks>
|
||||
private static byte[] Bytes(int length, int seed = 0)
|
||||
{
|
||||
var content = new byte[length];
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
content[i] = (byte)((i + seed) % 251);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private static Task<TransferSnapshot> RunToCompletionAsync(
|
||||
FileTransferQueue queue,
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length) =>
|
||||
AwaitFinishAsync(queue, () => queue.Enqueue(direction, localPath, remotePath, length));
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes, starts a transfer, and completes when a transfer reaches a state it will not leave.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The subscription goes on <em>before</em> the transfer starts, because the pump runs on a thread-pool
|
||||
/// thread: a small transfer can be finished before <c>Enqueue</c> has returned its id, so subscribing
|
||||
/// afterwards would wait for an event that has already happened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which is also why it does not match on the id — there is nothing to match against yet. Every caller
|
||||
/// has exactly one transfer in flight, which is what makes "a transfer finished" and "this transfer
|
||||
/// finished" the same statement. The one test with several running uses
|
||||
/// <see cref="WaitForQuietAsync"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<TransferSnapshot> AwaitFinishAsync(FileTransferQueue queue, Action start)
|
||||
{
|
||||
var finished = new TaskCompletionSource<TransferSnapshot>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnChanged(object? sender, TransferChangedEventArgs e)
|
||||
{
|
||||
if (e.Transfer.IsFinished)
|
||||
{
|
||||
finished.TrySetResult(e.Transfer);
|
||||
}
|
||||
}
|
||||
|
||||
queue.Changed += OnChanged;
|
||||
|
||||
try
|
||||
{
|
||||
start();
|
||||
|
||||
return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
queue.Changed -= OnChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits until nothing is queued or running.</summary>
|
||||
private static async Task WaitForQuietAsync(FileTransferQueue queue)
|
||||
{
|
||||
var deadline = TimeSpan.FromSeconds(30);
|
||||
var waited = TimeSpan.Zero;
|
||||
|
||||
while (queue.IsBusy && waited < deadline)
|
||||
{
|
||||
await Task.Delay(20, Token);
|
||||
waited += TimeSpan.FromMilliseconds(20);
|
||||
}
|
||||
|
||||
queue.IsBusy.ShouldBeFalse("the queue should have drained");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"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.transfer": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user