diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83f7d4f..7deae0b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,8 +23,8 @@ env:
jobs:
build:
- name: build and test (ubuntu)
- runs-on: ubuntu-latest
+ name: build and test
+ runs-on: [linux]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -52,24 +52,4 @@ jobs:
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
- # dominate a cold run.
-
- build-windows:
- name: build (windows)
- runs-on: windows-latest
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
- - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: global.json
- cache: true
- cache-dependency-path: '**/packages.lock.json'
-
- - name: restore
- run: dotnet restore DodoSSH.slnx --locked-mode
-
- # Build only. Day-to-day development happens in Rider on Windows, so a
- # Windows-specific compile break must fail CI even though the tests run on Linux.
- - name: build
- run: dotnet build DodoSSH.slnx --no-restore --configuration Release
+ # dominate a cold run.
\ No newline at end of file
diff --git a/DodoSSH.slnx b/DodoSSH.slnx
index efb273d..5d27ab4 100644
--- a/DodoSSH.slnx
+++ b/DodoSSH.slnx
@@ -25,6 +25,7 @@
+
@@ -39,6 +40,7 @@
+
diff --git a/README.md b/README.md
index 05b88d6..d32c930 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -165,6 +166,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
@@ -245,7 +277,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.
diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md
index dda7657..af8c604 100644
--- a/docs/design-import-gaps.md
+++ b/docs/design-import-gaps.md
@@ -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.
---
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index 7ad50bd..cee0488 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -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();
@@ -82,6 +86,7 @@ internal sealed partial class DodoSshApp : Application
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
TimeProvider.System,
+ connections,
passphraseProfile: null,
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
index 5489fa2..ce05c36 100644
--- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
+++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
@@ -28,6 +28,7 @@
+
diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
index e0d7805..5deb450 100644
--- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs
@@ -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.
///
///
-/// and 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
-/// docs/design-import-gaps.md: 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.
+/// 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 docs/design-import-gaps.md: 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. was the other one until M2 built it.
///
///
internal enum ShellScreen
@@ -64,7 +64,7 @@ internal enum ShellScreen
/// The host list and the terminals, which is where the application opens.
Hosts = 0,
- /// File transfer. Nothing implements it yet.
+ /// File transfer over SFTP: two directory panes and a queue.
Transfers = 1,
/// Everything in the vault that is not a host.
@@ -126,6 +126,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
+ ///
+ /// Created once and kept for the life of the process, like 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 . The vault
+ /// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
+ ///
+ private readonly TransfersViewModel transfers;
+
private IVaultServer? connection;
/// The refresh token last written to the cache, so a rotation is noticed without reading it back.
@@ -165,6 +173,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
string refreshToken,
CancellationToken cancellationToken);
+ ///
+ /// 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.
+ ///
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -173,6 +186,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
+ ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null)
{
@@ -186,6 +200,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;
@@ -257,6 +273,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
+ /// The transfers screen, which the window binds to whether or not a vault is open.
+ ///
+ /// Not nullable and never replaced, unlike . 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.
+ ///
+ internal TransfersViewModel Transfers => transfers;
+
///
/// Shells that were left running when the vault was locked.
///
@@ -958,6 +982,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.
@@ -1186,6 +1214,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;
@@ -1296,6 +1329,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// As Lock does, and before the session it reads from goes.
knownHosts.Close();
+ // The same detach locking does, and the same reasoning carried one step further: the host
+ // rows go because the vault behind them is about to be disposed, and the session and its
+ // queue stay because a transfer in flight is somebody's work. Signing out is the strongest
+ // thing this application does to itself and it still does not destroy that, for exactly the
+ // reason it does not close a shell — quitting DodoSSH is what ends both.
+ transfers.Detach();
+
if (Vault is { } open)
{
Vault = null;
@@ -1372,6 +1412,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);
diff --git a/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs
new file mode 100644
index 0000000..d3a2bbf
--- /dev/null
+++ b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs
@@ -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;
+
+/// One segment of a path, as a button in a breadcrumb trail.
+/// What the segment is called.
+/// The absolute path that reaches it.
+internal sealed record CrumbViewModel(string Name, string Path);
+
+/// One remote file or directory, as a row.
+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;
+
+ ///
+ /// 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.
+ ///
+ internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
+
+ internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
+
+ /// The mode as drwxr-xr-x, which is the design's PERMS column.
+ internal string Permissions => entry.Permissions;
+}
+
+/// One local file or directory, as a row.
+///
+/// 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 -rw-r--r--.
+///
+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);
+}
+
+/// How this screen writes a modification time.
+///
+///
+/// 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.
+///
+///
+/// The one place in this application that deliberately ignores the user's locale — see the App project's
+/// InvariantGlobalization, 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.
+///
+///
+internal static class Timestamps
+{
+ internal static string Format(DateTimeOffset moment) =>
+ moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+}
+
+/// One transfer, as a row in the queue.
+///
+/// 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 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.
+///
+internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
+{
+ [ObservableProperty]
+ private TransferSnapshot transfer = snapshot;
+
+ internal Guid Id => Transfer.Id;
+
+ internal string Name => Transfer.Name;
+
+ /// Which way, as an arrow the eye can scan a column of.
+ internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
+
+ /// The end that is not this machine, which is the one worth showing.
+ internal string Path => Transfer.Direction is TransferDirection.Download
+ ? Transfer.RemotePath
+ : Transfer.LocalPath;
+
+ internal double Percent => Transfer.Fraction * 100;
+
+ ///
+ /// What the row says about where it has got to.
+ ///
+ ///
+ /// 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.
+ ///
+ 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;
+
+ /// Whether a stopped transfer is worth offering to run again at all.
+ ///
+ /// Wider than : 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 .
+ ///
+ internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
+
+ internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
+
+ ///
+ /// 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".
+ ///
+ 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));
+ }
+}
+
+///
+/// The transfers screen: a host, two directory panes, and the queue between them.
+///
+///
+///
+/// Its connection is its own. SSH.NET cannot open an SFTP subsystem on a transport that is already
+/// carrying a shell — see ISftpSession — 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.
+///
+///
+/// It outlives a lock, as terminals do. This object is created once and the vault is attached to it
+/// on unlock and detached on lock, the same arrangement VaultKnownHostStore has and for the same
+/// reason: MainWindowViewModel.LockAsync 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.
+///
+///
+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));
+ }
+
+ /// The hosts that can be connected to, which is the vault's list.
+ ///
+ /// 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.
+ ///
+ internal ObservableCollection Hosts { get; } = [];
+
+ [ObservableProperty]
+ private HostRowViewModel? selectedHost;
+
+ ///
+ /// 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.
+ ///
+ [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;
+
+ /// The account and endpoint actually dialled, once connected.
+ [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;
+
+ /// Whether the chosen host will want something typed into the password box.
+ internal bool SelectedHostAsksForAPassword =>
+ SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
+
+ // ---- The remote pane ----
+
+ [ObservableProperty]
+ private string remotePath = string.Empty;
+
+ internal ObservableCollection RemoteEntries { get; } = [];
+
+ internal ObservableCollection RemoteTrail { get; } = [];
+
+ [ObservableProperty]
+ private RemoteEntryRowViewModel? selectedRemoteEntry;
+
+ /// What a new directory would be called, when the user is making one.
+ [ObservableProperty]
+ private string newRemoteFolder = string.Empty;
+
+ internal bool HasRemoteEntries => RemoteEntries.Count > 0;
+
+ // ---- The local pane ----
+
+ [ObservableProperty]
+ private string localPath = LocalDirectory.Home;
+
+ internal ObservableCollection LocalEntries { get; } = [];
+
+ internal ObservableCollection LocalTrail { get; } = [];
+
+ ///
+ /// The drives this machine has, as somewhere the local pane can jump to.
+ ///
+ ///
+ /// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
+ /// does not: above 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 D: would
+ /// be unreachable from an application whose whole purpose on this screen is to move one.
+ ///
+ internal ObservableCollection LocalRoots { get; } = [];
+
+ [ObservableProperty]
+ private LocalEntryRowViewModel? selectedLocalEntry;
+
+ internal bool HasLocalEntries => LocalEntries.Count > 0;
+
+ // ---- The queue ----
+
+ internal ObservableCollection Transfers { get; } = [];
+
+ internal bool HasTransfers => Transfers.Count > 0;
+
+ /// Whether a download of the chosen remote file would have somewhere to go.
+ internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
+
+ /// Whether an upload of the chosen local file would have somewhere to go.
+ internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
+
+ /// Takes an unlocked vault, so the host list has something in it.
+ 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);
+ }
+
+ ///
+ /// Gives up the vault, keeping the connection and anything in flight.
+ ///
+ ///
+ /// 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.
+ ///
+ internal void Detach()
+ {
+ vault = null;
+ knownHosts = null;
+
+ Hosts.Clear();
+ SelectedHost = null;
+ TypedPassword = string.Empty;
+ }
+
+ /// Opens a file-transfer session on the chosen host.
+ [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);
+ }
+
+ ///
+ /// Closes the file-transfer session.
+ ///
+ ///
+ /// 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.
+ ///
+ [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.";
+ }
+
+ /// Pins the offered host key and connects.
+ [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);
+ }
+
+ ///
+ /// Dismisses whichever host key card is showing, without pinning or forgetting anything.
+ ///
+ ///
+ /// 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.
+ ///
+ [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 ----
+
+ /// Goes to a remote directory.
+ [RelayCommand]
+ private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
+ {
+ await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
+ }
+
+ /// Goes up one remote directory.
+ [RelayCommand]
+ private async Task RemoteUpAsync(CancellationToken cancellationToken)
+ {
+ if (RemotePath.Length > 0)
+ {
+ await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
+ }
+ }
+
+ /// Re-reads the remote directory.
+ [RelayCommand]
+ private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
+ {
+ if (RemotePath.Length > 0)
+ {
+ await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
+ }
+ }
+
+ ///
+ /// Opens whatever is selected in the remote pane, if it is somewhere to go.
+ ///
+ ///
+ /// A symbolic link is tried as a directory: a listing carries lstat 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.
+ ///
+ [RelayCommand]
+ private async Task OpenRemoteAsync(CancellationToken cancellationToken)
+ {
+ if (SelectedRemoteEntry is { IsNavigable: true } row)
+ {
+ await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
+ }
+ }
+
+ /// Goes to a local directory.
+ [RelayCommand]
+ private void GoLocal(string path) => NavigateLocal(path);
+
+ /// Goes up one local directory, as far as the top of the drive.
+ ///
+ /// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
+ /// drive is , which is a list of places rather than a step upwards.
+ ///
+ [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.";
+ }
+
+ /// Re-reads the local directory.
+ [RelayCommand]
+ private void RefreshLocal() => NavigateLocal(LocalPath);
+
+ /// Opens whatever is selected in the local pane, if it is a directory.
+ [RelayCommand]
+ private void OpenLocal()
+ {
+ if (SelectedLocalEntry is { IsNavigable: true } row)
+ {
+ NavigateLocal(row.FullPath);
+ }
+ }
+
+ // ---- Moving files ----
+
+ /// Queues the chosen remote file for download into the local directory showing.
+ [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}.";
+ }
+
+ /// Queues the chosen local file for upload into the remote directory showing.
+ [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}.";
+ }
+
+ /// Stops one transfer.
+ [RelayCommand]
+ private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
+
+ /// Runs a stopped transfer again, resuming where there is something to resume from.
+ [RelayCommand]
+ private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
+
+ /// Removes one stopped transfer and whatever it left behind.
+ [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);
+ }
+ }
+
+ /// Clears the finished transfers, which have nothing left on disk.
+ [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 ----
+
+ /// Creates a directory on the host.
+ [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);
+ }
+
+ ///
+ /// Deletes the chosen remote file, or an empty directory.
+ ///
+ ///
+ /// Not recursive, and the refusal comes from the server rather than from a check here — see
+ /// ISftpSession.DeleteAsync. 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.
+ ///
+ [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);
+ }
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Goes to a remote directory, on its own.
+ ///
+ ///
+ /// Split from so that the commands which navigate as part of
+ /// something else — connecting, making a directory, deleting one — can list without going back through
+ /// the busy guard. 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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}";
+ }
+ }
+
+ ///
+ /// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
+ /// is C:\ — a root with a separator inside it, which splitting turns into a crumb called
+ /// C: that navigates to the process's current directory on that drive rather than to its root.
+ ///
+ private void RebuildLocalTrail()
+ {
+ var crumbs = new List();
+
+ 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();
+ }
+
+ /// The session, or a failure a queue row can carry.
+ 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;
+
+ }
+
+ ///
+ /// 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.
+ ///
+ 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));
+ });
+
+ ///
+ /// The same funnel VaultViewModel 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.
+ ///
+ private async Task RunAsync(string busyMessage, Func 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));
+}
diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
index e373ebe..45abb07 100644
--- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs
@@ -1892,7 +1892,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;
@@ -2162,8 +2162,38 @@ internal sealed partial class VaultViewModel(
/// answered by the more specific of the two rather than by whichever the code happened to check.
///
///
+ ///
+ /// Works out how to reach a host, or says why it cannot.
+ ///
+ ///
+ /// The same resolution the Connect button performs, exposed because file transfer opens its own
+ /// connection — see ISftpSession — 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 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.
+ ///
+ 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)
{
@@ -2208,7 +2238,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);
}
///
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 7fd644d..2a1be7b 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -184,20 +184,15 @@
-
-
-
- An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).
- Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).
- A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).
- Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.
-
-
-
+
+
+
+
-
+
+
@@ -39,8 +45,8 @@
The Ctrl+K host search, overlaid on the window.
///
-/// The keys it responds to are handled by rather than here, because two of them —
-/// Ctrl+K to open and Escape to close — have to work when this control does not exist yet or has just
-/// stopped existing. Handling the arrows in the same place keeps the whole chord set in one method.
+///
+/// Everything a palette does once it is open belongs here rather than in : the four
+/// keys it answers to, the press outside it that dismisses it, and taking the keyboard the moment it appears.
+/// Opening it is the window's business, because Ctrl+K has to work when this control is not showing.
+///
+///
+/// The split used to fall the other way, and the cost was that none of it could be tested. Showing
+/// initialises WebView2, which refuses the headless dispatcher's thread — see
+/// LayoutHarnessTests.WhyTheWindowItselfIsNeverShown — so behaviour that lived on the window could only
+/// be checked by hand. A hosts in a bare window and takes real key and pointer
+/// input.
+///
///
internal sealed partial class QuickConnect : UserControl
{
- public QuickConnect() => InitializeComponent();
+ public QuickConnect()
+ {
+ InitializeComponent();
- /// The box, so the window can put the caret in it the moment the palette opens.
+ // Tunnelled, and deliberately: the query box below is on the route these keys take, and a text box
+ // that grows a use for Enter or the arrows — a multi-line box, a completion list — would take them
+ // before a bubbling handler here ever ran. The palette owns them while it is open, so it says so at
+ // the point on the route where nothing else has had a chance yet.
+ AddHandler(KeyDownEvent, OnPaletteKey, RoutingStrategies.Tunnel);
+ }
+
+ /// The box, so the caret can be put in it the moment the palette opens.
internal TextBox QueryBox => Query;
+
+ private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
+
+ ///
+ /// Answers one of the palette's keys, wherever in the window it was pressed.
+ ///
+ ///
+ /// Internal because calls it too, for the case this control's own tunnelled
+ /// handler cannot see: a key routes through here only while the focus is inside the palette, and the
+ /// window is what catches Escape when it is not.
+ ///
+ internal void HandleKey(KeyEventArgs e)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+
+ if (Shell is not { IsSearching: true } shell)
+ {
+ return;
+ }
+
+ switch (e.Key)
+ {
+ case Key.Escape:
+ shell.CloseSearchCommand.Execute(null);
+ e.Handled = true;
+ break;
+
+ case Key.Enter:
+ shell.ConnectToSearchResultCommand.Execute(null);
+ e.Handled = true;
+ break;
+
+ case Key.Down:
+ Move(shell, 1);
+ e.Handled = true;
+ break;
+
+ case Key.Up:
+ Move(shell, -1);
+ e.Handled = true;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Takes the keyboard, so the palette can be typed into the instant it appears.
+ ///
+ ///
+ ///
+ /// Both halves, for the reason gives: the terminal's WebView is a native
+ /// child window that keeps Win32 focus even after it is collapsed, so focusing an Avalonia control without
+ /// the Win32 call produces a box with a caret in it that silently receives nothing.
+ ///
+ ///
+ /// Done here rather than by the window, and that is the fix rather than a tidying. Focus() on a
+ /// collapsed control is measurably a no-op that is not replayed when the control is revealed, and the
+ /// window's own attempt ran from the view model's PropertyChanged — ahead of the binding that makes
+ /// this control visible, so it focused a control that was still collapsed and the keyboard stayed wherever
+ /// it was.
+ ///
+ ///
+ /// Becoming visible is still too early on its own, which is why this is posted rather than called. A
+ /// control that has never been laid out has no visual children — measured: at the instant
+ /// IsVisible turns true the query box reports IsAttachedToVisualTree() == false, and focus is
+ /// refused to anything not in the tree. Layout runs at a higher priority than this callback, so by the
+ /// time it is picked up the box exists.
+ ///
+ ///
+ private void TakeKeyboard()
+ {
+ // The palette can have been dismissed between the post and the callback — a press on the wash, or a
+ // second Ctrl+K — and stealing the keyboard back into a control nobody can see would be worse than
+ // arriving late.
+ if (!IsVisible)
+ {
+ return;
+ }
+
+ if (TopLevel.GetTopLevel(this) is Window window)
+ {
+ NativeKeyboardFocus.ReturnTo(window);
+ }
+
+ Query.Focus();
+ }
+
+ /// Clamped rather than wrapped: a list that jumps from the last row to the first loses people.
+ private static void Move(MainWindowViewModel shell, int delta)
+ {
+ if (shell.SearchResults.Count == 0)
+ {
+ return;
+ }
+
+ var current = shell.SelectedSearchResult is { } selected
+ ? shell.SearchResults.IndexOf(selected)
+ : -1;
+
+ shell.SelectedSearchResult =
+ shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
+ }
+
+ ///
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ // Base first, so the visibility this control has just been given has already reached its descendants:
+ // focus is refused to anything not effectively visible, and the box below is a descendant.
+ base.OnPropertyChanged(change);
+
+ if (change.Property == IsVisibleProperty && change.GetNewValue())
+ {
+ Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
+ }
+ }
+
+ private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e);
+
+ ///
+ /// Only a press on the wash itself. Presses on the card bubble through here as well, and closing on those
+ /// would make the palette impossible to click into.
+ ///
+ private void OnBackdropPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!ReferenceEquals(e.Source, Backdrop) || Shell is not { IsSearching: true } shell)
+ {
+ return;
+ }
+
+ shell.CloseSearchCommand.Execute(null);
+ e.Handled = true;
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/SignOutCard.axaml b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
index ceb91c2..0a1593e 100644
--- a/src/DodoSSH.Client.App/Views/SignOutCard.axaml
+++ b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
@@ -41,6 +41,15 @@
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
new file mode 100644
index 0000000..1126182
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs
new file mode 100644
index 0000000..ece7bbd
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs
@@ -0,0 +1,48 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using DodoSSH.Client.App.ViewModels;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The two-pane file browser and the transfer queue.
+///
+///
+/// Its data context is the TransfersViewModel, 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
+/// MainWindowViewModel.LockAsync.
+///
+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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ private void OnRemoteActivated(object? sender, TappedEventArgs e)
+ {
+ if (DataContext is TransfersViewModel transfers)
+ {
+ _ = transfers.OpenRemoteCommand.ExecuteAsync(null);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.App/packages.lock.json b/src/DodoSSH.Client.App/packages.lock.json
index f409d37..2eb2975 100644
--- a/src/DodoSSH.Client.App/packages.lock.json
+++ b/src/DodoSSH.Client.App/packages.lock.json
@@ -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"
},
diff --git a/src/DodoSSH.Client.Ssh/SftpSession.cs b/src/DodoSSH.Client.Ssh/SftpSession.cs
new file mode 100644
index 0000000..6bff153
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SftpSession.cs
@@ -0,0 +1,357 @@
+using System.Globalization;
+
+namespace DodoSSH.Client.Ssh;
+
+/// What kind of thing a remote directory entry is.
+///
+/// Taken from the attributes a listing already carries, which are lstat attributes: a symbolic link
+/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
+/// stays its own answer and the caller finds out what it leads to by
+/// trying to list it — see .
+///
+public enum SftpEntryKind
+{
+ /// An ordinary file.
+ File = 0,
+
+ /// A directory.
+ Directory = 1,
+
+ /// A symbolic link, to something this listing did not resolve.
+ SymbolicLink = 2,
+
+ /// A socket, device, pipe or anything else that is not one of the three above.
+ Other = 3,
+}
+
+/// One entry in a remote directory.
+/// The entry's own name, with no path.
+/// The absolute path, which is what every operation takes.
+/// What it is.
+/// Size in bytes. Meaningless for anything that is not a file, and zero there.
+/// When it was last written.
+/// The mode as drwxr-xr-x; see .
+///
+/// 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.
+///
+public sealed record SftpEntry(
+ string Name,
+ string FullPath,
+ SftpEntryKind Kind,
+ long Length,
+ DateTimeOffset LastWriteTimeUtc,
+ string Permissions)
+{
+ /// Whether this is somewhere the file browser can navigate into.
+ ///
+ /// 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.
+ ///
+ public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
+}
+
+///
+/// Renders POSIX permission bits the way ls -l does.
+///
+///
+/// Nothing in this repository formatted a mode before, and the design's file listing has a PERMS
+/// column. It is written from the individual bits rather than from an octal mode because that is the shape
+/// SFTP hands over — ISftpFile 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.
+///
+/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
+/// surface them on ISftpFile, and a column that showed rwx where rws was true would be
+/// worse than one that never claims to render them.
+///
+///
+public static class PosixMode
+{
+ /// Formats one entry's mode, kind character included.
+ 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 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 triple, bool read, bool write, bool execute)
+ {
+ triple[0] = read ? 'r' : '-';
+ triple[1] = write ? 'w' : '-';
+ triple[2] = execute ? 'x' : '-';
+ }
+ }
+}
+
+///
+/// Remote paths, which are POSIX paths whatever this client is running on.
+///
+///
+/// Not . The BCL's path helpers use the local platform's separator, so on Windows
+/// Path.Combine("/var", "log") yields /var\log — 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.
+///
+public static class SftpPath
+{
+ /// The root of a remote filesystem.
+ public const string Root = "/";
+
+ /// Joins a directory and a name.
+ public static string Combine(string directory, string name)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(directory);
+
+ return directory.EndsWith('/') ? directory + name : directory + "/" + name;
+ }
+
+ /// The directory holding a path, or the path itself when it is already the root.
+ 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],
+ };
+ }
+
+ /// The last segment of a path, or the root when that is all there is.
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Whether a path is already anchored at the root.
+ public static bool IsAbsolute(string path) => path.StartsWith('/');
+
+ ///
+ /// The segments of a path, for a breadcrumb trail.
+ ///
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+}
+
+///
+/// A file-transfer session on one host.
+///
+///
+///
+/// This is a connection, not a channel. The obvious shape would have been
+/// ISshConnection.OpenSftpAsync, opening the SFTP subsystem beside the shell on the transport that is
+/// already up — which is what SSH itself allows and what docs/design-import-gaps.md assumed it would
+/// take. SSH.NET does not offer it: SftpClient derives from BaseClient and owns its own
+/// transport, and there is no supported way to hand it an existing SshClient's session. So opening one
+/// of these authenticates again.
+///
+///
+/// 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. is a
+/// connect, and host key trust is checked on it exactly as it is for a shell.
+///
+///
+/// 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.
+///
+///
+public interface ISftpSession : IAsyncDisposable
+{
+ /// Whether the transport is still up.
+ bool IsConnected { get; }
+
+ /// The host key that was accepted for this session.
+ HostKeyPresentation HostKey { get; }
+
+ ///
+ /// Where the session starts, which is the account's home directory.
+ ///
+ ///
+ /// 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.
+ ///
+ string HomeDirectory { get; }
+
+ ///
+ /// Lists a directory.
+ ///
+ ///
+ /// . and .. 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.
+ ///
+ /// The path is not a directory, or is not readable.
+ Task> ListAsync(string path, CancellationToken cancellationToken);
+
+ /// What one path is, or null when nothing is there.
+ Task StatAsync(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Opens a remote file for reading, starting at an offset.
+ ///
+ /// The file.
+ /// Where to start, which is what makes an interrupted download resumable.
+ /// Abandons the open.
+ Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken);
+
+ ///
+ /// Opens a remote file for writing, starting at an offset.
+ ///
+ ///
+ /// 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.
+ ///
+ /// The file.
+ /// Where to start, which is what makes an interrupted upload resumable.
+ /// Abandons the open.
+ Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
+
+ /// Creates one directory, whose parent must exist.
+ Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
+
+ ///
+ /// Deletes a file, or an empty directory.
+ ///
+ ///
+ /// 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.
+ ///
+ Task DeleteAsync(string path, CancellationToken cancellationToken);
+
+ /// Renames or moves a path within the same host.
+ Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
+}
+
+///
+/// Something on the remote filesystem could not be reached, and the server said why.
+///
+///
+/// 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 — SftpPathNotFoundException,
+/// SftpPermissionDeniedException, and a bare SshException for the rest — and the path is not on
+/// all of them.
+///
+public sealed class SftpPathException(string path, string message, Exception? innerException = null)
+ : Exception(message, innerException)
+{
+ /// The path the failure was about.
+ public string Path { get; } = path;
+}
+
+/// Opens file-transfer sessions.
+///
+/// Declared beside and implemented by the same type, because both start
+/// with the same handshake and the same host key decision. See for why this is a
+/// separate connect rather than a channel on a connection that already exists.
+///
+public interface ISftpSessionFactory
+{
+ ///
+ /// Connects, authenticates, and starts the SFTP subsystem.
+ ///
+ ///
+ /// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
+ /// explicit confirmation, and retry.
+ ///
+ ///
+ /// The presented key differs from the pin. There is no retry path.
+ ///
+ Task OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
+}
+
+/// Formats a byte count the way a file browser shows one.
+///
+/// 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.
+///
+public static class ByteSize
+{
+ private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];
+
+ /// Formats a byte count to three significant figures.
+ 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]}");
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
index b3e5b86..d818786 100644
--- a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
+++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
@@ -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.
///
-public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory
+public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
+ : ISshConnectionFactory, ISftpSessionFactory
{
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
+ ///
+ /// How much of a file SSH.NET reads or writes per SFTP request.
+ ///
+ ///
+ /// 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.
+ ///
+ private const uint SftpBufferSize = 64 * 1024;
+
///
public async Task 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!);
+ }
+
+ ///
+ ///
+ /// A second connection to the host rather than a second channel on one that may already be open — see
+ /// 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.
+ ///
+ public async Task 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);
+ }
+
+ ///
+ /// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
+ ///
+ ///
+ /// Shared by the shell and the file-transfer paths over BaseClient, which is where SSH.NET puts
+ /// both ConnectAsync and HostKeyReceived. 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.
+ ///
+ private async Task 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;
}
///
diff --git a/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs
new file mode 100644
index 0000000..bb28263
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SshNetSftpSession.cs
@@ -0,0 +1,250 @@
+using Renci.SshNet;
+using Renci.SshNet.Common;
+using Renci.SshNet.Sftp;
+
+namespace DodoSSH.Client.Ssh;
+
+/// An SSH.NET-backed file-transfer session.
+///
+/// Thin on purpose. Everything above this reasons about and ,
+/// 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.
+///
+internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
+ : ISftpSession
+{
+ ///
+ public bool IsConnected => client.IsConnected;
+
+ ///
+ public HostKeyPresentation HostKey { get; } = hostKey;
+
+ ///
+ public string HomeDirectory { get; } = homeDirectory;
+
+ ///
+ public async Task> ListAsync(string path, CancellationToken cancellationToken)
+ {
+ var entries = new List();
+
+ 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;
+ }
+
+ ///
+ public async Task 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);
+ }
+ }
+
+ ///
+ public Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
+ OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
+
+ ///
+ public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
+ OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
+
+ ///
+ 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);
+ }
+ }
+
+ ///
+ 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);
+ }
+ }
+
+ ///
+ 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);
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ client.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// Seeking after the open rather than asking for an appending mode. FileMode.Append 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 not 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.
+ ///
+ private async Task 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;
+ }
+
+ /// Directories first, then links, then everything else.
+ 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));
+ }
+
+ ///
+ /// 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
+ /// carrying that message. What must not be caught is cancellation and
+ /// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
+ ///
+ 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),
+ };
+}
diff --git a/src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj b/src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj
new file mode 100644
index 0000000..faa242e
--- /dev/null
+++ b/src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Transfer/FileTransferQueue.cs b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
new file mode 100644
index 0000000..4e78dea
--- /dev/null
+++ b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
@@ -0,0 +1,870 @@
+using System.Buffers;
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Transfer;
+
+/// Which way the bytes are going.
+public enum TransferDirection
+{
+ /// From the host to this machine.
+ Download = 0,
+
+ /// From this machine to the host.
+ Upload = 1,
+}
+
+/// Where one transfer has got to.
+public enum TransferState
+{
+ /// Waiting for the one in front of it.
+ Queued = 0,
+
+ /// Moving bytes.
+ Running = 1,
+
+ /// Finished, and the file is at its final name.
+ Completed = 2,
+
+ /// Stopped by a failure, which describes.
+ Failed = 3,
+
+ /// Stopped because the user asked.
+ Cancelled = 4,
+}
+
+///
+/// One transfer, as the queue last saw it.
+///
+///
+/// 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.
+///
+/// Identifies the transfer for and the rest.
+/// Which way it is going.
+/// The file's own name, which is what a queue row is headed with.
+/// The local end, whichever end that is.
+/// The remote end.
+///
+/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
+///
+/// How many have moved, resumed bytes included.
+/// Where it has got to.
+/// Recent throughput, or zero when nothing is moving.
+/// Why it stopped, when it stopped badly.
+public sealed record TransferSnapshot(
+ Guid Id,
+ TransferDirection Direction,
+ string Name,
+ string LocalPath,
+ string RemotePath,
+ long Length,
+ long Transferred,
+ TransferState State,
+ double BytesPerSecond,
+ string? Failure)
+{
+ /// How far along, between 0 and 1.
+ ///
+ /// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
+ /// row says ; a progress bar that filled instead would be the one
+ /// case where the bar told a different story from the state beside it.
+ ///
+ public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);
+
+ /// Whether this transfer will not move again on its own.
+ public bool IsFinished =>
+ State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
+
+ ///
+ /// Whether there is a partial file to carry on from.
+ ///
+ ///
+ /// Only for a transfer this queue itself left partial. See for why
+ /// a partial file found lying about is not resumed.
+ ///
+ public bool CanResume =>
+ State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
+}
+
+/// A transfer whose state or progress has moved.
+/// The transfer, as it now is.
+public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
+{
+ /// The transfer.
+ public TransferSnapshot Transfer { get; } = transfer;
+}
+
+///
+/// The transfer queue: one file at a time, resumable, over one SFTP session.
+///
+///
+///
+/// One at a time. 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.
+///
+///
+/// Nothing is written at its final name until it is complete. 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.
+///
+///
+/// Resume is within a run of the application. A part file left by an interrupted transfer is carried
+/// on from by , 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
+/// docs/design-import-gaps.md on the missing preferences store.
+///
+///
+public sealed class FileTransferQueue : IAsyncDisposable
+{
+ /// What an in-flight transfer's destination is called until it is finished.
+ ///
+ /// Long and specific rather than the conventional .part, 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.
+ ///
+ internal const string PartSuffix = ".dodossh-part";
+
+ ///
+ /// 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.
+ ///
+ private const int BufferSize = 64 * 1024;
+
+ ///
+ /// 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.
+ ///
+ private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);
+
+ ///
+ /// 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.
+ ///
+ private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
+
+ private readonly Func> sessions;
+ private readonly TimeProvider clock;
+ private readonly List transfers = [];
+ private readonly Lock gate = new();
+ private readonly CancellationTokenSource lifetime = new();
+
+ private Task pump = Task.CompletedTask;
+ private bool disposed;
+
+ ///
+ /// 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.
+ ///
+ /// Time source, so throughput is measurable without waiting for real seconds.
+ public FileTransferQueue(Func> sessions, TimeProvider clock)
+ {
+ this.sessions = sessions;
+ this.clock = clock;
+ }
+
+ ///
+ /// Raised whenever a transfer's state or progress changes.
+ ///
+ ///
+ /// Raised on the pump's thread, 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 TerminalWorkspace.SessionEnded.
+ ///
+ public event EventHandler? Changed;
+
+ /// Every transfer this queue knows about, in the order they were added.
+ public IReadOnlyList Snapshot()
+ {
+ lock (gate)
+ {
+ return [.. transfers.Select(Describe)];
+ }
+ }
+
+ /// Whether anything is queued or running.
+ public bool IsBusy
+ {
+ get
+ {
+ lock (gate)
+ {
+ return transfers.Exists(entry =>
+ entry.State is TransferState.Queued or TransferState.Running);
+ }
+ }
+ }
+
+ ///
+ /// Adds a transfer and starts the queue if it is not already running.
+ ///
+ /// Which way.
+ /// The local end. For a download this is the file to create.
+ /// The remote end. For an upload this is the file to create.
+ ///
+ /// 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.
+ ///
+ /// The transfer's id.
+ 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;
+ }
+
+ ///
+ /// Stops a transfer, or takes a queued one out of the queue.
+ ///
+ ///
+ /// The part file is left where it is, which is what makes a resume rather than a
+ /// restart. is how a row and its part file go.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Puts a stopped transfer back in the queue, carrying on from where it stopped.
+ ///
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Removes one finished transfer, and whatever it left on disk.
+ ///
+ ///
+ /// 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.
+ ///
+ /// Whether there was such a transfer to discard.
+ public async Task 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;
+ }
+
+ /// Removes every completed transfer, which have nothing left on disk to clean up.
+ /// How many rows went.
+ public int ClearCompleted()
+ {
+ lock (gate)
+ {
+ return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
+ }
+ }
+
+ ///
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ /// Takes the next queued transfer and marks it running.
+ 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);
+ }
+
+ ///
+ /// How far a resume may start, given what is actually on the destination.
+ ///
+ ///
+ /// 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.
+ ///
+ 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.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.Shared.Return(buffer);
+ }
+ }
+
+ /// Writes progress onto the entry, and says whether it is worth telling anyone.
+ 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));
+ }
+
+ /// Callers hold : every field read here is written from the pump.
+ 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);
+
+ ///
+ /// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
+ /// last told anything.
+ ///
+ ///
+ /// 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.
+ ///
+ private sealed class ProgressMeter(TimeProvider clock, long startOffset)
+ {
+ private long sampleAt = clock.GetTimestamp();
+ private long sampleBytes = startOffset;
+ private long announcedAt = clock.GetTimestamp();
+
+ /// Total bytes moved, resumed bytes included.
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ /// One transfer's mutable state, which only the queue touches and only under its gate.
+ 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; }
+
+ /// Whether the next run may carry on from a part file rather than starting again.
+ public bool Resume { get; set; }
+
+ public CancellationTokenSource? Cancellation { get; set; }
+ }
+}
diff --git a/src/DodoSSH.Client.Transfer/LocalDirectory.cs b/src/DodoSSH.Client.Transfer/LocalDirectory.cs
new file mode 100644
index 0000000..c9514cc
--- /dev/null
+++ b/src/DodoSSH.Client.Transfer/LocalDirectory.cs
@@ -0,0 +1,126 @@
+namespace DodoSSH.Client.Transfer;
+
+/// One entry in a local directory.
+/// The entry's own name.
+/// The absolute path.
+/// Whether it can be navigated into.
+/// Size in bytes, or zero for a directory.
+/// When it was last written.
+///
+/// Deliberately the same shape as SftpEntry 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 PERMS column has no honest value at all.
+///
+public sealed record LocalEntry(
+ string Name,
+ string FullPath,
+ bool IsDirectory,
+ long Length,
+ DateTimeOffset LastWriteTimeUtc);
+
+///
+/// The local half of the file browser.
+///
+///
+///
+/// The first System.IO in the client outside the cache and the device key, and it stays behind this
+/// one type on purpose: everything above reasons about , so the transfers screen and
+/// its view model never enumerate a directory themselves.
+///
+///
+/// Nothing here throws for one unreadable entry. 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:\Users. Those entries are skipped; a directory that cannot be opened at all is still a failure,
+/// because there is nothing to show.
+///
+///
+public static class LocalDirectory
+{
+ /// Where the local pane opens.
+ ///
+ /// 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.
+ ///
+ public static string Home =>
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);
+
+ ///
+ /// Lists a directory, directories first and then by name.
+ ///
+ /// The directory could not be opened.
+ /// The directory could not be opened.
+ public static IReadOnlyList List(string path)
+ {
+ var directory = new DirectoryInfo(path);
+ var entries = new List();
+
+ // 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;
+ }
+
+ ///
+ /// The directory holding a path, or null when it is already a root.
+ ///
+ ///
+ /// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
+ /// remote's does: above C:\ is the list of drives, which is not a directory. The remote pane stops
+ /// at /, which is.
+ ///
+ public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
+
+ ///
+ /// Where the local pane can start from: the drives on Windows, and the root elsewhere.
+ ///
+ ///
+ /// Ready drives only. An empty optical drive or a disconnected network mapping is listed by
+ /// and throws on the first attempt to read it, which would put a row on
+ /// screen whose only behaviour is an error.
+ ///
+ public static IReadOnlyList Roots()
+ {
+ var roots = new List();
+
+ 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;
+ }
+}
diff --git a/src/DodoSSH.Client.Transfer/packages.lock.json b/src/DodoSSH.Client.Transfer/packages.lock.json
new file mode 100644
index 0000000..c9a7349
--- /dev/null
+++ b/src/DodoSSH.Client.Transfer/packages.lock.json
@@ -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"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
index b81edce..399bcc5 100644
--- a/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
+++ b/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
@@ -6,6 +6,7 @@ using Microsoft.IdentityModel.Tokens;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using WireMock.Settings;
namespace DodoSSH.Api.Tests;
@@ -30,7 +31,11 @@ public sealed class StubIdentityProvider : IDisposable
var rsa = RSA.Create(2048);
signingKey = new RsaSecurityKey(rsa) { KeyId = KeyId };
- server = WireMockServer.Start();
+ // Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
+ // prompt the first time each test executable runs — per binary path, so a new worktree or
+ // configuration asks again. Port 0 still picks a free port and reports it on server.Url, which is
+ // what Authority below is built from, so the issuer the tokens claim follows the binding.
+ server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
Authority = server.Url!.TrimEnd('/');
StubDiscovery();
diff --git a/tests/DodoSSH.Client.Api.Tests/StubServer.cs b/tests/DodoSSH.Client.Api.Tests/StubServer.cs
index dd5f46d..cd69f49 100644
--- a/tests/DodoSSH.Client.Api.Tests/StubServer.cs
+++ b/tests/DodoSSH.Client.Api.Tests/StubServer.cs
@@ -4,6 +4,7 @@ using DodoSSH.Contracts;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using WireMock.Settings;
namespace DodoSSH.Client.Api.Tests;
@@ -15,7 +16,14 @@ namespace DodoSSH.Client.Api.Tests;
///
internal sealed class StubServer : IDisposable
{
- private readonly WireMockServer server = WireMockServer.Start();
+ ///
+ /// Bound to loopback explicitly. WireMock's default listens on every interface, which makes Windows
+ /// Firewall prompt the first time each test executable runs — and the prompt is per binary path, so a
+ /// new worktree or configuration asks again. Port 0 still picks a free port and reports it on
+ /// .
+ ///
+ private readonly WireMockServer server = WireMockServer.Start(
+ new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs
new file mode 100644
index 0000000..b59ca2c
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/QuickConnectTests.cs
@@ -0,0 +1,301 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using DodoSSH.Client.App.ViewModels;
+using DodoSSH.Client.App.Views;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Session.Tests;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using DodoSSH.Crypto;
+using NSubstitute;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// How the quick-connect palette answers a keyboard and a pointer.
+///
+///
+///
+/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
+/// used to live on MainWindow — which cannot be shown here at all, because attaching the terminal's
+/// WebView initialises WebView2 on a thread it refuses. See
+/// . A UserControl hosts in a bare
+/// window, takes real key and pointer input, and can therefore be held to what it promises.
+///
+///
+/// Three things were wrong and each has a test here: nothing answered a press outside the palette, so the one
+/// gesture everybody tries first did nothing; the caret never reached the query box, because the window
+/// focused it from the view model's PropertyChanged — ahead of the binding that reveals the control,
+/// and focus on a collapsed control is a no-op; and the keys were answered only by a handler on the window,
+/// which anything on the route could have taken first.
+///
+///
+/// A real over a real unlocked vault, for the same reason the layout suite
+/// uses one: compiled bindings resolve against the declared type, and the palette's list is populated by the
+/// vault's own hosts. Nothing here reaches a network — the connect the Enter test performs fails inside the
+/// vault's own error handling, which is fine, because what Enter promises is to take the highlighted result
+/// and close.
+///
+///
+public sealed class QuickConnectTests : IAsyncLifetime
+{
+ private const string Passphrase = "a sufficiently long passphrase";
+ private const string ServerUrl = "https://dodossh.example";
+
+ /// Far below the shipped profile: nothing here attacks a wrap.
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private readonly FakeAccountServer server = new();
+ private readonly StubKeyBinding keyBinding = new();
+ private readonly VaultKnownHostStore knownHosts = new();
+
+ private ClientCacheFactory caches = null!;
+ private TerminalWorkspace workspace = null!;
+ private VaultSession session = null!;
+ private VaultViewModel vault = null!;
+ private MainWindowViewModel shell = null!;
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ caches = ClientCacheFactory.ForMemory($"palette-{Guid.CreateVersion7():N}");
+ await caches.MigrateAsync(Token);
+
+ await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
+ .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
+ outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
+ session = outcome.Session!;
+
+ workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
+ Substitute.For(),
+ TimeProvider.System);
+
+ await knownHosts.OpenAsync(session, Token);
+
+ vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
+
+ await SeedAsync();
+
+ shell = new MainWindowViewModel(
+ ClientPaths.Default,
+ caches,
+ workspace,
+ knownHosts,
+ Substitute.For(),
+ (_, _) => throw new NotSupportedException("nothing here signs in"),
+ TimeProvider.System,
+ // Never asked for a session: the palette searches the host list and connects through the
+ // vault's own command, and nothing on this screen transfers a file.
+ Substitute.For(),
+ CheapProfile)
+ {
+ // The state the palette is only ever open in. Assigned rather than reached through the unlock
+ // path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
+ State = ShellState.Unlocked,
+ Vault = vault,
+ };
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await shell.DisposeAsync();
+ knownHosts.Close();
+ await workspace.DisposeAsync();
+ await session.DisposeAsync();
+ caches.Dispose();
+ }
+
+ ///
+ /// The gesture everybody tries first, and the one that did nothing at all: the wash took no pointer input,
+ /// so the only ways out of the palette were a key and the button that opened it.
+ ///
+ [Fact]
+ public async Task APressOnTheWashClosesThePalette()
+ {
+ await OnThePaletteAsync((_, window) =>
+ {
+ // The bottom-left corner: the card is 520 wide, centred, and starts 90 pixels down, so nothing
+ // here belongs to it.
+ window.MouseDown(new Point(12, 520), MouseButton.Left);
+
+ shell.IsSearching.ShouldBeFalse();
+ });
+ }
+
+ ///
+ /// The other half of the same rule, and the one that makes it worth a handler rather than a press anywhere
+ /// closing: a press on the card bubbles through the wash on its way out, so a handler that did not check
+ /// where the press started would close the palette the moment somebody clicked into the box.
+ ///
+ [Fact]
+ public async Task APressOnTheCardDoesNotClose()
+ {
+ await OnThePaletteAsync((palette, window) =>
+ {
+ window.MouseDown(Centre(palette.QueryBox, window), MouseButton.Left);
+
+ shell.IsSearching.ShouldBeTrue();
+ });
+ }
+
+ [Fact]
+ public async Task EscapeClosesThePalette()
+ {
+ await OnThePaletteAsync((palette, window) =>
+ {
+ palette.QueryBox.Focus().ShouldBeTrue();
+
+ window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
+
+ shell.IsSearching.ShouldBeFalse();
+ });
+ }
+
+ ///
+ /// The second assertion is the whole reason the selection is moved by hand rather than by letting the list
+ /// take focus: a palette whose arrow keys moved the caret out of the query box would stop receiving the
+ /// next character typed.
+ ///
+ [Fact]
+ public async Task TheArrowsMoveTheSelectionAndLeaveTheKeyboardInTheBox()
+ {
+ await OnThePaletteAsync((palette, window) =>
+ {
+ palette.QueryBox.Focus().ShouldBeTrue();
+
+ shell.SearchResults.Count.ShouldBeGreaterThan(2, "an empty list would prove nothing");
+ shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
+
+ window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
+ shell.SelectedSearchResult.ShouldBe(shell.SearchResults[1]);
+
+ window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
+ shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
+
+ // Clamped rather than wrapped, which is the palette's own rule.
+ window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
+ shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
+
+ palette.QueryBox.IsFocused.ShouldBeTrue("the arrows must not move the caret out of the box");
+ });
+ }
+
+ ///
+ /// What Enter promises is to take the highlighted row: the palette closes and the vault is pointed at that
+ /// host. The connection it then asks for fails in this suite — there is no server and no shell — and it
+ /// fails inside the vault's own handling, which is the point of connecting through the vault's command
+ /// rather than opening a session from the palette.
+ ///
+ [Fact]
+ public async Task EnterTakesTheHighlightedResult()
+ {
+ await OnThePaletteAsync((palette, window) =>
+ {
+ palette.QueryBox.Focus().ShouldBeTrue();
+
+ window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
+ var highlighted = shell.SelectedSearchResult.ShouldNotBeNull();
+
+ window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
+
+ shell.IsSearching.ShouldBeFalse();
+ vault.SelectedHost?.EntityId.ShouldBe(highlighted.EntityId);
+ });
+ }
+
+ ///
+ /// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
+ /// focused it from the view model's PropertyChanged, which runs before the binding that reveals the
+ /// control, and Focus() on a collapsed control is a no-op that is never replayed. Becoming visible
+ /// is the moment that cannot be too early, so that is where the palette takes the keyboard — and this is
+ /// the test that says so.
+ ///
+ [Fact]
+ public async Task ThePaletteTakesTheKeyboardWhenItAppears()
+ {
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var elsewhere = new TextBox();
+ var palette = new QuickConnect { DataContext = shell, IsVisible = false };
+
+ var window = new Window { Content = new Panel { Children = { elsewhere, palette } } };
+ LayoutHarness.Settle(window, 900, 600);
+
+ try
+ {
+ elsewhere.Focus().ShouldBeTrue();
+
+ shell.ToggleSearchCommand.Execute(null);
+ palette.IsVisible = true;
+
+ // The layout pass the application's dispatcher would run anyway. Without it the query box
+ // is not in the visual tree yet, which is the whole reason the palette defers this.
+ Dispatcher.UIThread.RunJobs();
+
+ palette.QueryBox.IsFocused.ShouldBeTrue();
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ // ---- Helpers ----
+
+ /// Opens the palette in a window the size the application's is, and runs one body against it.
+ private Task OnThePaletteAsync(Action body) =>
+ LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ shell.ToggleSearchCommand.Execute(null);
+ shell.IsSearching.ShouldBeTrue("every case here starts with the palette open");
+
+ var palette = new QuickConnect { DataContext = shell };
+ var window = new Window { Content = palette };
+ LayoutHarness.Settle(window, 900, 600);
+
+ try
+ {
+ body(palette, window);
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+
+ private static Point Centre(Visual control, Visual window) =>
+ control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
+ ?? throw new InvalidOperationException("the control is not in this window's tree");
+
+ /// Enough hosts that the arrow keys have somewhere to go.
+ private async Task SeedAsync()
+ {
+ for (var i = 0; i < 6; i++)
+ {
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = $"host-{i}";
+ vault.EditorHostname = $"host-{i}.internal";
+ vault.EditorUsername = "deploy";
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ }
+
+ await vault.LoadAsync(Token);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 491e54e..81f536b 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -8,6 +8,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;
@@ -62,6 +63,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
///
private MainWindowViewModel shell = null!;
+ ///
+ /// 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.
+ ///
+ private TransfersViewModel transfers = null!;
+
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
@@ -98,15 +107,24 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
new UnavailableDeviceKeyStore(),
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
TimeProvider.System,
+ Substitute.For(),
CheapProfile);
+ transfers = new TransfersViewModel(
+ Substitute.For(), 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);
}
///
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
+ await transfers.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
@@ -290,6 +308,73 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
+ // ---- The transfers screen ----
+
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [Fact]
+ public async Task TheTransfersScreenFitsBeforeAnythingIsConnected()
+ {
+ await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
+ }
+
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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
+ /// DodoSSH.Client.Transfer.Tests.
+ ///
+ ///
+ [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());
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task TheTransfersScreenFitsWithTheHostKeyCardShowing()
+ {
+ transfers.PendingHostKey = new HostKeyPresentation(
+ "db.internal", 22, "ssh-ed25519", "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG");
+
+ await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
+ }
+
// ---- The chrome ----
///
@@ -460,8 +545,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
public async Task TheSignOutCardFitsTheCardItIsShownIn()
{
// Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
- // have, and a locked vault carries the longer of the two warnings.
+ // have, an open transfer session adds a line beneath it, and a locked vault carries the longer of
+ // the two warnings.
shell.LiveSessionCount = 1;
+ shell.Transfers.IsConnected = true;
await MeasureCardAsync(new SignOutCard());
}
@@ -513,6 +600,48 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
+ /// Lays the transfers screen out at the width it gets beside the nav rail.
+ private Task MeasureTransfersAsync(Action> 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);
+
+ /// Puts one transfer on the queue in a given state, without moving a byte.
+ 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)));
+
/// Lays the vault screen out at the width it gets once the nav rail has taken its column.
private Task MeasureVaultAsync(Action> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json b/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json
index 44cf99f..87f860e 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json
+++ b/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json
@@ -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"
},
diff --git a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
index 20c4ec9..37d5c5d 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeSsh.cs
@@ -10,7 +10,7 @@ namespace DodoSSH.Client.App.Tests;
/// sshd — which DodoSSH.Client.Ssh.Tests already covers against a container. What this makes
/// testable is everything the connect path does around the connection.
///
-internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
+internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory
{
/// Thrown instead of connecting, when set. Used for the host-key paths.
internal Exception? Failure { get; set; }
@@ -18,6 +18,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
/// Requests this factory was asked for, in order.
internal List Requests { get; } = [];
+ /// Requests for a file-transfer session, in order.
+ ///
+ /// Kept apart from 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.
+ ///
+ internal List SftpRequests { get; } = [];
+
///
public Task ConnectAsync(
SshConnectionRequest request,
@@ -29,6 +37,81 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
? Task.FromException(failure)
: Task.FromResult(new FakeSshConnection(request));
}
+
+ ///
+ public Task OpenSftpAsync(
+ SshConnectionRequest request,
+ CancellationToken cancellationToken)
+ {
+ SftpRequests.Add(request);
+
+ return Failure is { } failure
+ ? Task.FromException(failure)
+ : Task.FromResult(new FakeSftpSession(request));
+ }
+}
+
+/// A remote filesystem with one directory in it.
+///
+/// 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
+/// DodoSSH.Client.Transfer.Tests, and the real subsystem against a container in
+/// DodoSSH.Client.Ssh.Tests.
+///
+internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession
+{
+ ///
+ public bool IsConnected { get; private set; } = true;
+
+ ///
+ public HostKeyPresentation HostKey { get; } =
+ new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
+
+ ///
+ public string HomeDirectory => $"/home/{request.Username}";
+
+ ///
+ public Task> ListAsync(string path, CancellationToken cancellationToken) =>
+ Task.FromResult>(
+ [
+ new SftpEntry(
+ "notes.txt",
+ SftpPath.Combine(path, "notes.txt"),
+ SftpEntryKind.File,
+ 12,
+ DateTimeOffset.UnixEpoch,
+ "-rw-r--r--"),
+ ]);
+
+ ///
+ public Task StatAsync(string path, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ ///
+ public Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
+ Task.FromResult(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
+
+ ///
+ public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
+ Task.FromResult(new MemoryStream());
+
+ ///
+ public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
+ Task.CompletedTask;
+
+ ///
+ public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
+ Task.CompletedTask;
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ IsConnected = false;
+ return ValueTask.CompletedTask;
+ }
}
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 5b3ac18..f9431ec 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -123,6 +123,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
deviceKeys,
SignInAsync,
TimeProvider.System,
+ ssh,
CheapProfile,
ResumeAsync);
@@ -307,6 +308,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);
@@ -752,6 +754,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("unreachable"),
TimeProvider.System,
+ ssh,
CheapProfile);
await using var _ = offline.ConfigureAwait(false);
@@ -2079,6 +2082,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
keys ?? new UnavailableDeviceKeyStore(),
(_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
TimeProvider.System,
+ ssh,
CheapProfile,
resume);
@@ -2208,6 +2212,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);
@@ -2555,6 +2560,85 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.NeedsServer);
}
+ // ---- 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"]);
+ }
+
+ ///
+ /// The lock policy, applied to the other thing that can be in flight. LockAsync 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.
+ ///
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
diff --git a/tests/DodoSSH.Client.App.Tests/packages.lock.json b/tests/DodoSSH.Client.App.Tests/packages.lock.json
index 1a41877..aa10825 100644
--- a/tests/DodoSSH.Client.App.Tests/packages.lock.json
+++ b/tests/DodoSSH.Client.App.Tests/packages.lock.json
@@ -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"
},
diff --git a/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs b/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs
index b235770..c0aa700 100644
--- a/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs
+++ b/tests/DodoSSH.Client.Auth.Tests/StubProvider.cs
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
+using WireMock.Settings;
namespace DodoSSH.Client.Auth.Tests;
@@ -16,7 +17,10 @@ internal sealed class StubProvider : IDisposable
bool advertiseS256 = true,
string? issuerOverride = null)
{
- server = WireMockServer.Start();
+ // Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
+ // prompt the first time each test executable runs — per binary path, so a new worktree or
+ // configuration asks again. Port 0 still picks a free port and reports it on server.Url.
+ server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
Authority = new Uri(server.Url!.TrimEnd('/'), UriKind.Absolute);
StubDiscovery(advertiseS256, issuerOverride);
diff --git a/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs b/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs
new file mode 100644
index 0000000..bed0101
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/RemotePathTests.cs
@@ -0,0 +1,99 @@
+namespace DodoSSH.Client.Ssh.Tests;
+
+///
+/// Remote paths, permission bits and byte counts — the three things the file browser renders.
+///
+///
+/// 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.
+///
+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);
+ }
+}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/SftpSessionTests.cs b/tests/DodoSSH.Client.Ssh.Tests/SftpSessionTests.cs
new file mode 100644
index 0000000..2498f0f
--- /dev/null
+++ b/tests/DodoSSH.Client.Ssh.Tests/SftpSessionTests.cs
@@ -0,0 +1,262 @@
+using System.Text;
+
+namespace DodoSSH.Client.Ssh.Tests;
+
+///
+/// The SFTP subsystem, against a real sshd.
+///
+///
+///
+/// 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 PERMS 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.
+///
+///
+/// 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 session is shared too, and that is a limit of the server rather than tidiness — see
+/// , which explains what opening one per test did to the rest of the
+/// assembly.
+///
+///
+[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(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(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(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(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));
+
+ /// A directory of this test's own, under the account's home.
+ private static async Task 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 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);
+ }
+}
diff --git a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
index d50eace..44cd011 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
@@ -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;
/// Host port the container's sshd is published on.
public ushort Port => container!.GetMappedPublicPort(SshPort);
@@ -68,9 +71,69 @@ public sealed class SshServerFixture : IAsyncLifetime
await container.StartAsync();
}
+ ///
+ /// One file-transfer session, opened on first use and shared by every test that wants one.
+ ///
+ ///
+ ///
+ /// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
+ /// sshd's MaxStartups 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 refused 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.
+ ///
+ ///
+ /// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
+ /// named after itself. See ISftpSession, which is one channel and is used by one caller at a
+ /// time.
+ ///
+ ///
+ public async ValueTask 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();
+ }
+ }
+
///
public async ValueTask DisposeAsync()
{
+ if (sftp is not null)
+ {
+ await sftp.DisposeAsync();
+ }
+
+ sftpGate.Dispose();
+
if (container is not null)
{
await container.DisposeAsync();
diff --git a/tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj b/tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj
new file mode 100644
index 0000000..b6f100b
--- /dev/null
+++ b/tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs b/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs
new file mode 100644
index 0000000..f246782
--- /dev/null
+++ b/tests/DodoSSH.Client.Transfer.Tests/FakeSftpSession.cs
@@ -0,0 +1,258 @@
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Transfer.Tests;
+
+///
+/// A remote filesystem in a dictionary.
+///
+///
+/// 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 SftpSessionTests.
+///
+internal sealed class FakeSftpSession : ISftpSession
+{
+ private readonly Dictionary files = new(StringComparer.Ordinal);
+ private readonly HashSet directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
+ private readonly Lock gate = new();
+
+ ///
+ public bool IsConnected => true;
+
+ ///
+ public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
+
+ ///
+ public string HomeDirectory => "/home/dodo";
+
+ /// Throws once, this many bytes into the next read, and then stops doing so.
+ ///
+ /// 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.
+ ///
+ public int? FailReadAfter { get; set; }
+
+ /// The offsets was asked to start at, in order.
+ ///
+ /// 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.
+ ///
+ public List ReadOffsets { get; } = [];
+
+ /// Puts a file on the fake host.
+ public void Seed(string path, byte[] content)
+ {
+ lock (gate)
+ {
+ files[path] = content;
+ directories.Add(SftpPath.Parent(path));
+ }
+ }
+
+ /// What is at a path, or null.
+ public byte[]? Read(string path)
+ {
+ lock (gate)
+ {
+ return files.GetValueOrDefault(path);
+ }
+ }
+
+ /// Whether anything is at a path.
+ public bool Exists(string path)
+ {
+ lock (gate)
+ {
+ return files.ContainsKey(path) || directories.Contains(path);
+ }
+ }
+
+ ///
+ public Task> ListAsync(string path, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ if (!directories.Contains(path))
+ {
+ throw new SftpPathException(path, $"{path} is not there.");
+ }
+
+ IReadOnlyList 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);
+ }
+ }
+
+ ///
+ public Task StatAsync(string path, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ if (files.TryGetValue(path, out var content))
+ {
+ return Task.FromResult(Describe(path, content.Length));
+ }
+
+ return Task.FromResult(
+ directories.Contains(path)
+ ? new SftpEntry(
+ SftpPath.Name(path),
+ path,
+ SftpEntryKind.Directory,
+ 0,
+ DateTimeOffset.UnixEpoch,
+ "drwxr-xr-x")
+ : null);
+ }
+ }
+
+ ///
+ public Task 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(
+ new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
+ }
+ }
+
+ ///
+ public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ var existing = files.GetValueOrDefault(path, []);
+
+ return Task.FromResult(new CommittingStream(
+ existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
+ content =>
+ {
+ lock (gate)
+ {
+ files[path] = content;
+ }
+ }));
+ }
+ }
+
+ ///
+ public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
+ {
+ lock (gate)
+ {
+ directories.Add(path);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ 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;
+ }
+
+ ///
+ 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--");
+
+ /// A read that dies partway through, the way a dropped connection does.
+ private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
+ {
+ public override int Read(Span buffer)
+ {
+ Guard();
+
+ return base.Read(buffer);
+ }
+
+ public override ValueTask ReadAsync(
+ Memory 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.");
+ }
+ }
+ }
+
+ /// A write that lands on the fake host when it is disposed.
+ private sealed class CommittingStream(byte[] prefix, Action commit) : MemoryStream()
+ {
+ private bool committed;
+
+ public override void Close()
+ {
+ if (!committed)
+ {
+ committed = true;
+ commit([.. prefix, .. ToArray()]);
+ }
+
+ base.Close();
+ }
+ }
+}
diff --git a/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs b/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs
new file mode 100644
index 0000000..b5b4731
--- /dev/null
+++ b/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs
@@ -0,0 +1,365 @@
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.Transfer.Tests;
+
+///
+/// The transfer queue: what ends up on disk, and what happens when a transfer stops halfway.
+///
+///
+/// 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.
+///
+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;
+
+ ///
+ 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();
+ 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();
+
+ 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(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(host), TimeProvider.System);
+
+ private string Local(string name) => Path.Combine(workspace, name);
+
+ private static string Remote(string name) => SftpPath.Combine(RemoteDirectory, name);
+
+ ///
+ /// 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.
+ ///
+ 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 RunToCompletionAsync(
+ FileTransferQueue queue,
+ TransferDirection direction,
+ string localPath,
+ string remotePath,
+ long length) =>
+ AwaitFinishAsync(queue, () => queue.Enqueue(direction, localPath, remotePath, length));
+
+ ///
+ /// Subscribes, starts a transfer, and completes when a transfer reaches a state it will not leave.
+ ///
+ ///
+ ///
+ /// The subscription goes on before the transfer starts, because the pump runs on a thread-pool
+ /// thread: a small transfer can be finished before Enqueue has returned its id, so subscribing
+ /// afterwards would wait for an event that has already happened.
+ ///
+ ///
+ /// 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
+ /// instead.
+ ///
+ ///
+ private static async Task AwaitFinishAsync(FileTransferQueue queue, Action start)
+ {
+ var finished = new TaskCompletionSource(
+ 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;
+ }
+ }
+
+ /// Waits until nothing is queued or running.
+ 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");
+ }
+}
diff --git a/tests/DodoSSH.Client.Transfer.Tests/packages.lock.json b/tests/DodoSSH.Client.Transfer.Tests/packages.lock.json
new file mode 100644
index 0000000..273d4e4
--- /dev/null
+++ b/tests/DodoSSH.Client.Transfer.Tests/packages.lock.json
@@ -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"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file