Public Access
Merge branch 'main' into claude/vault-unlock-logout-autosync-a84c35
ci / build and test (push) Failing after 3s
ci / build and test (push) Failing after 3s
Four files needed a hand, and all four were two branches adding something in the same place rather than either changing what the other did. The shell's constructor now takes both new parameters: main's SFTP session factory, which it must have because it builds the transfers view model, and this branch's optional resume handler, which stays last so every existing test that constructs a shell without one still gets a shell that can only be online because somebody signed in during this run. App.axaml.cs, ShellFlowTests and QuickConnectTests pass the pair; the layout suite keeps both of its new fields. Signing out now detaches the transfers screen exactly as locking does, and the confirmation says that an open transfer session survives it. That is the same policy both sides already argue for their own case: signing out destroys this machine's copy of the vault, not work that authenticated before it. QuickConnectTests did not compile on main — the SFTP commit added a constructor parameter and the quick-connect suite, merged from a parallel branch just before it, was still calling the old one. Fixed here rather than worked around, since the merged tree has to build. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 980 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -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.
|
||||
@@ -25,6 +25,7 @@
|
||||
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<Project Path="src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</Folder>
|
||||
|
||||
<Folder Name="/tests/">
|
||||
@@ -39,6 +40,7 @@
|
||||
<Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj" />
|
||||
|
||||
@@ -65,8 +65,9 @@ src/
|
||||
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
|
||||
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
|
||||
DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place
|
||||
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
||||
DodoSSH.Client.Ssh connections, PTY shells, SFTP, host key trust
|
||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
||||
tests/ one test project per source project
|
||||
docs/adr/ architecture decision records
|
||||
@@ -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.
|
||||
|
||||
+36
-14
@@ -18,9 +18,13 @@ M2 and M3 arriving in a design before it arrives in the code.
|
||||
Three facts explain nearly every row below.
|
||||
|
||||
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing
|
||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No SFTP subsystem, no port forwarding, no ProxyJump. That
|
||||
one fact removes the whole file-transfer screen, the `FORWARDS` chip, the status bar's port list, and every
|
||||
`via bastion-eu` in the design.
|
||||
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes
|
||||
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design.
|
||||
|
||||
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
|
||||
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
|
||||
its own transport. See [File transfer](#file-transfer-the-designs-sftp-screen), which is the one place in
|
||||
this document where what shipped differs from what the row predicted.
|
||||
|
||||
**Teams are schema and nothing else.** The `team` and `team_membership` tables exist from the first
|
||||
migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or
|
||||
@@ -50,7 +54,7 @@ protocol rather than a protocol change.
|
||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
|
||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||
| Status bar `sftp · 2 transfers` | client-ssh | File transfer. See below. | Omitted. |
|
||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||
@@ -86,18 +90,36 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
|
||||
## File transfer (the design's SFTP screen)
|
||||
|
||||
Nothing on this screen exists. It is listed in the nav rail and reaches a screen that says so, naming the
|
||||
milestone and what is missing — see `ShellScreen` for why it is not simply dropped from the rail.
|
||||
**Built in M2.** The screen ships: two directory panes, a breadcrumb trail on each, and a queue that moves
|
||||
one file at a time with progress, throughput and resume. What follows is what it does *not* do, and one
|
||||
thing this document got wrong before it was built.
|
||||
|
||||
| Design element | Layer | What it would take |
|
||||
**The correction.** The row below used to say an SFTP subsystem channel on `ISshConnection` was what it
|
||||
would take. SSH.NET does not offer that: `SftpClient` derives from `BaseClient` and owns its own transport,
|
||||
and there is no supported way to hand it a session an `SshClient` already has. So the transfers screen
|
||||
**opens a second authenticated connection** to the host rather than a second channel on the terminal's. That
|
||||
is visible to a user — the host records a second login, and a host whose password is typed each time asks
|
||||
for it again on this screen — so it is named for what it is: `ISftpSessionFactory.OpenSftpAsync` is a
|
||||
connect, and it goes through the same host key gate, the same pin and the same two refusals a shell does.
|
||||
|
||||
| Design element | Layer | What ships |
|
||||
| --- | --- | --- |
|
||||
| SFTP itself | client-ssh | An SFTP subsystem channel on `ISshConnection`. There is no `SftpClient`, `ScpClient` or transfer type anywhere in `src/`. |
|
||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | The channel, plus a listing record and a POSIX mode formatter — nothing in the repo formats a `drwxr-xr-x`. |
|
||||
| Local listing | client-app | The App project contains no `System.IO` usage at all. The only paths this client knows are its own two files. |
|
||||
| Path breadcrumbs, per-host last directory | client-storage | Navigation state for two panes, and somewhere to persist it. There is no settings table. |
|
||||
| Transfer queue, progress, throughput | client-ssh | A transfer engine. **Do not reach for `CreditWindow`** — that is a 256 KiB flow-control window for terminal output, not a transfer primitive. |
|
||||
| `resume supported` | client-ssh | Offset-based reads and writes, plus partial-transfer bookkeeping that survives a restart. |
|
||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. |
|
||||
| SFTP itself | client-ssh | `ISftpSession` over SSH.NET's `SftpClient`: listing, stat, offset-based read and write, mkdir, delete and rename. Delete is deliberately **not** recursive. |
|
||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
||||
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
||||
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
||||
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
|
||||
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
||||
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
||||
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
||||
| Transferring a directory | client-transfer | Recursive enumeration on both sides, and a policy for what a partial directory means. | One file at a time. A directory cannot be selected as a transfer source. |
|
||||
|
||||
**Not to be reached for:** `CreditWindow` is a 256 KiB flow-control window for terminal output, not a
|
||||
transfer primitive. The queue does its own 64 KiB copy loop and shares nothing with the terminal data plane.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -58,9 +58,13 @@ internal sealed partial class DodoSshApp : Application
|
||||
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
|
||||
var knownHosts = new VaultKnownHostStore();
|
||||
|
||||
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
|
||||
// and the same host key decision, and composing two would mean two snapshots of the pins.
|
||||
var connections = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var workspace = new TerminalWorkspace(
|
||||
new AvaloniaTerminalAssetProvider(),
|
||||
new SshNetConnectionFactory(knownHosts),
|
||||
connections,
|
||||
TimeProvider.System);
|
||||
|
||||
workspace.Start();
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -52,11 +53,10 @@ internal enum ShellState
|
||||
/// in you are, the other is what you are looking at once you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
|
||||
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
|
||||
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
|
||||
/// three entries would make the eventual arrival of the other two look like a new product rather than a
|
||||
/// milestone.
|
||||
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
|
||||
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
|
||||
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
|
||||
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
@@ -64,7 +64,7 @@ internal enum ShellScreen
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer. Nothing implements it yet.</summary>
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
Transfers = 1,
|
||||
|
||||
/// <summary>Everything in the vault that is not a host.</summary>
|
||||
@@ -126,6 +126,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
|
||||
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -165,6 +173,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <param name="sftpSessions">
|
||||
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
|
||||
/// application — one type implements both — and a separate parameter because it is a separate capability
|
||||
/// and the tests that drive this state machine have no use for it.
|
||||
/// </param>
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
|
||||
/// property that went null on lock would be a transfer nothing could report on afterwards.
|
||||
/// </remarks>
|
||||
internal TransfersViewModel Transfers => transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Shells that were left running when the vault was locked.
|
||||
/// </summary>
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the segment is called.</param>
|
||||
/// <param name="Path">The absolute path that reaches it.</param>
|
||||
internal sealed record CrumbViewModel(string Name, string Path);
|
||||
|
||||
/// <summary>One remote file or directory, as a row.</summary>
|
||||
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
{
|
||||
internal SftpEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsNavigable;
|
||||
|
||||
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
||||
|
||||
/// <remarks>
|
||||
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
||||
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
||||
/// </remarks>
|
||||
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
|
||||
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
||||
internal string Permissions => entry.Permissions;
|
||||
}
|
||||
|
||||
/// <summary>One local file or directory, as a row.</summary>
|
||||
/// <remarks>
|
||||
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
|
||||
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
|
||||
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
|
||||
/// </remarks>
|
||||
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
||||
{
|
||||
internal LocalEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsDirectory;
|
||||
|
||||
internal bool IsFile => !entry.IsDirectory;
|
||||
|
||||
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
/// <summary>How this screen writes a modification time.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
|
||||
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
|
||||
/// not written the same way, which is the only thing anybody does with this column.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one place in this application that deliberately ignores the user's locale — see the App project's
|
||||
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
|
||||
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class Timestamps
|
||||
{
|
||||
internal static string Format(DateTimeOffset moment) =>
|
||||
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>One transfer, as a row in the queue.</summary>
|
||||
/// <remarks>
|
||||
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
|
||||
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
|
||||
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
|
||||
/// only ever read from the UI thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private TransferSnapshot transfer = snapshot;
|
||||
|
||||
internal Guid Id => Transfer.Id;
|
||||
|
||||
internal string Name => Transfer.Name;
|
||||
|
||||
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
|
||||
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
|
||||
|
||||
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
|
||||
internal string Path => Transfer.Direction is TransferDirection.Download
|
||||
? Transfer.RemotePath
|
||||
: Transfer.LocalPath;
|
||||
|
||||
internal double Percent => Transfer.Fraction * 100;
|
||||
|
||||
/// <summary>
|
||||
/// What the row says about where it has got to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
|
||||
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
|
||||
/// whether it is still moving.
|
||||
/// </remarks>
|
||||
internal string Progress => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "queued",
|
||||
TransferState.Running =>
|
||||
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
|
||||
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
|
||||
TransferState.Completed => ByteSize.Format(Transfer.Length),
|
||||
TransferState.Cancelled when Transfer.Transferred > 0 =>
|
||||
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
|
||||
TransferState.Cancelled => "stopped",
|
||||
_ => Transfer.Failure ?? "failed",
|
||||
};
|
||||
|
||||
internal string StateLabel => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "QUEUED",
|
||||
TransferState.Running => "RUNNING",
|
||||
TransferState.Completed => "DONE",
|
||||
TransferState.Cancelled => "STOPPED",
|
||||
_ => "FAILED",
|
||||
};
|
||||
|
||||
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
|
||||
|
||||
internal bool IsFinished => Transfer.IsFinished;
|
||||
|
||||
internal bool CanResume => Transfer.CanResume;
|
||||
|
||||
internal bool HasFailed => Transfer.State is TransferState.Failed;
|
||||
|
||||
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
|
||||
/// <remarks>
|
||||
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
|
||||
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
|
||||
/// wording differs. See <see cref="RetryLabel"/>.
|
||||
/// </remarks>
|
||||
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
|
||||
|
||||
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
|
||||
|
||||
/// <remarks>
|
||||
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
|
||||
/// raising only the ones that happened to change is how a progress bar moves under a label that still
|
||||
/// says "queued".
|
||||
/// </remarks>
|
||||
partial void OnTransferChanged(TransferSnapshot value)
|
||||
{
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
OnPropertyChanged(nameof(Percent));
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsFinished));
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
OnPropertyChanged(nameof(CanRetry));
|
||||
OnPropertyChanged(nameof(HasFailed));
|
||||
OnPropertyChanged(nameof(RetryLabel));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
|
||||
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
|
||||
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
|
||||
/// The consequence a user sees is that the host records a second login, and that a host whose password is
|
||||
/// typed each time asks for it again here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
|
||||
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
|
||||
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
|
||||
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
|
||||
/// host list — those are decrypted vault items — and not the connection or the queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private readonly ISftpSessionFactory sftp;
|
||||
private readonly FileTransferQueue queue;
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
{
|
||||
this.sftp = sftp;
|
||||
|
||||
// The supplier answers with whatever session is current at the moment a transfer starts, which is
|
||||
// what lets a queue survive a disconnect and reconnect without every queued row failing.
|
||||
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
|
||||
queue.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
|
||||
// which is the life of the process.
|
||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
||||
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
|
||||
}
|
||||
|
||||
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
|
||||
/// <remarks>
|
||||
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
|
||||
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
||||
/// be a second decrypted copy of a secret for no gain.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <remarks>
|
||||
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
||||
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
||||
/// screen that quietly reused it would make a one-time password appear to work twice.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string typedPassword = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = "Choose a host and connect to browse its files.";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
|
||||
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string remotePath = string.Empty;
|
||||
|
||||
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
||||
|
||||
/// <summary>What a new directory would be called, when the user is making one.</summary>
|
||||
[ObservableProperty]
|
||||
private string newRemoteFolder = string.Empty;
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string localPath = LocalDirectory.Home;
|
||||
|
||||
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The drives this machine has, as somewhere the local pane can jump to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
|
||||
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
|
||||
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
|
||||
/// be unreachable from an application whose whole purpose on this screen is to move one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private LocalEntryRowViewModel? selectedLocalEntry;
|
||||
|
||||
internal bool HasLocalEntries => LocalEntries.Count > 0;
|
||||
|
||||
// ---- The queue ----
|
||||
|
||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||
|
||||
internal bool HasTransfers => Transfers.Count > 0;
|
||||
|
||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
|
||||
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
||||
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
// Read once per unlock rather than per navigation: a drive appearing while the application is open
|
||||
// is possible and rare, and probing every removable drive on every click into a folder is not.
|
||||
LocalRoots.Clear();
|
||||
|
||||
foreach (var root in LocalDirectory.Roots())
|
||||
{
|
||||
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
|
||||
}
|
||||
|
||||
RefreshLocalCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives up the vault, keeping the connection and anything in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
|
||||
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
|
||||
/// </remarks>
|
||||
internal void Detach()
|
||||
{
|
||||
vault = null;
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
SelectedHost = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
Status = "Choose a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Connecting to {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
try
|
||||
{
|
||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact, decided here rather than inherited from a terminal. File transfer is
|
||||
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
||||
// same pin a shell would then find.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
return;
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. Nothing was connected.";
|
||||
return;
|
||||
}
|
||||
|
||||
TypedPassword = string.Empty;
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the file-transfer session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
|
||||
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
|
||||
/// accident.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (queue.IsBusy)
|
||||
{
|
||||
Status = "There are transfers still running. Stop them first, or let them finish.";
|
||||
return;
|
||||
}
|
||||
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
Status = "Disconnected. Anything already transferred is where it landed.";
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered host key and connects.</summary>
|
||||
[RelayCommand]
|
||||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingHostKey is not { } presentation || knownHosts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
|
||||
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
|
||||
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
|
||||
/// with two clicks instead of one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
var wasOffered = PendingHostKey is not null;
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
Status = wasOffered
|
||||
? "The host key was not trusted, so nothing was connected."
|
||||
: "Nothing was connected.";
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
/// <summary>Goes to a remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Goes up one remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoteUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
|
||||
/// directory reports as a link and resolving every one of them would be a round trip per row. The
|
||||
/// failure, when it is a link to a file, is the server's own and says so.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is { IsNavigable: true } row)
|
||||
{
|
||||
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Goes to a local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void GoLocal(string path) => NavigateLocal(path);
|
||||
|
||||
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
|
||||
/// <remarks>
|
||||
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
|
||||
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void LocalUp()
|
||||
{
|
||||
if (LocalDirectory.Parent(LocalPath) is { } parent)
|
||||
{
|
||||
NavigateLocal(parent);
|
||||
return;
|
||||
}
|
||||
|
||||
Status = "That is the top of this drive. Use the drive list to go to another one.";
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void RefreshLocal() => NavigateLocal(LocalPath);
|
||||
|
||||
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenLocal()
|
||||
{
|
||||
if (SelectedLocalEntry is { IsNavigable: true } row)
|
||||
{
|
||||
NavigateLocal(row.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Moving files ----
|
||||
|
||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } row)
|
||||
{
|
||||
Status = "Choose a file on the host to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(LocalPath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for download into {LocalPath}.";
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Upload()
|
||||
{
|
||||
if (SelectedLocalEntry is not { IsFile: true } row)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
|
||||
|
||||
/// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
|
||||
[RelayCommand]
|
||||
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
|
||||
|
||||
/// <summary>Removes one stopped transfer and whatever it left behind.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
|
||||
// removed anyway would take the only view of a transfer that was still running.
|
||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearCompleted()
|
||||
{
|
||||
queue.ClearCompleted();
|
||||
|
||||
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---- Changing the remote directory ----
|
||||
|
||||
/// <summary>Creates a directory on the host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var name = NewRemoteFolder.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
Status = "Type a name for the new directory.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
// One directory, whose parent must exist. A name with a separator in it would be a request to
|
||||
// create a path, and this button creates a directory in the one on screen.
|
||||
Status = "A directory name cannot contain '/'. Make one level at a time.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Creating {name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession()
|
||||
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
NewRemoteFolder = string.Empty;
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created {name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose something on the host to delete.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
queue.Changed -= OnTransferChanged;
|
||||
|
||||
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
|
||||
// unwinding before the transport under it goes.
|
||||
await queue.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes to a remote directory, on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
|
||||
/// something else — connecting, making a directory, deleting one — can list without going back through
|
||||
/// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
|
||||
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
|
||||
/// anywhere to explain it.
|
||||
/// </remarks>
|
||||
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
RemotePath = path;
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
RemoteEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RemoteTrail.Clear();
|
||||
|
||||
foreach (var (name, crumb) in SftpPath.Trail(path))
|
||||
{
|
||||
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
||||
}
|
||||
|
||||
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
||||
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
||||
/// something that returns before the click has finished being handled.
|
||||
/// </remarks>
|
||||
private void NavigateLocal(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = LocalDirectory.List(path);
|
||||
|
||||
LocalPath = Path.GetFullPath(path);
|
||||
SelectedLocalEntry = null;
|
||||
|
||||
LocalEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
LocalEntries.Add(new LocalEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RebuildLocalTrail();
|
||||
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
|
||||
// pane would look like a directory that had become empty.
|
||||
Status = $"{path} could not be read: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
|
||||
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
|
||||
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
|
||||
/// </remarks>
|
||||
private void RebuildLocalTrail()
|
||||
{
|
||||
var crumbs = new List<CrumbViewModel>();
|
||||
|
||||
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
|
||||
{
|
||||
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
|
||||
}
|
||||
|
||||
LocalTrail.Clear();
|
||||
|
||||
foreach (var crumb in crumbs)
|
||||
{
|
||||
LocalTrail.Add(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshHosts()
|
||||
{
|
||||
Hosts.Clear();
|
||||
|
||||
if (vault is not { } open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var host in open.Hosts)
|
||||
{
|
||||
Hosts.Add(host);
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
private async Task CloseSessionAsync()
|
||||
{
|
||||
if (session is { } open)
|
||||
{
|
||||
session = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
|
||||
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
|
||||
/// instead of one for the first change and one for the rest.
|
||||
/// </remarks>
|
||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||
{
|
||||
existing.Transfer = e.Transfer;
|
||||
return;
|
||||
}
|
||||
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||
/// pane permanently disabled.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
Status = busyMessage;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Cancelled.";
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
|
||||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||
}
|
||||
@@ -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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Works out how to reach a host, or says why it cannot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same resolution the Connect button performs, exposed because file transfer opens its own
|
||||
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
|
||||
/// username" would be a second place for a dangling binding to be silently turned back into a typed
|
||||
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
|
||||
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
|
||||
/// password offered on the other.
|
||||
/// </remarks>
|
||||
internal bool TryBuildConnectionRequest(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
|
||||
{
|
||||
request = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new SshConnectionRequest(
|
||||
host.Hostname, host.Port, authentication.Username, authentication.Credential);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryBuildAuthentication(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -184,20 +184,15 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
|
||||
Title="FILE TRANSFER"
|
||||
Milestone="MILESTONE M2"
|
||||
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
|
||||
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
|
||||
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
|
||||
@@ -96,9 +96,15 @@ internal sealed partial class MainWindow : Window
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tunnelled handler rather than <c>KeyBindings</c>, because three of these four keys have to be
|
||||
/// intercepted before the control under the pointer sees them: Escape and the arrows belong to the
|
||||
/// palette while it is open, and the palette's own text box would otherwise eat them.
|
||||
/// Ctrl+K is here rather than on the palette because it has to work when the palette is not showing, and
|
||||
/// it is a plain handler rather than a <c>KeyBinding</c> so that toggling stays one code path with the
|
||||
/// rest of the chord set.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The palette's own keys are forwarded rather than answered: <see cref="QuickConnect"/> intercepts them
|
||||
/// on their way down while the focus is inside it, and this is the net for when it is not — a press that
|
||||
/// arrives with nothing focused, or from a control on the screen behind, still has to close the palette
|
||||
/// rather than fall through to whatever is underneath it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
|
||||
@@ -121,61 +127,12 @@ internal sealed partial class MainWindow : Window
|
||||
}
|
||||
else if (viewModel.IsSearching)
|
||||
{
|
||||
HandlePaletteKey(viewModel, e);
|
||||
Palette.HandleKey(e);
|
||||
}
|
||||
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The selection is moved here rather than by letting the list take focus, because the list taking
|
||||
/// focus is exactly what would stop the query box receiving the next character typed.
|
||||
/// </remarks>
|
||||
private static void HandlePaletteKey(MainWindowViewModel viewModel, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
viewModel.CloseSearchCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
viewModel.ConnectToSearchResultCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Move(viewModel, 1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Move(viewModel, -1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
private static void Move(MainWindowViewModel viewModel, int delta)
|
||||
{
|
||||
if (viewModel.SearchResults.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = viewModel.SelectedSearchResult is { } selected
|
||||
? viewModel.SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
viewModel.SelectedSearchResult =
|
||||
viewModel.SearchResults[Math.Clamp(current + delta, 0, viewModel.SearchResults.Count - 1)];
|
||||
}
|
||||
|
||||
private void Attach(MainWindowViewModel? viewModel)
|
||||
{
|
||||
if (shell is { } previous)
|
||||
@@ -233,11 +190,18 @@ internal sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
// The palette is a text box somebody is expected to start typing into immediately, so opening it
|
||||
// has to move the caret there — including out of the terminal, which needs the Win32 half as well.
|
||||
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
|
||||
// expected to start typing into immediately — but the palette does that for itself when it becomes
|
||||
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
|
||||
// still collapsed is a no-op that is not replayed when it is revealed.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
|
||||
{
|
||||
ReleaseKeyboardTo(viewModel.IsSearching ? Palette.QueryBox : KeyboardHome);
|
||||
if (!viewModel.IsSearching)
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
|
||||
Two of them — TRANSFERS and TEAM — reach screens that say they are not built. They are in the rail
|
||||
anyway rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screens
|
||||
behind these two say plainly what is missing, and a rail that quietly had three entries would make file
|
||||
transfer and sharing look like a change of product rather than the next two milestones. Both are
|
||||
recorded in docs/design-import-gaps.md.
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
plainly what is missing, and a rail that quietly had four entries would make sharing look like a change
|
||||
of product rather than the next milestone. It is recorded in docs/design-import-gaps.md. FILES was the
|
||||
other one until M2 built it.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
@@ -30,7 +30,7 @@
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="File transfer over SSH. Not built yet — see the screen for what is missing." />
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
an overlay across the middle of this window would be painted underneath it and take no clicks.
|
||||
-->
|
||||
|
||||
<Border Background="#CC0A0C0B">
|
||||
<!--
|
||||
The wash is named and takes a press, because clicking away from a palette is how every palette closes.
|
||||
The handler answers only for presses whose source is the wash itself, which is what separates "outside"
|
||||
from "inside": a press anywhere on the card below reports that control as its source and bubbles through
|
||||
here on its way to the window.
|
||||
-->
|
||||
<Border x:Name="Backdrop" Background="#CC0A0C0B" PointerPressed="OnBackdropPressed">
|
||||
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
@@ -39,8 +45,8 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the window's key handler owns both, because a
|
||||
ListBox that took focus would take the arrow keys away from the box being typed into.
|
||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||
took focus would take the arrow keys away from the box being typed into.
|
||||
-->
|
||||
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
|
||||
@@ -1,17 +1,174 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The Ctrl+K host search, overlaid on the window.</summary>
|
||||
/// <remarks>
|
||||
/// The keys it responds to are handled by <see cref="MainWindow"/> 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.
|
||||
/// <para>
|
||||
/// Everything a palette does once it is open belongs here rather than in <see cref="MainWindow"/>: 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The split used to fall the other way, and the cost was that none of it could be tested. Showing
|
||||
/// <see cref="MainWindow"/> initialises WebView2, which refuses the headless dispatcher's thread — see
|
||||
/// <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c> — so behaviour that lived on the window could only
|
||||
/// be checked by hand. A <see cref="UserControl"/> hosts in a bare window and takes real key and pointer
|
||||
/// input.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class QuickConnect : UserControl
|
||||
{
|
||||
public QuickConnect() => InitializeComponent();
|
||||
public QuickConnect()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
/// <summary>The box, so the window can put the caret in it the moment the palette opens.</summary>
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
||||
internal TextBox QueryBox => Query;
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <summary>
|
||||
/// Answers one of the palette's keys, wherever in the window it was pressed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Internal because <see cref="MainWindow"/> 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard, so the palette can be typed into the instant it appears.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Done here rather than by the window, and that is the fix rather than a tidying. <c>Focus()</c> 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 <c>PropertyChanged</c> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>IsVisible</c> turns true the query box reports <c>IsAttachedToVisualTree() == false</c>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
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)];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<bool>())
|
||||
{
|
||||
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPaletteKey(object? sender, KeyEventArgs e) => HandleKey(e);
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The other thing that outlives signing out, on the same reasoning and worth its own line because it is
|
||||
a connection this machine holds rather than a window it shows: a transfer in flight authenticated
|
||||
before any of this and keeps writing into its part file afterwards.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Transfers.IsConnected, FallbackValue=False}"
|
||||
Text="A file-transfer session is open on this machine. Signing out does not close it either — it goes when DodoSSH does." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TransfersScreen"
|
||||
x:DataType="vm:TransfersViewModel">
|
||||
|
||||
<!--
|
||||
File transfer: this machine on the left, the host on the right, and the queue underneath.
|
||||
|
||||
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
|
||||
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
|
||||
so browsing a host's files is a second authenticated connection rather than a second channel, and
|
||||
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
|
||||
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
|
||||
about a file on the machine this client is developed on.
|
||||
|
||||
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
|
||||
layer has not got. See docs/design-import-gaps.md.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="5" Classes="hint" Text="{Binding Status}" FontSize="10.5"
|
||||
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ The two panes ============ -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="ghost" Content="{Binding Name}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
<TextBlock Classes="mono" Text="›" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="3" Classes="hint" FontSize="11" Margin="24" MaxWidth="260"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
|
||||
<!--
|
||||
Pointing at the pane the file is going to, which is the only reading that survives the panes
|
||||
being side by side: the left-hand pane is this machine, so an upload points right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
|
||||
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
|
||||
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
|
||||
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Classes="mono" Text="/" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||||
"that name is already taken" is a dead end.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasRemoteEntries}">
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
|
||||
IsVisible="{Binding !IsConnected}" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Nothing in this directory."
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The queue ============ -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="10.5" Margin="12,4,12,14"
|
||||
IsVisible="{Binding !HasTransfers}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="11"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10.5"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="9.5"
|
||||
Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding StateLabel}" />
|
||||
</Border>
|
||||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
||||
IsVisible="{Binding HasTransfers}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||||
trusted for this too, but a host nobody has connected to at all is met here first.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasPendingHostKey}">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
|
||||
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
|
||||
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, and because
|
||||
// opening a directory has to be reachable without the mouse as well: both lists are ListBoxes, so
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
}
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, which is what a double-click on a directory can be: the command reports its own
|
||||
/// failures onto the screen's status line, and awaiting it here would mean an event handler that returns
|
||||
/// a task nothing observes — the same thing with a warning suppressed.
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>What kind of thing a remote directory entry is.</summary>
|
||||
/// <remarks>
|
||||
/// Taken from the attributes a listing already carries, which are <c>lstat</c> attributes: a symbolic link
|
||||
/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
|
||||
/// <see cref="SftpEntryKind.SymbolicLink"/> stays its own answer and the caller finds out what it leads to by
|
||||
/// trying to list it — see <see cref="ISftpSession.ListAsync"/>.
|
||||
/// </remarks>
|
||||
public enum SftpEntryKind
|
||||
{
|
||||
/// <summary>An ordinary file.</summary>
|
||||
File = 0,
|
||||
|
||||
/// <summary>A directory.</summary>
|
||||
Directory = 1,
|
||||
|
||||
/// <summary>A symbolic link, to something this listing did not resolve.</summary>
|
||||
SymbolicLink = 2,
|
||||
|
||||
/// <summary>A socket, device, pipe or anything else that is not one of the three above.</summary>
|
||||
Other = 3,
|
||||
}
|
||||
|
||||
/// <summary>One entry in a remote directory.</summary>
|
||||
/// <param name="Name">The entry's own name, with no path.</param>
|
||||
/// <param name="FullPath">The absolute path, which is what every operation takes.</param>
|
||||
/// <param name="Kind">What it is.</param>
|
||||
/// <param name="Length">Size in bytes. Meaningless for anything that is not a file, and zero there.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <param name="Permissions">The mode as <c>drwxr-xr-x</c>; see <see cref="PosixMode"/>.</param>
|
||||
/// <remarks>
|
||||
/// A record of what the server said rather than a handle. Nothing here holds a channel open, so a listing can
|
||||
/// be kept on a screen after the session behind it has gone — which is what the transfers screen does while a
|
||||
/// connection is being re-established.
|
||||
/// </remarks>
|
||||
public sealed record SftpEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
SftpEntryKind Kind,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc,
|
||||
string Permissions)
|
||||
{
|
||||
/// <summary>Whether this is somewhere the file browser can navigate into.</summary>
|
||||
/// <remarks>
|
||||
/// True for a symbolic link as well as a directory, because a link to a directory is the ordinary way a
|
||||
/// remote filesystem is laid out and refusing to open one would make those paths unreachable. A link to a
|
||||
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
||||
/// </remarks>
|
||||
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders POSIX permission bits the way <c>ls -l</c> does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing in this repository formatted a mode before, and the design's file listing has a <c>PERMS</c>
|
||||
/// column. It is written from the individual bits rather than from an octal mode because that is the shape
|
||||
/// SFTP hands over — <c>ISftpFile</c> exposes nine booleans and a set of kind predicates, and reassembling
|
||||
/// them into an octal number only to take it apart again would be a round trip through a representation
|
||||
/// neither end uses.
|
||||
/// <para>
|
||||
/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
|
||||
/// surface them on <c>ISftpFile</c>, and a column that showed <c>rwx</c> where <c>rws</c> was true would be
|
||||
/// worse than one that never claims to render them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class PosixMode
|
||||
{
|
||||
/// <summary>Formats one entry's mode, kind character included.</summary>
|
||||
public static string Format(
|
||||
SftpEntryKind kind,
|
||||
bool ownerRead,
|
||||
bool ownerWrite,
|
||||
bool ownerExecute,
|
||||
bool groupRead,
|
||||
bool groupWrite,
|
||||
bool groupExecute,
|
||||
bool othersRead,
|
||||
bool othersWrite,
|
||||
bool othersExecute)
|
||||
{
|
||||
Span<char> mode = stackalloc char[10];
|
||||
|
||||
mode[0] = kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 'd',
|
||||
SftpEntryKind.SymbolicLink => 'l',
|
||||
SftpEntryKind.File => '-',
|
||||
_ => '?',
|
||||
};
|
||||
|
||||
Write(mode[1..4], ownerRead, ownerWrite, ownerExecute);
|
||||
Write(mode[4..7], groupRead, groupWrite, groupExecute);
|
||||
Write(mode[7..10], othersRead, othersWrite, othersExecute);
|
||||
|
||||
return new string(mode);
|
||||
|
||||
static void Write(Span<char> triple, bool read, bool write, bool execute)
|
||||
{
|
||||
triple[0] = read ? 'r' : '-';
|
||||
triple[1] = write ? 'w' : '-';
|
||||
triple[2] = execute ? 'x' : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote paths, which are POSIX paths whatever this client is running on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not <see cref="Path"/>.</b> The BCL's path helpers use the local platform's separator, so on Windows
|
||||
/// <c>Path.Combine("/var", "log")</c> yields <c>/var\log</c> — a path the remote will not resolve and which
|
||||
/// fails as "no such file" somewhere the user cannot see the backslash. Every remote path in this codebase
|
||||
/// goes through here.
|
||||
/// </remarks>
|
||||
public static class SftpPath
|
||||
{
|
||||
/// <summary>The root of a remote filesystem.</summary>
|
||||
public const string Root = "/";
|
||||
|
||||
/// <summary>Joins a directory and a name.</summary>
|
||||
public static string Combine(string directory, string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||
|
||||
return directory.EndsWith('/') ? directory + name : directory + "/" + name;
|
||||
}
|
||||
|
||||
/// <summary>The directory holding a path, or the path itself when it is already the root.</summary>
|
||||
public static string Parent(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
|
||||
return slash switch
|
||||
{
|
||||
< 0 => Root,
|
||||
0 => Root,
|
||||
_ => trimmed[..slash],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The last segment of a path, or the root when that is all there is.</summary>
|
||||
/// <remarks>
|
||||
/// The root names itself. Taking the text after its only separator leaves an empty string, which as a
|
||||
/// heading or a tab label is a blank where a path should be.
|
||||
/// </remarks>
|
||||
public static string Name(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
var name = slash < 0 ? trimmed : trimmed[(slash + 1)..];
|
||||
|
||||
return name.Length == 0 ? Root : name;
|
||||
}
|
||||
|
||||
/// <summary>Whether a path is already anchored at the root.</summary>
|
||||
public static bool IsAbsolute(string path) => path.StartsWith('/');
|
||||
|
||||
/// <summary>
|
||||
/// The segments of a path, for a breadcrumb trail.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Each segment paired with the absolute path that reaches it, root first. An empty list for the root
|
||||
/// itself, which has no segment to name.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<(string Name, string Path)> Trail(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trail = new List<(string, string)>();
|
||||
var walked = string.Empty;
|
||||
|
||||
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
walked = Combine(walked.Length == 0 ? Root : walked, segment);
|
||||
trail.Add((segment, walked));
|
||||
}
|
||||
|
||||
return trail;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file-transfer session on one host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a connection, not a channel.</b> The obvious shape would have been
|
||||
/// <c>ISshConnection.OpenSftpAsync</c>, opening the SFTP subsystem beside the shell on the transport that is
|
||||
/// already up — which is what SSH itself allows and what <c>docs/design-import-gaps.md</c> assumed it would
|
||||
/// take. SSH.NET does not offer it: <c>SftpClient</c> derives from <c>BaseClient</c> and owns its own
|
||||
/// transport, and there is no supported way to hand it an existing <c>SshClient</c>'s session. So opening one
|
||||
/// of these authenticates again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is named for that rather than dressed up as a channel, because the difference is visible to a user: the
|
||||
/// host sees a second login, a one-time password would be asked for twice, and closing every terminal on a
|
||||
/// host does not close its file browser. <see cref="ISshConnectionFactory.OpenSftpAsync"/> is a
|
||||
/// <em>connect</em>, and host key trust is checked on it exactly as it is for a shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One session is one channel, and everything on it shares that channel's window — so the queue that drives
|
||||
/// this runs one transfer at a time. That is a throughput decision rather than a safety one: two large
|
||||
/// transfers over one channel do not go faster than one, they arrive later and both at once. Browsing while a
|
||||
/// transfer runs is fine, and is the point of not opening a session per transfer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISftpSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Whether the transport is still up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the session starts, which is the account's home directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Absolute, because the server resolves it during the handshake. It is the only path this layer knows
|
||||
/// without asking, and it is what a file browser should open on.
|
||||
/// </remarks>
|
||||
string HomeDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>.</c> and <c>..</c> are dropped: every remote directory has them, no user is choosing between them,
|
||||
/// and the way up is a breadcrumb rather than a row. Ordered directories first and then by name, which is
|
||||
/// what a file browser has to show and what saves every caller sorting it again.
|
||||
/// </remarks>
|
||||
/// <exception cref="SftpPathException">The path is not a directory, or is not readable.</exception>
|
||||
Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>What one path is, or null when nothing is there.</summary>
|
||||
Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for reading, starting at an offset.
|
||||
/// </summary>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted download resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for writing, starting at an offset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates the file when it is not there, and does not truncate one that is — an offset of zero over an
|
||||
/// existing file overwrites from the beginning and leaves any tail beyond what is written. Callers are
|
||||
/// expected to write to a path nothing else holds; the transfer queue writes to a part file for exactly
|
||||
/// this reason.
|
||||
/// </remarks>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted upload resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not recursive. A recursive remote delete is the one operation on this screen that can
|
||||
/// destroy something no undo reaches, and offering it behind the same button as deleting one file is how
|
||||
/// that happens by accident. A non-empty directory fails, and says so.
|
||||
/// </remarks>
|
||||
Task DeleteAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Renames or moves a path within the same host.</summary>
|
||||
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the remote filesystem could not be reached, and the server said why.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One exception for the whole surface rather than one per operation, because there is exactly one thing a
|
||||
/// caller does with any of them: show the message beside the path it was about. SSH.NET raises several
|
||||
/// unrelated types for what a user experiences as one condition — <c>SftpPathNotFoundException</c>,
|
||||
/// <c>SftpPermissionDeniedException</c>, and a bare <c>SshException</c> for the rest — and the path is not on
|
||||
/// all of them.
|
||||
/// </remarks>
|
||||
public sealed class SftpPathException(string path, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
/// <summary>The path the failure was about.</summary>
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
|
||||
/// <summary>Opens file-transfer sessions.</summary>
|
||||
/// <remarks>
|
||||
/// Declared beside <see cref="ISshConnectionFactory"/> and implemented by the same type, because both start
|
||||
/// with the same handshake and the same host key decision. See <see cref="ISftpSession"/> for why this is a
|
||||
/// separate connect rather than a channel on a connection that already exists.
|
||||
/// </remarks>
|
||||
public interface ISftpSessionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects, authenticates, and starts the SFTP subsystem.
|
||||
/// </summary>
|
||||
/// <exception cref="SshHostKeyUnknownException">
|
||||
/// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
|
||||
/// explicit confirmation, and retry.
|
||||
/// </exception>
|
||||
/// <exception cref="SshHostKeyMismatchException">
|
||||
/// The presented key differs from the pin. There is no retry path.
|
||||
/// </exception>
|
||||
Task<ISftpSession> OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Formats a byte count the way a file browser shows one.</summary>
|
||||
/// <remarks>
|
||||
/// Here rather than in the view model because the transfer queue's own progress reporting needs the same
|
||||
/// wording, and two spellings of "1.4 MB" in one window reads as two different measurements.
|
||||
/// </remarks>
|
||||
public static class ByteSize
|
||||
{
|
||||
private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
/// <summary>Formats a byte count to three significant figures.</summary>
|
||||
public static string Format(long bytes)
|
||||
{
|
||||
if (bytes < 1024)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{bytes} B");
|
||||
}
|
||||
|
||||
double scaled = bytes;
|
||||
var unit = 0;
|
||||
|
||||
while (scaled >= 1024 && unit < Units.Length - 1)
|
||||
{
|
||||
scaled /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
// One decimal below ten, none above: "9.4 MB" and "512 MB" are both three characters of information,
|
||||
// and "512.3 MB" is a precision the number does not have by the time it is that large.
|
||||
return scaled < 10
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{scaled:0.0} {Units[unit]}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{scaled:0} {Units[unit]}");
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,22 @@ namespace DodoSSH.Client.Ssh;
|
||||
/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
|
||||
/// exception the caller resolves asynchronously.
|
||||
/// </remarks>
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
: ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// How much of a file SSH.NET reads or writes per SFTP request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SSH.NET's default is 32 KiB, which is a request per 32 KiB and a round trip's latency between each on
|
||||
/// a link the window would happily keep full. 64 KiB is the largest an OpenSSH server accepts without
|
||||
/// negotiation, so it is the ceiling rather than a guess — anything above it is answered with a shorter
|
||||
/// read, which SSH.NET handles but which buys nothing.
|
||||
/// </remarks>
|
||||
private const uint SftpBufferSize = 64 * 1024;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -25,6 +37,62 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SshClient(BuildConnectionInfo(request));
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A second connection to the host rather than a second channel on one that may already be open — see
|
||||
/// <see cref="ISftpSession"/> for why SSH.NET leaves no choice. Everything that guards a shell guards this
|
||||
/// too, because it is the same handshake: the same host key gate, the same pin, the same two refusals.
|
||||
/// </remarks>
|
||||
public async Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
// like a field — and the session's own remark promises this is the one path known without asking.
|
||||
string home;
|
||||
|
||||
try
|
||||
{
|
||||
home = client.WorkingDirectory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetSftpSession(client, gate.Presented!, home);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the shell and the file-transfer paths over <c>BaseClient</c>, which is where SSH.NET puts
|
||||
/// both <c>ConnectAsync</c> and <c>HostKeyReceived</c>. The alternative was the same twelve lines twice,
|
||||
/// and the half worth getting wrong is the translation: without it a user who has never seen a host is
|
||||
/// told the connection was lost.
|
||||
/// </remarks>
|
||||
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||
BaseClient client,
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
@@ -47,7 +115,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
using Renci.SshNet.Sftp;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>An SSH.NET-backed file-transfer session.</summary>
|
||||
/// <remarks>
|
||||
/// Thin on purpose. Everything above this reasons about <see cref="SftpEntry"/> and <see cref="Stream"/>,
|
||||
/// which is what keeps the transfer queue and the file browser testable without a server — and the one thing
|
||||
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
||||
/// caller can act on.
|
||||
/// </remarks>
|
||||
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
: ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => client.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<SftpEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var file in client
|
||||
.ListDirectoryAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// Every directory has these and no user is choosing between them. The way up is the
|
||||
// breadcrumb trail, which cannot be mistaken for a file.
|
||||
if (file.Name is "." or "..")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(Describe(file));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
// Directories first and then by name, which is the order a file browser shows and the order that
|
||||
// saves every caller sorting it again. Ordinal, because a remote filesystem's names are bytes the
|
||||
// server never claimed a culture for, and a listing whose order depended on this machine's locale
|
||||
// would put the same directory in two orders on two of a user's machines.
|
||||
entries.Sort(static (left, right) => left.Kind == right.Kind
|
||||
? string.CompareOrdinal(left.Name, right.Name)
|
||||
: Rank(left.Kind).CompareTo(Rank(right.Kind)));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Describe(file);
|
||||
}
|
||||
catch (SftpPathNotFoundException)
|
||||
{
|
||||
// Absent is an answer rather than a failure. Every caller here is asking whether something is
|
||||
// already there, and turning "no" into an exception would put a try/catch at each of them.
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// One call for both kinds: SSH.NET stats the path and issues rmdir or remove accordingly. A
|
||||
// non-empty directory fails here, which is the refusal this interface promises.
|
||||
await client.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.RenameFileAsync(fromPath, toPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
// Named for the destination, which is what the caller chose and what a collision is about. The
|
||||
// source is a path the transfer queue made up.
|
||||
throw Translate(toPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
client.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Seeking after the open rather than asking for an appending mode. <c>FileMode.Append</c> would give the
|
||||
/// right position for an upload and nothing for a download, and a resume has to be able to start at an
|
||||
/// offset that is <em>not</em> the end — a part file whose tail was written by an interrupted transfer is
|
||||
/// exactly that case, and the queue truncates to a known-good length before resuming.
|
||||
/// </remarks>
|
||||
private async Task<Stream> OpenAsync(
|
||||
string path,
|
||||
FileMode mode,
|
||||
FileAccess access,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(offset);
|
||||
|
||||
SftpFileStream stream;
|
||||
|
||||
try
|
||||
{
|
||||
stream = await client.OpenAsync(path, mode, access, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
if (offset == 0)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
stream.Seek(offset, SeekOrigin.Begin);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>Directories first, then links, then everything else.</summary>
|
||||
private static int Rank(SftpEntryKind kind) => kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 0,
|
||||
SftpEntryKind.SymbolicLink => 1,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
private static SftpEntry Describe(ISftpFile file)
|
||||
{
|
||||
// Checked in this order because the predicates are not exclusive: a symbolic link to a directory
|
||||
// answers true to both, and calling that a directory would hide the fact that opening it depends on
|
||||
// the server resolving a link. Asking about the link first is the honest reading of an lstat.
|
||||
var kind = file switch
|
||||
{
|
||||
{ IsSymbolicLink: true } => SftpEntryKind.SymbolicLink,
|
||||
{ IsDirectory: true } => SftpEntryKind.Directory,
|
||||
{ IsRegularFile: true } => SftpEntryKind.File,
|
||||
_ => SftpEntryKind.Other,
|
||||
};
|
||||
|
||||
return new SftpEntry(
|
||||
file.Name,
|
||||
file.FullName,
|
||||
kind,
|
||||
|
||||
// Only a file's length means anything. A directory's is the size of its own inode, which is a
|
||||
// number no user has ever wanted in a SIZE column.
|
||||
kind is SftpEntryKind.File ? file.Length : 0,
|
||||
new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
PosixMode.Format(
|
||||
kind,
|
||||
file.OwnerCanRead,
|
||||
file.OwnerCanWrite,
|
||||
file.OwnerCanExecute,
|
||||
file.GroupCanRead,
|
||||
file.GroupCanWrite,
|
||||
file.GroupCanExecute,
|
||||
file.OthersCanRead,
|
||||
file.OthersCanWrite,
|
||||
file.OthersCanExecute));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="SshException"/> covers the SFTP-specific types as well, since both derive from it. It is
|
||||
/// deliberately wide: the SFTP protocol returns a status code and a server-supplied message for
|
||||
/// everything from a missing file to a full disk, and SSH.NET surfaces most of them as a bare
|
||||
/// <see cref="SshException"/> carrying that message. What must <em>not</em> be caught is cancellation and
|
||||
/// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
|
||||
/// </remarks>
|
||||
private static bool IsPathFailure(Exception exception) =>
|
||||
exception is SshException or IOException or UnauthorizedAccessException;
|
||||
|
||||
private static SftpPathException Translate(string path, Exception exception) => exception switch
|
||||
{
|
||||
SftpPathNotFoundException => new SftpPathException(path, $"{path} is not there.", exception),
|
||||
|
||||
SftpPermissionDeniedException => new SftpPathException(
|
||||
path, $"The server refused access to {path}.", exception),
|
||||
|
||||
// The server's own words. They are the only description of a full disk, a quota or a read-only mount
|
||||
// that this client could produce, and inventing a friendlier sentence would lose them.
|
||||
_ => new SftpPathException(path, $"{path}: {exception.Message}", exception),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The transfer engine: a queue, one transfer at a time, and the local half of a two-pane file
|
||||
browser. Its own project rather than more of DodoSSH.Client.Ssh, because the two answer
|
||||
different questions — that one is about reaching a host, this one is about moving bytes and
|
||||
what to do when moving them stops halfway — and because this is the only client project that
|
||||
deliberately touches the local filesystem.
|
||||
|
||||
Avalonia-free, like every client project but App. The queue is driven by tests against a real
|
||||
temporary directory and a fake SFTP session, with no UI thread anywhere.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Transfer.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,870 @@
|
||||
using System.Buffers;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>Which way the bytes are going.</summary>
|
||||
public enum TransferDirection
|
||||
{
|
||||
/// <summary>From the host to this machine.</summary>
|
||||
Download = 0,
|
||||
|
||||
/// <summary>From this machine to the host.</summary>
|
||||
Upload = 1,
|
||||
}
|
||||
|
||||
/// <summary>Where one transfer has got to.</summary>
|
||||
public enum TransferState
|
||||
{
|
||||
/// <summary>Waiting for the one in front of it.</summary>
|
||||
Queued = 0,
|
||||
|
||||
/// <summary>Moving bytes.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Finished, and the file is at its final name.</summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>Stopped by a failure, which <see cref="TransferSnapshot.Failure"/> describes.</summary>
|
||||
Failed = 3,
|
||||
|
||||
/// <summary>Stopped because the user asked.</summary>
|
||||
Cancelled = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One transfer, as the queue last saw it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A snapshot rather than a live object, because the queue mutates its entries from whichever thread the
|
||||
/// pump is on and the interface reads them from the UI thread. Handing out an immutable record per change is
|
||||
/// what lets the view model be a plain list of values with no locking of its own.
|
||||
/// </remarks>
|
||||
/// <param name="Id">Identifies the transfer for <see cref="FileTransferQueue.Cancel"/> and the rest.</param>
|
||||
/// <param name="Direction">Which way it is going.</param>
|
||||
/// <param name="Name">The file's own name, which is what a queue row is headed with.</param>
|
||||
/// <param name="LocalPath">The local end, whichever end that is.</param>
|
||||
/// <param name="RemotePath">The remote end.</param>
|
||||
/// <param name="Length">
|
||||
/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
|
||||
/// </param>
|
||||
/// <param name="Transferred">How many have moved, resumed bytes included.</param>
|
||||
/// <param name="State">Where it has got to.</param>
|
||||
/// <param name="BytesPerSecond">Recent throughput, or zero when nothing is moving.</param>
|
||||
/// <param name="Failure">Why it stopped, when it stopped badly.</param>
|
||||
public sealed record TransferSnapshot(
|
||||
Guid Id,
|
||||
TransferDirection Direction,
|
||||
string Name,
|
||||
string LocalPath,
|
||||
string RemotePath,
|
||||
long Length,
|
||||
long Transferred,
|
||||
TransferState State,
|
||||
double BytesPerSecond,
|
||||
string? Failure)
|
||||
{
|
||||
/// <summary>How far along, between 0 and 1.</summary>
|
||||
/// <remarks>
|
||||
/// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
|
||||
/// row says <see cref="TransferState.Completed"/>; a progress bar that filled instead would be the one
|
||||
/// case where the bar told a different story from the state beside it.
|
||||
/// </remarks>
|
||||
public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);
|
||||
|
||||
/// <summary>Whether this transfer will not move again on its own.</summary>
|
||||
public bool IsFinished =>
|
||||
State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a partial file to carry on from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only for a transfer this queue itself left partial. See <see cref="FileTransferQueue.Retry"/> for why
|
||||
/// a partial file found lying about is not resumed.
|
||||
/// </remarks>
|
||||
public bool CanResume =>
|
||||
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
||||
}
|
||||
|
||||
/// <summary>A transfer whose state or progress has moved.</summary>
|
||||
/// <param name="transfer">The transfer, as it now is.</param>
|
||||
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
||||
{
|
||||
/// <summary>The transfer.</summary>
|
||||
public TransferSnapshot Transfer { get; } = transfer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfer queue: one file at a time, resumable, over one SFTP session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One at a time.</b> Everything here shares one channel's window, so a second concurrent transfer does
|
||||
/// not make the pair finish sooner — it makes both finish later, and it makes the progress of each
|
||||
/// unreadable. A serial queue also means the throughput figure on a row is the throughput of the link, which
|
||||
/// is the only reading of that number anybody acts on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing is written at its final name until it is complete.</b> Every transfer goes to a part file
|
||||
/// beside its destination and is renamed into place at the end, so an interrupted transfer can never be
|
||||
/// mistaken for a finished one — which matters most for the thing people actually do with this screen, which
|
||||
/// is copy a build artefact onto a server and then run it. A destination that already exists is refused
|
||||
/// outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody
|
||||
/// else's process is serving is the worse of the two failures.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resume is within a run of the application.</b> A part file left by an interrupted transfer is carried
|
||||
/// on from by <see cref="Retry"/>, which knows the source it came from. A part file found at startup is not:
|
||||
/// nothing here records what wrote it, and resuming a file from the middle on the strength of its name
|
||||
/// matching is how a corrupted artefact gets delivered without anything reporting a failure. Making that
|
||||
/// survive a restart needs somewhere to write the bookkeeping, which this client does not yet have — see
|
||||
/// <c>docs/design-import-gaps.md</c> on the missing preferences store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FileTransferQueue : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What an in-flight transfer's destination is called until it is finished.</summary>
|
||||
/// <remarks>
|
||||
/// Long and specific rather than the conventional <c>.part</c>, because this file appears in a directory
|
||||
/// somebody else may be looking at — on a shared server, in a deployment directory — and a name that
|
||||
/// says which program left it there is the difference between a question and an incident.
|
||||
/// </remarks>
|
||||
internal const string PartSuffix = ".dodossh-part";
|
||||
|
||||
/// <remarks>
|
||||
/// The same 64 KiB the SFTP session reads in, so a copy is one read and one write per SFTP request with
|
||||
/// no re-chunking in between.
|
||||
/// </remarks>
|
||||
private const int BufferSize = 64 * 1024;
|
||||
|
||||
/// <remarks>
|
||||
/// Throughput over a window rather than since the start, because the number people read it for is "is it
|
||||
/// still going, and how fast now" — an average since the start of a resumed multi-gigabyte transfer
|
||||
/// answers a question nobody asked. Half a second is long enough that one slow request does not make it
|
||||
/// jump and short enough that a stall shows up before it is worth investigating.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <remarks>
|
||||
/// A progress event per buffer would be some thousands a second on a fast link, each one marshalled to
|
||||
/// the UI thread to move a bar by a pixel. Ten a second is smooth to a person and free to the window.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly Func<CancellationToken, Task<ISftpSession>> sessions;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly List<Entry> transfers = [];
|
||||
private readonly Lock gate = new();
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task pump = Task.CompletedTask;
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="sessions">
|
||||
/// Where the queue gets a session from, asked once per drain. A delegate rather than a session, because
|
||||
/// the screen owns the connection and may have re-established it since the last transfer ran — and a
|
||||
/// queue holding a stale session would fail every row with a socket error instead of reconnecting.
|
||||
/// </param>
|
||||
/// <param name="clock">Time source, so throughput is measurable without waiting for real seconds.</param>
|
||||
public FileTransferQueue(Func<CancellationToken, Task<ISftpSession>> sessions, TimeProvider clock)
|
||||
{
|
||||
this.sessions = sessions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a transfer's state or progress changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Raised on the pump's thread</b>, which is a thread-pool thread. A handler that touches an
|
||||
/// observable collection has to marshal; this project has no toolkit to do it with, which is exactly why
|
||||
/// it does not try. The same arrangement as <c>TerminalWorkspace.SessionEnded</c>.
|
||||
/// </remarks>
|
||||
public event EventHandler<TransferChangedEventArgs>? Changed;
|
||||
|
||||
/// <summary>Every transfer this queue knows about, in the order they were added.</summary>
|
||||
public IReadOnlyList<TransferSnapshot> Snapshot()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. transfers.Select(Describe)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether anything is queued or running.</summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.Exists(entry =>
|
||||
entry.State is TransferState.Queued or TransferState.Running);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a transfer and starts the queue if it is not already running.
|
||||
/// </summary>
|
||||
/// <param name="direction">Which way.</param>
|
||||
/// <param name="localPath">The local end. For a download this is the file to create.</param>
|
||||
/// <param name="remotePath">The remote end. For an upload this is the file to create.</param>
|
||||
/// <param name="length">
|
||||
/// How many bytes there are, from the listing on the side that already has the file. Taken as given
|
||||
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
||||
/// would be a round trip to learn something already on screen.
|
||||
/// </param>
|
||||
/// <returns>The transfer's id.</returns>
|
||||
public Guid Enqueue(
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Direction = direction,
|
||||
LocalPath = localPath,
|
||||
RemotePath = remotePath,
|
||||
Name = direction is TransferDirection.Download
|
||||
? SftpPath.Name(remotePath)
|
||||
: Path.GetFileName(localPath),
|
||||
Length = length,
|
||||
State = TransferState.Queued,
|
||||
};
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
transfers.Add(entry);
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a transfer, or takes a queued one out of the queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file is left where it is, which is what makes <see cref="Retry"/> a resume rather than a
|
||||
/// restart. <see cref="Discard"/> is how a row and its part file go.
|
||||
/// </remarks>
|
||||
public void Cancel(Guid id)
|
||||
{
|
||||
Entry? cancelled = null;
|
||||
CancellationTokenSource? running = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case TransferState.Running:
|
||||
// Marked by the run itself when the cancellation lands, so a transfer that was already
|
||||
// finishing is not relabelled after the fact.
|
||||
running = entry.Cancellation;
|
||||
break;
|
||||
|
||||
case TransferState.Queued:
|
||||
entry.State = TransferState.Cancelled;
|
||||
cancelled = entry;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
running?.Cancel();
|
||||
|
||||
if (cancelled is not null)
|
||||
{
|
||||
Announce(cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a stopped transfer back in the queue, carrying on from where it stopped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resumes rather than restarts, and only because this object watched the part file being written: it
|
||||
/// knows which source those bytes came from. That is the whole of the bookkeeping the design's "resume
|
||||
/// supported" needs, and the reason it does not survive a restart — see the remark on this type.
|
||||
/// </remarks>
|
||||
public void Retry(Guid id)
|
||||
{
|
||||
Entry? retried = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (Find(id) is not { State: TransferState.Failed or TransferState.Cancelled } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = TransferState.Queued;
|
||||
entry.Failure = null;
|
||||
entry.Resume = entry.Transferred > 0;
|
||||
retried = entry;
|
||||
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(retried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes one finished transfer, and whatever it left on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named for the destruction rather than for the tidying. A cancelled transfer's part file holds real
|
||||
/// bytes that took real time to move, and the row is the only thing that knows the part file exists — so
|
||||
/// removing the row without removing the file would leave litter nobody could attribute, and removing it
|
||||
/// silently under a word like "clear" would throw away a resumable transfer without saying so.
|
||||
/// </remarks>
|
||||
/// <returns>Whether there was such a transfer to discard.</returns>
|
||||
public async Task<bool> DiscardAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
Entry? discarded;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry || !IsFinished(entry.State))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
transfers.Remove(entry);
|
||||
discarded = entry;
|
||||
}
|
||||
|
||||
await RemovePartFileAsync(discarded, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Removes every completed transfer, which have nothing left on disk to clean up.</summary>
|
||||
/// <returns>How many rows went.</returns>
|
||||
public int ClearCompleted()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Cancels whatever is running and waits for it, rather than abandoning the pump. A transfer in flight
|
||||
/// holds an open remote file and an open local one, and letting the process move on while they are still
|
||||
/// being written is how a part file ends up longer than the bytes that reached it.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Task running;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
running = pump;
|
||||
}
|
||||
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The pump reports failures onto the rows it was running; a fault escaping here would be one
|
||||
// nothing is left to show.
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Started under the gate and restarted whenever it has finished, which is what makes "one at a time"
|
||||
/// true without a dedicated thread waiting on an empty queue for the life of the application.
|
||||
/// </remarks>
|
||||
private void EnsurePumping()
|
||||
{
|
||||
if (pump.IsCompleted && !disposed)
|
||||
{
|
||||
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (TakeNext() is { } entry)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
|
||||
ISftpSession session;
|
||||
|
||||
try
|
||||
{
|
||||
session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The session is what every remaining row needs, so a failure to get one is reported on the
|
||||
// row that asked and the next iteration asks again. Failing the whole queue would hide which
|
||||
// transfer was affected behind a single message.
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
||||
private Entry? TakeNext()
|
||||
{
|
||||
Entry? next;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
next = transfers.Find(entry => entry.State is TransferState.Queued);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
next.State = TransferState.Running;
|
||||
next.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
next.BytesPerSecond = 0;
|
||||
|
||||
// Reset unless this is a resume, so a retry from the start does not open with a bar most of the
|
||||
// way along that then jumps back.
|
||||
if (!next.Resume)
|
||||
{
|
||||
next.Transferred = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Announce(next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task RunAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = entry.Cancellation?.Token ?? cancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
await DownloadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UploadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Stop(entry, TransferState.Completed, failure: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CancellationTokenSource? source;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
source = entry.Cancellation;
|
||||
entry.Cancellation = null;
|
||||
}
|
||||
|
||||
source?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.LocalPath;
|
||||
|
||||
if (File.Exists(destination))
|
||||
{
|
||||
throw new IOException(
|
||||
$"{Path.GetFileName(destination)} is already in that folder. Rename or remove it first — "
|
||||
+ "nothing here overwrites a file you already have.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var offset = ResumableLength(entry, new FileInfo(part));
|
||||
|
||||
if (offset == 0 && File.Exists(part))
|
||||
{
|
||||
// A part file this transfer is not resuming from. It belongs to an earlier attempt at the same
|
||||
// destination, and starting a fresh transfer by appending to it would produce a file that is
|
||||
// longer than the source and wrong in the middle.
|
||||
File.Delete(part);
|
||||
}
|
||||
|
||||
await ReadIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Only now, with both handles closed — which is what makes the copy its own method rather than a
|
||||
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
||||
// failure would leave a complete transfer looking like an incomplete one.
|
||||
File.Move(part, destination);
|
||||
}
|
||||
|
||||
private async Task ReadIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var remote = await session
|
||||
.OpenReadAsync(entry.RemotePath, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
var local = new FileStream(
|
||||
part,
|
||||
offset == 0 ? FileMode.Create : FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UploadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.RemotePath;
|
||||
|
||||
if (await session.StatAsync(destination, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
throw new IOException(
|
||||
$"{SftpPath.Name(destination)} is already in that directory on the host. Rename or remove it "
|
||||
+ "first — nothing here overwrites a file that is already there.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
var offset = ResumableLength(entry, existing);
|
||||
|
||||
if (offset == 0 && existing is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WriteIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var local = new FileStream(
|
||||
entry.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
var remote = await session.OpenWriteAsync(part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(local, remote, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far a resume may start, given what is actually on the destination.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file's own length, not the transfer's recorded progress, and never longer than the source.
|
||||
/// The two can disagree — a cancellation lands between a write completing and the counter moving, and a
|
||||
/// buffered write may not have reached the disk at all — and only one of them is a fact about the bytes
|
||||
/// that are there. Trusting the counter would resume past bytes that were never written, which is the
|
||||
/// one way a resumed transfer can produce a corrupt file that nothing reports.
|
||||
/// </remarks>
|
||||
private static long ResumableLength(Entry entry, FileInfo part) =>
|
||||
part.Exists ? ResumableLength(entry, part.Length) : 0;
|
||||
|
||||
private static long ResumableLength(Entry entry, SftpEntry? part) =>
|
||||
part is null ? 0 : ResumableLength(entry, part.Length);
|
||||
|
||||
private static long ResumableLength(Entry entry, long partLength)
|
||||
{
|
||||
if (!entry.Resume || partLength <= 0 || partLength >= entry.Length)
|
||||
{
|
||||
// A part file at or beyond the source's length is not a resume point; it is evidence that the
|
||||
// source changed under a previous attempt. Starting again is the only answer that ends with the
|
||||
// right bytes.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return partLength;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(
|
||||
Stream source,
|
||||
Stream destination,
|
||||
Entry entry,
|
||||
long startOffset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
var meter = new ProgressMeter(clock, startOffset);
|
||||
|
||||
try
|
||||
{
|
||||
var transferred = startOffset;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await source
|
||||
.ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await destination
|
||||
.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
transferred += read;
|
||||
|
||||
if (Record(entry, meter, transferred))
|
||||
{
|
||||
Announce(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Flushed before the caller closes the handles and renames, so a write still sitting in a buffer
|
||||
// is not counted as delivered by a rename that beat it to the disk.
|
||||
await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes progress onto the entry, and says whether it is worth telling anyone.</summary>
|
||||
private bool Record(Entry entry, ProgressMeter meter, long transferred)
|
||||
{
|
||||
var reading = meter.Read(transferred);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
|
||||
if (reading.BytesPerSecond is { } rate)
|
||||
{
|
||||
entry.BytesPerSecond = rate;
|
||||
}
|
||||
}
|
||||
|
||||
return reading.WorthAnnouncing;
|
||||
}
|
||||
|
||||
private void Stop(Entry entry, TransferState state, string? failure)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
entry.State = state;
|
||||
entry.Failure = failure;
|
||||
entry.BytesPerSecond = 0;
|
||||
entry.Resume = false;
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
}
|
||||
|
||||
private async Task RemovePartFileAsync(Entry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
File.Delete(entry.LocalPath + PartSuffix);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
var part = entry.RemotePath + PartSuffix;
|
||||
|
||||
if (await session.StatAsync(part, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best effort, and deliberately silent. The row the user asked to remove is already gone, and a
|
||||
// remote part file that outlives it is litter rather than a failure — reporting it would mean
|
||||
// putting an error on a screen for an operation that did what was asked.
|
||||
}
|
||||
}
|
||||
|
||||
private Entry? Find(Guid id) => transfers.Find(entry => entry.Id == id);
|
||||
|
||||
private static bool IsFinished(TransferState state) =>
|
||||
state is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
private void Announce(Entry entry)
|
||||
{
|
||||
TransferSnapshot snapshot;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = Describe(entry);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new TransferChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
/// <remarks>Callers hold <see cref="gate"/>: every field read here is written from the pump.</remarks>
|
||||
private static TransferSnapshot Describe(Entry entry) => new(
|
||||
entry.Id,
|
||||
entry.Direction,
|
||||
entry.Name,
|
||||
entry.LocalPath,
|
||||
entry.RemotePath,
|
||||
entry.Length,
|
||||
entry.Transferred,
|
||||
entry.State,
|
||||
entry.BytesPerSecond,
|
||||
entry.Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
|
||||
/// last told anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own type because both are stateful across iterations, and the alternative — four locals threaded
|
||||
/// through the loop by reference — is how the sampling and the announcing end up sharing a timestamp and
|
||||
/// quietly becoming one interval. They are deliberately different: half a second is the right window to
|
||||
/// measure a rate over, and a tenth of a second is the right rate to repaint at.
|
||||
/// </remarks>
|
||||
private sealed class ProgressMeter(TimeProvider clock, long startOffset)
|
||||
{
|
||||
private long sampleAt = clock.GetTimestamp();
|
||||
private long sampleBytes = startOffset;
|
||||
private long announcedAt = clock.GetTimestamp();
|
||||
|
||||
/// <param name="transferred">Total bytes moved, resumed bytes included.</param>
|
||||
/// <returns>
|
||||
/// A new throughput figure when the window has elapsed and null when it has not, so a rate is never
|
||||
/// recomputed from a sample too short to mean anything; and whether this is a moment to repaint.
|
||||
/// </returns>
|
||||
public (double? BytesPerSecond, bool WorthAnnouncing) Read(long transferred)
|
||||
{
|
||||
var now = clock.GetTimestamp();
|
||||
var sinceSample = clock.GetElapsedTime(sampleAt, now);
|
||||
|
||||
double? rate = null;
|
||||
|
||||
if (sinceSample >= ThroughputWindow)
|
||||
{
|
||||
rate = (transferred - sampleBytes) / sinceSample.TotalSeconds;
|
||||
sampleAt = now;
|
||||
sampleBytes = transferred;
|
||||
}
|
||||
|
||||
if (clock.GetElapsedTime(announcedAt, now) < ProgressInterval)
|
||||
{
|
||||
return (rate, false);
|
||||
}
|
||||
|
||||
announcedAt = now;
|
||||
|
||||
return (rate, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One transfer's mutable state, which only the queue touches and only under its gate.</summary>
|
||||
private sealed class Entry
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
public required TransferDirection Direction { get; init; }
|
||||
|
||||
public required string LocalPath { get; init; }
|
||||
|
||||
public required string RemotePath { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required long Length { get; init; }
|
||||
|
||||
public required TransferState State { get; set; }
|
||||
|
||||
public long Transferred { get; set; }
|
||||
|
||||
public double BytesPerSecond { get; set; }
|
||||
|
||||
public string? Failure { get; set; }
|
||||
|
||||
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
||||
public bool Resume { get; set; }
|
||||
|
||||
public CancellationTokenSource? Cancellation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>One entry in a local directory.</summary>
|
||||
/// <param name="Name">The entry's own name.</param>
|
||||
/// <param name="FullPath">The absolute path.</param>
|
||||
/// <param name="IsDirectory">Whether it can be navigated into.</param>
|
||||
/// <param name="Length">Size in bytes, or zero for a directory.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <remarks>
|
||||
/// Deliberately the same shape as <c>SftpEntry</c> minus the permission string, because the two panes of the
|
||||
/// file browser show the same columns and a local mode rendered in POSIX notation would be a fiction on
|
||||
/// Windows — where the design's <c>PERMS</c> column has no honest value at all.
|
||||
/// </remarks>
|
||||
public sealed record LocalEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
bool IsDirectory,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The local half of the file browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The first <c>System.IO</c> in the client outside the cache and the device key, and it stays behind this
|
||||
/// one type on purpose: everything above reasons about <see cref="LocalEntry"/>, so the transfers screen and
|
||||
/// its view model never enumerate a directory themselves.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here throws for one unreadable entry.</b> A local directory listing on Windows routinely
|
||||
/// contains things the user cannot stat — a junction into another profile, a file another process holds
|
||||
/// open — and a browser that failed the whole listing for one of them would be unable to show
|
||||
/// <c>C:\Users</c>. Those entries are skipped; a directory that cannot be opened at all is still a failure,
|
||||
/// because there is nothing to show.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDirectory
|
||||
{
|
||||
/// <summary>Where the local pane opens.</summary>
|
||||
/// <remarks>
|
||||
/// The user profile rather than the process's working directory, which for a desktop application is
|
||||
/// wherever it happened to be launched from — an installation directory nobody keeps files in.
|
||||
/// </remarks>
|
||||
public static string Home =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory, directories first and then by name.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">The directory could not be opened.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">The directory could not be opened.</exception>
|
||||
public static IReadOnlyList<LocalEntry> List(string path)
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
var entries = new List<LocalEntry>();
|
||||
|
||||
// EnumerateFileSystemInfos rather than GetFileSystemInfos: the enumerating form yields entries as it
|
||||
// reads them, so a directory of fifty thousand files does not have to be materialised twice.
|
||||
foreach (var entry in directory.EnumerateFileSystemInfos())
|
||||
{
|
||||
try
|
||||
{
|
||||
var isDirectory = entry.Attributes.HasFlag(FileAttributes.Directory);
|
||||
|
||||
entries.Add(new LocalEntry(
|
||||
entry.Name,
|
||||
entry.FullName,
|
||||
isDirectory,
|
||||
isDirectory ? 0 : ((FileInfo)entry).Length,
|
||||
new DateTimeOffset(entry.LastWriteTimeUtc, TimeSpan.Zero)));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// One entry this process cannot stat. Skipped rather than shown as a row with no facts on
|
||||
// it, and skipped rather than failing the listing — see the remark on this type.
|
||||
}
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) => left.IsDirectory == right.IsDirectory
|
||||
? string.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase)
|
||||
: right.IsDirectory.CompareTo(left.IsDirectory));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The directory holding a path, or null when it is already a root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
|
||||
/// remote's does: above <c>C:\</c> is the list of drives, which is not a directory. The remote pane stops
|
||||
/// at <c>/</c>, which is.
|
||||
/// </remarks>
|
||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
||||
|
||||
/// <summary>
|
||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ready drives only. An empty optical drive or a disconnected network mapping is listed by
|
||||
/// <see cref="DriveInfo.GetDrives"/> and throws on the first attempt to read it, which would put a row on
|
||||
/// screen whose only behaviour is an error.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> Roots()
|
||||
{
|
||||
var roots = new List<string>();
|
||||
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (drive.IsReady)
|
||||
{
|
||||
roots.Add(drive.RootDirectory.FullName);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A drive that fails even to answer whether it is ready. Nothing to show.
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
/// </remarks>
|
||||
internal sealed class StubServer : IDisposable
|
||||
{
|
||||
private readonly WireMockServer server = WireMockServer.Start();
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <see cref="WireMockServer.Url"/>.
|
||||
/// </remarks>
|
||||
private readonly WireMockServer server = WireMockServer.Start(
|
||||
new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
|
||||
|
||||
internal Uri BaseUrl => new(server.Url!, UriKind.Absolute);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// How the quick-connect palette answers a keyboard and a pointer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
|
||||
/// used to live on <c>MainWindow</c> — which cannot be shown here at all, because attaching the terminal's
|
||||
/// WebView initialises WebView2 on a thread it refuses. See
|
||||
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. A <c>UserControl</c> hosts in a bare
|
||||
/// window, takes real key and pointer input, and can therefore be held to what it promises.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>PropertyChanged</c> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real <see cref="MainWindowViewModel"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class QuickConnectTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
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<IDeviceKeyStore>(),
|
||||
(_, _) => 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<ISftpSessionFactory>(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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 <c>PropertyChanged</c>, which runs before the binding that reveals the
|
||||
/// control, and <c>Focus()</c> 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.
|
||||
/// </remarks>
|
||||
[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 ----
|
||||
|
||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||
private Task OnThePaletteAsync(Action<QuickConnect, Window> 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");
|
||||
|
||||
/// <remarks>Enough hosts that the arrow keys have somewhere to go.</remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </remarks>
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
/// <remarks>
|
||||
/// Over a substitute factory that is never asked for a session. Every shape measured here is one the
|
||||
/// screen is in before a connection exists or after one has failed, which is deliberate: the two panes
|
||||
/// are at their widest with the local one full and the remote one carrying its explanation, and a
|
||||
/// connected pane is the same template with shorter names in it.
|
||||
/// </remarks>
|
||||
private TransfersViewModel transfers = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
transfers = new TransfersViewModel(
|
||||
Substitute.For<ISftpSessionFactory>(), TimeProvider.System);
|
||||
|
||||
await SeedAsync();
|
||||
|
||||
// Attached after seeding, so the host picker has something in it and the local pane has listed this
|
||||
// machine's home directory — which is what puts real names of real length into the row template.
|
||||
transfers.Attach(vault, knownHosts);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await 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 ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The widest thing in this window and the one with the least room to give: two file listings side by
|
||||
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
|
||||
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
|
||||
/// a button and a chip on one line.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
|
||||
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
|
||||
/// of real length rather than with fixtures chosen to fit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsBeforeAnythingIsConnected()
|
||||
{
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The queue is the half of this screen that only exists once something has been asked for, so a shape
|
||||
/// nothing puts a row into is a shape never laid out. Three rows, because the row template changes with
|
||||
/// the state: a running one shows a bar and a STOP, a stopped one shows RESUME and DISCARD, and a failed
|
||||
/// one carries the server's own sentence in the column the other two put a byte count in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rows are placed directly rather than driven through the queue. What is being measured is the
|
||||
/// template at each state, and running a real transfer to reach those states would put a thread-pool
|
||||
/// hand-off and a filesystem in the middle of a test about rectangles. What the queue does is measured in
|
||||
/// <c>DodoSSH.Client.Transfer.Tests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithTransfersInTheQueue()
|
||||
{
|
||||
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
|
||||
TransferState.Running, bytesPerSecond: 6_500_000);
|
||||
|
||||
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
|
||||
TransferState.Cancelled);
|
||||
|
||||
Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed,
|
||||
failure: "deploy.sh is already in that directory on the host. Rename or remove it first — "
|
||||
+ "nothing here overwrites a file that is already there.");
|
||||
|
||||
transfers.Transfers.Count.ShouldBe(3);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The trust card covers the whole screen, and it is the one thing here a user cannot get past without
|
||||
/// pressing something — so a button of its own that fell outside the window would leave the screen
|
||||
/// permanently blocked.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithTheHostKeyCardShowing()
|
||||
{
|
||||
transfers.PendingHostKey = new HostKeyPresentation(
|
||||
"db.internal", 22, "ssh-ed25519", "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG");
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
// ---- The chrome ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
|
||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Puts one transfer on the queue in a given state, without moving a byte.</summary>
|
||||
private void Enqueue(
|
||||
TransferDirection direction,
|
||||
string name,
|
||||
long length,
|
||||
long transferred,
|
||||
TransferState state,
|
||||
double bytesPerSecond = 0,
|
||||
string? failure = null) =>
|
||||
transfers.Transfers.Add(new TransferRowViewModel(new TransferSnapshot(
|
||||
Guid.CreateVersion7(),
|
||||
direction,
|
||||
name,
|
||||
Path.Combine(Path.GetTempPath(), name),
|
||||
SftpPath.Combine("/srv/releases", name),
|
||||
length,
|
||||
transferred,
|
||||
state,
|
||||
bytesPerSecond,
|
||||
failure)));
|
||||
|
||||
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
||||
|
||||
@@ -487,7 +487,8 @@
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
@@ -538,6 +539,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// sshd — which <c>DodoSSH.Client.Ssh.Tests</c> already covers against a container. What this makes
|
||||
/// testable is everything the connect path does <em>around</em> the connection.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
|
||||
internal Exception? Failure { get; set; }
|
||||
@@ -18,6 +18,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>Requests for a file-transfer session, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
||||
/// test asserting that opening a terminal did not also open one would have nothing to look at if the two
|
||||
/// shared a list.
|
||||
/// </remarks>
|
||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -29,6 +37,81 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory
|
||||
? Task.FromException<ISshConnection>(failure)
|
||||
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SftpRequests.Add(request);
|
||||
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISftpSession>(failure)
|
||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A remote filesystem with one directory in it.</summary>
|
||||
/// <remarks>
|
||||
/// Enough for the shell suite, which is about what the screen does around a session rather than about
|
||||
/// moving bytes. The queue's own behaviour is covered against a fuller fake in
|
||||
/// <c>DodoSSH.Client.Transfer.Tests</c>, and the real subsystem against a container in
|
||||
/// <c>DodoSSH.Client.Ssh.Tests</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => $"/home/{request.Username}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
[
|
||||
new SftpEntry(
|
||||
"notes.txt",
|
||||
SftpPath.Combine(path, "notes.txt"),
|
||||
SftpEntryKind.File,
|
||||
12,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--"),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<SftpEntry?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsConnected = false;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> argues that
|
||||
/// locking must not destroy work — it is what somebody does when they walk away from the machine, which
|
||||
/// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have
|
||||
/// dropped the session and with it whatever was moving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect");
|
||||
|
||||
// What it does take is the host list, and it has to: those rows carry decrypted secrets and the
|
||||
// vault they came from has just been disposed.
|
||||
shell.Transfers.Hosts.ShouldBeEmpty();
|
||||
shell.Transfers.SelectedHost.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made
|
||||
/// visible: browsing a host's files authenticates again rather than reusing the terminal's connection.
|
||||
/// It is asserted rather than merely written down because the host's audit log shows a second login,
|
||||
/// and somebody will eventually be asked to explain it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(1);
|
||||
ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.SftpRequests.Count.ShouldBe(1);
|
||||
ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either");
|
||||
|
||||
// It opens on the account's home directory, which is the only path the layer knows without asking.
|
||||
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
||||
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
||||
}
|
||||
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
@@ -476,7 +476,8 @@
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
@@ -527,6 +528,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Remote paths, permission bits and byte counts — the three things the file browser renders.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not in the SSH collection, so this class needs no container. Every case here is one where the obvious
|
||||
/// implementation is wrong on Windows, at a filesystem root, or on a number that has just crossed a
|
||||
/// threshold — which is to say, one that a listing of somebody's home directory would not reveal.
|
||||
/// </remarks>
|
||||
public sealed class RemotePathTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/var/log", "syslog", "/var/log/syslog")]
|
||||
[InlineData("/", "etc", "/etc")]
|
||||
[InlineData("/home/dodo/", "notes", "/home/dodo/notes")]
|
||||
public void Combine_JoinsWithExactlyOneSeparator(string directory, string name, string expected)
|
||||
{
|
||||
// Deliberately not Path.Combine, which on Windows would yield "/var/log\syslog" — a path the remote
|
||||
// cannot resolve, failing as "no such file" somewhere the backslash is invisible.
|
||||
SftpPath.Combine(directory, name).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/var/log/syslog", "/var/log")]
|
||||
[InlineData("/var/log", "/var")]
|
||||
[InlineData("/var", "/")]
|
||||
[InlineData("/", "/")]
|
||||
[InlineData("/var/log/", "/var")]
|
||||
public void Parent_StopsAtTheRoot(string path, string expected)
|
||||
{
|
||||
// The root is its own parent rather than null, which is what lets the breadcrumb's "up" be a plain
|
||||
// navigation with nothing above it to special-case.
|
||||
SftpPath.Parent(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/var/log/syslog", "syslog")]
|
||||
[InlineData("/var/log/", "log")]
|
||||
[InlineData("/", "/")]
|
||||
public void Name_IsTheLastSegment(string path, string expected)
|
||||
{
|
||||
SftpPath.Name(path).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trail_NamesEverySegmentWithThePathThatReachesIt()
|
||||
{
|
||||
SftpPath.Trail("/var/log/nginx").ShouldBe(
|
||||
[("var", "/var"), ("log", "/var/log"), ("nginx", "/var/log/nginx")]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trail_IsEmptyAtTheRoot()
|
||||
{
|
||||
// The root has no segment to name. The breadcrumb draws it as a leading separator, so a trail with a
|
||||
// phantom empty crumb in it would render as a button with no label.
|
||||
SftpPath.Trail("/").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixMode_RendersTheKindCharacterAndThreeTriples()
|
||||
{
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.Directory,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: true,
|
||||
groupRead: true, groupWrite: false, groupExecute: true,
|
||||
othersRead: true, othersWrite: false, othersExecute: true)
|
||||
.ShouldBe("drwxr-xr-x");
|
||||
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.File,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: false,
|
||||
groupRead: true, groupWrite: false, groupExecute: false,
|
||||
othersRead: false, othersWrite: false, othersExecute: false)
|
||||
.ShouldBe("-rw-r-----");
|
||||
|
||||
PosixMode.Format(
|
||||
SftpEntryKind.SymbolicLink,
|
||||
ownerRead: true, ownerWrite: true, ownerExecute: true,
|
||||
groupRead: true, groupWrite: true, groupExecute: true,
|
||||
othersRead: true, othersWrite: true, othersExecute: true)
|
||||
.ShouldBe("lrwxrwxrwx");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0 B")]
|
||||
[InlineData(1023, "1023 B")]
|
||||
[InlineData(1024, "1.0 KB")]
|
||||
[InlineData(10 * 1024, "10 KB")]
|
||||
[InlineData(1536 * 1024, "1.5 MB")]
|
||||
[InlineData(5L * 1024 * 1024 * 1024, "5.0 GB")]
|
||||
public void ByteSize_KeepsOneDecimalOnlyWhileItMeansSomething(long bytes, string expected)
|
||||
{
|
||||
// The boundary at ten is the whole rule: "9.4 MB" and "512 MB" carry the same information, and
|
||||
// "512.3 MB" claims a precision the figure does not have by the time it is that large.
|
||||
ByteSize.Format(bytes).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The SFTP subsystem, against a real sshd.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything this suite is about is behaviour of a server rather than of this code: whether a listing
|
||||
/// carries the permission bits the design's <c>PERMS</c> column needs, whether opening at an offset really
|
||||
/// starts there, and whether a rename over a name that already exists fails rather than silently replacing —
|
||||
/// which the transfer queue's part-file scheme depends on. None of it can be established against a mock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One directory per test, named after it, because the container is shared with every other suite in the
|
||||
/// assembly and a test that cleaned up by emptying the home directory would take another test's fixture with
|
||||
/// it. The <em>session</em> is shared too, and that is a limit of the server rather than tidiness — see
|
||||
/// <see cref="SshServerFixture.SftpAsync"/>, which explains what opening one per test did to the rest of the
|
||||
/// assembly.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(SshCollection.Name)]
|
||||
public sealed class SftpSessionTests(SshServerFixture fixture)
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task AnOpenedSession_StartsInTheAccountsHomeDirectory()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
// Absolute, because the server canonicalises it during the handshake. A relative answer would make
|
||||
// every path the browser builds relative too, and the breadcrumb trail meaningless.
|
||||
SftpPath.IsAbsolute(sftp.HomeDirectory).ShouldBeTrue(sftp.HomeDirectory);
|
||||
sftp.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AListing_CarriesKindSizeAndPermissions()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(AListing_CarriesKindSizeAndPermissions));
|
||||
var content = "the quick brown fox"u8.ToArray();
|
||||
|
||||
await WriteAsync(sftp, SftpPath.Combine(directory, "a-file"), content);
|
||||
await sftp.CreateDirectoryAsync(SftpPath.Combine(directory, "a-directory"), Token);
|
||||
|
||||
var entries = await sftp.ListAsync(directory, Token);
|
||||
|
||||
// Directories first: the order this interface promises, and what the file browser shows without
|
||||
// sorting again.
|
||||
entries.Select(entry => entry.Name).ShouldBe(["a-directory", "a-file"]);
|
||||
|
||||
var file = entries[1];
|
||||
file.Kind.ShouldBe(SftpEntryKind.File);
|
||||
file.Length.ShouldBe(content.Length);
|
||||
file.FullPath.ShouldBe(SftpPath.Combine(directory, "a-file"));
|
||||
|
||||
// The one column nothing in this repository could render before. The exact bits depend on the
|
||||
// server's umask, so what is pinned is the shape and the kind character rather than the mode.
|
||||
file.Permissions.Length.ShouldBe(10);
|
||||
file.Permissions[0].ShouldBe('-');
|
||||
file.Permissions.ShouldStartWith("-rw");
|
||||
|
||||
entries[0].Kind.ShouldBe(SftpEntryKind.Directory);
|
||||
entries[0].Permissions[0].ShouldBe('d');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AListing_DropsTheDotEntries()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(AListing_DropsTheDotEntries));
|
||||
|
||||
// Empty, which is the case where "." and ".." are the whole listing — so a browser that showed them
|
||||
// would present an empty directory as one holding two things.
|
||||
var entries = await sftp.ListAsync(directory, Token);
|
||||
|
||||
entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListingSomethingThatIsNotADirectory_FailsWithThePath()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(ListingSomethingThatIsNotADirectory_FailsWithThePath));
|
||||
|
||||
var file = SftpPath.Combine(directory, "not-a-directory");
|
||||
await WriteAsync(sftp, file, "x"u8.ToArray());
|
||||
|
||||
// The path is the whole point of the translation. SSH.NET's own exception for this carries the
|
||||
// server's message and nothing about which path was asked for, and the browser has to say which
|
||||
// row failed.
|
||||
var failure = await Should.ThrowAsync<SftpPathException>(async () =>
|
||||
await sftp.ListAsync(file, Token));
|
||||
|
||||
failure.Path.ShouldBe(file);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stat_AnswersNullForSomethingThatIsNotThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(Stat_AnswersNullForSomethingThatIsNotThere));
|
||||
|
||||
// Absent is an answer rather than a failure: the transfer queue asks this before every upload to
|
||||
// find out whether it would be overwriting something, and that question has a "no".
|
||||
(await sftp.StatAsync(SftpPath.Combine(directory, "nothing-here"), Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpeningAtAnOffset_ReadsFromThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(OpeningAtAnOffset_ReadsFromThere));
|
||||
var path = SftpPath.Combine(directory, "resumable");
|
||||
|
||||
await WriteAsync(sftp, path, "0123456789"u8.ToArray());
|
||||
|
||||
// The whole of resume, in one call. If the server ignored the offset this would read the file from
|
||||
// the start and a resumed download would silently duplicate its first half.
|
||||
var stream = await sftp.OpenReadAsync(path, 4, Token);
|
||||
await using var scope = stream.ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
(await reader.ReadToEndAsync(Token)).ShouldBe("456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritingAtAnOffset_LeavesWhatWasAlreadyThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(WritingAtAnOffset_LeavesWhatWasAlreadyThere));
|
||||
var path = SftpPath.Combine(directory, "appended");
|
||||
|
||||
await WriteAsync(sftp, path, "0123"u8.ToArray());
|
||||
|
||||
var stream = await sftp.OpenWriteAsync(path, 4, Token);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await stream.WriteAsync("456789"u8.ToArray(), Token);
|
||||
await stream.FlushAsync(Token);
|
||||
}
|
||||
|
||||
// The other half of resume: an upload that carried on from an offset must not have truncated the
|
||||
// bytes an earlier attempt already delivered.
|
||||
(await ReadAllAsync(sftp, path)).ShouldBe("0123456789");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_RefusesToReplaceSomethingThatIsAlreadyThere()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(
|
||||
sftp, nameof(Rename_RefusesToReplaceSomethingThatIsAlreadyThere));
|
||||
|
||||
var part = SftpPath.Combine(directory, "artefact.dodossh-part");
|
||||
var destination = SftpPath.Combine(directory, "artefact");
|
||||
|
||||
await WriteAsync(sftp, part, "new"u8.ToArray());
|
||||
await WriteAsync(sftp, destination, "old"u8.ToArray());
|
||||
|
||||
// The transfer queue's last step, and the assumption underneath its promise never to overwrite: it
|
||||
// checks the destination before starting, and this is what stops a file that appeared in the
|
||||
// meantime from being replaced anyway. SFTP's rename is specified not to clobber, and this is the
|
||||
// check that the server this project tests against actually behaves that way.
|
||||
await Should.ThrowAsync<SftpPathException>(async () =>
|
||||
await sftp.RenameAsync(part, destination, Token));
|
||||
|
||||
(await ReadAllAsync(sftp, destination)).ShouldBe("old");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingANonEmptyDirectory_Fails()
|
||||
{
|
||||
var sftp = await fixture.SftpAsync(Token);
|
||||
|
||||
var directory = await MakeDirectoryAsync(sftp, nameof(DeletingANonEmptyDirectory_Fails));
|
||||
|
||||
await WriteAsync(sftp, SftpPath.Combine(directory, "occupant"), "x"u8.ToArray());
|
||||
|
||||
// Deliberate, and the reason this interface has no recursive delete: the one destructive operation
|
||||
// on the transfers screen must not be able to take a directory tree with it.
|
||||
await Should.ThrowAsync<SftpPathException>(async () => await sftp.DeleteAsync(directory, Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe()
|
||||
{
|
||||
var factory = new SshNetConnectionFactory(new InMemoryKnownHostStore());
|
||||
|
||||
// File transfer opens its own connection, so it makes its own first-contact decision. The failure
|
||||
// that matters is the one this asserts is *not* raised: a bare connection error would send the user
|
||||
// looking at the network for what is a fingerprint they have not approved.
|
||||
var refusal = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.OpenSftpAsync(Request(), Token));
|
||||
|
||||
refusal.Presentation.Host.ShouldBe(fixture.Host);
|
||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||
}
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
SshServerFixture.Username,
|
||||
new SshPasswordCredential(SshServerFixture.Password));
|
||||
|
||||
/// <summary>A directory of this test's own, under the account's home.</summary>
|
||||
private static async Task<string> MakeDirectoryAsync(ISftpSession sftp, string name)
|
||||
{
|
||||
var path = SftpPath.Combine(sftp.HomeDirectory, $"sftp-{name}");
|
||||
|
||||
if (await sftp.StatAsync(path, Token) is not null)
|
||||
{
|
||||
// A previous run of the same test in a container that outlived it. Emptying it is enough:
|
||||
// nothing here creates nested directories.
|
||||
foreach (var entry in await sftp.ListAsync(path, Token))
|
||||
{
|
||||
await sftp.DeleteAsync(entry.FullPath, Token);
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
await sftp.CreateDirectoryAsync(path, Token);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(ISftpSession sftp, string path, byte[] content)
|
||||
{
|
||||
var stream = await sftp.OpenWriteAsync(path, 0, Token);
|
||||
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await stream.WriteAsync(content, Token);
|
||||
await stream.FlushAsync(Token);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadAllAsync(ISftpSession sftp, string path)
|
||||
{
|
||||
var stream = await sftp.OpenReadAsync(path, 0, Token);
|
||||
await using var scope = stream.ConfigureAwait(false);
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
return await reader.ReadToEndAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
private readonly SemaphoreSlim sftpGate = new(1, 1);
|
||||
|
||||
private IContainer? container;
|
||||
private ISftpSession? sftp;
|
||||
|
||||
/// <summary>Host port the container's sshd is published on.</summary>
|
||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||
@@ -68,9 +71,69 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
await container.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One file-transfer session, opened on first use and shared by every test that wants one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
|
||||
/// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
|
||||
/// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
|
||||
/// the host key — so a suite that opened its own session per test made two handshakes per test and
|
||||
/// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
|
||||
/// "the connection was closed by the remote host", a different few each run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
||||
/// named after itself. See <c>ISftpSession</c>, which is one channel and is used by one caller at a
|
||||
/// time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<ISftpSession> SftpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await sftpGate.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (sftp is not null)
|
||||
{
|
||||
return sftp;
|
||||
}
|
||||
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
Host, Port, Username, new SshPasswordCredential(Password));
|
||||
|
||||
try
|
||||
{
|
||||
// Learned by being refused, which is the only way this client learns a host key.
|
||||
return sftp = await factory.OpenSftpAsync(request, cancellationToken);
|
||||
}
|
||||
catch (SshHostKeyUnknownException unknown)
|
||||
{
|
||||
await knownHosts.TrustAsync(unknown.Presentation, cancellationToken);
|
||||
}
|
||||
|
||||
return sftp = await factory.OpenSftpAsync(request, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sftpGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (sftp is not null)
|
||||
{
|
||||
await sftp.DisposeAsync();
|
||||
}
|
||||
|
||||
sftpGate.Dispose();
|
||||
|
||||
if (container is not null)
|
||||
{
|
||||
await container.DisposeAsync();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The queue, against a real temporary directory and a fake host. Real files on the local side
|
||||
because the part-file scheme is about what is on disk when a transfer stops halfway, and a
|
||||
filesystem abstraction would let that be right in the test and wrong in the product; a fake on
|
||||
the remote side because the failures worth pinning here — a read that dies mid-file, a
|
||||
destination that appears while a transfer is queued — are ones no real server can be asked for
|
||||
on cue. What the real server does answer is in DodoSSH.Client.Ssh.Tests.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,258 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A remote filesystem in a dictionary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Here rather than a container because what this suite is about is the queue's behaviour when a transfer
|
||||
/// goes wrong halfway — a read that dies after so many bytes, a destination that appears while a transfer is
|
||||
/// queued — and neither can be asked of a real server on cue. That the real server behaves as this fake
|
||||
/// pretends is established separately, against sshd, in <c>SftpSessionTests</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSftpSession : ISftpSession
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
|
||||
private readonly Lock gate = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => "/home/dodo";
|
||||
|
||||
/// <summary>Throws once, this many bytes into the next read, and then stops doing so.</summary>
|
||||
/// <remarks>
|
||||
/// One-shot on purpose: every resume test is "it broke, then it did not", and a fake that kept failing
|
||||
/// would need turning off at exactly the point the assertion is about.
|
||||
/// </remarks>
|
||||
public int? FailReadAfter { get; set; }
|
||||
|
||||
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
|
||||
/// <remarks>
|
||||
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
|
||||
/// whether the second attempt started at the offset or at zero.
|
||||
/// </remarks>
|
||||
public List<long> ReadOffsets { get; } = [];
|
||||
|
||||
/// <summary>Puts a file on the fake host.</summary>
|
||||
public void Seed(string path, byte[] content)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
files[path] = content;
|
||||
directories.Add(SftpPath.Parent(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What is at a path, or null.</summary>
|
||||
public byte[]? Read(string path)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return files.GetValueOrDefault(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether anything is at a path.</summary>
|
||||
public bool Exists(string path)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return files.ContainsKey(path) || directories.Contains(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!directories.Contains(path))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
|
||||
IReadOnlyList<SftpEntry> entries =
|
||||
[
|
||||
.. files
|
||||
.Where(file => string.Equals(SftpPath.Parent(file.Key), path, StringComparison.Ordinal))
|
||||
.Select(file => Describe(file.Key, file.Value.Length)),
|
||||
];
|
||||
|
||||
return Task.FromResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (files.TryGetValue(path, out var content))
|
||||
{
|
||||
return Task.FromResult<SftpEntry?>(Describe(path, content.Length));
|
||||
}
|
||||
|
||||
return Task.FromResult<SftpEntry?>(
|
||||
directories.Contains(path)
|
||||
? new SftpEntry(
|
||||
SftpPath.Name(path),
|
||||
path,
|
||||
SftpEntryKind.Directory,
|
||||
0,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"drwxr-xr-x")
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
ReadOffsets.Add(offset);
|
||||
|
||||
if (!files.TryGetValue(path, out var content))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
|
||||
var failAfter = FailReadAfter;
|
||||
FailReadAfter = null;
|
||||
|
||||
return Task.FromResult<Stream>(
|
||||
new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var existing = files.GetValueOrDefault(path, []);
|
||||
|
||||
return Task.FromResult<Stream>(new CommittingStream(
|
||||
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
||||
content =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
files[path] = content;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
directories.Add(path);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!files.Remove(path) && !directories.Remove(path))
|
||||
{
|
||||
throw new SftpPathException(path, $"{path} is not there.");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!files.TryGetValue(fromPath, out var content))
|
||||
{
|
||||
throw new SftpPathException(fromPath, $"{fromPath} is not there.");
|
||||
}
|
||||
|
||||
// SFTP's rename does not replace, and the queue's promise never to overwrite rests on it. A fake
|
||||
// that clobbered would make the one test about that promise pass for the wrong reason.
|
||||
if (files.ContainsKey(toPath))
|
||||
{
|
||||
throw new SftpPathException(toPath, $"{toPath} is already there.");
|
||||
}
|
||||
|
||||
files.Remove(fromPath);
|
||||
files[toPath] = content;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private static SftpEntry Describe(string path, int length) => new(
|
||||
SftpPath.Name(path),
|
||||
path,
|
||||
SftpEntryKind.File,
|
||||
length,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--");
|
||||
|
||||
/// <summary>A read that dies partway through, the way a dropped connection does.</summary>
|
||||
private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
|
||||
{
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
Guard();
|
||||
|
||||
return base.Read(buffer);
|
||||
}
|
||||
|
||||
public override ValueTask<int> ReadAsync(
|
||||
Memory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guard();
|
||||
|
||||
return base.ReadAsync(buffer, cancellationToken);
|
||||
}
|
||||
|
||||
private void Guard()
|
||||
{
|
||||
if (failAfter is { } limit && Position >= limit)
|
||||
{
|
||||
throw new IOException("The connection dropped.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A write that lands on the fake host when it is disposed.</summary>
|
||||
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
|
||||
{
|
||||
private bool committed;
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
if (!committed)
|
||||
{
|
||||
committed = true;
|
||||
commit([.. prefix, .. ToArray()]);
|
||||
}
|
||||
|
||||
base.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The transfer queue: what ends up on disk, and what happens when a transfer stops halfway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every assertion here is about a promise the queue makes in prose — nothing is written at its final name
|
||||
/// until it is complete, a destination that already exists is refused rather than overwritten, an
|
||||
/// interrupted transfer carries on rather than starting again, and a partial file left by something else is
|
||||
/// never resumed from. Those are the four ways a file transfer can quietly deliver the wrong bytes.
|
||||
/// </remarks>
|
||||
public sealed class FileTransferQueueTests : IDisposable
|
||||
{
|
||||
private const string RemoteDirectory = "/home/dodo";
|
||||
|
||||
private readonly FakeSftpSession host = new();
|
||||
private readonly string workspace = Directory.CreateTempSubdirectory("dodossh-transfer-").FullName;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(workspace, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A handle the runtime has not released yet. The directory is under the system temporary path
|
||||
// and failing a passing test over it would be the wrong trade.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADownload_LandsAtItsFinalNameWithNoPartFileLeftBehind()
|
||||
{
|
||||
var content = Bytes(300_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
finished.Transferred.ShouldBe(content.Length);
|
||||
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
File.Exists(Local("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADownloadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
||||
{
|
||||
host.Seed(Remote("artefact.tar"), Bytes(1_000));
|
||||
await File.WriteAllTextAsync(Local("artefact.tar"), "something else entirely", Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 1_000);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
finished.Failure.ShouldNotBeNull();
|
||||
|
||||
// The whole point of the refusal: what was there is still there, unread and unreplaced.
|
||||
(await File.ReadAllTextAsync(Local("artefact.tar"), Token)).ShouldBe("something else entirely");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnInterruptedDownload_CarriesOnFromWhereItStopped()
|
||||
{
|
||||
var content = Bytes(300_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
host.FailReadAfter = 100_000;
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var broken = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
broken.State.ShouldBe(TransferState.Failed);
|
||||
broken.Transferred.ShouldBeInRange(1, content.Length - 1);
|
||||
broken.CanResume.ShouldBeTrue();
|
||||
|
||||
// The part file is what makes this a resume rather than a restart, and it is deliberately not at the
|
||||
// destination's name — nothing may look like a finished download until it is one.
|
||||
var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
|
||||
File.Exists(part).ShouldBeTrue();
|
||||
File.Exists(Local("artefact.tar")).ShouldBeFalse();
|
||||
|
||||
var partLength = new FileInfo(part).Length;
|
||||
|
||||
var resumed = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));
|
||||
|
||||
resumed.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
// Asked for the second time at the offset the part file had reached, which is the only direct
|
||||
// evidence that the bytes already moved were not moved again.
|
||||
host.ReadOffsets.ShouldBe([0, partLength]);
|
||||
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AFreshDownload_WillNotResumeFromAPartFileItDidNotWrite()
|
||||
{
|
||||
var content = Bytes(200_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
|
||||
// Litter from something else entirely — an earlier run of the application, or an earlier attempt at
|
||||
// a file of the same name that has since changed. Resuming on the strength of the name matching is
|
||||
// how a corrupt artefact gets delivered with nothing reporting a failure.
|
||||
await File.WriteAllBytesAsync(
|
||||
Local("artefact.tar") + FileTransferQueue.PartSuffix, Bytes(50_000, seed: 7), Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
host.ReadOffsets.ShouldBe([0]);
|
||||
(await File.ReadAllBytesAsync(Local("artefact.tar"), Token)).ShouldBe(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpload_LandsAtItsFinalNameOnTheHost()
|
||||
{
|
||||
var content = Bytes(150_000);
|
||||
await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Completed);
|
||||
|
||||
host.Read(Remote("artefact.tar")).ShouldBe(content);
|
||||
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
||||
{
|
||||
var existing = "the running deployment"u8.ToArray();
|
||||
host.Seed(Remote("artefact.tar"), existing);
|
||||
await File.WriteAllBytesAsync(Local("artefact.tar"), Bytes(1_000), Token);
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), 1_000);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
host.Read(Remote("artefact.tar")).ShouldBe(existing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransfersRunOneAtATime()
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
host.Seed(Remote($"file-{i}"), Bytes(200_000, seed: i));
|
||||
}
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var running = new HashSet<Guid>();
|
||||
var most = 0;
|
||||
var gate = new Lock();
|
||||
|
||||
queue.Changed += (_, e) =>
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (e.Transfer.State is TransferState.Running)
|
||||
{
|
||||
running.Add(e.Transfer.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
running.Remove(e.Transfer.Id);
|
||||
}
|
||||
|
||||
most = Math.Max(most, running.Count);
|
||||
}
|
||||
};
|
||||
|
||||
var ids = new List<Guid>();
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ids.Add(queue.Enqueue(
|
||||
TransferDirection.Download, Local($"file-{i}"), Remote($"file-{i}"), 200_000));
|
||||
}
|
||||
|
||||
await WaitForQuietAsync(queue);
|
||||
|
||||
queue.Snapshot().ShouldAllBe(transfer => transfer.State == TransferState.Completed);
|
||||
|
||||
// The claim the whole design rests on: one channel, one transfer, so the throughput on a row is the
|
||||
// throughput of the link rather than a share of it.
|
||||
lock (gate)
|
||||
{
|
||||
most.ShouldBe(1);
|
||||
}
|
||||
|
||||
ids.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellingAQueuedTransfer_TakesItOutOfTheQueueWithoutStartingIt()
|
||||
{
|
||||
host.Seed(Remote("slow"), Bytes(2_000_000));
|
||||
host.Seed(Remote("never"), Bytes(1_000));
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, Local("slow"), Remote("slow"), 2_000_000);
|
||||
var second = queue.Enqueue(TransferDirection.Download, Local("never"), Remote("never"), 1_000);
|
||||
|
||||
queue.Cancel(second);
|
||||
|
||||
await WaitForQuietAsync(queue);
|
||||
|
||||
var cancelled = queue.Snapshot().Single(transfer => transfer.Id == second);
|
||||
|
||||
cancelled.State.ShouldBe(TransferState.Cancelled);
|
||||
cancelled.Transferred.ShouldBe(0);
|
||||
File.Exists(Local("never")).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscardingAStoppedDownload_TakesItsPartFileWithIt()
|
||||
{
|
||||
var content = Bytes(200_000);
|
||||
host.Seed(Remote("artefact.tar"), content);
|
||||
host.FailReadAfter = 60_000;
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var broken = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
var part = Local("artefact.tar") + FileTransferQueue.PartSuffix;
|
||||
File.Exists(part).ShouldBeTrue();
|
||||
|
||||
(await queue.DiscardAsync(broken.Id, Token)).ShouldBeTrue();
|
||||
|
||||
// The row was the only thing that knew the part file existed, so removing one without the other
|
||||
// would leave bytes on disk nobody could attribute.
|
||||
queue.Snapshot().ShouldBeEmpty();
|
||||
File.Exists(part).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATransferWithNoSessionToRunOn_FailsOnItsOwnRowRatherThanSilently()
|
||||
{
|
||||
await using var queue = new FileTransferQueue(
|
||||
_ => Task.FromException<ISftpSession>(new IOException("the host is not reachable")),
|
||||
TimeProvider.System);
|
||||
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local("artefact.tar"), Remote("artefact.tar"), 10);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed);
|
||||
finished.Failure.ShouldBe("the host is not reachable");
|
||||
}
|
||||
|
||||
private FileTransferQueue NewQueue() =>
|
||||
new(_ => Task.FromResult<ISftpSession>(host), TimeProvider.System);
|
||||
|
||||
private string Local(string name) => Path.Combine(workspace, name);
|
||||
|
||||
private static string Remote(string name) => SftpPath.Combine(RemoteDirectory, name);
|
||||
|
||||
/// <remarks>
|
||||
/// Repeatable rather than random, so a failure can be reproduced, and not all one byte, so a resumed
|
||||
/// transfer that started from the wrong offset produces a file that differs rather than one that happens
|
||||
/// to match.
|
||||
/// </remarks>
|
||||
private static byte[] Bytes(int length, int seed = 0)
|
||||
{
|
||||
var content = new byte[length];
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
content[i] = (byte)((i + seed) % 251);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private static Task<TransferSnapshot> RunToCompletionAsync(
|
||||
FileTransferQueue queue,
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length) =>
|
||||
AwaitFinishAsync(queue, () => queue.Enqueue(direction, localPath, remotePath, length));
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes, starts a transfer, and completes when a transfer reaches a state it will not leave.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The subscription goes on <em>before</em> the transfer starts, because the pump runs on a thread-pool
|
||||
/// thread: a small transfer can be finished before <c>Enqueue</c> has returned its id, so subscribing
|
||||
/// afterwards would wait for an event that has already happened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which is also why it does not match on the id — there is nothing to match against yet. Every caller
|
||||
/// has exactly one transfer in flight, which is what makes "a transfer finished" and "this transfer
|
||||
/// finished" the same statement. The one test with several running uses
|
||||
/// <see cref="WaitForQuietAsync"/> instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<TransferSnapshot> AwaitFinishAsync(FileTransferQueue queue, Action start)
|
||||
{
|
||||
var finished = new TaskCompletionSource<TransferSnapshot>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnChanged(object? sender, TransferChangedEventArgs e)
|
||||
{
|
||||
if (e.Transfer.IsFinished)
|
||||
{
|
||||
finished.TrySetResult(e.Transfer);
|
||||
}
|
||||
}
|
||||
|
||||
queue.Changed += OnChanged;
|
||||
|
||||
try
|
||||
{
|
||||
start();
|
||||
|
||||
return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
queue.Changed -= OnChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits until nothing is queued or running.</summary>
|
||||
private static async Task WaitForQuietAsync(FileTransferQueue queue)
|
||||
{
|
||||
var deadline = TimeSpan.FromSeconds(30);
|
||||
var waited = TimeSpan.Zero;
|
||||
|
||||
while (queue.IsBusy && waited < deadline)
|
||||
{
|
||||
await Task.Delay(20, Token);
|
||||
waited += TimeSpan.FromMilliseconds(20);
|
||||
}
|
||||
|
||||
queue.IsBusy.ShouldBeFalse("the queue should have drained");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
|
||||
"dependencies": {
|
||||
"Castle.Core": "5.1.1"
|
||||
}
|
||||
},
|
||||
"Shouldly": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.0, )",
|
||||
"resolved": "4.3.0",
|
||||
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
|
||||
"dependencies": {
|
||||
"DiffEngine": "11.3.0",
|
||||
"EmptyFiles": "4.4.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.2, )",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
|
||||
"dependencies": {
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "6.0.0"
|
||||
}
|
||||
},
|
||||
"DiffEngine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.0",
|
||||
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
|
||||
"dependencies": {
|
||||
"EmptyFiles": "4.4.0",
|
||||
"System.Management": "6.0.1"
|
||||
}
|
||||
},
|
||||
"EmptyFiles": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Win32.Registry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
|
||||
},
|
||||
"System.Management": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.1",
|
||||
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.27.0",
|
||||
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
|
||||
},
|
||||
"xunit.v3.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
|
||||
},
|
||||
"xunit.v3.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3.core.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
|
||||
"Microsoft.Testing.Platform": "1.9.1",
|
||||
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.inproc.console": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
|
||||
"dependencies": {
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.27.0",
|
||||
"xunit.v3.assert": "[3.2.2]",
|
||||
"xunit.v3.core.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "[5.0.0]",
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.inproc.console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
|
||||
"dependencies": {
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user