Public Access
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</Folder>
|
||||
|
||||
<Folder Name="/tests/">
|
||||
@@ -39,6 +40,7 @@
|
||||
<Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.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.Domain.Tests/DodoSSH.Domain.Tests.csproj" />
|
||||
|
||||
@@ -65,8 +65,9 @@ src/
|
||||
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
|
||||
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
|
||||
DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place
|
||||
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
||||
DodoSSH.Client.Ssh connections, PTY shells, SFTP, host key trust
|
||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
||||
tests/ one test project per source project
|
||||
docs/adr/ architecture decision records
|
||||
@@ -150,6 +151,37 @@ authentication asks for the password every time, because nothing in the interfac
|
||||
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
|
||||
launch, because no device key is registered.
|
||||
|
||||
### Moving files
|
||||
|
||||
**FILES** in the nav rail is a two-pane browser: this machine on the left, the host on the right, and a
|
||||
queue underneath. Choose a host, press **CONNECT**, then select a file in either pane and press the arrow
|
||||
pointing the way you want it to go.
|
||||
|
||||
Two things about it are worth expecting rather than discovering.
|
||||
|
||||
**It is a second connection, not a second channel.** SSH itself would allow the SFTP subsystem to open
|
||||
beside a shell on the transport that is already up; SSH.NET does not offer that — its `SftpClient` owns its
|
||||
own transport — so pressing CONNECT here authenticates again. The host records a second login, and a host
|
||||
whose password you type each time will ask for it again on this screen. Host key trust is shared: a
|
||||
fingerprint approved for a terminal is approved here, and one approved here reaches your other machines with
|
||||
the next sync.
|
||||
|
||||
**Nothing is written at its final name until it is complete.** Every transfer goes to a `.dodossh-part` file
|
||||
beside its destination and is renamed into place at the end, so an interrupted transfer can never be
|
||||
mistaken for a finished one — which matters most for what people actually use this for, which is copying a
|
||||
build artefact onto a server and then running it. A destination that already exists is refused outright
|
||||
rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end.
|
||||
**RESUME** on a stopped transfer carries on from what the part file already holds.
|
||||
|
||||
Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing
|
||||
records which source wrote a part file, and resuming one on the strength of its name matching is how a
|
||||
corrupt artefact gets delivered with nothing reporting a failure. A part file found at startup is started
|
||||
over.
|
||||
|
||||
What is not here: transferring a directory, dragging between the panes, and routing a transfer through a
|
||||
bastion — the last needs jump hosts the connection layer has not got. All three are in
|
||||
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
||||
|
||||
### End-to-end verification
|
||||
|
||||
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
|
||||
@@ -230,7 +262,11 @@ off-Windows.
|
||||
a key are written at version 2 and become read-only on an older build. Hosts that do not are still
|
||||
written at version 1, byte-identically to before the field existed — which is what keeps upgrading one
|
||||
machine from making a team's whole vault uneditable everywhere else.
|
||||
- **M2 — full personal vault**, robust sync, relay.
|
||||
- **M2 — full personal vault**, robust sync, relay. *File transfer done:* an SFTP session, a two-pane file
|
||||
browser with a real remote listing — names, sizes, modification times and `drwxr-xr-x` permission bits —
|
||||
and a queue that moves one file at a time with progress, throughput and resume. See
|
||||
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
||||
are consequences rather than choices.
|
||||
- **M3 — teams**, sharing, ACLs.
|
||||
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
||||
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
||||
|
||||
+36
-14
@@ -18,9 +18,13 @@ M2 and M3 arriving in a design before it arrives in the code.
|
||||
Three facts explain nearly every row below.
|
||||
|
||||
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
|
||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No SFTP subsystem, no port forwarding, no ProxyJump. That
|
||||
one fact removes the whole file-transfer screen, the `FORWARDS` chip, the status bar's port list, and every
|
||||
`via bastion-eu` in the design.
|
||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
|
||||
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
|
||||
|
||||
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
|
||||
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
|
||||
its own transport. See [File transfer](#file-transfer-the-designs-sftp-screen), which is the one place in
|
||||
this document where what shipped differs from what the row predicted.
|
||||
|
||||
**Teams are schema and nothing else.** The `team` and `team_membership` tables exist from the first
|
||||
migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or
|
||||
@@ -50,7 +54,7 @@ protocol rather than a protocol change.
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
|
||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||
| Status bar `sftp · 2 transfers` | client-ssh | File transfer. See below. | Omitted. |
|
||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||
@@ -86,18 +90,36 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
|
||||
## File transfer (the design's SFTP screen)
|
||||
|
||||
Nothing on this screen exists. It is listed in the nav rail and reaches a screen that says so, naming the
|
||||
milestone and what is missing — see `ShellScreen` for why it is not simply dropped from the rail.
|
||||
**Built in M2.** The screen ships: two directory panes, a breadcrumb trail on each, and a queue that moves
|
||||
one file at a time with progress, throughput and resume. What follows is what it does *not* do, and one
|
||||
thing this document got wrong before it was built.
|
||||
|
||||
| Design element | Layer | What it would take |
|
||||
**The correction.** The row below used to say an SFTP subsystem channel on `ISshConnection` was what it
|
||||
would take. SSH.NET does not offer that: `SftpClient` derives from `BaseClient` and owns its own transport,
|
||||
and there is no supported way to hand it a session an `SshClient` already has. So the transfers screen
|
||||
**opens a second authenticated connection** to the host rather than a second channel on the terminal's. That
|
||||
is visible to a user — the host records a second login, and a host whose password is typed each time asks
|
||||
for it again on this screen — so it is named for what it is: `ISftpSessionFactory.OpenSftpAsync` is a
|
||||
connect, and it goes through the same host key gate, the same pin and the same two refusals a shell does.
|
||||
|
||||
| Design element | Layer | What ships |
|
||||
| --- | --- | --- |
|
||||
| SFTP itself | client-ssh | An SFTP subsystem channel on `ISshConnection`. There is no `SftpClient`, `ScpClient` or transfer type anywhere in `src/`. |
|
||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | The channel, plus a listing record and a POSIX mode formatter — nothing in the repo formats a `drwxr-xr-x`. |
|
||||
| Local listing | client-app | The App project contains no `System.IO` usage at all. The only paths this client knows are its own two files. |
|
||||
| Path breadcrumbs, per-host last directory | client-storage | Navigation state for two panes, and somewhere to persist it. There is no settings table. |
|
||||
| Transfer queue, progress, throughput | client-ssh | A transfer engine. **Do not reach for `CreditWindow`** — that is a 256 KiB flow-control window for terminal output, not a transfer primitive. |
|
||||
| `resume supported` | client-ssh | Offset-based reads and writes, plus partial-transfer bookkeeping that survives a restart. |
|
||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. |
|
||||
| SFTP itself | client-ssh | `ISftpSession` over SSH.NET's `SftpClient`: listing, stat, offset-based read and write, mkdir, delete and rename. Delete is deliberately **not** recursive. |
|
||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
||||
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
||||
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
||||
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
|
||||
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
||||
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
||||
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
||||
| Transferring a directory | client-transfer | Recursive enumeration on both sides, and a policy for what a partial directory means. | One file at a time. A directory cannot be selected as a transfer source. |
|
||||
|
||||
**Not to be reached for:** `CreditWindow` is a 256 KiB flow-control window for terminal output, not a
|
||||
transfer primitive. The queue does its own 64 KiB copy loop and shares nothing with the terminal data plane.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -58,9 +58,13 @@ internal sealed partial class DodoSshApp : Application
|
||||
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
|
||||
var knownHosts = new VaultKnownHostStore();
|
||||
|
||||
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
|
||||
// and the same host key decision, and composing two would mean two snapshots of the pins.
|
||||
var connections = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var workspace = new TerminalWorkspace(
|
||||
new AvaloniaTerminalAssetProvider(),
|
||||
new SshNetConnectionFactory(knownHosts),
|
||||
connections,
|
||||
TimeProvider.System);
|
||||
|
||||
workspace.Start();
|
||||
@@ -81,7 +85,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
connections);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -52,11 +53,10 @@ internal enum ShellState
|
||||
/// in you are, the other is what you are looking at once you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
|
||||
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
|
||||
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
|
||||
/// three entries would make the eventual arrival of the other two look like a new product rather than a
|
||||
/// milestone.
|
||||
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
|
||||
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
|
||||
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
|
||||
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
@@ -64,7 +64,7 @@ internal enum ShellScreen
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer. Nothing implements it yet.</summary>
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
Transfers = 1,
|
||||
|
||||
/// <summary>Everything in the vault that is not a host.</summary>
|
||||
@@ -118,6 +118,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
|
||||
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
private IVaultServer? connection;
|
||||
private bool disposed;
|
||||
|
||||
@@ -132,6 +140,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <param name="sftpSessions">
|
||||
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
|
||||
/// application — one type implements both — and a separate parameter because it is a separate capability
|
||||
/// and the tests that drive this state machine have no use for it.
|
||||
/// </param>
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -140,6 +153,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
@@ -151,6 +165,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
|
||||
transfers = new TransfersViewModel(sftpSessions, clock);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
@@ -222,6 +238,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
|
||||
/// property that went null on lock would be a transfer nothing could report on afterwards.
|
||||
/// </remarks>
|
||||
internal TransfersViewModel Transfers => transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Shells that were left running when the vault was locked.
|
||||
/// </summary>
|
||||
@@ -900,6 +924,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts);
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
// second old.
|
||||
@@ -947,6 +975,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.Close();
|
||||
|
||||
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
|
||||
// holding references to them. What it does not give up is its connection or its queue — a transfer
|
||||
// in flight is exactly the work this method exists not to destroy.
|
||||
transfers.Detach();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
@@ -973,6 +1006,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
|
||||
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
|
||||
// that reached it.
|
||||
await transfers.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the segment is called.</param>
|
||||
/// <param name="Path">The absolute path that reaches it.</param>
|
||||
internal sealed record CrumbViewModel(string Name, string Path);
|
||||
|
||||
/// <summary>One remote file or directory, as a row.</summary>
|
||||
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
{
|
||||
internal SftpEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsNavigable;
|
||||
|
||||
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
||||
|
||||
/// <remarks>
|
||||
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
||||
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
||||
/// </remarks>
|
||||
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
|
||||
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
||||
internal string Permissions => entry.Permissions;
|
||||
}
|
||||
|
||||
/// <summary>One local file or directory, as a row.</summary>
|
||||
/// <remarks>
|
||||
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
|
||||
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
|
||||
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
|
||||
/// </remarks>
|
||||
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
||||
{
|
||||
internal LocalEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsDirectory;
|
||||
|
||||
internal bool IsFile => !entry.IsDirectory;
|
||||
|
||||
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
/// <summary>How this screen writes a modification time.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
|
||||
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
|
||||
/// not written the same way, which is the only thing anybody does with this column.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one place in this application that deliberately ignores the user's locale — see the App project's
|
||||
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
|
||||
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class Timestamps
|
||||
{
|
||||
internal static string Format(DateTimeOffset moment) =>
|
||||
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>One transfer, as a row in the queue.</summary>
|
||||
/// <remarks>
|
||||
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
|
||||
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
|
||||
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
|
||||
/// only ever read from the UI thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private TransferSnapshot transfer = snapshot;
|
||||
|
||||
internal Guid Id => Transfer.Id;
|
||||
|
||||
internal string Name => Transfer.Name;
|
||||
|
||||
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
|
||||
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
|
||||
|
||||
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
|
||||
internal string Path => Transfer.Direction is TransferDirection.Download
|
||||
? Transfer.RemotePath
|
||||
: Transfer.LocalPath;
|
||||
|
||||
internal double Percent => Transfer.Fraction * 100;
|
||||
|
||||
/// <summary>
|
||||
/// What the row says about where it has got to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
|
||||
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
|
||||
/// whether it is still moving.
|
||||
/// </remarks>
|
||||
internal string Progress => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "queued",
|
||||
TransferState.Running =>
|
||||
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
|
||||
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
|
||||
TransferState.Completed => ByteSize.Format(Transfer.Length),
|
||||
TransferState.Cancelled when Transfer.Transferred > 0 =>
|
||||
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
|
||||
TransferState.Cancelled => "stopped",
|
||||
_ => Transfer.Failure ?? "failed",
|
||||
};
|
||||
|
||||
internal string StateLabel => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "QUEUED",
|
||||
TransferState.Running => "RUNNING",
|
||||
TransferState.Completed => "DONE",
|
||||
TransferState.Cancelled => "STOPPED",
|
||||
_ => "FAILED",
|
||||
};
|
||||
|
||||
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
|
||||
|
||||
internal bool IsFinished => Transfer.IsFinished;
|
||||
|
||||
internal bool CanResume => Transfer.CanResume;
|
||||
|
||||
internal bool HasFailed => Transfer.State is TransferState.Failed;
|
||||
|
||||
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
|
||||
/// <remarks>
|
||||
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
|
||||
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
|
||||
/// wording differs. See <see cref="RetryLabel"/>.
|
||||
/// </remarks>
|
||||
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
|
||||
|
||||
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
|
||||
|
||||
/// <remarks>
|
||||
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
|
||||
/// raising only the ones that happened to change is how a progress bar moves under a label that still
|
||||
/// says "queued".
|
||||
/// </remarks>
|
||||
partial void OnTransferChanged(TransferSnapshot value)
|
||||
{
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
OnPropertyChanged(nameof(Percent));
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsFinished));
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
OnPropertyChanged(nameof(CanRetry));
|
||||
OnPropertyChanged(nameof(HasFailed));
|
||||
OnPropertyChanged(nameof(RetryLabel));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
|
||||
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
|
||||
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
|
||||
/// The consequence a user sees is that the host records a second login, and that a host whose password is
|
||||
/// typed each time asks for it again here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
|
||||
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
|
||||
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
|
||||
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
|
||||
/// host list — those are decrypted vault items — and not the connection or the queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private readonly ISftpSessionFactory sftp;
|
||||
private readonly FileTransferQueue queue;
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
{
|
||||
this.sftp = sftp;
|
||||
|
||||
// The supplier answers with whatever session is current at the moment a transfer starts, which is
|
||||
// what lets a queue survive a disconnect and reconnect without every queued row failing.
|
||||
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
|
||||
queue.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
|
||||
// which is the life of the process.
|
||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
||||
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
|
||||
}
|
||||
|
||||
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
|
||||
/// <remarks>
|
||||
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
|
||||
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
||||
/// be a second decrypted copy of a secret for no gain.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <remarks>
|
||||
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
||||
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
||||
/// screen that quietly reused it would make a one-time password appear to work twice.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string typedPassword = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = "Choose a host and connect to browse its files.";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
|
||||
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string remotePath = string.Empty;
|
||||
|
||||
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
||||
|
||||
/// <summary>What a new directory would be called, when the user is making one.</summary>
|
||||
[ObservableProperty]
|
||||
private string newRemoteFolder = string.Empty;
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string localPath = LocalDirectory.Home;
|
||||
|
||||
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The drives this machine has, as somewhere the local pane can jump to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
|
||||
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
|
||||
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
|
||||
/// be unreachable from an application whose whole purpose on this screen is to move one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private LocalEntryRowViewModel? selectedLocalEntry;
|
||||
|
||||
internal bool HasLocalEntries => LocalEntries.Count > 0;
|
||||
|
||||
// ---- The queue ----
|
||||
|
||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||
|
||||
internal bool HasTransfers => Transfers.Count > 0;
|
||||
|
||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
|
||||
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
||||
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
// Read once per unlock rather than per navigation: a drive appearing while the application is open
|
||||
// is possible and rare, and probing every removable drive on every click into a folder is not.
|
||||
LocalRoots.Clear();
|
||||
|
||||
foreach (var root in LocalDirectory.Roots())
|
||||
{
|
||||
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
|
||||
}
|
||||
|
||||
RefreshLocalCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives up the vault, keeping the connection and anything in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
|
||||
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
|
||||
/// </remarks>
|
||||
internal void Detach()
|
||||
{
|
||||
vault = null;
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
SelectedHost = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
Status = "Choose a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Connecting to {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
try
|
||||
{
|
||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact, decided here rather than inherited from a terminal. File transfer is
|
||||
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
||||
// same pin a shell would then find.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
return;
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. Nothing was connected.";
|
||||
return;
|
||||
}
|
||||
|
||||
TypedPassword = string.Empty;
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the file-transfer session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
|
||||
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
|
||||
/// accident.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (queue.IsBusy)
|
||||
{
|
||||
Status = "There are transfers still running. Stop them first, or let them finish.";
|
||||
return;
|
||||
}
|
||||
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
Status = "Disconnected. Anything already transferred is where it landed.";
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered host key and connects.</summary>
|
||||
[RelayCommand]
|
||||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingHostKey is not { } presentation || knownHosts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
|
||||
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
|
||||
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
|
||||
/// with two clicks instead of one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
var wasOffered = PendingHostKey is not null;
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
Status = wasOffered
|
||||
? "The host key was not trusted, so nothing was connected."
|
||||
: "Nothing was connected.";
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
/// <summary>Goes to a remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Goes up one remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoteUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
|
||||
/// directory reports as a link and resolving every one of them would be a round trip per row. The
|
||||
/// failure, when it is a link to a file, is the server's own and says so.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is { IsNavigable: true } row)
|
||||
{
|
||||
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Goes to a local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void GoLocal(string path) => NavigateLocal(path);
|
||||
|
||||
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
|
||||
/// <remarks>
|
||||
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
|
||||
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void LocalUp()
|
||||
{
|
||||
if (LocalDirectory.Parent(LocalPath) is { } parent)
|
||||
{
|
||||
NavigateLocal(parent);
|
||||
return;
|
||||
}
|
||||
|
||||
Status = "That is the top of this drive. Use the drive list to go to another one.";
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void RefreshLocal() => NavigateLocal(LocalPath);
|
||||
|
||||
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenLocal()
|
||||
{
|
||||
if (SelectedLocalEntry is { IsNavigable: true } row)
|
||||
{
|
||||
NavigateLocal(row.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Moving files ----
|
||||
|
||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } row)
|
||||
{
|
||||
Status = "Choose a file on the host to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(LocalPath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for download into {LocalPath}.";
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Upload()
|
||||
{
|
||||
if (SelectedLocalEntry is not { IsFile: true } row)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
|
||||
|
||||
/// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
|
||||
[RelayCommand]
|
||||
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
|
||||
|
||||
/// <summary>Removes one stopped transfer and whatever it left behind.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
|
||||
// removed anyway would take the only view of a transfer that was still running.
|
||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearCompleted()
|
||||
{
|
||||
queue.ClearCompleted();
|
||||
|
||||
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---- Changing the remote directory ----
|
||||
|
||||
/// <summary>Creates a directory on the host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var name = NewRemoteFolder.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
Status = "Type a name for the new directory.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
// One directory, whose parent must exist. A name with a separator in it would be a request to
|
||||
// create a path, and this button creates a directory in the one on screen.
|
||||
Status = "A directory name cannot contain '/'. Make one level at a time.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Creating {name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession()
|
||||
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
NewRemoteFolder = string.Empty;
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created {name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose something on the host to delete.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
queue.Changed -= OnTransferChanged;
|
||||
|
||||
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
|
||||
// unwinding before the transport under it goes.
|
||||
await queue.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes to a remote directory, on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
|
||||
/// something else — connecting, making a directory, deleting one — can list without going back through
|
||||
/// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
|
||||
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
|
||||
/// anywhere to explain it.
|
||||
/// </remarks>
|
||||
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
RemotePath = path;
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
RemoteEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RemoteTrail.Clear();
|
||||
|
||||
foreach (var (name, crumb) in SftpPath.Trail(path))
|
||||
{
|
||||
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
||||
}
|
||||
|
||||
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
||||
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
||||
/// something that returns before the click has finished being handled.
|
||||
/// </remarks>
|
||||
private void NavigateLocal(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = LocalDirectory.List(path);
|
||||
|
||||
LocalPath = Path.GetFullPath(path);
|
||||
SelectedLocalEntry = null;
|
||||
|
||||
LocalEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
LocalEntries.Add(new LocalEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RebuildLocalTrail();
|
||||
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
|
||||
// pane would look like a directory that had become empty.
|
||||
Status = $"{path} could not be read: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
|
||||
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
|
||||
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
|
||||
/// </remarks>
|
||||
private void RebuildLocalTrail()
|
||||
{
|
||||
var crumbs = new List<CrumbViewModel>();
|
||||
|
||||
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
|
||||
{
|
||||
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
|
||||
}
|
||||
|
||||
LocalTrail.Clear();
|
||||
|
||||
foreach (var crumb in crumbs)
|
||||
{
|
||||
LocalTrail.Add(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshHosts()
|
||||
{
|
||||
Hosts.Clear();
|
||||
|
||||
if (vault is not { } open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var host in open.Hosts)
|
||||
{
|
||||
Hosts.Add(host);
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
private async Task CloseSessionAsync()
|
||||
{
|
||||
if (session is { } open)
|
||||
{
|
||||
session = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
|
||||
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
|
||||
/// instead of one for the first change and one for the rest.
|
||||
/// </remarks>
|
||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||
{
|
||||
existing.Transfer = e.Transfer;
|
||||
return;
|
||||
}
|
||||
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||
/// pane permanently disabled.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
Status = busyMessage;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Cancelled.";
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
|
||||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||
}
|
||||
@@ -1817,7 +1817,7 @@ internal sealed partial class VaultViewModel(
|
||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||
// thing and got another, and the host is the last place that would say so.
|
||||
if (!TryBuildAuthentication(row.Host, out var authentication, out var refusal))
|
||||
if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -2087,8 +2087,38 @@ internal sealed partial class VaultViewModel(
|
||||
/// answered by the more specific of the two rather than by whichever the code happened to check.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Works out how to reach a host, or says why it cannot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same resolution the Connect button performs, exposed because file transfer opens its own
|
||||
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
|
||||
/// username" would be a second place for a dangling binding to be silently turned back into a typed
|
||||
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
|
||||
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
|
||||
/// password offered on the other.
|
||||
/// </remarks>
|
||||
internal bool TryBuildConnectionRequest(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
|
||||
{
|
||||
request = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new SshConnectionRequest(
|
||||
host.Hostname, host.Port, authentication.Username, authentication.Credential);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryBuildAuthentication(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -2133,7 +2163,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
return Complete(
|
||||
host.Username, new SshPasswordCredential(ConnectPassword), out authentication, out reason);
|
||||
host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -184,20 +184,15 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
|
||||
Title="FILE TRANSFER"
|
||||
Milestone="MILESTONE M2"
|
||||
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
|
||||
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
|
||||
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
|
||||
Two of them — TRANSFERS and TEAM — reach screens that say they are not built. They are in the rail
|
||||
anyway rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screens
|
||||
behind these two say plainly what is missing, and a rail that quietly had three entries would make file
|
||||
transfer and sharing look like a change of product rather than the next two milestones. Both are
|
||||
recorded in docs/design-import-gaps.md.
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
plainly what is missing, and a rail that quietly had four entries would make sharing look like a change
|
||||
of product rather than the next milestone. It is recorded in docs/design-import-gaps.md. FILES was the
|
||||
other one until M2 built it.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
@@ -30,7 +30,7 @@
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="File transfer over SSH. Not built yet — see the screen for what is missing." />
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TransfersScreen"
|
||||
x:DataType="vm:TransfersViewModel">
|
||||
|
||||
<!--
|
||||
File transfer: this machine on the left, the host on the right, and the queue underneath.
|
||||
|
||||
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
|
||||
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
|
||||
so browsing a host's files is a second authenticated connection rather than a second channel, and
|
||||
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
|
||||
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
|
||||
about a file on the machine this client is developed on.
|
||||
|
||||
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
|
||||
layer has not got. See docs/design-import-gaps.md.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="5" Classes="hint" Text="{Binding Status}" FontSize="10.5"
|
||||
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ The two panes ============ -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="ghost" Content="{Binding Name}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
<TextBlock Classes="mono" Text="›" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="3" Classes="hint" FontSize="11" Margin="24" MaxWidth="260"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
|
||||
<!--
|
||||
Pointing at the pane the file is going to, which is the only reading that survives the panes
|
||||
being side by side: the left-hand pane is this machine, so an upload points right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
|
||||
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
|
||||
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
|
||||
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Classes="mono" Text="/" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||||
"that name is already taken" is a dead end.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasRemoteEntries}">
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
|
||||
IsVisible="{Binding !IsConnected}" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Nothing in this directory."
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The queue ============ -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="10.5" Margin="12,4,12,14"
|
||||
IsVisible="{Binding !HasTransfers}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="11"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10.5"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="9.5"
|
||||
Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding StateLabel}" />
|
||||
</Border>
|
||||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
||||
IsVisible="{Binding HasTransfers}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||||
trusted for this too, but a host nobody has connected to at all is met here first.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasPendingHostKey}">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
|
||||
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
|
||||
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, and because
|
||||
// opening a directory has to be reachable without the mouse as well: both lists are ListBoxes, so
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
}
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, which is what a double-click on a directory can be: the command reports its own
|
||||
/// failures onto the screen's status line, and awaiting it here would mean an event handler that returns
|
||||
/// a task nothing observes — the same thing with a warning suppressed.
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,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,357 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>What kind of thing a remote directory entry is.</summary>
|
||||
/// <remarks>
|
||||
/// Taken from the attributes a listing already carries, which are <c>lstat</c> attributes: a symbolic link
|
||||
/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
|
||||
/// <see cref="SftpEntryKind.SymbolicLink"/> stays its own answer and the caller finds out what it leads to by
|
||||
/// trying to list it — see <see cref="ISftpSession.ListAsync"/>.
|
||||
/// </remarks>
|
||||
public enum SftpEntryKind
|
||||
{
|
||||
/// <summary>An ordinary file.</summary>
|
||||
File = 0,
|
||||
|
||||
/// <summary>A directory.</summary>
|
||||
Directory = 1,
|
||||
|
||||
/// <summary>A symbolic link, to something this listing did not resolve.</summary>
|
||||
SymbolicLink = 2,
|
||||
|
||||
/// <summary>A socket, device, pipe or anything else that is not one of the three above.</summary>
|
||||
Other = 3,
|
||||
}
|
||||
|
||||
/// <summary>One entry in a remote directory.</summary>
|
||||
/// <param name="Name">The entry's own name, with no path.</param>
|
||||
/// <param name="FullPath">The absolute path, which is what every operation takes.</param>
|
||||
/// <param name="Kind">What it is.</param>
|
||||
/// <param name="Length">Size in bytes. Meaningless for anything that is not a file, and zero there.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <param name="Permissions">The mode as <c>drwxr-xr-x</c>; see <see cref="PosixMode"/>.</param>
|
||||
/// <remarks>
|
||||
/// A record of what the server said rather than a handle. Nothing here holds a channel open, so a listing can
|
||||
/// be kept on a screen after the session behind it has gone — which is what the transfers screen does while a
|
||||
/// connection is being re-established.
|
||||
/// </remarks>
|
||||
public sealed record SftpEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
SftpEntryKind Kind,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc,
|
||||
string Permissions)
|
||||
{
|
||||
/// <summary>Whether this is somewhere the file browser can navigate into.</summary>
|
||||
/// <remarks>
|
||||
/// True for a symbolic link as well as a directory, because a link to a directory is the ordinary way a
|
||||
/// remote filesystem is laid out and refusing to open one would make those paths unreachable. A link to a
|
||||
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
||||
/// </remarks>
|
||||
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders POSIX permission bits the way <c>ls -l</c> does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing in this repository formatted a mode before, and the design's file listing has a <c>PERMS</c>
|
||||
/// column. It is written from the individual bits rather than from an octal mode because that is the shape
|
||||
/// SFTP hands over — <c>ISftpFile</c> exposes nine booleans and a set of kind predicates, and reassembling
|
||||
/// them into an octal number only to take it apart again would be a round trip through a representation
|
||||
/// neither end uses.
|
||||
/// <para>
|
||||
/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
|
||||
/// surface them on <c>ISftpFile</c>, and a column that showed <c>rwx</c> where <c>rws</c> was true would be
|
||||
/// worse than one that never claims to render them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class PosixMode
|
||||
{
|
||||
/// <summary>Formats one entry's mode, kind character included.</summary>
|
||||
public static string Format(
|
||||
SftpEntryKind kind,
|
||||
bool ownerRead,
|
||||
bool ownerWrite,
|
||||
bool ownerExecute,
|
||||
bool groupRead,
|
||||
bool groupWrite,
|
||||
bool groupExecute,
|
||||
bool othersRead,
|
||||
bool othersWrite,
|
||||
bool othersExecute)
|
||||
{
|
||||
Span<char> mode = stackalloc char[10];
|
||||
|
||||
mode[0] = kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 'd',
|
||||
SftpEntryKind.SymbolicLink => 'l',
|
||||
SftpEntryKind.File => '-',
|
||||
_ => '?',
|
||||
};
|
||||
|
||||
Write(mode[1..4], ownerRead, ownerWrite, ownerExecute);
|
||||
Write(mode[4..7], groupRead, groupWrite, groupExecute);
|
||||
Write(mode[7..10], othersRead, othersWrite, othersExecute);
|
||||
|
||||
return new string(mode);
|
||||
|
||||
static void Write(Span<char> triple, bool read, bool write, bool execute)
|
||||
{
|
||||
triple[0] = read ? 'r' : '-';
|
||||
triple[1] = write ? 'w' : '-';
|
||||
triple[2] = execute ? 'x' : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote paths, which are POSIX paths whatever this client is running on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not <see cref="Path"/>.</b> The BCL's path helpers use the local platform's separator, so on Windows
|
||||
/// <c>Path.Combine("/var", "log")</c> yields <c>/var\log</c> — a path the remote will not resolve and which
|
||||
/// fails as "no such file" somewhere the user cannot see the backslash. Every remote path in this codebase
|
||||
/// goes through here.
|
||||
/// </remarks>
|
||||
public static class SftpPath
|
||||
{
|
||||
/// <summary>The root of a remote filesystem.</summary>
|
||||
public const string Root = "/";
|
||||
|
||||
/// <summary>Joins a directory and a name.</summary>
|
||||
public static string Combine(string directory, string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||
|
||||
return directory.EndsWith('/') ? directory + name : directory + "/" + name;
|
||||
}
|
||||
|
||||
/// <summary>The directory holding a path, or the path itself when it is already the root.</summary>
|
||||
public static string Parent(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
|
||||
return slash switch
|
||||
{
|
||||
< 0 => Root,
|
||||
0 => Root,
|
||||
_ => trimmed[..slash],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The last segment of a path, or the root when that is all there is.</summary>
|
||||
/// <remarks>
|
||||
/// The root names itself. Taking the text after its only separator leaves an empty string, which as a
|
||||
/// heading or a tab label is a blank where a path should be.
|
||||
/// </remarks>
|
||||
public static string Name(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
var name = slash < 0 ? trimmed : trimmed[(slash + 1)..];
|
||||
|
||||
return name.Length == 0 ? Root : name;
|
||||
}
|
||||
|
||||
/// <summary>Whether a path is already anchored at the root.</summary>
|
||||
public static bool IsAbsolute(string path) => path.StartsWith('/');
|
||||
|
||||
/// <summary>
|
||||
/// The segments of a path, for a breadcrumb trail.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Each segment paired with the absolute path that reaches it, root first. An empty list for the root
|
||||
/// itself, which has no segment to name.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<(string Name, string Path)> Trail(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trail = new List<(string, string)>();
|
||||
var walked = string.Empty;
|
||||
|
||||
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
walked = Combine(walked.Length == 0 ? Root : walked, segment);
|
||||
trail.Add((segment, walked));
|
||||
}
|
||||
|
||||
return trail;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file-transfer session on one host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a connection, not a channel.</b> The obvious shape would have been
|
||||
/// <c>ISshConnection.OpenSftpAsync</c>, opening the SFTP subsystem beside the shell on the transport that is
|
||||
/// already up — which is what SSH itself allows and what <c>docs/design-import-gaps.md</c> assumed it would
|
||||
/// take. SSH.NET does not offer it: <c>SftpClient</c> derives from <c>BaseClient</c> and owns its own
|
||||
/// transport, and there is no supported way to hand it an existing <c>SshClient</c>'s session. So opening one
|
||||
/// of these authenticates again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is named for that rather than dressed up as a channel, because the difference is visible to a user: the
|
||||
/// host sees a second login, a one-time password would be asked for twice, and closing every terminal on a
|
||||
/// host does not close its file browser. <see cref="ISshConnectionFactory.OpenSftpAsync"/> is a
|
||||
/// <em>connect</em>, and host key trust is checked on it exactly as it is for a shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One session is one channel, and everything on it shares that channel's window — so the queue that drives
|
||||
/// this runs one transfer at a time. That is a throughput decision rather than a safety one: two large
|
||||
/// transfers over one channel do not go faster than one, they arrive later and both at once. Browsing while a
|
||||
/// transfer runs is fine, and is the point of not opening a session per transfer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISftpSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Whether the transport is still up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the session starts, which is the account's home directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Absolute, because the server resolves it during the handshake. It is the only path this layer knows
|
||||
/// without asking, and it is what a file browser should open on.
|
||||
/// </remarks>
|
||||
string HomeDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>.</c> and <c>..</c> are dropped: every remote directory has them, no user is choosing between them,
|
||||
/// and the way up is a breadcrumb rather than a row. Ordered directories first and then by name, which is
|
||||
/// what a file browser has to show and what saves every caller sorting it again.
|
||||
/// </remarks>
|
||||
/// <exception cref="SftpPathException">The path is not a directory, or is not readable.</exception>
|
||||
Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>What one path is, or null when nothing is there.</summary>
|
||||
Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for reading, starting at an offset.
|
||||
/// </summary>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted download resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for writing, starting at an offset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates the file when it is not there, and does not truncate one that is — an offset of zero over an
|
||||
/// existing file overwrites from the beginning and leaves any tail beyond what is written. Callers are
|
||||
/// expected to write to a path nothing else holds; the transfer queue writes to a part file for exactly
|
||||
/// this reason.
|
||||
/// </remarks>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted upload resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not recursive. A recursive remote delete is the one operation on this screen that can
|
||||
/// destroy something no undo reaches, and offering it behind the same button as deleting one file is how
|
||||
/// that happens by accident. A non-empty directory fails, and says so.
|
||||
/// </remarks>
|
||||
Task DeleteAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Renames or moves a path within the same host.</summary>
|
||||
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the remote filesystem could not be reached, and the server said why.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One exception for the whole surface rather than one per operation, because there is exactly one thing a
|
||||
/// caller does with any of them: show the message beside the path it was about. SSH.NET raises several
|
||||
/// unrelated types for what a user experiences as one condition — <c>SftpPathNotFoundException</c>,
|
||||
/// <c>SftpPermissionDeniedException</c>, and a bare <c>SshException</c> for the rest — and the path is not on
|
||||
/// all of them.
|
||||
/// </remarks>
|
||||
public sealed class SftpPathException(string path, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
/// <summary>The path the failure was about.</summary>
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
|
||||
/// <summary>Opens file-transfer sessions.</summary>
|
||||
/// <remarks>
|
||||
/// Declared beside <see cref="ISshConnectionFactory"/> and implemented by the same type, because both start
|
||||
/// with the same handshake and the same host key decision. See <see cref="ISftpSession"/> for why this is a
|
||||
/// separate connect rather than a channel on a connection that already exists.
|
||||
/// </remarks>
|
||||
public interface ISftpSessionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects, authenticates, and starts the SFTP subsystem.
|
||||
/// </summary>
|
||||
/// <exception cref="SshHostKeyUnknownException">
|
||||
/// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
|
||||
/// explicit confirmation, and retry.
|
||||
/// </exception>
|
||||
/// <exception cref="SshHostKeyMismatchException">
|
||||
/// The presented key differs from the pin. There is no retry path.
|
||||
/// </exception>
|
||||
Task<ISftpSession> OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Formats a byte count the way a file browser shows one.</summary>
|
||||
/// <remarks>
|
||||
/// Here rather than in the view model because the transfer queue's own progress reporting needs the same
|
||||
/// wording, and two spellings of "1.4 MB" in one window reads as two different measurements.
|
||||
/// </remarks>
|
||||
public static class ByteSize
|
||||
{
|
||||
private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
/// <summary>Formats a byte count to three significant figures.</summary>
|
||||
public static string Format(long bytes)
|
||||
{
|
||||
if (bytes < 1024)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{bytes} B");
|
||||
}
|
||||
|
||||
double scaled = bytes;
|
||||
var unit = 0;
|
||||
|
||||
while (scaled >= 1024 && unit < Units.Length - 1)
|
||||
{
|
||||
scaled /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
// One decimal below ten, none above: "9.4 MB" and "512 MB" are both three characters of information,
|
||||
// and "512.3 MB" is a precision the number does not have by the time it is that large.
|
||||
return scaled < 10
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{scaled:0.0} {Units[unit]}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{scaled:0} {Units[unit]}");
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,22 @@ namespace DodoSSH.Client.Ssh;
|
||||
/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
|
||||
/// exception the caller resolves asynchronously.
|
||||
/// </remarks>
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
: ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// How much of a file SSH.NET reads or writes per SFTP request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SSH.NET's default is 32 KiB, which is a request per 32 KiB and a round trip's latency between each on
|
||||
/// a link the window would happily keep full. 64 KiB is the largest an OpenSSH server accepts without
|
||||
/// negotiation, so it is the ceiling rather than a guess — anything above it is answered with a shorter
|
||||
/// read, which SSH.NET handles but which buys nothing.
|
||||
/// </remarks>
|
||||
private const uint SftpBufferSize = 64 * 1024;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -25,6 +37,62 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SshClient(BuildConnectionInfo(request));
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A second connection to the host rather than a second channel on one that may already be open — see
|
||||
/// <see cref="ISftpSession"/> for why SSH.NET leaves no choice. Everything that guards a shell guards this
|
||||
/// too, because it is the same handshake: the same host key gate, the same pin, the same two refusals.
|
||||
/// </remarks>
|
||||
public async Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
// like a field — and the session's own remark promises this is the one path known without asking.
|
||||
string home;
|
||||
|
||||
try
|
||||
{
|
||||
home = client.WorkingDirectory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetSftpSession(client, gate.Presented!, home);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the shell and the file-transfer paths over <c>BaseClient</c>, which is where SSH.NET puts
|
||||
/// both <c>ConnectAsync</c> and <c>HostKeyReceived</c>. The alternative was the same twelve lines twice,
|
||||
/// and the half worth getting wrong is the translation: without it a user who has never seen a host is
|
||||
/// told the connection was lost.
|
||||
/// </remarks>
|
||||
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||
BaseClient client,
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
@@ -47,7 +115,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
using Renci.SshNet.Sftp;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>An SSH.NET-backed file-transfer session.</summary>
|
||||
/// <remarks>
|
||||
/// Thin on purpose. Everything above this reasons about <see cref="SftpEntry"/> and <see cref="Stream"/>,
|
||||
/// which is what keeps the transfer queue and the file browser testable without a server — and the one thing
|
||||
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
||||
/// caller can act on.
|
||||
/// </remarks>
|
||||
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
: ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => client.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<SftpEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var file in client
|
||||
.ListDirectoryAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// Every directory has these and no user is choosing between them. The way up is the
|
||||
// breadcrumb trail, which cannot be mistaken for a file.
|
||||
if (file.Name is "." or "..")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(Describe(file));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
// Directories first and then by name, which is the order a file browser shows and the order that
|
||||
// saves every caller sorting it again. Ordinal, because a remote filesystem's names are bytes the
|
||||
// server never claimed a culture for, and a listing whose order depended on this machine's locale
|
||||
// would put the same directory in two orders on two of a user's machines.
|
||||
entries.Sort(static (left, right) => left.Kind == right.Kind
|
||||
? string.CompareOrdinal(left.Name, right.Name)
|
||||
: Rank(left.Kind).CompareTo(Rank(right.Kind)));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Describe(file);
|
||||
}
|
||||
catch (SftpPathNotFoundException)
|
||||
{
|
||||
// Absent is an answer rather than a failure. Every caller here is asking whether something is
|
||||
// already there, and turning "no" into an exception would put a try/catch at each of them.
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// One call for both kinds: SSH.NET stats the path and issues rmdir or remove accordingly. A
|
||||
// non-empty directory fails here, which is the refusal this interface promises.
|
||||
await client.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.RenameFileAsync(fromPath, toPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
// Named for the destination, which is what the caller chose and what a collision is about. The
|
||||
// source is a path the transfer queue made up.
|
||||
throw Translate(toPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
client.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Seeking after the open rather than asking for an appending mode. <c>FileMode.Append</c> would give the
|
||||
/// right position for an upload and nothing for a download, and a resume has to be able to start at an
|
||||
/// offset that is <em>not</em> the end — a part file whose tail was written by an interrupted transfer is
|
||||
/// exactly that case, and the queue truncates to a known-good length before resuming.
|
||||
/// </remarks>
|
||||
private async Task<Stream> OpenAsync(
|
||||
string path,
|
||||
FileMode mode,
|
||||
FileAccess access,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(offset);
|
||||
|
||||
SftpFileStream stream;
|
||||
|
||||
try
|
||||
{
|
||||
stream = await client.OpenAsync(path, mode, access, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
if (offset == 0)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
stream.Seek(offset, SeekOrigin.Begin);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>Directories first, then links, then everything else.</summary>
|
||||
private static int Rank(SftpEntryKind kind) => kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 0,
|
||||
SftpEntryKind.SymbolicLink => 1,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
private static SftpEntry Describe(ISftpFile file)
|
||||
{
|
||||
// Checked in this order because the predicates are not exclusive: a symbolic link to a directory
|
||||
// answers true to both, and calling that a directory would hide the fact that opening it depends on
|
||||
// the server resolving a link. Asking about the link first is the honest reading of an lstat.
|
||||
var kind = file switch
|
||||
{
|
||||
{ IsSymbolicLink: true } => SftpEntryKind.SymbolicLink,
|
||||
{ IsDirectory: true } => SftpEntryKind.Directory,
|
||||
{ IsRegularFile: true } => SftpEntryKind.File,
|
||||
_ => SftpEntryKind.Other,
|
||||
};
|
||||
|
||||
return new SftpEntry(
|
||||
file.Name,
|
||||
file.FullName,
|
||||
kind,
|
||||
|
||||
// Only a file's length means anything. A directory's is the size of its own inode, which is a
|
||||
// number no user has ever wanted in a SIZE column.
|
||||
kind is SftpEntryKind.File ? file.Length : 0,
|
||||
new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
PosixMode.Format(
|
||||
kind,
|
||||
file.OwnerCanRead,
|
||||
file.OwnerCanWrite,
|
||||
file.OwnerCanExecute,
|
||||
file.GroupCanRead,
|
||||
file.GroupCanWrite,
|
||||
file.GroupCanExecute,
|
||||
file.OthersCanRead,
|
||||
file.OthersCanWrite,
|
||||
file.OthersCanExecute));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="SshException"/> covers the SFTP-specific types as well, since both derive from it. It is
|
||||
/// deliberately wide: the SFTP protocol returns a status code and a server-supplied message for
|
||||
/// everything from a missing file to a full disk, and SSH.NET surfaces most of them as a bare
|
||||
/// <see cref="SshException"/> carrying that message. What must <em>not</em> be caught is cancellation and
|
||||
/// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
|
||||
/// </remarks>
|
||||
private static bool IsPathFailure(Exception exception) =>
|
||||
exception is SshException or IOException or UnauthorizedAccessException;
|
||||
|
||||
private static SftpPathException Translate(string path, Exception exception) => exception switch
|
||||
{
|
||||
SftpPathNotFoundException => new SftpPathException(path, $"{path} is not there.", exception),
|
||||
|
||||
SftpPermissionDeniedException => new SftpPathException(
|
||||
path, $"The server refused access to {path}.", exception),
|
||||
|
||||
// The server's own words. They are the only description of a full disk, a quota or a read-only mount
|
||||
// that this client could produce, and inventing a friendlier sentence would lose them.
|
||||
_ => new SftpPathException(path, $"{path}: {exception.Message}", exception),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The transfer engine: a queue, one transfer at a time, and the local half of a two-pane file
|
||||
browser. Its own project rather than more of DodoSSH.Client.Ssh, because the two answer
|
||||
different questions — that one is about reaching a host, this one is about moving bytes and
|
||||
what to do when moving them stops halfway — and because this is the only client project that
|
||||
deliberately touches the local filesystem.
|
||||
|
||||
Avalonia-free, like every client project but App. The queue is driven by tests against a real
|
||||
temporary directory and a fake SFTP session, with no UI thread anywhere.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Transfer.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,870 @@
|
||||
using System.Buffers;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>Which way the bytes are going.</summary>
|
||||
public enum TransferDirection
|
||||
{
|
||||
/// <summary>From the host to this machine.</summary>
|
||||
Download = 0,
|
||||
|
||||
/// <summary>From this machine to the host.</summary>
|
||||
Upload = 1,
|
||||
}
|
||||
|
||||
/// <summary>Where one transfer has got to.</summary>
|
||||
public enum TransferState
|
||||
{
|
||||
/// <summary>Waiting for the one in front of it.</summary>
|
||||
Queued = 0,
|
||||
|
||||
/// <summary>Moving bytes.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Finished, and the file is at its final name.</summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>Stopped by a failure, which <see cref="TransferSnapshot.Failure"/> describes.</summary>
|
||||
Failed = 3,
|
||||
|
||||
/// <summary>Stopped because the user asked.</summary>
|
||||
Cancelled = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One transfer, as the queue last saw it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A snapshot rather than a live object, because the queue mutates its entries from whichever thread the
|
||||
/// pump is on and the interface reads them from the UI thread. Handing out an immutable record per change is
|
||||
/// what lets the view model be a plain list of values with no locking of its own.
|
||||
/// </remarks>
|
||||
/// <param name="Id">Identifies the transfer for <see cref="FileTransferQueue.Cancel"/> and the rest.</param>
|
||||
/// <param name="Direction">Which way it is going.</param>
|
||||
/// <param name="Name">The file's own name, which is what a queue row is headed with.</param>
|
||||
/// <param name="LocalPath">The local end, whichever end that is.</param>
|
||||
/// <param name="RemotePath">The remote end.</param>
|
||||
/// <param name="Length">
|
||||
/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
|
||||
/// </param>
|
||||
/// <param name="Transferred">How many have moved, resumed bytes included.</param>
|
||||
/// <param name="State">Where it has got to.</param>
|
||||
/// <param name="BytesPerSecond">Recent throughput, or zero when nothing is moving.</param>
|
||||
/// <param name="Failure">Why it stopped, when it stopped badly.</param>
|
||||
public sealed record TransferSnapshot(
|
||||
Guid Id,
|
||||
TransferDirection Direction,
|
||||
string Name,
|
||||
string LocalPath,
|
||||
string RemotePath,
|
||||
long Length,
|
||||
long Transferred,
|
||||
TransferState State,
|
||||
double BytesPerSecond,
|
||||
string? Failure)
|
||||
{
|
||||
/// <summary>How far along, between 0 and 1.</summary>
|
||||
/// <remarks>
|
||||
/// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
|
||||
/// row says <see cref="TransferState.Completed"/>; a progress bar that filled instead would be the one
|
||||
/// case where the bar told a different story from the state beside it.
|
||||
/// </remarks>
|
||||
public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);
|
||||
|
||||
/// <summary>Whether this transfer will not move again on its own.</summary>
|
||||
public bool IsFinished =>
|
||||
State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a partial file to carry on from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only for a transfer this queue itself left partial. See <see cref="FileTransferQueue.Retry"/> for why
|
||||
/// a partial file found lying about is not resumed.
|
||||
/// </remarks>
|
||||
public bool CanResume =>
|
||||
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
||||
}
|
||||
|
||||
/// <summary>A transfer whose state or progress has moved.</summary>
|
||||
/// <param name="transfer">The transfer, as it now is.</param>
|
||||
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
||||
{
|
||||
/// <summary>The transfer.</summary>
|
||||
public TransferSnapshot Transfer { get; } = transfer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfer queue: one file at a time, resumable, over one SFTP session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One at a time.</b> Everything here shares one channel's window, so a second concurrent transfer does
|
||||
/// not make the pair finish sooner — it makes both finish later, and it makes the progress of each
|
||||
/// unreadable. A serial queue also means the throughput figure on a row is the throughput of the link, which
|
||||
/// is the only reading of that number anybody acts on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing is written at its final name until it is complete.</b> Every transfer goes to a part file
|
||||
/// beside its destination and is renamed into place at the end, so an interrupted transfer can never be
|
||||
/// mistaken for a finished one — which matters most for the thing people actually do with this screen, which
|
||||
/// is copy a build artefact onto a server and then run it. A destination that already exists is refused
|
||||
/// outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody
|
||||
/// else's process is serving is the worse of the two failures.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resume is within a run of the application.</b> A part file left by an interrupted transfer is carried
|
||||
/// on from by <see cref="Retry"/>, which knows the source it came from. A part file found at startup is not:
|
||||
/// nothing here records what wrote it, and resuming a file from the middle on the strength of its name
|
||||
/// matching is how a corrupted artefact gets delivered without anything reporting a failure. Making that
|
||||
/// survive a restart needs somewhere to write the bookkeeping, which this client does not yet have — see
|
||||
/// <c>docs/design-import-gaps.md</c> on the missing preferences store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FileTransferQueue : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What an in-flight transfer's destination is called until it is finished.</summary>
|
||||
/// <remarks>
|
||||
/// Long and specific rather than the conventional <c>.part</c>, because this file appears in a directory
|
||||
/// somebody else may be looking at — on a shared server, in a deployment directory — and a name that
|
||||
/// says which program left it there is the difference between a question and an incident.
|
||||
/// </remarks>
|
||||
internal const string PartSuffix = ".dodossh-part";
|
||||
|
||||
/// <remarks>
|
||||
/// The same 64 KiB the SFTP session reads in, so a copy is one read and one write per SFTP request with
|
||||
/// no re-chunking in between.
|
||||
/// </remarks>
|
||||
private const int BufferSize = 64 * 1024;
|
||||
|
||||
/// <remarks>
|
||||
/// Throughput over a window rather than since the start, because the number people read it for is "is it
|
||||
/// still going, and how fast now" — an average since the start of a resumed multi-gigabyte transfer
|
||||
/// answers a question nobody asked. Half a second is long enough that one slow request does not make it
|
||||
/// jump and short enough that a stall shows up before it is worth investigating.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <remarks>
|
||||
/// A progress event per buffer would be some thousands a second on a fast link, each one marshalled to
|
||||
/// the UI thread to move a bar by a pixel. Ten a second is smooth to a person and free to the window.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly Func<CancellationToken, Task<ISftpSession>> sessions;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly List<Entry> transfers = [];
|
||||
private readonly Lock gate = new();
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task pump = Task.CompletedTask;
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="sessions">
|
||||
/// Where the queue gets a session from, asked once per drain. A delegate rather than a session, because
|
||||
/// the screen owns the connection and may have re-established it since the last transfer ran — and a
|
||||
/// queue holding a stale session would fail every row with a socket error instead of reconnecting.
|
||||
/// </param>
|
||||
/// <param name="clock">Time source, so throughput is measurable without waiting for real seconds.</param>
|
||||
public FileTransferQueue(Func<CancellationToken, Task<ISftpSession>> sessions, TimeProvider clock)
|
||||
{
|
||||
this.sessions = sessions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a transfer's state or progress changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Raised on the pump's thread</b>, which is a thread-pool thread. A handler that touches an
|
||||
/// observable collection has to marshal; this project has no toolkit to do it with, which is exactly why
|
||||
/// it does not try. The same arrangement as <c>TerminalWorkspace.SessionEnded</c>.
|
||||
/// </remarks>
|
||||
public event EventHandler<TransferChangedEventArgs>? Changed;
|
||||
|
||||
/// <summary>Every transfer this queue knows about, in the order they were added.</summary>
|
||||
public IReadOnlyList<TransferSnapshot> Snapshot()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. transfers.Select(Describe)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether anything is queued or running.</summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.Exists(entry =>
|
||||
entry.State is TransferState.Queued or TransferState.Running);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a transfer and starts the queue if it is not already running.
|
||||
/// </summary>
|
||||
/// <param name="direction">Which way.</param>
|
||||
/// <param name="localPath">The local end. For a download this is the file to create.</param>
|
||||
/// <param name="remotePath">The remote end. For an upload this is the file to create.</param>
|
||||
/// <param name="length">
|
||||
/// How many bytes there are, from the listing on the side that already has the file. Taken as given
|
||||
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
||||
/// would be a round trip to learn something already on screen.
|
||||
/// </param>
|
||||
/// <returns>The transfer's id.</returns>
|
||||
public Guid Enqueue(
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Direction = direction,
|
||||
LocalPath = localPath,
|
||||
RemotePath = remotePath,
|
||||
Name = direction is TransferDirection.Download
|
||||
? SftpPath.Name(remotePath)
|
||||
: Path.GetFileName(localPath),
|
||||
Length = length,
|
||||
State = TransferState.Queued,
|
||||
};
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
transfers.Add(entry);
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a transfer, or takes a queued one out of the queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file is left where it is, which is what makes <see cref="Retry"/> a resume rather than a
|
||||
/// restart. <see cref="Discard"/> is how a row and its part file go.
|
||||
/// </remarks>
|
||||
public void Cancel(Guid id)
|
||||
{
|
||||
Entry? cancelled = null;
|
||||
CancellationTokenSource? running = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case TransferState.Running:
|
||||
// Marked by the run itself when the cancellation lands, so a transfer that was already
|
||||
// finishing is not relabelled after the fact.
|
||||
running = entry.Cancellation;
|
||||
break;
|
||||
|
||||
case TransferState.Queued:
|
||||
entry.State = TransferState.Cancelled;
|
||||
cancelled = entry;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
running?.Cancel();
|
||||
|
||||
if (cancelled is not null)
|
||||
{
|
||||
Announce(cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a stopped transfer back in the queue, carrying on from where it stopped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resumes rather than restarts, and only because this object watched the part file being written: it
|
||||
/// knows which source those bytes came from. That is the whole of the bookkeeping the design's "resume
|
||||
/// supported" needs, and the reason it does not survive a restart — see the remark on this type.
|
||||
/// </remarks>
|
||||
public void Retry(Guid id)
|
||||
{
|
||||
Entry? retried = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (Find(id) is not { State: TransferState.Failed or TransferState.Cancelled } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = TransferState.Queued;
|
||||
entry.Failure = null;
|
||||
entry.Resume = entry.Transferred > 0;
|
||||
retried = entry;
|
||||
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(retried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes one finished transfer, and whatever it left on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named for the destruction rather than for the tidying. A cancelled transfer's part file holds real
|
||||
/// bytes that took real time to move, and the row is the only thing that knows the part file exists — so
|
||||
/// removing the row without removing the file would leave litter nobody could attribute, and removing it
|
||||
/// silently under a word like "clear" would throw away a resumable transfer without saying so.
|
||||
/// </remarks>
|
||||
/// <returns>Whether there was such a transfer to discard.</returns>
|
||||
public async Task<bool> DiscardAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
Entry? discarded;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry || !IsFinished(entry.State))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
transfers.Remove(entry);
|
||||
discarded = entry;
|
||||
}
|
||||
|
||||
await RemovePartFileAsync(discarded, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Removes every completed transfer, which have nothing left on disk to clean up.</summary>
|
||||
/// <returns>How many rows went.</returns>
|
||||
public int ClearCompleted()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Cancels whatever is running and waits for it, rather than abandoning the pump. A transfer in flight
|
||||
/// holds an open remote file and an open local one, and letting the process move on while they are still
|
||||
/// being written is how a part file ends up longer than the bytes that reached it.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Task running;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
running = pump;
|
||||
}
|
||||
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The pump reports failures onto the rows it was running; a fault escaping here would be one
|
||||
// nothing is left to show.
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Started under the gate and restarted whenever it has finished, which is what makes "one at a time"
|
||||
/// true without a dedicated thread waiting on an empty queue for the life of the application.
|
||||
/// </remarks>
|
||||
private void EnsurePumping()
|
||||
{
|
||||
if (pump.IsCompleted && !disposed)
|
||||
{
|
||||
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (TakeNext() is { } entry)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
|
||||
ISftpSession session;
|
||||
|
||||
try
|
||||
{
|
||||
session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The session is what every remaining row needs, so a failure to get one is reported on the
|
||||
// row that asked and the next iteration asks again. Failing the whole queue would hide which
|
||||
// transfer was affected behind a single message.
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
||||
private Entry? TakeNext()
|
||||
{
|
||||
Entry? next;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
next = transfers.Find(entry => entry.State is TransferState.Queued);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
next.State = TransferState.Running;
|
||||
next.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
next.BytesPerSecond = 0;
|
||||
|
||||
// Reset unless this is a resume, so a retry from the start does not open with a bar most of the
|
||||
// way along that then jumps back.
|
||||
if (!next.Resume)
|
||||
{
|
||||
next.Transferred = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Announce(next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task RunAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = entry.Cancellation?.Token ?? cancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
await DownloadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UploadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Stop(entry, TransferState.Completed, failure: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CancellationTokenSource? source;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
source = entry.Cancellation;
|
||||
entry.Cancellation = null;
|
||||
}
|
||||
|
||||
source?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.LocalPath;
|
||||
|
||||
if (File.Exists(destination))
|
||||
{
|
||||
throw new IOException(
|
||||
$"{Path.GetFileName(destination)} is already in that folder. Rename or remove it first — "
|
||||
+ "nothing here overwrites a file you already have.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var offset = ResumableLength(entry, new FileInfo(part));
|
||||
|
||||
if (offset == 0 && File.Exists(part))
|
||||
{
|
||||
// A part file this transfer is not resuming from. It belongs to an earlier attempt at the same
|
||||
// destination, and starting a fresh transfer by appending to it would produce a file that is
|
||||
// longer than the source and wrong in the middle.
|
||||
File.Delete(part);
|
||||
}
|
||||
|
||||
await ReadIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Only now, with both handles closed — which is what makes the copy its own method rather than a
|
||||
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
||||
// failure would leave a complete transfer looking like an incomplete one.
|
||||
File.Move(part, destination);
|
||||
}
|
||||
|
||||
private async Task ReadIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var remote = await session
|
||||
.OpenReadAsync(entry.RemotePath, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
var local = new FileStream(
|
||||
part,
|
||||
offset == 0 ? FileMode.Create : FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UploadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.RemotePath;
|
||||
|
||||
if (await session.StatAsync(destination, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
throw new IOException(
|
||||
$"{SftpPath.Name(destination)} is already in that directory on the host. Rename or remove it "
|
||||
+ "first — nothing here overwrites a file that is already there.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
var offset = ResumableLength(entry, existing);
|
||||
|
||||
if (offset == 0 && existing is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WriteIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var local = new FileStream(
|
||||
entry.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
var remote = await session.OpenWriteAsync(part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(local, remote, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far a resume may start, given what is actually on the destination.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file's own length, not the transfer's recorded progress, and never longer than the source.
|
||||
/// The two can disagree — a cancellation lands between a write completing and the counter moving, and a
|
||||
/// buffered write may not have reached the disk at all — and only one of them is a fact about the bytes
|
||||
/// that are there. Trusting the counter would resume past bytes that were never written, which is the
|
||||
/// one way a resumed transfer can produce a corrupt file that nothing reports.
|
||||
/// </remarks>
|
||||
private static long ResumableLength(Entry entry, FileInfo part) =>
|
||||
part.Exists ? ResumableLength(entry, part.Length) : 0;
|
||||
|
||||
private static long ResumableLength(Entry entry, SftpEntry? part) =>
|
||||
part is null ? 0 : ResumableLength(entry, part.Length);
|
||||
|
||||
private static long ResumableLength(Entry entry, long partLength)
|
||||
{
|
||||
if (!entry.Resume || partLength <= 0 || partLength >= entry.Length)
|
||||
{
|
||||
// A part file at or beyond the source's length is not a resume point; it is evidence that the
|
||||
// source changed under a previous attempt. Starting again is the only answer that ends with the
|
||||
// right bytes.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return partLength;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(
|
||||
Stream source,
|
||||
Stream destination,
|
||||
Entry entry,
|
||||
long startOffset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
var meter = new ProgressMeter(clock, startOffset);
|
||||
|
||||
try
|
||||
{
|
||||
var transferred = startOffset;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await source
|
||||
.ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await destination
|
||||
.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
transferred += read;
|
||||
|
||||
if (Record(entry, meter, transferred))
|
||||
{
|
||||
Announce(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Flushed before the caller closes the handles and renames, so a write still sitting in a buffer
|
||||
// is not counted as delivered by a rename that beat it to the disk.
|
||||
await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes progress onto the entry, and says whether it is worth telling anyone.</summary>
|
||||
private bool Record(Entry entry, ProgressMeter meter, long transferred)
|
||||
{
|
||||
var reading = meter.Read(transferred);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
|
||||
if (reading.BytesPerSecond is { } rate)
|
||||
{
|
||||
entry.BytesPerSecond = rate;
|
||||
}
|
||||
}
|
||||
|
||||
return reading.WorthAnnouncing;
|
||||
}
|
||||
|
||||
private void Stop(Entry entry, TransferState state, string? failure)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
entry.State = state;
|
||||
entry.Failure = failure;
|
||||
entry.BytesPerSecond = 0;
|
||||
entry.Resume = false;
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
}
|
||||
|
||||
private async Task RemovePartFileAsync(Entry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
File.Delete(entry.LocalPath + PartSuffix);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
var part = entry.RemotePath + PartSuffix;
|
||||
|
||||
if (await session.StatAsync(part, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best effort, and deliberately silent. The row the user asked to remove is already gone, and a
|
||||
// remote part file that outlives it is litter rather than a failure — reporting it would mean
|
||||
// putting an error on a screen for an operation that did what was asked.
|
||||
}
|
||||
}
|
||||
|
||||
private Entry? Find(Guid id) => transfers.Find(entry => entry.Id == id);
|
||||
|
||||
private static bool IsFinished(TransferState state) =>
|
||||
state is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
private void Announce(Entry entry)
|
||||
{
|
||||
TransferSnapshot snapshot;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = Describe(entry);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new TransferChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
/// <remarks>Callers hold <see cref="gate"/>: every field read here is written from the pump.</remarks>
|
||||
private static TransferSnapshot Describe(Entry entry) => new(
|
||||
entry.Id,
|
||||
entry.Direction,
|
||||
entry.Name,
|
||||
entry.LocalPath,
|
||||
entry.RemotePath,
|
||||
entry.Length,
|
||||
entry.Transferred,
|
||||
entry.State,
|
||||
entry.BytesPerSecond,
|
||||
entry.Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
|
||||
/// last told anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own type because both are stateful across iterations, and the alternative — four locals threaded
|
||||
/// through the loop by reference — is how the sampling and the announcing end up sharing a timestamp and
|
||||
/// quietly becoming one interval. They are deliberately different: half a second is the right window to
|
||||
/// measure a rate over, and a tenth of a second is the right rate to repaint at.
|
||||
/// </remarks>
|
||||
private sealed class ProgressMeter(TimeProvider clock, long startOffset)
|
||||
{
|
||||
private long sampleAt = clock.GetTimestamp();
|
||||
private long sampleBytes = startOffset;
|
||||
private long announcedAt = clock.GetTimestamp();
|
||||
|
||||
/// <param name="transferred">Total bytes moved, resumed bytes included.</param>
|
||||
/// <returns>
|
||||
/// A new throughput figure when the window has elapsed and null when it has not, so a rate is never
|
||||
/// recomputed from a sample too short to mean anything; and whether this is a moment to repaint.
|
||||
/// </returns>
|
||||
public (double? BytesPerSecond, bool WorthAnnouncing) Read(long transferred)
|
||||
{
|
||||
var now = clock.GetTimestamp();
|
||||
var sinceSample = clock.GetElapsedTime(sampleAt, now);
|
||||
|
||||
double? rate = null;
|
||||
|
||||
if (sinceSample >= ThroughputWindow)
|
||||
{
|
||||
rate = (transferred - sampleBytes) / sinceSample.TotalSeconds;
|
||||
sampleAt = now;
|
||||
sampleBytes = transferred;
|
||||
}
|
||||
|
||||
if (clock.GetElapsedTime(announcedAt, now) < ProgressInterval)
|
||||
{
|
||||
return (rate, false);
|
||||
}
|
||||
|
||||
announcedAt = now;
|
||||
|
||||
return (rate, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One transfer's mutable state, which only the queue touches and only under its gate.</summary>
|
||||
private sealed class Entry
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
public required TransferDirection Direction { get; init; }
|
||||
|
||||
public required string LocalPath { get; init; }
|
||||
|
||||
public required string RemotePath { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required long Length { get; init; }
|
||||
|
||||
public required TransferState State { get; set; }
|
||||
|
||||
public long Transferred { get; set; }
|
||||
|
||||
public double BytesPerSecond { get; set; }
|
||||
|
||||
public string? Failure { get; set; }
|
||||
|
||||
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
||||
public bool Resume { get; set; }
|
||||
|
||||
public CancellationTokenSource? Cancellation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>One entry in a local directory.</summary>
|
||||
/// <param name="Name">The entry's own name.</param>
|
||||
/// <param name="FullPath">The absolute path.</param>
|
||||
/// <param name="IsDirectory">Whether it can be navigated into.</param>
|
||||
/// <param name="Length">Size in bytes, or zero for a directory.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <remarks>
|
||||
/// Deliberately the same shape as <c>SftpEntry</c> minus the permission string, because the two panes of the
|
||||
/// file browser show the same columns and a local mode rendered in POSIX notation would be a fiction on
|
||||
/// Windows — where the design's <c>PERMS</c> column has no honest value at all.
|
||||
/// </remarks>
|
||||
public sealed record LocalEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
bool IsDirectory,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The local half of the file browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The first <c>System.IO</c> in the client outside the cache and the device key, and it stays behind this
|
||||
/// one type on purpose: everything above reasons about <see cref="LocalEntry"/>, so the transfers screen and
|
||||
/// its view model never enumerate a directory themselves.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here throws for one unreadable entry.</b> A local directory listing on Windows routinely
|
||||
/// contains things the user cannot stat — a junction into another profile, a file another process holds
|
||||
/// open — and a browser that failed the whole listing for one of them would be unable to show
|
||||
/// <c>C:\Users</c>. Those entries are skipped; a directory that cannot be opened at all is still a failure,
|
||||
/// because there is nothing to show.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDirectory
|
||||
{
|
||||
/// <summary>Where the local pane opens.</summary>
|
||||
/// <remarks>
|
||||
/// The user profile rather than the process's working directory, which for a desktop application is
|
||||
/// wherever it happened to be launched from — an installation directory nobody keeps files in.
|
||||
/// </remarks>
|
||||
public static string Home =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory, directories first and then by name.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">The directory could not be opened.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">The directory could not be opened.</exception>
|
||||
public static IReadOnlyList<LocalEntry> List(string path)
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
var entries = new List<LocalEntry>();
|
||||
|
||||
// EnumerateFileSystemInfos rather than GetFileSystemInfos: the enumerating form yields entries as it
|
||||
// reads them, so a directory of fifty thousand files does not have to be materialised twice.
|
||||
foreach (var entry in directory.EnumerateFileSystemInfos())
|
||||
{
|
||||
try
|
||||
{
|
||||
var isDirectory = entry.Attributes.HasFlag(FileAttributes.Directory);
|
||||
|
||||
entries.Add(new LocalEntry(
|
||||
entry.Name,
|
||||
entry.FullName,
|
||||
isDirectory,
|
||||
isDirectory ? 0 : ((FileInfo)entry).Length,
|
||||
new DateTimeOffset(entry.LastWriteTimeUtc, TimeSpan.Zero)));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// One entry this process cannot stat. Skipped rather than shown as a row with no facts on
|
||||
// it, and skipped rather than failing the listing — see the remark on this type.
|
||||
}
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) => left.IsDirectory == right.IsDirectory
|
||||
? string.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase)
|
||||
: right.IsDirectory.CompareTo(left.IsDirectory));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The directory holding a path, or null when it is already a root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
|
||||
/// remote's does: above <c>C:\</c> is the list of drives, which is not a directory. The remote pane stops
|
||||
/// at <c>/</c>, which is.
|
||||
/// </remarks>
|
||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
||||
|
||||
/// <summary>
|
||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ready drives only. An empty optical drive or a disconnected network mapping is listed by
|
||||
/// <see cref="DriveInfo.GetDrives"/> and throws on the first attempt to read it, which would put a row on
|
||||
/// screen whose only behaviour is an error.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> Roots()
|
||||
{
|
||||
var roots = new List<string>();
|
||||
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (drive.IsReady)
|
||||
{
|
||||
roots.Add(drive.RootDirectory.FullName);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A drive that fails even to answer whether it is ready. Nothing to show.
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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