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 4ebf4c4..f018ec1 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 @@ -150,6 +151,37 @@ authentication asks for the password every time, because nothing in the interfac credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every launch, because no device key is registered. +### Moving files + +**FILES** in the nav rail is a two-pane browser: this machine on the left, the host on the right, and a +queue underneath. Choose a host, press **CONNECT**, then select a file in either pane and press the arrow +pointing the way you want it to go. + +Two things about it are worth expecting rather than discovering. + +**It is a second connection, not a second channel.** SSH itself would allow the SFTP subsystem to open +beside a shell on the transport that is already up; SSH.NET does not offer that — its `SftpClient` owns its +own transport — so pressing CONNECT here authenticates again. The host records a second login, and a host +whose password you type each time will ask for it again on this screen. Host key trust is shared: a +fingerprint approved for a terminal is approved here, and one approved here reaches your other machines with +the next sync. + +**Nothing is written at its final name until it is complete.** Every transfer goes to a `.dodossh-part` file +beside its destination and is renamed into place at the end, so an interrupted transfer can never be +mistaken for a finished one — which matters most for what people actually use this for, which is copying a +build artefact onto a server and then running it. A destination that already exists is refused outright +rather than overwritten; the remote pane has **DELETE** and **MKDIR** so that refusal is not a dead end. +**RESUME** on a stopped transfer carries on from what the part file already holds. + +Resume works within a run of the application and not across a restart, and that limit is deliberate: nothing +records which source wrote a part file, and resuming one on the strength of its name matching is how a +corrupt artefact gets delivered with nothing reporting a failure. A part file found at startup is started +over. + +What is not here: transferring a directory, dragging between the panes, and routing a transfer through a +bastion — the last needs jump hosts the connection layer has not got. All three are in +[`docs/design-import-gaps.md`](docs/design-import-gaps.md). + ### End-to-end verification One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it @@ -230,7 +262,11 @@ off-Windows. a key are written at version 2 and become read-only on an older build. Hosts that do not are still written at version 1, byte-identically to before the field existed — which is what keeps upgrading one machine from making a team's whole vault uneditable everywhere else. -- **M2 — full personal vault**, robust sync, relay. +- **M2 — full personal vault**, robust sync, relay. *File transfer done:* an SFTP session, a two-pane file + browser with a real remote listing — names, sizes, modification times and `drwxr-xr-x` permission bits — + and a queue that moves one file at a time with progress, throughput and resume. See + [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which + are consequences rather than choices. - **M3 — teams**, sharing, ACLs. - **M4 — hardening and ops**, packaging, self-hosting guide. - **M5 — multi-provider OIDC**, key rotation, per-item content keys. diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index a1dd79a..0717a27 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 1b8496a..814305a 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(); @@ -81,7 +85,8 @@ internal sealed partial class DodoSshApp : Application async (url, cancellationToken) => await ServerConnection .SignInAsync(url, browser, TimeProvider.System, cancellationToken) .ConfigureAwait(false), - TimeProvider.System); + TimeProvider.System, + connections); desktop.MainWindow = new MainWindow { DataContext = viewModel }; 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 c22a633..e5294ca 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. @@ -118,6 +118,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; private bool disposed; @@ -132,6 +140,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// internal delegate Task SignInHandler(Uri serverUrl, 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, @@ -140,6 +153,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp IDeviceKeyStore deviceKeys, SignInHandler signIn, TimeProvider clock, + ISftpSessionFactory sftpSessions, Argon2Profile? passphraseProfile = null) { this.paths = paths; @@ -151,6 +165,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp this.clock = clock; this.passphraseProfile = passphraseProfile; + transfers = new TransfersViewModel(sftpSessions, clock); + // Subscribed for the life of the process, because the workspace lives that long and so does the tab // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; @@ -222,6 +238,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp [ObservableProperty] private VaultViewModel? vault; + /// 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. /// @@ -900,6 +924,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp await Vault.LoadAsync(cancellationToken).ConfigureAwait(true); + // After the load, because what the transfers screen takes from the vault is the host list and an + // empty one would leave its picker blank until the next unlock. + transfers.Attach(Vault, knownHosts); + // After the list exists, and it matters after a lock rather than after the first unlock: shells kept // running while the vault was closed, so some of these hosts are connected before their rows are a // second old. @@ -947,6 +975,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp // reappearing behind a lock screen. knownHosts.Close(); + // Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is + // holding references to them. What it does not give up is its connection or its queue — a transfer + // in flight is exactly the work this method exists not to destroy. + transfers.Detach(); + if (Vault is { } open) { Vault = null; @@ -973,6 +1006,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp knownHosts.Close(); + // Before the vault, and it waits: a transfer still writing has an open remote file and an open local + // one, and a process that exits while those are in flight leaves a part file longer than the bytes + // that reached it. + await transfers.DisposeAsync().ConfigureAwait(false); + if (Vault is { } open) { await open.DisposeAsync().ConfigureAwait(false); 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 978ae24..7d52025 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -1817,7 +1817,7 @@ internal sealed partial class VaultViewModel( // Refused rather than quietly falling back to the password box. A host set up for key-only access // that silently starts offering a password is the failure worth ruling out — the user asked for one // thing and got another, and the host is the last place that would say so. - if (!TryBuildAuthentication(row.Host, out var authentication, out var refusal)) + if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal)) { Status = refusal; return; @@ -2087,8 +2087,38 @@ internal sealed partial class VaultViewModel( /// answered by the more specific of the two rather than by whichever the code happened to check. /// /// + /// + /// 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) { @@ -2133,7 +2163,7 @@ internal sealed partial class VaultViewModel( } return Complete( - host.Username, new SshPasswordCredential(ConnectPassword), out authentication, out reason); + host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason); } /// diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml index 413c1ce..241a3b7 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. - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +