Free the terminal from the Hosts screen, and fill the room it left

The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
2026-07-31 20:30:05 +02:00
parent 1292084af9
commit d07b336868
163 changed files with 24491 additions and 647 deletions
+16
View File
@@ -100,6 +100,22 @@
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
-->
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
<!--
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
managed only — no native assets — which is the bar this file sets for anything that gets
pinned. Taken rather than hand-rolled because the alternative here is implementing SigV4
request signing, and unlike the openssh-key-v1 container (which had no library at all) a
maintained implementation of this exists and is the one every S3-compatible service tests
against.
AWSSDK.Core is declared and pinned forward. What AWSSDK.S3 4.0.101.6 resolves on its own is
4.0.1, which is covered by GHSA-9cvc-h2w8-phrp — low severity, and this repository builds
with NuGet audit as errors, so "low" is not a reason to carry it. 4.0.100.9 is past it and
inside the same major. Same treatment as the OpenApi and SQLitePCLRaw entries above, and the
same standing obligation: this is now ours to keep current.
-->
<PackageVersion Include="AWSSDK.S3" Version="4.0.101.6" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.100.9" />
<!--
Avalonia 12.1.0, with the WebView control on 12.0.1 — the latest it has shipped. Its
dependency is Avalonia >= 12.0.0 with no upper bound and it targets net10.0, so the skew
+4
View File
@@ -20,11 +20,13 @@
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
<Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<Project Path="src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
<Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<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.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
<Project Path="src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</Folder>
@@ -35,11 +37,13 @@
<Project Path="tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Import.Tests/DodoSSH.Client.Import.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
<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.ObjectStore.Tests/DodoSSH.Client.ObjectStore.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" />
+44
View File
@@ -68,10 +68,15 @@ src/
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.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
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
docs/design-import-gaps.md what the client's design asked for and this build has not got
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
docs/android-port.md what an Android client would take, and what has been decided about it
```
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the
@@ -239,6 +244,11 @@ Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md)
deployment gotchas that have already cost time once. Read it before assuming something works
off-Windows.
Android has been audited and scoped, but not started:
[`docs/android-port.md`](docs/android-port.md) records what ports as it stands (most of the core), what
does not (most of the interface), the decisions taken about what an Android client would be — phone-first,
keychain plus a terminal — and the spike that gates all of it.
### Conventions the build enforces
- Warnings are errors. `dotnet format --verify-no-changes` gates CI.
@@ -294,6 +304,40 @@ off-Windows.
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.
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
Group membership is a field on the *host* rather than a member list on the group, so filing two machines at
once on two laptops is two independent writes instead of one contested one; and groups are flat, because a
parent pointer merged field by field lets two offline clients build a cycle that nothing can repair.
Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot
honestly say "run this command", only "type this into whatever is there".
*Logs done:* what has been connected to, and what has been changed in the keychain. Both are synced,
encrypted items rather than local files, because the point of them is auditing a shared vault — a log only
one machine can read is a diagnostic, not an audit trail. Two consequences are stated rather than implied.
The connection log records the host's name, the address dialled, when, for how long and by which
account on which machine — but **not** the SSH username, which is a detail of the host and is in the host's
own logs. The keychain log records the **names** of the fields an edit touched and never their contents.
What that costs is in [ADR 0001](docs/adr/0001-e2ee-trust-model.md): the server still cannot read a single
field, but one row per connection with a server-side timestamp tells it your connection rate and the hours
you work. Retention bounds it — ninety days or five thousand entries per kind, whichever bites first.
*Buckets done:* S3-compatible object storage is a second kind of remote on the Files screen, beside a host.
Prefixes are directories, objects are files, and transfers go the same way through the same queue. The
bucket, its endpoint and its keys are a keychain item like any other, encrypted end to end — which matters
more than usual here, because for anybody self-hosting MinIO or Ceph the endpoint is an address on their
own network.
Three things a bucket cannot do are refused with a reason rather than approximated: there are no
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
+20
View File
@@ -82,6 +82,26 @@ operator all see ciphertext only. OIDC account takeover alone yields nothing rea
complete sharing graph regardless of settings. Host addresses are plaintext when relay is
enabled for that host; see [ADR 0004](0004-relay-authorization.md) for why that is a
security requirement rather than a convenience.
- **The connection and activity logs widen that leak, deliberately.** They are ordinary vault
items — every field sealed, no plaintext column of any kind, not even a timestamp — but
there is one row per connection and one per keychain edit, and rows have `updated_at`. So
the operator can read a user's connection *rate and timing* off the change log without
decrypting anything: how many machines somebody touched this morning, and at what hour they
stopped. That is a real increase over what item counts alone gave away.
It is the price of the logs being auditable at all. Kept on the machine that produced them
they cannot be read by an administrator, cannot survive a reinstall, and cannot be checked
against anything — which makes them a diagnostic rather than an audit trail, and the point
of them is the audit trail once shared vaults land. Retention bounds the exposure rather
than removing it: ninety days or five thousand entries per kind, whichever bites first.
Two things keep it as narrow as it can be. The payload records the host's *label* and the
address as dialled but **not** the SSH username — "who in this organisation opened a shell"
is the audit question, and "which account they logged in as" is a detail of the host, whose
own logs already have it. And the activity log records the **names** of the fields that
changed and never their values — the same rule
[ADR 0006](0006-observability-stack.md) imposes on the server's own `audit_event.detail`,
arrived at independently on the other side of the encryption boundary.
- **Supply chain becomes the largest practical hole.** An operator who wants the secrets
attacks the client, not the crypto. Release signing with a key not held by the server, and
eventually reproducible builds, matter more here than in a conventional product.
+452
View File
@@ -0,0 +1,452 @@
# An Android client: what it would take
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they
were taken against.
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out.
**What was actually checked**, so the rest can be read with the right amount of trust:
- Every project's target framework, read from `Directory.Build.props` and the `.csproj` files.
- The target frameworks each pinned package ships, read out of the local NuGet cache — so these are the
assemblies this solution would actually resolve, not what a package's README claims.
- Every site in `src/` that names a Windows API, a Windows path convention, or a desktop lifetime.
**What was not**: nothing was compiled for Android, nothing was run on a device or emulator, and no Android
SDK is installed here. Every statement below about *runtime* behaviour is reasoning from the code and the
platform's documented rules, and is marked where it matters.
---
## The headline
The port is smaller than it looks in one dimension and much larger in another.
**The core is already portable.** Every project targets plain `net10.0`, with no `net10.0-windows` anywhere
and no conditional compilation. All the Windows-specific code now sits in one project — `DodoSSH.Client.App`,
the desktop head. The cryptography, the sync engine, the local cache, the SSH layer, the item kinds and every
view model are platform-neutral today, and that is not luck: it is what the project structure has been
enforcing all along.
(One file was out of place when this was written — `WindowsDeviceKeyStore`, in `DodoSSH.Client.Session`. It
has since been moved, which is the only code change this audit produced; see §4.)
**The product is not.** DodoSSH is a two-pane file browser, a tab strip, a nav rail and a terminal, laid out
at a minimum of 880×560, driven by hover, right-click, middle-click and drag-and-drop. A phone is about
360dp wide and has none of those inputs. Roughly none of the *interface* ports; the question an Android
client really asks is not "will this compile" but "what is the Android product".
Two platform rules make that sharper, and they are the things most likely to be underestimated:
- **Scoped storage.** Android has no arbitrary local filesystem for an app to browse. The left-hand pane of
the Files screen — this machine's drives and directories — has no Android equivalent at all.
- **Background execution.** Android stops a backgrounded process. A terminal client whose whole premise is
that a shell survives locking the vault, and a transfer queue that runs for minutes, both assume a process
that keeps running. On Android that needs a foreground service with a persistent notification, or the
feature changes shape.
Neither is a porting problem; both were product decisions, and both have now been taken — a foreground
service, and a single remote pane. See [Decisions](#decisions-taken).
---
## What ports as it stands
Verified from the resolved package assemblies.
| Dependency | Ships for Android | Note |
| --- | --- | --- |
| `libsodium` 1.0.22 | ✅ `android-arm64`, `android-arm`, `android-x64`, `android-x86` | The native half of all the cryptography |
| `NSec.Cryptography` 26.4.0 | ⚠️ no Android-specific build | Ships `net9.0` plus iOS/tvOS/MacCatalyst. The plain `net9.0` assembly should load, since the platform-specific part is libsodium — but this is the one dependency worth proving with a build before anything else |
| `SSH.NET` 2025.1.0 | ✅ `netstandard2.0`, `net8.0` | Sockets only; needs the `INTERNET` permission |
| `SQLitePCLRaw.bundle_e_sqlite3` 2.1.12 | ✅ `net6.0-android31.0` | The local cache |
| `AWSSDK.S3` 4.0 | ✅ `netstandard2.0`, `net8.0` | |
| `CommunityToolkit.Mvvm` 8.4.2 | ✅ `netstandard2.0` | Every view model |
| `Avalonia.Controls.WebView` 12.0.1 | ✅ `net10.0-android36.0` | The surprise — see below |
| `Avalonia.Desktop` 12.1.1 | ❌ `net10.0` only | Replaced by `Avalonia.Android`, not ported |
So: **`DodoSSH.Client.Domain`, `.Storage`, `.Sync`, `.Api`, `.Auth`, `.Ssh`, `.Terminal`, `.Transfer`,
`.ObjectStore`, `.Import` and `.Crypto` should all target `net10.0-android` unchanged.** That is the great
majority of the code, including all of the cryptography and all of the sync protocol.
`DodoSSH.Client.Session` needed one file moved and no longer does — see §4. `DodoSSH.Client.App` is the
desktop head and does not port; an Android head would be a sibling project sharing its view models.
---
## What does not port, in order of how much it costs
### 1. The interface — the largest item by far, and it is not a port
880×560 minimum, a 54-pixel nav rail, a 268-pixel host sidebar, a two-pane file browser with six columns per
pane, a tab strip, and a layout suite (`DodoSSH.Client.App.Layout.Tests`, 64 tests) whose entire premise is
that everything fits at that minimum.
None of it survives a phone. What an Android client would be is a different product with the same core:
probably a host list, a terminal, and a single-pane file browser, with the keychain, snippets, logs and pins
as screens rather than as a rail. The desktop screens are not a starting point for that — they are a
different answer to a different question.
**This is where the real effort is**, and it is design effort before it is engineering effort. Everything
else on this list is a week or two of work; this is the product.
**Decided: phone first.** See [Decisions](#phone-first) — which means a redesign rather than a reflow, and
rules out the cheaper tablet route deliberately.
### 2. The Files screen's left pane has no Android equivalent
Scoped storage means an app sees its own directory and whatever the user hands it through the system
picker. There is no browsable `C:\` or `/home`. So the two-pane layout — the thing the whole screen is built
around — does not exist on Android.
The honest shapes are: a one-pane remote browser with **download to** and **upload from** going through the
system document picker, or a remote-to-remote transfer tool with no local side at all. Both are fine; both
mean the transfer queue's local half (`LocalDirectory`, the drive list, the breadcrumb trail) is desktop-only
code.
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
**Decided: one remote pane and the document picker**, and out of the first scope. See
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
### 3. Background execution
`TerminalWorkspace` keeps shells running across a vault lock, deliberately and documented. `FileTransferQueue`
runs one transfer at a time for as long as it takes. `VaultViewModel` runs an auto-sync pass every minute.
All three assume a process Android will stop.
The options are a foreground service with a notification for as long as a session or a transfer is live
(which is what every serious SSH client on Android does), or accepting that backgrounding the app drops the
connection. The first is not hard; it is a decision about what the app is allowed to do to the user's
battery and notification shade, and it wants taking deliberately.
**Decided: a foreground service** while a shell or a transfer is live. See
[Decisions](#sessions-survive-backgrounding-via-a-foreground-service).
### 4. The device key store — the cheap one, because the seam exists
`WindowsDeviceKeyStore` is DPAPI over a TPM-held key. `IDeviceKeyStore` is already the interface everything
else uses, with three methods and an `IsAvailableAsync` that exists precisely so a platform without a
keystore can say no.
Android's equivalent is the Android Keystore, with StrongBox where the hardware has it, and it is a closer
match than the Windows one: it can require biometric or device-credential authentication to release the key,
which is what the unlock screen would want anyway. **This is a straightforward implementation of an existing
interface**, and it is the piece of Android integration most clearly worth doing well.
**Done, ahead of any decision:** `WindowsDeviceKeyStore` used to sit in `DodoSSH.Client.Session`, which was
the one thing keeping that project from being portable. It now lives in `DodoSSH.Client.App/Platform/`, and
its factory is `DesktopDeviceKeyStores` — named for the head it belongs to. `IDeviceKeyStore` and
`UnavailableDeviceKeyStore` stayed behind, because they are the seam rather than an implementation.
The move cost nothing but a namespace, which is the useful part of the finding: the session layer takes a
store and has never known which one, so an Android implementation drops into the same hole. Verified by the
build and the suite, with the two Windows-only tests moving to `DodoSSH.Client.App.Tests` alongside it.
### 5. Sign-in
`BrowserLauncher` uses `Process.Start(UseShellExecute: true)`; `LoopbackCallbackListener` implements RFC 8252
§7.3 loopback redirect with a raw `TcpListener`.
Neither is right on Android. `Process.Start` does not exist; the platform way is an `Intent`, and the
platform way to receive the redirect is a Custom Tab plus an app link or a custom scheme. Loopback redirect
*might* work, and should not be used: on a shared device any other app can bind a loopback port, which is
exactly the attack RFC 8252 §8.3 warns about and the reason app links exist.
So this is a second implementation of an existing shape rather than a port. The PKCE flow, the discovery, the
key binding and the token handling above it are all unchanged.
### 6. `ClientPaths`
Branches Windows / macOS / XDG, with an explicit comment about wanting a *local, non-roaming* directory
because two machines sharing one cache file corrupts the outbox. On Android the right answer is the app's own
`filesDir`, which is per-app, non-roaming and not user-visible — it satisfies the requirement more cleanly
than any desktop platform does. One more branch, or better, the value injected by the head. `ClientPaths`
already takes an explicit directory for exactly this reason.
### 7. `Environment.MachineName`
Used as the device name on connection and activity log entries, and when registering a device. On Android it
returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
real device name from the head.
### 8. The Windows-only bits of the desktop head
Listed for completeness; none of these is ported, they are simply absent from an Android head.
- `NativeKeyboardFocus``user32.dll SetFocus`, and the whole documented asymmetry about focus not
returning from the WebView. Android's focus model is different and this problem may simply not exist there.
- `Program.Main``[STAThread]` (required by WebView2 specifically) and `StartWithClassicDesktopLifetime`.
An Android head is an `AvaloniaMainActivity` instead.
- Middle-click tab close, right-click, hover states, drag-and-drop between panes.
### 9. The terminal — better news than expected, with one unknown
`Avalonia.Controls.WebView` ships a `net10.0-android36.0` target, which was the single fact most likely to
sink this. And the transport underneath is more portable than it looks: `TerminalDataPlane` serves the page
and the binary protocol over a **loopback WebSocket**, and an Android WebView can load `http://127.0.0.1:port`
just as WebView2 does. The xterm.js bundles are embedded resources and are platform-neutral.
**Unverified, and it is the thing to check first if this goes ahead:** whether Avalonia's Android WebView
composites the same way — a native view above everything Avalonia draws. If it does, the occlusion rule in
`docs/platform-flags.md` applies unchanged and `IsTerminalShowing` keeps doing its job. If it does not, the
rule is unnecessary rather than wrong, which is the harmless direction.
The parts that are definitely different are the on-screen keyboard, and the fact that a terminal on a phone
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
accessory key row for this. That is UI work, not porting.
---
## Decisions taken
Four, each recorded with the reasoning that was actually weighed rather than only the outcome.
### Scope: the keychain and a terminal
Not a companion, and not the file browser. Everything that is already a list or a form — hosts, groups,
keys, passwords, snippets, pins, logs — plus opening a shell.
The terminal is the expensive half and it is the half that makes it an SSH client rather than a viewer. It
depends on the WebView spike coming back clean; if it does not, the companion subset is what is left and is
still worth shipping, so the work is ordered to find that out early.
### File transfer, when it comes: one pane and the document picker
Out of the first scope, decided now so the seams are not built the wrong way. A single remote pane, with
Android's document picker for moving files in and out.
This is the shape scoped storage allows, and the interesting part is how little it costs: `FileTransferQueue`
and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket behind that interface beside
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live.
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
notification is to look at.
### Phone first
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
and much of the current layout could have been reused — and the phone is the device people have with them,
which for an SSH client is most of the point.
So the interface is a redesign rather than a reflow, and that is the largest single item of work here. The
nav rail, the 268-pixel sidebar and the two-pane browser do not survive. What does survive is everything
behind them: every view model, every command, every piece of state.
---
## What the interface actually has to carry
Written for designing the phone client. It is an inventory of what exists today and what each part is
*for* — not a layout, and not a claim that any of it should look the same.
**Read it as a checklist of things that need somewhere to go.** The desktop has room to put a warning, a
confirmation and a form on screen at once; a phone does not, and the states most easily lost are the ones
that appear rarely and matter most. Those are marked **◆**.
### Getting in: six states before the app is usable
`ShellState`, and every one of them is a screen.
1. **Starting** — reading the local cache to find out whether this machine is enrolled.
2. **Needs a server** — nothing cached: name a server, sign in through the browser. The only state that
requires a network.
3. **Needs enrollment** — signed in, but the account has no vault key yet. Choose a passphrase.
4. **◆ Showing the recovery code** — *the user must not be able to click past this.* It is the only moment
the code exists; losing it along with the passphrase means the vault is unrecoverable, and there is no
server-side reset by design. On desktop it is a whole screen with a confirmation. It needs to stay one.
5. **Locked** — the unlock screen. Passphrase box, optional device unlock (biometric on Android), a status
line, and the line saying this works with no network. **◆** Also carries two disclosures: how many shells
are still connected behind the lock screen, and the paragraph explaining that *locked* describes the
keychain and not this machine's access to the hosts. Plus **reset this machine** for a forgotten
passphrase.
6. **Unlocked** — everything below.
### Chrome that is present on every screen
- **Titlebar** — vault name, account name, a search affordance (Ctrl+K on desktop), and a sync dot with a
label: synced, pending count, offline, unreachable.
- **Status bar** — the selected terminal's live dot and address, the vault's last status sentence, the sync
label again, and the search hint.
- **Nav rail** — eight destinations: `HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS`. Five characters is a
desktop constraint, not a product one; the phone can use words.
- **Terminal strip** — always visible, above every screen. Tabs with a close cross *inside* each tab, a `+`,
and a sentence when there are none. This is what makes a terminal a surface the window switches to rather
than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
- **Quick connect** — a search palette over hosts that connects on Enter.
### The nine destinations
**1. Hosts** — the list of machines, and what is known about the selected one.
- *Sidebar:* filter box; group headings with a chevron and a count, **shown only when groups exist**; host
rows carrying a connected dot, name, sync badge, address and one word for how it authenticates.
- *Editor* (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering
typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining
that relay puts the address on the server in plain text, and **◆ forget host key** — the only way back from
a legitimately rebuilt server.
- *Actions:* new / edit / delete, **replaced in place** by the delete confirmation rather than stacked under
it.
- *Right column:* the connect banner — a password box only for a host that asks for one, a sentence in its
place when it does not, and CONNECT.
- **◆ Unknown host key prompt** — fingerprint shown in full, TRUST AND CONNECT / CANCEL. Appears on first
contact with any host.
- **◆ Changed host key refusal** — deliberately has *no* continue button. Presenting this as dismissible is
the one design mistake that matters here.
- **◆ Conflict log** — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge
is only allowed to pick a winner because this exists.
- *Groups panel:* the groups as chips with host counts, a name box that both adds and renames, delete with
its own confirmation counting the affected hosts.
**2. Files** — two panes and a queue.
- *Bar:* a HOST / BUCKET toggle, the matching picker, a password box for hosts that need one, CONNECT
(or OPEN for a bucket), DISCONNECT, a connected chip, a status line.
- *Panes:* breadcrumb trail, drive roots on the local side, and a listing with name, size, modified and —
remote only — POSIX permissions. Each pane has an empty state and a drop highlight in two flavours,
accepting and refusing.
- *Queue:* direction arrow, name, the remote path, progress with bytes and rate, state, and RESUME / RETRY /
stop per row.
- **◆ The host key prompts appear here too.** File transfer is a second, separate connection that makes its
own trust decision.
- On Android this becomes one remote pane plus the document picker — see the decision above — but the queue,
its states and the prompts are unchanged.
**3. Keychain** — everything that is not a host.
- *Categories:* ALL / SSH KEYS / PASSWORDS / BUCKETS, each with a count.
- *Table:* name, type, one line of detail, sync badge. The detail is what is *known about* an item and never
the secret.
- *Four editors,* which are four different shapes: an SSH key (label, private key armour shown unmasked so a
truncated paste is visible, passphrase, public key, notes); **generate a key** (algorithm choice, comment,
and it fills the editor rather than saving); a password (label, username, password masked, notes); a bucket
(label, bucket, access key id, secret access key masked, region, endpoint, **◆ a path-style checkbox with
the sentence explaining why**, notes).
- **◆ Delete confirmations that count** — "three hosts authenticate with this key and will refuse to connect".
The number is the difference between a sentence somebody reads and one they click past.
**4. Pins** — the host keys this keychain has approved.
- Filter that matches **fingerprints as well as names**, because the workflow is "the operator published
SHA256:… — do I have that one?".
- Table: host, port, algorithm, **◆ fingerprint never truncated**, approved date.
- Detail pane with the fingerprint in full again, a "no host uses this" chip, the note that the date is
derived from the item id and means first approval rather than last use, and FORGET THIS HOST KEY.
**5. Snippets** — saved commands.
- Filter matching the command text as well as the name.
- Rows: name, **◆ a "runs immediately" chip**, sync badge, and a one-line preview with newlines shown as `⏎`.
- Editor: name, a multi-line command box, notes, and **◆ a "press Enter after inserting this" checkbox with
the paragraph explaining that leaving it off is the whole safety property**.
- Detail pane with the command in full and two buttons that **name the terminal they will type into**
`TYPE INTO prod-db` and, only for a snippet marked as running, `RUN IN prod-db`. Plus the "no terminal
open" state, and the sentence saying whatever is in the terminal receives this.
**6. Logs** — two logs behind one screen.
- A CONNECTIONS / KEYCHAIN toggle and a refresh.
- *Connections:* live dot, host, address, **◆ "still open" rather than a dash for a session in progress**,
kind (terminal or files), started, device, and a chip for a refused host key.
- *Keychain:* item, type, what happened, **the names of the fields that changed**, when.
**7. Preferences** — import `~/.ssh/config`, register or forget this device, sign out.
**8. Team** — nothing behind it, and the screen says so rather than being hidden from the rail.
**9. Import** — a preview before anything is written: tick per row, alias, resolved address, how it
authenticates, a warnings chip, an "already in the keychain" badge, TICK ALL / NONE, and IMPORT N HOSTS.
Nothing is stored until the button.
### States that cut across every screen
- **Empty states**, with copy written per screen — each says what the thing is *for*, not "no items".
- **A read-only item**, written by a newer client: shown, refused for editing, with a message saying to
update. Re-encoding would silently drop a colleague's field.
- **Sync badges** per row: not synced yet, refused and waiting on a person, read-only.
- **Unreadable items** — a count of things that would not decrypt, which is the signal that new key grants
are needed after a rekey.
- **Busy**, and **offline / unreachable**, which are different from each other and both different from
"everything is synced".
### The five most likely to be lost on a phone
In the order I would worry about them, and all of them are full-width blocks today with nowhere obvious to
go at 360dp:
1. The changed-host-key refusal, which must not become dismissible.
2. The recovery code screen, which must not become skippable.
3. The counted delete confirmations, which are the difference between a decision and a reflex.
4. The unknown-host-key prompt, which is the one interruption that is genuinely load-bearing.
5. The conflict log, which is what makes the merge honest rather than last-writer-wins.
---
## The order of work
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.Client.Session` is now free
of Windows APIs entirely.
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half
settles NSec-on-Android, libsodium's native resolution, SQLite and whether the sync stack runs; the second
settles whether the terminal is possible at all, which the scope decision now depends on. Both are cheap
and both are gates — nothing after this is worth starting until it comes back.
4. **Android device key store**, with biometric or device-credential release. A straightforward
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well:
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is.
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
6. **The foreground service**, before the terminal rather than after it. A session that dies on backgrounding
would otherwise shape every decision made while building the screen, and be expensive to unpick.
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate.
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
Tab or arrows, and every Android SSH client ships one for exactly this reason.
Steps 1 and 2 are done. Step 3 is a few days and retires nearly all the remaining technical risk. Steps 46
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates
everything else put together, and step 8 is small only because step 7 came first.
---
## Still open
Neither of these blocks the spike, and both want answering before there is anything to release.
- **Which Android versions.** Less forced than it first looked: the packages *compile against* API 36 and 31
respectively, which constrains `targetSdk` rather than `minSdk`. The floor is therefore a real choice about
which devices are worth supporting, and it should be made deliberately rather than inherited from whatever
restores. Worth settling before the interface work, since it decides which platform APIs are available to
design against.
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
but it is a change to a documented security property of this product, and it should be reasoned about in
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
costs reach.
## Smaller things, decided by default
Recorded so they are choices rather than accidents. Any of them is cheap to revisit.
- **`ClientPaths`** gets the app's own `filesDir`, injected by the head rather than branched for inside the
record — which already takes an explicit directory for exactly this reason. It satisfies the
local-and-non-roaming requirement more cleanly than any desktop platform does.
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
something like `localhost` on Android and would make every entry from a phone indistinguishable.
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
confirmed to exist before anything is written to solve it.
+62 -2
View File
@@ -36,12 +36,72 @@ caller's own personal one. That removes the Teams screen entirely, and with it e
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
**Three things the design did not ask for and this build now has.** A key can be generated in the client
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
`OpenSshKeyWriter` for why there was no alternative and why it is written unencrypted). Hosts can be
imported from `~/.ssh/config` (`DodoSSH.Client.Import`; it reads no key material, and `ProxyJump` is
recorded as intent because the SSH layer still has no jump hosts). And the file-transfer screen takes drag
and drop in four directions — remote to Explorer is the one that does not ship, because it needs a virtual
file the platform layer cannot supply; see `docs/manual-checks.md`.
**The rail has six destinations, not five.** The design gives host keys no slot at all. They were a fourth
category on the keychain screen for a while and are now a screen of their own, `KnownHostsScreen`, because
the other categories are things somebody creates and edits and a pin is a decision recorded at the moment
of connecting — and because comparing an untruncated fingerprint against a published one needs a column
layout the shared table could not give it.
**The product surface says "keychain" and everything under it says "vault", deliberately.** The nav rail,
the unlock and enrollment copy, and every sentence a user reads now call the encrypted store a keychain.
The wire does not: the route is `/api/v1/vaults/{vaultId}`, the tables are `vault` and `vault_key_grant`,
the CLR types are `VaultViewModel` and `VaultSession`, and `docs/crypto.md` — which is normative — says
"vault key". Renaming those would break every deployed client, need a table-rename migration, and put
`CryptoSpec.AadResourceType.Vault = 3` inside the blast radius of a find-and-replace, where changing it
would make every item in every vault permanently unreadable. So the split is the answer rather than a
stage on the way to one: read "vault" in this repository as the name of the cryptographic object, and
"keychain" as what the product calls it.
One thing cuts the other way and is worth knowing before planning any of this: **the wire protocol already
reserves the slots**. `SyncEntityType` (`src/DodoSSH.Contracts/SyncEntityType.cs`) has `HostGroup = 4`,
`Tag = 5`, `HostTag = 6`, `HostCredential = 7`, `Snippet = 8` and `PortForward = 9` — reserved, unused, and
already covered by the AAD resource-type table. Groups, tags and snippets are new item types on an existing
protocol rather than a protocol change.
Two of those slots are now taken. `HostGroup` and `Snippet` shipped as full item kinds — a table, an EF
configuration, a server kind that refuses every plaintext field, a codec, a merge, a cipher and a repository
— and neither needed a contract change, which is what the reservation bought. `Tag`, `HostTag`,
`HostCredential` and `PortForward` are still reserved and still unused.
**A third was added for something the design never mentioned**: `ObjectStore = 13`, an S3-compatible bucket
and the keys that reach it. It is a keychain item like any other — the endpoint and the secret access key are
both inside the payload, and the server refuses every plaintext field including the endpoint, because for
everybody self-hosting that is an address on their own network.
What made it cheap is that the transfer queue never needed anything SSH-specific: `IRemoteFileStore` was
lifted out of `ISftpSession` with only the host key left behind, and a bucket is the same contract with a
different implementation. Three things a bucket genuinely cannot do are refused with a reason rather than
approximated — there are no directories (only keys with slashes in them, and a marker object for an empty
one), no resumable upload (an object cannot be written from the middle), and no atomic rename (a copy and a
delete). Downloads *do* resume, because a ranged GET is part of the protocol.
**Two members were added rather than claimed**: `ConnectionLogEntry = 11` and `ActivityLogEntry = 12`, the
first additions to `SyncEntityType` since it was frozen. Logs are synced items rather than local files
because they are audit records — an administrator has to be able to read a shared vault's history once teams
land, and a log kept only on the machine that produced it can be neither read nor trusted by anybody else.
ADR 0001 records what that costs in metadata; it is not free and it is not hidden.
Three properties are worth knowing before touching them. An entry is **written once, at close** and never
updated, which is what lets a synced log avoid a merge entirely. Neither kind is **audited**, which is the
guard that stops the activity log producing an entry for every entry it writes. And both are **excluded from
the pending-change count and from what a background sync announces** — those numbers answer "how much of my
work is not yet safe", and a log entry is not somebody's work.
**One plaintext field was taken away rather than used.** `SyncPlaintextFields.GroupId` has existed since the
contract was frozen and `host.group_id` since the first migration; no client ever wrote either. Group
membership is inside the encrypted payload instead, the column is dropped, and the server now refuses the
field with a reason — because what it would have handed the operator is a clustering of every user's estate,
and ADR 0004's test for a plaintext concession is that the server *cannot function* without it. The wire
field cannot be removed and stays as a permanently refused member; `SyncEndpointTests` holds the refusal.
---
## Chrome — titlebar, nav rail, status bar
@@ -71,13 +131,13 @@ caption buttons and window title drawn on top of the application's own — two s
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. |
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | One collapsible heading, named after the vault — the only grouping a host actually has. A second appears when a second vault becomes reachable. |
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Flat, not nested. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
| Group badge `TEAM·PLATFORM` | server | Teams. | Omitted. |
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | Omitted. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
+582
View File
@@ -0,0 +1,582 @@
# Things a person still has to check
Automated tests cover what they can reach. This file is the rest: the checks that need a real window, a
real network, a real remote host, or a real Explorer — and the reasons each one is out of reach.
Three constraints put things on this list, and they are worth knowing before adding to it:
- **`MainWindow` cannot be laid out by a test.** WebView2's adapter refuses the headless dispatcher's MTA
thread — see `LayoutHarnessTests.WhyTheWindowItselfIsNeverShown`. Anything that has to be measured lives
on a `UserControl` instead, and what is left in the window is unmeasured by construction.
- **Headless Avalonia has no native window.** So nothing about Win32 focus, about the WebView collapsing,
or about a drag that crosses into another application can be asserted. A headless test of any of those
would pass and confirm the wrong belief.
- **No network, no container, no remote host** in the ordinary suite. The SSH suites that do use one
(`DodoSSH.Client.Ssh.Tests`, `DodoSSH.SystemTests`) need Docker and are the exception.
Each item says what to do, what a pass looks like, and what a failure would mean.
---
## Phase 1 — the shell and the tab strip
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
Open two terminals, then visit every nav rail entry in turn — HOSTS, FILES, KEYS, TEAM, PREFS.
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
five.
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
left edge with the rest unreachable. This window has shipped that defect once. The single wrapper `Panel`
bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossible.
### 1.2 A tab clicked from another screen takes the keyboard
Go to FILES with a terminal open. Click the tab. **Start typing immediately, without clicking anything
else.**
**Pass:** every character reaches the shell, including the first.
**Failure means:** `FocusTerminalWhenLaidOut` in `MainWindow.axaml.cs` is posting too early.
`NativeControlHost` re-pushes its bounds on the next layout pass, so focusing ahead of that pass races the
thing the focus depends on. The symptom is losing only the first keystroke or two, which is why this has to
be typed immediately rather than after a pause.
### 1.3 Leaving a terminal gives the keyboard back
With a terminal focused, click FILES. Type into the filter box.
**Pass:** the characters appear in the box.
**Failure means:** `ReleaseKeyboardTo` is missing on that path. **Collapsing the WebView does not release
the keyboard** — the native child window goes on holding Win32 focus and Avalonia then sees no key events
at all, so the screen that just appeared silently swallows everything. This was a latent bug before the
strip rework and is now on the hot path. See `docs/platform-flags.md`.
### 1.4 The middle click closes tabs and only tabs
Middle-click a tab (closes it), the strip background to the right of the last tab (closes nothing), and the
`+` button (closes nothing, opens nothing).
**Pass:** as described. Covered by `TerminalTabsTests` headlessly, so this is a confirmation that headless
pointer input matches a real mouse rather than a first look.
### 1.5 Connecting from the palette while on another screen
Press Ctrl+K from the FILES screen and connect to a host whose key is not yet approved.
**Pass:** the window lands on HOSTS with the fingerprint prompt visible and answerable.
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
question that cannot be reached.
---
## Phase 2 — Known Hosts as its own page
### 2.1 The fingerprint column is readable end to end
Connect to two or three hosts, approving each fingerprint. Go to PINS and widen the window to its minimum
(880px), then to something ordinary.
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
**Failure means:** the one thing this screen is for has been broken. A truncated fingerprint cannot be
compared against a published one — it can only be glanced at, which is the habit pinning exists to replace.
`TheHostKeysScreenFitsWithPinsAndOneSelected` measures this at the minimum width, so a failure here is a
size the harness does not cover.
### 2.2 The APPROVED date is plausible
**Pass:** it is roughly the day you first connected to that host.
**Failure means:** the version 7 identifier is being read in the wrong byte order — the symptom is dates
tens of thousands of years out, not an error. Covered by `Uuid7TimestampTests`, so this is a confirmation
that the ids reaching the screen really are the ones this client minted. A pin restored from an older
client or another implementation shows `—`, which is correct rather than a failure.
### 2.3 Forgetting a pin reaches the server
With two machines signed in to the same account: forget a pin on one, sync the other.
**Pass:** the pin is gone on both, and the second machine asks you to check the fingerprint again on the
next connection.
**Failure means:** the forward from the screen's command to the vault's has lost the push. Withdrawing
trust that stays withdrawn only locally is the failure mode that matters here — the machines still refusing
to reach a rebuilt server are the other ones.
---
## Phase 2 — Generating a key
Most of this one *is* covered: `KeyAuthenticationTests.AKeyThisClientGenerated_AuthenticatesAgainstARealServer`
installs a generated public line on a real OpenSSH server in a container and connects with the private half,
for both algorithms. That is the claim that mattered, and it is automated. What is left is the interface
around it.
### 2.4 COPY PUBLIC KEY actually reaches the clipboard
Generate a key, save it, select it, press COPY PUBLIC KEY, then paste somewhere.
**Pass:** one `ssh-ed25519 AAAA… comment` line.
**Failure means:** the clipboard closure in `App.axaml.cs` is not finding the window. No test can see this —
the view models take a delegate precisely so they never touch a visual, which means the one real
implementation of that delegate is exercised by nothing but a person. `CopyingAPublicKey_WithNoClipboard_SaysSo`
covers only the branch where there is none.
### 2.5 RSA-4096 does not freeze the window
Choose RSA 4096 and press GENERATE. While it runs, drag the window and click around.
**Pass:** the window keeps painting and the status line says it is working.
**Failure means:** the `Task.Run` is not actually taking the work off the UI thread. Ed25519 is instant and
will not show this, so it has to be tried with RSA.
### 2.6 The generated key works end to end, by hand
Generate a key, save it, copy the public line, add it to a real host's `~/.ssh/authorized_keys`, bind the
host to the key in its editor, and connect.
**Pass:** it connects without a password.
This duplicates the container test on purpose. The container runs one image; the thing worth knowing is
that it works against whatever you actually run.
---
## Phase 2 — Importing ssh_config
The parser has 22 cases over the shapes a real file contains, and the end-to-end path is covered by
`ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked`. What no test can do is read *your* file.
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
Preferences → IMPORT HOSTS → SCAN. Do not press import yet.
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
above the table account for anything missing.
**What to look for specifically:**
- A `Host *` block's `User` should appear on hosts that set none, and **not** override hosts that set one.
- Entries whose name is a pattern (`*.internal`, `bastion-?`) should be *absent* from the table and named
in the warnings.
- `Match` blocks should be counted in the warnings and their settings should not have leaked onto any host.
- A `ProxyCommand` should be reported as dropped, not silently kept.
**Failure means:** the importer and `ssh` disagree about what your file means, which produces bookmarks
that nearly connect. That is worse than an import that refused, so it is worth reading the table properly
once.
### 2.8 Nothing is written until the button
Scan, then navigate away without importing.
**Pass:** the Hosts screen is unchanged.
### 2.9 Imported hosts are correct
Import a couple, then open one on the Hosts screen.
**Pass:** the address, port and username match the config, and the notes record any `IdentityFile` path and
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting should ask for a password even
where the config named a key, because **no key material is read**; binding it to a key in the keychain is a
separate act.
---
## Phase 2 — Drag and drop on the SFTP page
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
window and cannot synthesise a platform drag, so a test that claimed to drop a file from Explorer would
pass while confirming nothing. What is automated is the policy — `TransferQueueingTests` covers what may be
queued, what is skipped and what is said about it — and the wiring between a real drag and that policy is
covered by nothing at all.
Connect the SFTP page to a host first. All four of these should queue transfers.
### 2.10 Explorer → remote pane
Drag one file, then several, from Explorer onto the right-hand pane.
**Pass:** the pane outlines in accent colour while the pointer is over it, and the drop queues one transfer
per file into the directory showing.
### 2.11 Local pane → remote pane
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
2.10 passing points at the drag *source*, not the drop target.
### 2.12 Remote pane → local pane
**Pass:** the left pane outlines and the drop queues a download.
### 2.13 Local pane → Explorer
**Pass:** the file copies out.
### 2.14 The highlight clears · **the one most likely to be wrong**
Drag something over a pane and then out of it again without dropping.
**Pass:** the outline appears and then goes away.
**Failure means:** an overlay is participating in hit testing. It lays out identically either way — which is
why the layout test cannot catch it — but once visible it swallows the `DragOver` events underneath it, so
the pointer appears to leave immediately, the highlight sticks, and the drop lands nowhere. The fix is
`IsHitTestVisible="False"` on the highlight `Border` in `TransfersScreen.axaml`.
### 2.15 Dropping while disconnected
Disconnect, then drag a file over the remote pane.
**Pass:** the pane outlines in red and says "Connect to a host first." Nothing is queued on drop.
### 2.16 A click still selects a row
Click rows in both panes, and drag a row a few pixels without releasing.
**Pass:** a click selects; a small movement does not start a drag.
**Failure means:** the 4-pixel threshold in `TransfersScreen.axaml.cs` is not doing its job, and selecting a
row has become impossible.
### Not implemented: remote pane → Explorer
Dragging a *remote* file out to Explorer is deliberately absent. It needs the source to supply a virtual
file — on Windows, `CFSTR_FILEDESCRIPTORW` plus `CFSTR_FILECONTENTS` with delayed rendering — and Avalonia's
`IDataTransfer` marshals `DataFormat.File` only from a storage item that resolves to a real local path.
Pre-downloading to a temp file does not help: the shell demands the bytes during the drop. The only route is
a native COM `IDataObject` behind a platform interface, Windows-only and outside Avalonia's supported
surface. Use the ← button, or drag the file to the local pane first.
---
## Phase 3 — Host groups and snippets
Two synced item kinds, a sidebar that now draws headings, and one new frame between the host process and the
renderer. The data half of all of that is covered: the payloads round-trip, the server refuses the plaintext
fields, the sidebar's grouping and the snippet policy are in `ShellFlowTests`, and both new screens are
measured. What is left here is the part that only exists inside a WebView, plus the two-machine cases no
single-process test can reach.
### 3.1 A keychain with no groups looks exactly as it did
Open the hosts screen without creating any group.
**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing
saying the hosts are unfiled.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
### 3.2 Filing hosts, and folding a heading
Make two groups, file some hosts into each through the host editor, then click a heading.
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the
sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model
directly; what it cannot see is whether the `ListBox` writes something else back through the binding first.
### 3.3 Deleting a group with hosts in it
Select a group with hosts and press DELETE.
**Pass:** the question names how many hosts are filed under it and says they stay. Agreeing removes the
group; the hosts reappear under UNGROUPED with everything else about them unchanged.
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
`HostGroupRepository`.
### 3.4 A group deleted on another machine · **needs two machines**
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
again. Back on A, sync.
**Pass:** A shows the host under UNGROUPED. Open that host's editor: the group picker shows "(a group that is
no longer here)" and *keeps it selected*. Change the port and save.
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
### 3.5 A grouped host stays editable on an older build · **needs two builds**
Only worth doing before a release that ships alongside an older client. A host filed into a group is written
at payload schema 4; an older build must show it and refuse to edit it, rather than editing it and dropping
the group.
**Pass:** the older build says the host was written by a newer version. A host with *no* group still edits
normally there — that is what makes the version a maximum over the fields present rather than a stamp.
### 3.6 Inserting a snippet · **the one that cannot be tested here**
Open a terminal, go to SNIPS, select a snippet with `Press Enter after inserting this` **off**, and press the
insert button.
**Pass:** the terminal comes forward with the command sitting at the prompt, not run. The button named the
tab it was going to — check that it named the right one if several are open.
**Failure means:** if the command runs by itself, the flag byte or the JavaScript that reads it is wrong. If
nothing appears at all, the frame reached a pane the page does not have.
### 3.7 A multi-line snippet does not run line by line · **the reason the opcode exists**
Save a snippet whose command is three lines — `echo one`, `echo two`, `echo three` — with the run flag off,
and insert it into a **bash or zsh** session.
**Pass:** all three lines sit at the prompt as one pending command, and nothing runs until Enter. Modern
shells turn bracketed paste on, xterm.js sees that in the output stream, and `term.paste` wraps the text.
**Failure means:** if the first two lines execute and the third waits, the text went through as plain input
and the bracketing did not happen — which is the whole failure this frame was added to prevent.
**Then insert the same snippet into something with bracketed paste off** — a raw `sh`, or a session inside
`vi`. The lines *will* run there, and that is correct and unavoidable: without the mode there is no way to
distinguish pasted newlines from typed ones. It is why the screen says "types this into whatever is there"
rather than "runs this command".
### 3.8 RUN presses Enter
Select a snippet with the run flag on and press RUN.
**Pass:** the command runs.
**Failure means:** if the command appears and does not run, the `\r` is going through `paste` instead of
`input` — inside the bracketed wrapper it is literal text, so nothing executes.
### 3.9 Inserting into a tab whose remote has hung up
Open a terminal, `exit` it, leave the tab open, then insert a snippet at it.
**Pass:** the screen says that tab is no longer connected, and nothing is claimed to have been sent.
### 3.10 Both new kinds reach a second machine · **needs two machines**
Make a group and a snippet on A and sync; sync B.
**Pass:** both arrive, with their names and — for the snippet — its run flag intact. A snippet whose flag
arrives *set* when it was saved unset is the one failure here worth stopping for.
---
## Phase 4 — Logs
Both logs are synced item kinds with the whole pipeline covered: payloads round-trip, the server refuses
every plaintext field, retention is tested against a real vault, the activity hook is tested through the
repository every kind writes through, and the recursion guard has a test of its own. What is left here is
the part that only happens across a lock, across a process exit, or across two machines.
### 4.1 A connection is recorded when the tab closes, not before
Connect to a host, leave the tab open, and open LOGS.
**Pass:** the connection is at the top of CONNECTIONS with a green dot and the words **still open** — not a
dash, and not a duration. Close the tab and press REFRESH: the same connection now has a duration.
**Failure means:** a dash instead of "still open" reads as a recording that failed, which is the opposite of
what is happening. A duration before the tab closes means an entry is being written at open, which would
also mean it gets written twice.
### 4.2 The duration is plausible
Connect, wait a measured minute or two, disconnect.
**Pass:** the LASTED column agrees with the clock, rounded to whole units.
**Failure means:** a wildly wrong number points at the two timestamps coming from different clocks — both
should come from the workspace's own `TimeProvider`.
### 4.3 Closing the application records the tabs that were open · **the one most likely to be wrong**
Open two terminals and close the DodoSSH window without closing the tabs. Start it again, unlock, open LOGS.
**Pass:** both connections have entries, with durations running up to the moment you closed the window.
**Failure means:** they are the ordinary way a session ends, and the workspace's own close-outs happen while
it tears sessions down — *after* the vault they would be written into has gone. `ConnectionRecorder`'s
`DisposeAsync` closes the tickets itself, before the session is disposed, and waits up to two seconds for
the queue. If the entries are missing, that ordering has been broken; if closing the window became slow,
the bounded wait has.
### 4.4 A shell open across a lock still gets its entry
Connect to a host, lock the keychain from the titlebar, unlock again, then close the tab.
**Pass:** the connection is recorded, into the vault it was made in.
**Failure means:** the ticket keeps the repository it was opened against precisely so this works. A missing
entry means it is reading the current one instead, which would also mean an entry could be filed into the
wrong vault once shared vaults land.
### 4.5 A refused host key is recorded
Connect to a host, approve its key, then change the key on the remote (or edit the pin) so the next
connection is refused.
**Pass:** an entry appears with **host key refused** beside it. This is the row the connection log most
exists for — a changed host key is refused with no way past it, so the status line is otherwise its only
trace.
### 4.6 The SFTP session is recorded separately
Open the files screen and connect, then disconnect.
**Pass:** an entry with **files** in the KIND column, separate from any terminal entry.
**Failure means:** if it is missing, our log disagrees with the remote's own `auth.log`, which records the
second login. Anybody comparing the two would be right to believe the host.
### 4.7 The keychain log records names and never values
Edit a stored password: change both the password and the username. Open LOGS → KEYCHAIN.
**Pass:** one row saying **changed**, with `Password, Username` in the FIELDS column.
**Failure means:** if any part of the old or new password appears anywhere on that screen, stop — that is
the one thing this payload must never carry, and it would now be synced to every machine in the vault.
### 4.8 A pin trusted at the prompt is recorded
Connect to a host you have never reached and approve the fingerprint.
**Pass:** the keychain log shows a `KnownHostKey` **created**. This write never goes through a screen, so it
is exactly the one a hook placed in the view models would have missed.
### 4.9 The pending count and the status line stay honest
Save a host while online and watch the titlebar and the status line for a minute.
**Pass:** the pending count returns to zero and stays there, and the status line keeps saying what the save
said — it does not get overwritten a moment later by a sync report.
**Failure means:** every user action queues a log entry a moment afterwards. If the count sticks at 1 or the
status line flickers to "Synchronised: 1 out", the log entries have stopped being excluded from the two
numbers that are about the user's own work.
### 4.10 Both logs reach a second machine · **needs two machines**
Connect and edit something on A, sync; then sync B and open LOGS there.
**Pass:** both entries are on B, with A's device name on them. This is the claim the whole decision to sync
these rests on.
### 4.11 Retention actually prunes · **slow, or needs a clock**
Only checkable honestly by leaving a vault in use for months, or by temporarily lowering
`LogRetention.Default` in a debug build and watching a prune remove the excess and push the tombstones.
**Pass:** the count comes down, and the second machine's copy comes down too at its next sync.
**Failure means:** these entries sync, so a prune that does not push leaves every other machine holding
them — and this machine deleting them again on every pass.
---
## Phase 6 — S3 as a remote in the file browser
The parts that are this client's own reasoning are covered: the path-to-key translation, what a bucket must
have before it can be stored, the server's refusal of every plaintext field, and the item kind end to end.
What is left needs a real endpoint, and a fake would only assert our reading of the protocol back at us.
**Get a bucket first.** MinIO in Docker is the cheapest way and exercises the harder path — path-style
addressing, a custom endpoint, and a region that is ignored:
```bash
docker run -p 9000:9000 -e MINIO_ROOT_USER=dodossh -e MINIO_ROOT_PASSWORD=dodossh-secret minio/minio server /data
```
### 6.1 Adding a bucket
Keychain → BUCKETS → `+ BUCKET`. Endpoint `http://localhost:9000`, path-style **on**, any region.
**Pass:** it saves, appears in the list with the bucket and endpoint under its name, and syncs.
**Failure means:** if saving is refused, read the message — the validation exists so the reason names the
field rather than arriving later as an SDK error about resolving a URI.
### 6.2 Path-style addressing · **the one most likely to be wrong**
Save the same bucket with path-style **off** and open it.
**Pass:** it fails, and the failure is a name-resolution error mentioning `bucket.localhost`.
**Why it is worth doing deliberately:** that is exactly what a user gets when they leave the checkbox at its
default against a self-hosted service, and the message names neither buckets nor the setting. Seeing it once
is what makes the hint under the checkbox worth its space.
### 6.3 Browsing
Files → BUCKET → pick it → OPEN.
**Pass:** the right pane lists the bucket root. Prefixes appear as directories in the directory colour;
objects appear as files with sizes and dates. The PERMS column is empty — a bucket has no POSIX mode, and a
plausible `-rw-r--r--` would be invented.
**Also check** that the timestamps agree with the local pane's. Both columns are UTC; if the bucket's are out
by your machine's offset, the `DateTime.Kind` handling in `S3FileStore.Utc` has regressed.
### 6.4 Upload, and the pipe underneath it
Upload a file of a few hundred megabytes.
**Pass:** it completes, the object is in the bucket at the right key and the right size, and the machine's
memory does not grow with the file. The upload streams through a pipe into a multipart upload — nothing is
buffered to disk twice and nothing is held whole in memory.
**Failure means:** if it hangs at the end, the pipe's writer is not being completed on disposal. If memory
tracks the file size, the multipart path is not being taken.
### 6.5 A failed upload surfaces at the write
Start an upload and stop MinIO halfway.
**Pass:** the queue row fails with a message from the service, reasonably promptly.
**Failure means:** if it hangs instead, the background upload is failing without completing the pipe's
reader — and the copy is blocked on a pipe nobody is draining. That is the case `S3UploadStream` completes
the reader *with* the exception for.
### 6.6 Download, and resume
Download a large object, stop it partway, and resume.
**Pass:** it resumes from where it stopped, and the finished file matches the original. This direction is the
one where a bucket is better than SFTP — a ranged GET is part of the protocol.
### 6.7 Upload resume is refused, and says why
Start an upload, stop it partway, and press RESUME.
**Pass:** it fails with a message saying an object cannot be written from the middle, so an interrupted
upload starts again rather than resuming. RETRY from the start works.
**Why this is not a bug:** objects are immutable. Multipart could rebuild an interrupted transfer, but only
by persisting the upload id and every part's ETag across the interruption. Refusing is honest; silently
starting from zero would corrupt the file.
### 6.8 Delete refuses a prefix with anything under it
Try to delete a directory in the bucket that has objects in it.
**Pass:** refused, saying there are still objects under it. Deleting an empty one works.
### 6.9 The keys never appear anywhere they should not
After adding and editing a bucket, open LOGS → KEYCHAIN.
**Pass:** the entry says `Secret access key` in the FIELDS column and nowhere on that screen does any part of
the key itself appear.
### 6.10 Against real AWS · **needs an account**
Repeat 6.1 and 6.3 with the endpoint blank, a real region, and path-style **off**.
**Pass:** it lists. This is the path that exercises `RegionEndpoint.GetBySystemName` and virtual-host
addressing, neither of which MinIO covers.
+22 -3
View File
@@ -114,9 +114,11 @@ off-screen page. Worth revisiting if idle power ever matters.
rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a
2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits
land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis.
Related and not yet addressed: the conflict log above the terminal is an `ItemsControl` with no
`ScrollViewer` and no `MaxHeight` on an `Auto` row, so enough conflicts squeeze the terminal row toward
nothing.
Related, and now fixed: the conflict log was an `ItemsControl` with no `ScrollViewer` and no `MaxHeight` on
an `Auto` row, so enough conflicts squeezed the row below it toward nothing. It survived that long because
it lived in `MainWindow.axaml`, which no test can lay out. Moving it into `HostsScreen.axaml` — a
`UserControl`, and therefore measurable — is what surfaced it; it now has both, and
`TheHostsScreenFitsWithAConflictLogTooLongToShow` fails without them.
**Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
@@ -241,6 +243,23 @@ MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia
esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual
re-download; the licence and versions are recorded here so that stays visible.
**Bracketed paste is the renderer's to decide, and it is why snippets go through a frame.** xterm tracks
`\e[?2004h` from the remote's own output and `Terminal.paste(text)` wraps the text in paste markers only
when the mode is on — which is what makes a shell treat embedded newlines as text rather than as "run
this". The host process cannot make that decision: `TerminalDataPlane` moves opaque bytes and never parses
output, so writing a snippet straight into the pump would mean guessing, and guessing wrong executes every
line of a multi-line command. Hence `TerminalServerOpcode.Paste`. Two consequences worth keeping:
- The Enter for a snippet marked as running goes through `Terminal.input('\r')`, **outside** the wrapper. A
`\r` appended to the pasted text is bracketed with it and arrives as a literal character, so nothing runs.
- Against a remote with bracketed paste *off* — a raw `sh`, or a session inside an editor — a multi-line
snippet does run line by line, and nothing can prevent that. It is a property of the terminal protocol,
not of this client, which is why the screen says "types this into whatever is there".
Both methods were confirmed present on the public API of the vendored `@xterm/xterm` 6.0.0 bundle before
being written against; neither is reachable from any test in this repository, so they are in
`docs/manual-checks.md` as checks 3.63.8.
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and
+503 -3
View File
@@ -71,6 +71,8 @@ internal static class ItemKinds
new[]
{
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
}.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
@@ -129,6 +131,20 @@ internal sealed class HostKind : IItemKind
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
/// violation surfacing as a 500.
/// </summary>
/// <remarks>
/// <para>
/// The group check comes <em>first</em>, ahead of the relay branch, and that placement is the point: the
/// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
/// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
/// to carry it.
/// </para>
/// <para>
/// <c>SyncPlaintextFields.GroupId</c> is part of a frozen wire contract and cannot be removed from it, so
/// refusing it here is what actually keeps the value out of the database. The column it used to be copied
/// into was dropped when groups landed; see <see cref="VaultHostGroup"/> for why membership travels inside
/// the payload instead.
/// </para>
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
@@ -136,6 +152,12 @@ internal sealed class HostKind : IItemKind
error = string.Empty;
if (fields.GroupId is not null)
{
error = "A host's group is inside its encrypted payload; the server does not store one.";
return false;
}
if (fields.RelayEnabled)
{
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
@@ -172,7 +194,6 @@ internal sealed class HostKind : IItemKind
host.RelayEnabled = fields.RelayEnabled;
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
host.Port = fields.RelayEnabled ? fields.Port : null;
host.GroupId = fields.GroupId;
}
/// <remarks>
@@ -197,8 +218,7 @@ internal sealed class HostKind : IItemKind
return new SyncPlaintextFields(
RelayEnabled: host.RelayEnabled,
Hostname: host.Hostname,
Port: host.Port,
GroupId: host.GroupId);
Port: host.Port);
}
}
@@ -487,3 +507,483 @@ internal sealed class KnownHostKeyKind : IItemKind
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Host groups: an envelope and nothing else.</summary>
/// <remarks>
/// The kind that closes a hole rather than opening one. <c>SyncPlaintextFields</c> has carried a
/// <c>GroupId</c> since the contract was frozen and <see cref="HostKind"/> used to copy it into a column;
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
/// server can read, which is the same answer <see cref="KnownHostKeyKind"/> gives for the same reason.
/// </remarks>
internal sealed class HostGroupKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.HostGroup;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.HostGroups
.Where(g => g.VaultId == vaultId && ids.Contains(g.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var group = new VaultHostGroup { Id = id, VaultId = vaultId };
database.HostGroups.Add(group);
return group;
}
/// <summary>
/// Refuses every plaintext field there is, including the one named after this type.
/// </summary>
/// <remarks>
/// A <c>GroupId</c> on a group would be a parent pointer, and groups are flat — see
/// <see cref="VaultHostGroup"/> for why nesting merged by a scalar three-way merge can produce a cycle
/// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by
/// accident.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A host group is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null)
{
error = "Host groups are flat, and a group's name is inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A host group has no public key.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Snippets: an envelope and nothing else.</summary>
/// <remarks>
/// A label column here would sort a list this server never draws, and the commands beside that label describe
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
/// <see cref="CredentialKind"/>, and for the aggregation reason rather than the secrecy one.
/// </remarks>
internal sealed class SnippetKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.Snippet;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.Snippet;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Snippets
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var snippet = new VaultSnippet { Id = id, VaultId = vaultId };
database.Snippets.Add(snippet);
return snippet;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A snippet is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A snippet has no public key.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Connection log entries: an envelope and nothing else.</summary>
/// <remarks>
/// <para>
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
/// <c>started_at</c> would let this server order and prune a log without any client's help. It gets none,
/// because a timestamp column on this table is a record of when each user works, and the times are the
/// interesting part of a connection log even when the hostnames are sealed.
/// </para>
/// <para>
/// <b>The server cannot enforce write-once, and does not pretend to.</b> That an entry is created and never
/// updated is a client rule — see <see cref="VaultConnectionLogEntry"/> — and the shared write path would
/// accept an upsert with a correct <c>expectedVersion</c> like any other. Adding a refusal here would be a
/// guarantee about payload semantics this server cannot read.
/// </para>
/// </remarks>
internal sealed class ConnectionLogEntryKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ConnectionLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };
database.ConnectionLog.Add(entry);
return entry;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials; what was connected to stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A log entry names what it is about inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Activity log entries: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="ConnectionLogEntryKind"/>. <c>SyncPlaintextFields.Kind</c> exists and would fit
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
/// that each look harmless.
/// </remarks>
internal sealed class ActivityLogEntryKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ActivityLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };
database.ActivityLog.Add(entry);
return entry;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "Which item a log entry is about stays inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Object stores: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="CredentialKind"/>, because it holds the same class of thing. A secret access key
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
/// weigh.
/// </remarks>
internal sealed class ObjectStoreKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ObjectStore;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ObjectStores
.Where(o => o.VaultId == vaultId && ids.Contains(o.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var store = new VaultObjectStore { Id = id, VaultId = vaultId };
database.ObjectStores.Add(store);
return store;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <remarks>
/// The relay fields are refused although this type <em>does</em> hold an address, exactly as they are for
/// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
/// confused or trying to get the server to keep a list of where its users store data.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A bucket is not something the server dials; its endpoint stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A bucket's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A bucket carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
+57 -1
View File
@@ -329,13 +329,32 @@
runs horizontally and a left bar on a row of tabs reads as a divider between them.
-->
<Style Selector="Button.tab">
<Setter Property="Padding" Value="12,0" />
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
<Setter Property="Padding" Value="12,0,7,0" />
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<!--
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
state, because it is never the thing showing, and no right border, because it is not separating
itself from anything.
-->
<Style Selector="Button.tab.plus">
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0,2,0,0" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Raised}" />
</Style>
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
@@ -348,6 +367,34 @@
<Setter Property="BorderThickness" Value="0,2,0,0" />
</Style>
<!--
A pair of buttons standing in for a two-way choice, inside a pane rather than down a rail. Not the
.cat style, which stretches to fill a 176-pixel rail row and would be wrong at this width — and which
the category rail's own test counts, so borrowing it would have made this a fourth category.
-->
<Style Selector="Button.choice">
<Setter Property="Padding" Value="10,5" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="LetterSpacing" Value="0.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.choice /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="Button.choice:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.choice.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. -->
<Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
@@ -357,6 +404,15 @@
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<!--
The one inside a tab, as opposed to the ones in the titlebar. Rounded and small, because a square
full-height red panel inside a tab reads as a divider between two tabs rather than as part of one —
which is what it looked like while it was a sibling of the tab instead of a child.
-->
<Style Selector="Button.close.inline /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="3" />
</Style>
<!--
Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field
that changes border colour, and the two do not sit together in one row.
+29 -2
View File
@@ -1,6 +1,9 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.App.Terminal;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
@@ -47,6 +50,28 @@ internal sealed partial class DodoSshApp : Application
/// nowhere honest to release them.
/// </para>
/// </remarks>
/// <summary>
/// Puts one line of text on the system clipboard.
/// </summary>
/// <remarks>
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
/// <para>
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
/// models needs a visual and every test that drives them stays window-free.
/// </para>
/// </remarks>
private static Func<string, Task> ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
async text =>
{
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
var paths = ClientPaths.Default;
@@ -74,7 +99,7 @@ internal sealed partial class DodoSshApp : Application
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
var deviceKeys = DeviceKeyStores.ForThisMachine(paths);
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var viewModel = new MainWindowViewModel(
paths,
@@ -93,7 +118,9 @@ internal sealed partial class DodoSshApp : Application
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false));
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop));
desktop.MainWindow = new MainWindow { DataContext = viewModel };
@@ -25,8 +25,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</ItemGroup>
@@ -55,3 +57,4 @@
</ItemGroup>
</Project>
@@ -1,17 +1,28 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Picks the device key store this machine can actually offer.
/// Picks the device key store this desktop machine can actually offer.
/// </summary>
/// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
/// passphrase — which is the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
/// store and has never known which one. See <c>docs/android-port.md</c>.
/// </para>
/// </remarks>
public static class DeviceKeyStores
public static class DesktopDeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
@@ -0,0 +1,229 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks>
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
/// rather than an action.
/// </remarks>
internal sealed partial class ImportRowViewModel : ObservableObject
{
private readonly ImportedHost host;
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
{
this.host = host;
AlreadyPresent = alreadyPresent;
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
// for one machine is a thing people genuinely want — but it should take a click rather than be the
// default.
IsSelected = !alreadyPresent;
}
internal ImportedHost Host => host;
internal string Alias => host.Alias;
internal string Address => host.Address;
/// <summary>Whether a host with this address is already in the keychain.</summary>
internal bool AlreadyPresent { get; }
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
internal bool HasBadge => AlreadyPresent;
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
/// <remarks>
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
/// connection that does not work.
/// </remarks>
internal string Authentication => host.IdentityFiles.Count switch
{
0 => "password",
1 => $"a key on disk · {host.IdentityFiles[0]}",
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
};
internal bool HasWarnings => host.Warnings.Count > 0;
internal string Warnings => string.Join(" ", host.Warnings);
[ObservableProperty]
private bool isSelected;
}
/// <summary>
/// Reading <c>~/.ssh/config</c> and offering what it found.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
/// application did not write and may contain forty entries for machines that no longer exist, so the
/// interesting question is not "can it be parsed" but "which of these did you actually want".
/// </para>
/// <para>
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
/// for making one deliberately, and pasting an existing one is a deliberate act too.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
{
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
/// <summary>What was skipped or flattened, at document level.</summary>
internal ObservableCollection<string> Warnings { get; } = [];
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
internal string ConfigPath => locator.ConfigPath;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool hasScanned;
[ObservableProperty]
private bool isBusy;
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
internal int SelectedCount => Rows.Count(row => row.IsSelected);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
{
Rows.Clear();
Warnings.Clear();
HasScanned = false;
if (!locator.Exists)
{
Status = $"There is no {locator.ConfigPath} on this machine.";
RaiseListState();
return;
}
IsBusy = true;
try
{
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
foreach (var host in import.Hosts)
{
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
HasScanned = true;
Status = Rows.Count == 0
? "Nothing in that file could be imported as a host."
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
}
catch (IOException failure)
{
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
}
catch (UnauthorizedAccessException failure)
{
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Stores the ticked hosts.</summary>
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
var chosen = Rows.Where(row => row.IsSelected).ToList();
if (chosen.Count == 0)
{
Status = "Nothing is ticked.";
return;
}
IsBusy = true;
try
{
var imported = await vault
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
.ConfigureAwait(true);
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
// pressing the button twice harmless and visible rather than harmless and confusing.
foreach (var row in Rows.ToList())
{
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
}
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Ticks or unticks everything at once.</summary>
[RelayCommand]
private void ToggleAll()
{
var target = SelectedCount < Rows.Count;
foreach (var row in Rows)
{
row.IsSelected = target;
}
RaiseListState();
}
internal void NoteSelectionChanged() => RaiseListState();
/// <remarks>
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
/// to import a duplicate of something already stored under another name.
/// </remarks>
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
&& existing.Host.Port == host.Port
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
private void RaiseListState()
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportLabel));
}
}
@@ -0,0 +1,235 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One pinned host key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
/// What was wrong was that nothing ever showed them.
/// </para>
/// <para>
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
/// of pinning one is to compare it with what they published.
/// </para>
/// </remarks>
internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool isDialledByAHost)
{
internal Guid EntityId => pin.EntityId;
internal KnownHostSecret Pin => pin.Secret;
internal string Host => pin.Secret.Host;
internal int Port => pin.Secret.Port;
internal string Algorithm => pin.Secret.Algorithm;
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
internal string Label => pin.Secret.Label;
/// <summary>The fingerprint, in full.</summary>
/// <remarks>
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
/// this whole mechanism exists to replace.
/// </remarks>
internal string Fingerprint => pin.Secret.Fingerprint;
/// <summary>
/// Whether any host in this vault actually dials the endpoint this pin is for.
/// </summary>
/// <remarks>
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
/// worth deleting on the user's behalf.
/// </remarks>
internal bool IsDialledByAHost { get; } = isDialledByAHost;
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
internal string Badge => IsDialledByAHost
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
: "no host uses this";
/// <summary>
/// When this pin was approved, as far as anything here can tell.
/// </summary>
/// <remarks>
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
/// renders as a dash rather than as a guess.
/// </remarks>
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "—";
}
/// <summary>
/// The host keys this keychain has approved, and how to withdraw one.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
/// it produces, neither of which the vault has any use for.
/// </para>
/// <para>
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
/// answer a question nobody is asking.
/// </para>
/// </remarks>
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
internal KnownHostsViewModel(VaultViewModel vault)
{
this.vault = vault;
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
// copy of a trust decision is the one kind of staleness that matters here.
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
Rebuild();
}
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
/// <remarks>
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
/// — host, then port, then algorithm — is the one worth keeping.
/// </remarks>
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
/// <remarks>
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
/// and there is no reason for it to learn about this screen.
/// </remarks>
[ObservableProperty]
private KnownHostRowViewModel? selected;
internal bool HasPins => vault.KnownHostPins.Count > 0;
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
/// <summary>What the whole list amounts to, in one line.</summary>
/// <remarks>
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
/// might want to act on, and counting them is cheaper than reading a badge column.
/// </remarks>
internal string Summary
{
get
{
var total = vault.KnownHostPins.Count;
if (total == 0)
{
return string.Empty;
}
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
return unused == 0
? pins
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
}
}
internal string EmptyMessage => HasPins
? "No approved host key matches that."
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
+ "check — approving it puts it here.";
/// <summary>Withdraws trust in the selected pin.</summary>
/// <remarks>
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
/// server are the other ones.
/// </remarks>
[RelayCommand]
private async Task ForgetSelectedAsync()
{
if (Selected is null)
{
return;
}
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(KnownHostRowViewModel? value)
{
vault.SelectedKnownHost = value;
OnPropertyChanged(nameof(HasSelection));
}
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
VisiblePins.Clear();
foreach (var pin in vault.KnownHostPins.Where(Matches))
{
VisiblePins.Add(pin);
}
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
}
private bool Matches(KnownHostRowViewModel pin)
{
if (string.IsNullOrWhiteSpace(Filter))
{
return true;
}
var needle = Filter.Trim();
return Contains(pin.Host, needle)
|| Contains(pin.Algorithm, needle)
|| Contains(pin.Fingerprint, needle)
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
}
private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
@@ -0,0 +1,293 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>Which log the screen is showing.</summary>
internal enum LogSection
{
/// <summary>Connections that were made.</summary>
Connections,
/// <summary>Changes made to keychain items.</summary>
Activity,
}
/// <summary>One connection, as a row.</summary>
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
{
internal Guid EntityId => entry.EntityId;
internal string HostLabel => entry.Secret.HostLabel;
internal string Address => entry.Secret.Address;
/// <summary>When it started, in the reader's own conventions.</summary>
/// <remarks>
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
/// difference is the reason each is right. There, two panes are read against one another and a
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
/// the client csproj precisely so this works.
/// </remarks>
internal string Started =>
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
/// <summary>
/// How long it lasted, or that it has not finished.
/// </summary>
/// <remarks>
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
/// the vault; see <see cref="LogsViewModel"/>.
/// </remarks>
internal string Duration => isLive
? "still open"
: Humanise(entry.Secret.Duration);
internal bool IsLive => isLive;
internal string Outcome => entry.Secret.Outcome switch
{
ConnectionOutcome.Failed => "failed",
ConnectionOutcome.Refused => "host key refused",
_ => string.Empty,
};
internal bool HasOutcome => Outcome.Length > 0;
/// <summary>Whether this was a terminal or the file browser.</summary>
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
internal string DeviceName => entry.Secret.DeviceName;
/// <remarks>
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
/// rounding themselves.
/// </remarks>
private static string Humanise(TimeSpan duration)
{
if (duration < TimeSpan.FromMinutes(1))
{
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
}
if (duration < TimeSpan.FromHours(1))
{
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
}
return string.Create(
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
}
}
/// <summary>One keychain change, as a row.</summary>
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
{
internal Guid EntityId => entry.EntityId;
internal string ItemLabel => entry.Secret.ItemLabel;
internal string ItemKind => entry.Secret.ItemKind;
internal string Operation => entry.Secret.Operation switch
{
ActivityOperation.Created => "created",
ActivityOperation.Deleted => "deleted",
_ => "changed",
};
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
/// <summary>Which fields changed. Never what they changed to.</summary>
internal string ChangedFields => entry.Secret.ChangedFields;
internal bool HasChangedFields => ChangedFields.Length > 0;
internal string DeviceName => entry.Secret.DeviceName;
}
/// <summary>
/// What has been connected to, and what has been changed.
/// </summary>
/// <remarks>
/// <para>
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
/// workspace, and this screen is where the two are put side by side.
/// </para>
/// <para>
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
/// nobody is looking at.
/// </para>
/// </remarks>
internal sealed partial class LogsViewModel : ObservableObject
{
private readonly VaultSession session;
private readonly Func<IReadOnlyList<LiveConnection>> live;
/// <param name="session">The open vault, which holds both logs.</param>
/// <param name="live">
/// The connections that are open right now. A function rather than a list, because tabs open and close
/// while this screen is showing and it is not told about either.
/// </param>
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
{
this.session = session;
this.live = live;
}
/// <summary>Connections, newest first, with anything still open at the top.</summary>
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
/// <summary>Keychain changes, newest first.</summary>
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
/// <remarks>
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
/// a command can refuse it.
/// </remarks>
[ObservableProperty]
private LogSection section;
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private string status = string.Empty;
internal bool ShowsConnections => Section is LogSection.Connections;
internal bool ShowsActivity => Section is LogSection.Activity;
internal bool HasConnections => Connections.Count > 0;
internal bool HasActivity => Activity.Count > 0;
internal string EmptyMessage => Section is LogSection.Connections
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
+ "top and gets its line when you close the tab."
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
+ "names of the fields that changed, never their contents.";
/// <summary>Shows one of the two logs.</summary>
[RelayCommand]
private void ShowSection(LogSection section) => Section = section;
/// <summary>Re-reads both logs.</summary>
[RelayCommand]
private async Task RefreshAsync(CancellationToken cancellationToken)
{
if (IsBusy)
{
return;
}
IsBusy = true;
try
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = string.Empty;
}
catch (OperationCanceledException)
{
// Leaving the screen.
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
/// <summary>Reads both logs into the lists.</summary>
internal async Task ReloadAsync(CancellationToken cancellationToken)
{
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
Connections.Clear();
// The live ones first and above everything, because they are the only rows in this list that are
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
// are built from a different source and marked as live rather than merged into the same shape.
foreach (var open in live())
{
Connections.Add(new ConnectionLogRowViewModel(
new VaultItem<ConnectionLogSecret>(
Guid.Empty,
new ConnectionLogSecret
{
HostLabel = open.HostLabel,
Address = open.Address,
StartedAt = open.StartedAt,
DeviceName = open.DeviceName,
},
Version: 0,
HasUnsyncedChanges: false,
IsBlocked: false,
IsReadOnly: false),
isLive: true));
}
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
{
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
}
Activity.Clear();
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
{
Activity.Add(new ActivityLogRowViewModel(entry));
}
OnPropertyChanged(nameof(HasConnections));
OnPropertyChanged(nameof(HasActivity));
}
partial void OnSectionChanged(LogSection value)
{
OnPropertyChanged(nameof(ShowsConnections));
OnPropertyChanged(nameof(ShowsActivity));
OnPropertyChanged(nameof(EmptyMessage));
}
}
/// <summary>A connection that is open right now.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
/// <param name="DeviceName">This machine.</param>
/// <remarks>
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
/// that is still running has no entry there, because an entry is written once and at close — which is what
/// keeps a synced log from needing a merge.
/// </remarks>
internal sealed record LiveConnection(
string HostLabel,
string Address,
DateTimeOffset StartedAt,
string DeviceName);
@@ -6,6 +6,8 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
@@ -61,7 +63,7 @@ internal enum ShellState
/// </remarks>
internal enum ShellScreen
{
/// <summary>The host list and the terminals, which is where the application opens.</summary>
/// <summary>The host list, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
@@ -75,6 +77,52 @@ internal enum ShellScreen
/// <summary>Preferences.</summary>
Preferences = 4,
/// <summary>The host keys this keychain has approved.</summary>
/// <remarks>
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
/// </remarks>
KnownHosts = 5,
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
/// <remarks>
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
/// something almost nobody is looking at.
/// </remarks>
Import = 6,
/// <summary>The saved commands in this keychain.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Snippets = 7,
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
}
/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
Page = 0,
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
Terminal = 1,
}
/// <summary>
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <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
@@ -134,6 +189,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly TransfersViewModel transfers;
/// <summary>
/// Where connections are recorded, for as long as a vault is open to record them into.
/// </summary>
/// <remarks>
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
/// </remarks>
private readonly ConnectionRecorder connectionLog;
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -188,7 +252,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null)
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null)
{
this.paths = paths;
this.caches = caches;
@@ -199,9 +264,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.resume = resume;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
transfers = new TransfersViewModel(sftpSessions, clock);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
// running after it, and the entry it eventually produces belongs to the vault it was made in.
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
this.workspace.ConnectionLog = connectionLog;
// 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;
@@ -273,6 +345,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
/// <remarks>
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
/// vault — unlocking, locking and signing out — cannot get out of step with it.
/// </remarks>
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private LogsViewModel? logsScreen;
/// <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 —
@@ -370,9 +462,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// ---- Which screen is showing ----
/// <summary>
/// Which of the nav rail's screens the page area holds.
/// </summary>
/// <remarks>
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
/// It is what dismissing a terminal returns to.
/// </remarks>
[ObservableProperty]
private ShellScreen screen;
/// <summary>
/// Whether the page area is showing rather than a terminal.
/// </summary>
/// <remarks>
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
/// </remarks>
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" />
@@ -387,6 +498,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
/// at once is one too many.
/// </remarks>
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
@@ -396,16 +552,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
/// screens all use the full width), and the quick-connect palette.
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// quick-connect palette.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
/// to the same value.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
/// </para>
/// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
@@ -414,11 +582,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
private ShellSurface surface;
/// <summary>Points the nav rail at a screen.</summary>
/// <remarks>
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
/// </remarks>
[RelayCommand]
private void ShowScreen(ShellScreen target) => Screen = target;
private void ShowScreen(ShellScreen target)
{
Screen = target;
Surface = ShellSurface.Page;
}
// ---- Open terminals ----
@@ -470,6 +651,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
// leave a visible WebView with no pane in it, which reads as the application having broken.
if (Tabs.Count == 0)
{
Surface = ShellSurface.Page;
}
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
@@ -566,7 +755,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CloseSearch();
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
// screen — so connecting from the files screen without this would put the question behind the screen
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
// is what moves the surface to the terminal, and only if there is one.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
@@ -746,7 +941,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
await RunAsync(
"Creating your vault. This deliberately takes a moment…",
"Creating your keychain. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
@@ -796,7 +991,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (Passphrase.Length == 0)
{
StatusMessage = "Enter your vault passphrase.";
StatusMessage = "Enter your keychain passphrase.";
return;
}
@@ -950,23 +1145,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
Vault = new VaultViewModel(
session,
workspace,
knownHosts,
() => connection,
ReconnectAsync,
copyToClipboard,
connectionLog);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -984,7 +1172,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// 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);
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// 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
@@ -1007,6 +1195,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync();
}
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
/// <remarks>
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
/// called by the workspace — so both are attached here rather than constructed per session, and both are
/// released together on every path that closes a vault.
/// </remarks>
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
// The actor is the account that unlocked, which is what makes this an audit record rather than a
// list of events with nobody attached to them.
connectionLog.Open(session, session.Profile.UserId);
}
/// <summary>
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
/// </summary>
@@ -1214,6 +1433,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// reappearing behind a lock screen.
knownHosts.Close();
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
// about to be disposed. Tickets already open keep the repository they were opened against, so a
// shell still running closes out into the vault it was actually made in.
connectionLog.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.
@@ -1264,7 +1488,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
(false, _) =>
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
+ "counted from here, because the vault is locked.",
+ "counted from here, because the keychain is locked.",
(true, 0) =>
"Everything this machine has changed has reached the server, so nothing will be lost.",
(true, 1) =>
@@ -1328,6 +1552,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// As Lock does, and before the session it reads from goes.
knownHosts.Close();
connectionLog.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
@@ -1364,8 +1589,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
+ "itself is untouched. Sign in to set this machine up again.";
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
+ "keychain itself is untouched. Sign in to set this machine up again.";
}).ConfigureAwait(true);
}
@@ -1412,6 +1637,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
workspace.ConnectionLog = null;
await connectionLog.DisposeAsync().ConfigureAwait(false);
// 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.
@@ -1547,6 +1778,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
}
// Built from the vault and thrown away with it, here rather than at each of the three places a
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
RaiseSyncState();
}
@@ -1579,13 +1824,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
/// <remarks>
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
/// runs against a tab strip that already shows the session it is focusing.
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
/// <summary>
/// Takes a newly opened session into the tab strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these four steps is decided
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab);
RaiseTabState();
@@ -1594,6 +1848,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and load-bearing for every one after it.
SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
@@ -1617,15 +1876,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
// be a screen keeping the shell alive.
SnippetsScreen?.TargetChanged();
if (value is not null)
{
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
}
}
/// <summary>Brings one terminal's pane to the front.</summary>
/// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
/// say, the most recently opened would send a command somewhere the user is not looking.
/// </remarks>
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
/// <remarks>
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
/// </remarks>
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
];
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
/// <remarks>
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
/// would answer only one of those.
/// </remarks>
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
@@ -1689,10 +1983,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for.
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
// the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
}
@@ -1702,12 +1999,48 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// directions, and raising only the one that became true leaves the old button lit.
/// </remarks>
partial void OnScreenChanged(ShellScreen value)
{
RaiseSurfaceState();
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
if (value is ShellScreen.Logs && LogsScreen is { } logs)
{
_ = logs.RefreshCommand.ExecuteAsync(null);
}
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
/// narrow what became stale.
/// </remarks>
private void RaiseSurfaceState()
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));
OnPropertyChanged(nameof(IsTeamShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsTerminalShowing));
}
@@ -0,0 +1,338 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
/// <param name="SessionId">The terminal, or null when there is none open.</param>
/// <param name="Label">What that terminal is called, for the button.</param>
internal sealed record InsertTarget(uint? SessionId, string Label)
{
/// <summary>The answer when no tab is open.</summary>
internal static InsertTarget None { get; } = new(null, string.Empty);
/// <summary>Whether there is somewhere to insert into.</summary>
internal bool IsAvailable => SessionId is not null;
}
/// <summary>
/// The saved commands in this keychain, and how to get one into a terminal.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
/// the vault has any use for.
/// </para>
/// <para>
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para>
/// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Func<InsertTarget> target;
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
/// <param name="vault">The open keychain, which owns the list and the writing.</param>
/// <param name="target">
/// Which terminal is selected right now. A function rather than a value, because the answer changes every
/// time the user clicks a tab and this screen is not told about that.
/// </param>
/// <param name="insert">
/// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
/// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
/// neither of those is a property of the transport.
/// </param>
internal SnippetsViewModel(
VaultViewModel vault,
Func<InsertTarget> target,
Func<uint, string, bool, CancellationToken, Task<bool>> insert)
{
this.vault = vault;
this.target = target;
this.insert = insert;
vault.Snippets.CollectionChanged += OnSnippetsChanged;
Rebuild();
}
/// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
[ObservableProperty]
private SnippetRowViewModel? selected;
[ObservableProperty]
private bool isEditing;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorCommand = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
/// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
/// <remarks>
/// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
/// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
/// not want the same answer and one switch would end up left on by whoever needed it for the first.
/// </remarks>
[ObservableProperty]
private bool editorRunsOnInsert;
/// <summary>The snippet being edited, or null when the editor would create one.</summary>
[ObservableProperty]
private Guid? editingId;
[ObservableProperty]
private string status = string.Empty;
internal bool HasSnippets => vault.Snippets.Count > 0;
internal bool HasVisible => Visible.Count > 0;
internal bool HasSelection => Selected is not null;
/// <summary>Whether there is a terminal to insert into at all.</summary>
internal bool CanInsert => HasSelection && target().IsAvailable;
/// <summary>
/// What the insert button says, naming the terminal it will type into.
/// </summary>
/// <remarks>
/// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
/// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
/// command, at the moment that is least convenient to be wrong about.
/// </remarks>
internal string InsertLabel => target() is { IsAvailable: true } open
? $"TYPE INTO {open.Label}"
: "NO TERMINAL OPEN";
/// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
/// <summary>Whether the selected snippet is one marked as running on its own.</summary>
internal bool SelectionRuns => Selected?.RunsOnInsert is true;
internal string EmptyMessage => HasSnippets
? "No snippet matches that."
: "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
+ "typing it again.";
/// <summary>Starts a new snippet.</summary>
[RelayCommand]
private void New()
{
EditingId = null;
EditorLabel = string.Empty;
EditorCommand = string.Empty;
EditorNotes = string.Empty;
EditorRunsOnInsert = false;
IsEditing = true;
Status = "Adding a snippet.";
}
/// <summary>Opens the selected snippet for editing.</summary>
[RelayCommand]
private void Edit()
{
if (Selected is not { } row)
{
return;
}
if (row.IsReadOnly)
{
Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
return;
}
EditingId = row.EntityId;
EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void Cancel()
{
IsEditing = false;
EditingId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents.</summary>
[RelayCommand]
private async Task SaveAsync(CancellationToken cancellationToken)
{
var snippet = new SnippetSecret
{
Label = EditorLabel.Trim(),
// Not trimmed, and this is the field where that matters most. A here-document's terminator has
// to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
// for one that never comes, which reads as the snippet having hung the terminal.
Command = EditorCommand,
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RunsOnInsert = EditorRunsOnInsert,
};
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
if (!saved)
{
Status = vault.Status;
return;
}
IsEditing = false;
EditingId = null;
Status = vault.Status;
}
/// <summary>Deletes the selected snippet.</summary>
[RelayCommand]
private async Task DeleteAsync(CancellationToken cancellationToken)
{
if (Selected is not { } row)
{
return;
}
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
Status = vault.Status;
}
/// <summary>
/// Types the selected snippet into the selected terminal, without pressing Enter.
/// </summary>
/// <remarks>
/// The button that does not run anything, and it is the one a user should reach for. What it inserts
/// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
/// at the prompt as text and waits for a person to look at it.
/// </remarks>
[RelayCommand]
private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
/// <summary>
/// Types the selected snippet into the selected terminal and presses Enter.
/// </summary>
/// <remarks>
/// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
/// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
/// every one of them.
/// </remarks>
[RelayCommand]
private Task RunAsync(CancellationToken cancellationToken) =>
SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
/// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
/// <remarks>
/// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
/// this screen — a session survives locking the keychain — so a subscription in this direction would be
/// a screen holding the shell alive.
/// </remarks>
internal void TargetChanged()
{
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(InsertLabel));
OnPropertyChanged(nameof(RunLabel));
}
private async Task SendAsync(bool execute, CancellationToken cancellationToken)
{
if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
{
Status = "Open a terminal first — a snippet has to go somewhere.";
return;
}
var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
.ConfigureAwait(true);
Status = delivered
? execute
? $"Ran '{row.Label}' in {open.Label}."
: $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
: $"{open.Label} is no longer connected, so nothing was sent.";
}
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(SnippetRowViewModel? value)
{
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
}
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
internal bool IsCreating => EditingId is null;
private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
Visible.Clear();
foreach (var snippet in vault.Snippets.Where(Matches))
{
Visible.Add(snippet);
}
Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage));
}
/// <remarks>
/// The command is searched as well as the name and the notes, because half of what somebody remembers
/// about a saved command is a word that was in it.
/// </remarks>
private bool Matches(SnippetRowViewModel row)
{
var needle = Filter.Trim();
if (needle.Length == 0)
{
return true;
}
return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
bool Contains(string? value) =>
value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
}
@@ -3,12 +3,24 @@ using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
internal enum RemoteKind
{
/// <summary>A host, over SFTP.</summary>
Host,
/// <summary>An S3-compatible bucket.</summary>
Bucket,
}
/// <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>
@@ -234,7 +246,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
private ISftpSession? session;
private IRemoteFileStore? session;
private ConnectionRecorder? connectionLog;
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
private IObjectStoreFactory? objectStores;
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
/// <remarks>
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
/// row it came from may have been replaced by a background sync. The address is the one that was
/// actually dialled, which is the whole point of capturing it at connect.
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
private bool disposed;
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
@@ -243,7 +268,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// 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 = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
@@ -265,6 +290,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
/// <summary>
/// Which sort of remote the right-hand pane is about to open.
/// </summary>
/// <remarks>
/// <para>
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
/// the host editor's authentication picker does — and the reason is that these two are not
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
/// to look at than two clearly separate choices.
/// </para>
/// <para>
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
/// keychain's categories do: a selection binding moves before a command could refuse it.
/// </para>
/// </remarks>
[ObservableProperty]
private RemoteKind remote;
/// <summary>Whether the picker is showing hosts.</summary>
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
/// <summary>Whether the picker is showing buckets.</summary>
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
/// <summary>
/// What the button that opens the remote says.
/// </summary>
/// <remarks>
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
/// DISCONNECT step look like a bug rather than the shape of the protocol.
/// </remarks>
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
/// <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
@@ -282,6 +350,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private bool isConnected;
/// <summary>
/// Whether something is being dragged over the local pane, and whether it would be accepted.
/// </summary>
/// <remarks>
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
/// window that has stopped responding.
/// </remarks>
[ObservableProperty]
private bool isLocalDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropTarget;
/// <inheritdoc cref="IsLocalDropTarget" />
[ObservableProperty]
private bool isRemoteDropRefused;
/// <summary>The account and endpoint actually dialled, once connected.</summary>
[ObservableProperty]
private string? connectedTo;
@@ -298,7 +386,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <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 } };
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
// ---- The remote pane ----
@@ -370,10 +458,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
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)
/// <param name="openVault">The open keychain.</param>
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
/// <param name="log">
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
/// when the keychain locks still ends somewhere.
/// </param>
internal void Attach(
VaultViewModel openVault,
VaultKnownHostStore hostKeys,
ConnectionRecorder? log = null,
IObjectStoreFactory? buckets = null)
{
vault = openVault;
knownHosts = hostKeys;
connectionLog = log;
objectStores = buckets;
RefreshHosts();
@@ -402,13 +503,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
knownHosts = null;
Hosts.Clear();
Buckets.Clear();
SelectedHost = null;
SelectedBucket = null;
TypedPassword = string.Empty;
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
/// <summary>Shows one of the two kinds of remote in the picker.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
private void ShowRemote(RemoteKind kind) => Remote = kind;
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
[RelayCommand]
private Task ConnectAsync(CancellationToken cancellationToken) =>
Remote is RemoteKind.Bucket
? OpenBucketAsync(cancellationToken)
: ConnectToHostAsync(cancellationToken);
/// <summary>
/// Opens the chosen bucket.
/// </summary>
/// <remarks>
/// <para>
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
/// why the failure this reports is a listing failure rather than a connection one — there is no
/// connection to fail.
/// </para>
/// <para>
/// It goes through the same session field, the same queue and the same panes as a host, because by this
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
/// SFTP and needed no change.
/// </para>
/// </remarks>
private async Task OpenBucketAsync(CancellationToken cancellationToken)
{
if (objectStores is not { } factory)
{
Status = "This build cannot open buckets.";
return;
}
if (SelectedBucket is not { } row)
{
Status = "Choose a bucket first.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Opening {row.Label}…",
async () =>
{
await CloseSessionAsync().ConfigureAwait(true);
session = factory.Open(row.Store);
IsConnected = true;
ConnectedTo = string.Create(
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Opened {row.Label}.";
}).ConfigureAwait(true);
}
/// <summary>Opens a file-transfer session on the chosen host.</summary>
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
{
if (vault is not { } open || SelectedHost is not { } row)
{
@@ -457,6 +623,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
// would disagree with the host's — and anybody comparing the two would be right to believe
// the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
@@ -618,34 +790,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[RelayCommand]
private void Download()
{
if (SelectedRemoteEntry is not { IsFile: true } row)
if (SelectedRemoteEntry is not { } 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}.";
QueueDownloads([row]);
}
/// <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)
if (SelectedLocalEntry is not { } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
var destination = SftpPath.Combine(RemotePath, row.Name);
QueueUploads([row.FullPath]);
}
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
/// <summary>
/// Queues every one of these local paths for upload into the remote directory showing.
/// </summary>
/// <remarks>
/// <para>
/// The one path both the button and a drop go through, so there is one set of rules about what can be
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
/// were dragged, from this window's own pane or from the file manager.
/// </para>
/// <para>
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
/// </para>
/// <para>
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
/// about all five.
/// </para>
/// </remarks>
internal void QueueUploads(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
Status = $"Queued {row.Name} for upload into {RemotePath}.";
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
var missing = 0;
foreach (var path in paths)
{
if (Directory.Exists(path))
{
directories++;
continue;
}
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
// paths in an OS drop come from another process, which is not obliged to be right about them.
if (!File.Exists(path))
{
missing++;
continue;
}
var length = new FileInfo(path).Length;
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
queue.Enqueue(TransferDirection.Upload, path, destination, length);
queued++;
}
Status = Describe(queued, "upload into", RemotePath, directories, missing);
}
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
/// <inheritdoc cref="QueueUploads" path="/remarks" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
{
ArgumentNullException.ThrowIfNull(rows);
if (!IsConnected)
{
Status = "Connect to a host first.";
return;
}
var queued = 0;
var directories = 0;
foreach (var row in rows)
{
if (!row.IsFile)
{
directories++;
continue;
}
queue.Enqueue(
TransferDirection.Download,
Path.Combine(LocalPath, row.Name),
row.FullPath,
row.Entry.Length);
queued++;
}
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
}
/// <remarks>
/// One sentence for both directions and every shape of partial success. What it must never do is stay
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
/// looking for the other two in a queue they are not in.
/// </remarks>
private static string Describe(int queued, string verb, string destination, int directories, int missing)
{
var files = queued == 1 ? "1 file" : $"{queued} files";
var said = queued == 0
? "Nothing was queued."
: $"Queued {files} for {verb} {destination}.";
if (directories > 0)
{
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
said += $" {folders} skipped — only files can be transferred.";
}
if (missing > 0)
{
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
said += $" {gone} no longer there.";
}
return said;
}
/// <summary>Stops one transfer.</summary>
@@ -913,10 +1198,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
SelectedHost ??= Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var bucket in open.ObjectStores)
{
Buckets.Add(bucket);
}
SelectedBucket ??= Buckets.FirstOrDefault();
}
/// <summary>The session, or a failure a queue row can carry.</summary>
private ISftpSession RequireSession() =>
private IRemoteFileStore RequireSession() =>
session ?? throw new InvalidOperationException(
"This screen is not connected to a host, so there is nowhere to move the file.");
@@ -928,6 +1222,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await open.DisposeAsync().ConfigureAwait(true);
}
// Written whole here rather than through an open/close ticket, because this connection is not one
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
// with a real terminal's.
if (connected is { } record)
{
connected = null;
connectionLog?.Record(
record.Address,
record.HostLabel,
record.HostId,
ConnectionKind.Sftp,
record.StartedAt,
TimeProvider.System.GetUtcNow(),
ConnectionOutcome.Closed);
}
IsConnected = false;
ConnectedTo = null;
RemotePath = string.Empty;
@@ -990,6 +1301,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
/// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
/// picker that has nothing to do with passwords.
/// </remarks>
partial void OnRemoteChanged(RemoteKind value)
{
OnPropertyChanged(nameof(ShowsHostPicker));
OnPropertyChanged(nameof(ShowsBucketPicker));
OnPropertyChanged(nameof(ConnectLabel));
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnIsConnectedChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
File diff suppressed because it is too large Load Diff
+48 -6
View File
@@ -65,10 +65,37 @@
-->
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedHost}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<!--
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
The heading is a row rather than a container, which means the control will happily select it. That is
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
-->
<ListBox.DataTemplates>
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
@@ -106,7 +133,8 @@
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox.DataTemplates>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
@@ -148,6 +176,20 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Which group this host is filed under. Inside the encrypted payload like everything else here, so
the server learns nothing about how the estate is organised — and a group the vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
@@ -193,7 +235,7 @@
-->
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsConfirmingDeletion}">
IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
@@ -0,0 +1,252 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostsScreen"
x:DataType="vm:MainWindowViewModel">
<!--
The hosts screen: the list of machines, and what this application has to say about the one that is
selected.
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
promised: an overview.
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
has to reproduce by hand.
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
two cannot be put on one element.
-->
<Grid ColumnDefinitions="268,*">
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
one of them is fine.
-->
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="1">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
and shown; without this panel it would be last-writer-wins with a longer explanation.
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
for as long as it did because no test could lay this markup out; that is the other half of why this
file exists.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
The overview proper: what is known about the host the list has selected.
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
terminal until this screen stopped hosting one, and filling it with something that needed new state
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
list of forty.
-->
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<Panel Margin="24">
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
VerticalAlignment="Center" />
<Border Classes="chip" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
<TextBlock Text="CONNECTED" />
</Border>
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
</StackPanel>
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
MaxWidth="440" TextWrapping="Wrap"
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</ScrollViewer>
<!--
Groups: making them, renaming them, and taking them away.
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
in the host's own editor, on the left, for the same reason its key and its password are.
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
happen.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
</StackPanel>
<!--
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
third of this column to say what a row of chips says in one line.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding Vault.HasGroups}">
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
Background="Transparent" MaxHeight="72">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<StackPanel Margin="2,4" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
two panels share one pending deletion, and the sidebar checks the same way.
-->
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The hosts screen: the host list, and an overview of the one that is selected.
/// </summary>
/// <remarks>
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
/// against the second, where it does not exist.
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
public HostsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
/// </remarks>
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
}
@@ -0,0 +1,119 @@
<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.ImportScreen"
x:DataType="vm:ImportViewModel">
<!--
Importing ~/.ssh/config.
A preview and then a button, rather than one action, and that is the whole design. This reads a file
the application did not write, out of the user's home directory, and a real ssh_config often holds
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
says what each entry means; importing is a separate press on a set somebody has looked at.
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
something almost nobody is looking at.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
</Grid>
</Border>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
TextWrapping="Wrap" />
<!--
What could not be honoured, above the list rather than beside it. Every one of these is a way the
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
comparing the two needs to be told before they conclude the parser lost something.
-->
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<StackPanel Margin="14,0">
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</Grid>
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
IsVisible="{Binding HasWarnings}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding HasRows}">
<StackPanel Spacing="8">
<!--
Said before the button, not after. A key path is recorded and the key itself is not read: that is
the difference between a bookmark that connects and one that asks for a password, and somebody
who is not told will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,37 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Importing hosts from <c>~/.ssh/config</c>.
/// </summary>
/// <remarks>
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
/// </remarks>
internal sealed partial class ImportScreen : UserControl
{
public ImportScreen()
{
InitializeComponent();
// The count on the import button is derived from the ticks, and a CheckBox bound with
// {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
// reference back to the screen, the screen listens for the event they all bubble.
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
}
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
internal IInputElement KeyboardTarget => this;
private void OnTickChanged(object? sender, RoutedEventArgs e)
{
if (DataContext is ImportViewModel import)
{
import.NoteSelectionChanged();
}
}
}
@@ -0,0 +1,144 @@
<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.KnownHostsScreen"
x:DataType="vm:KnownHostsViewModel">
<!--
The host keys this keychain has approved.
These were a category on the keychain screen, alongside SSH keys and passwords, and they do not belong
there: the other two are things a person creates and edits, and a pin is a decision recorded at the
moment of connecting. Nobody goes looking for one in a list of credentials. They are also the only items
with a workflow of their own — compare a fingerprint against what the operator published — and that
workflow needs a filter and a column layout the shared table could not give them.
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
-->
<Grid ColumnDefinitions="*,244">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Matches fingerprints as well as host names, which is the point of it. What somebody does with
this screen is check whether a published SHA256:… is the one they approved, and searching only
by name would answer a different question.
-->
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
IsVisible="{Binding HasVisiblePins}">
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
ItemsSource="{Binding VisiblePins}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Never trimmed, and this column is why the table is laid out the way it is. The only thing
anybody does with a fingerprint is compare it character by character against one an operator
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
mechanism exists to replace.
-->
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="340"
IsVisible="{Binding !HasVisiblePins}" />
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="6">
<TextBlock Classes="hint" FontSize="11"
Text="Choose a pinned key to see it in full, and to withdraw it."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<Border Classes="chip warn" HorizontalAlignment="Left"
IsVisible="{Binding !Selected.IsDialledByAHost}">
<TextBlock Text="no host uses this" />
</Border>
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
Foreground="{StaticResource TextDim}" />
<!--
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
the item's own version 7 id — which records when the pin was created and knows nothing about
it being re-approved since. Presenting that as "last used" would be inventing a fact.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
HorizontalAlignment="Left"
Command="{Binding ForgetSelectedCommand}"
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The host keys this keychain has approved.
/// </summary>
/// <remarks>
/// Its data context is a <c>KnownHostsViewModel</c>, which is a screen-scoped wrapper over the vault rather
/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
/// <c>VaultViewModel</c>. See that class for why.
/// </remarks>
internal sealed partial class KnownHostsScreen : UserControl
{
public KnownHostsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
/// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
/// The box is also always there, where the list is empty on a fresh keychain, and <c>Focus()</c> on a
/// collapsed control is a no-op that is not replayed.
/// </remarks>
internal IInputElement KeyboardTarget => PinFilter;
}
@@ -0,0 +1,154 @@
<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.LogsScreen"
x:DataType="vm:LogsViewModel">
<!--
What has been connected to, and what has been changed.
Two logs behind one screen, chosen by two buttons rather than by a selector's selection — the same idiom
the keychain screen's categories use, and for the same reason: a selection binding moves before a command
can refuse it.
Both are ordinary synced keychain items, encrypted like everything else. The server holds them and cannot
read a single field; what it does learn is that rows exist and when they were written, which ADR 0001
records as the metadata this design cannot hide.
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
connection closes, so a live session is deliberately not in the vault yet.
-->
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,14,0" />
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
Classes.active="{Binding ShowsConnections}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}" />
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
Classes.active="{Binding ShowsActivity}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<!-- ============ Connections ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
IsVisible="{Binding HasConnections}">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
ItemsSource="{Binding Connections}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
Foreground="{StaticResource TextDim}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<!--
Only when there is something to say. A connection that opened and closed says nothing
here; one that was refused says so, and that is the row worth finding in a long list.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
</Border>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasConnections}" />
</Grid>
<!-- ============ Keychain changes ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
IsVisible="{Binding HasActivity}">
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The names of the fields that changed, and never what they changed to. A log that recorded
an old password would be a plaintext credential store with a vault drawn around it.
-->
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding HasChangedFields}" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasActivity}" />
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// What has been connected to, and what has been changed.
/// </summary>
/// <remarks>
/// Its data context is a <c>LogsViewModel</c>, a screen-scoped wrapper over the open session. Both logs are
/// ordinary synced keychain items; nothing about them is local.
/// </remarks>
internal sealed partial class LogsScreen : UserControl
{
public LogsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
/// take focus — <c>Focus()</c> on one is a no-op that nothing replays when it is revealed. The lists are
/// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
/// empty list has none.
/// </remarks>
internal IInputElement KeyboardTarget =>
DataContext is ViewModels.LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
}
+94 -134
View File
@@ -15,7 +15,8 @@
Focusable="True">
<!--
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
status bar.
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
@@ -28,6 +29,13 @@
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
chrome worth having.
TWO SURFACES, ONE RECTANGLE.
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
visible and reachable from every other one. What that costs is that the terminal and the pages now share
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
enum rather than two flags, so there is no way to write the state where both are showing.
THE OCCLUSION RULE, which every arrangement in this file obeys.
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
@@ -36,15 +44,16 @@
buttons unreachable, which this window has shipped once already.
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
IsTerminalShowing is the one place that decision is made: a locked vault, a screen other than Hosts, or
the quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
What the first connection after unlocking actually depends on is the await in
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is
that await, never this control's visibility.
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
only chooses between the pages.
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
@@ -64,159 +73,83 @@
<views:NavRail Grid.Column="0" />
<Panel Grid.Column="1">
<!-- ============ HOSTS + TERMINAL ============ -->
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
<!--
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
they have nothing to do with.
-->
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<views:TerminalTabs Grid.Row="0" />
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored
credential or a key wants nothing typed here — and a sentence in its place when it does not,
because "nothing needs typing" and "something needs typing and the box has not appeared yet"
look identical and only one of them is fine.
-->
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
</StackPanel>
</Border>
<Panel Grid.Row="1">
<StackPanel Grid.Row="2">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
other is a refusal. Presenting a changed key with a "continue" button is how users are
taught to click through the one warning that matters.
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
own markup.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode
is kept and shown; without this panel it would be last-writer-wins with a longer
explanation.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process tree, so twenty tabs would cost twenty of them.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
-->
<NativeWebView Grid.Row="3" x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Grid>
</Grid>
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
<!-- ============ FILES ============ -->
<!--
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.
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 ============ -->
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this element's
visibility is the shell's business and its data context is the vault, and put both on one element
and IsVisible resolves against the vault as well, where IsVaultScreen does not exist.
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does
not exist.
-->
<Panel IsVisible="{Binding IsVaultScreen}">
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ TEAM ============ -->
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
Title="TEAM"
Milestone="MILESTONE M3"
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
Summary="The design shows members, roles, shared keychains and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every keychain that is not your own — so there is nobody to list and no shared keychain to open."
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Keychain screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
<views:NotBuiltScreen.Missing>
<sys:List x:TypeArguments="x:String">
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
<x:String>Access to a keychain somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
<x:String>Sharing an item, which is the point of the screen: today a keychain key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
</sys:List>
</views:NotBuiltScreen.Missing>
</views:NotBuiltScreen>
@@ -224,7 +157,34 @@
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
</Panel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process tree, so twenty tabs would cost twenty of them.
A sibling of the page area rather than a child of any screen, which is the structural half of
the tab rework: the terminal belongs to the window now, not to the hosts screen.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
</Grid>
</Grid>
<!--
@@ -262,12 +222,12 @@
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}"
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
@@ -275,14 +235,14 @@
<!--
Shown once and impossible to skip. This is the only moment the code exists, and losing it
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
design.
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
by design.
-->
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Write this recovery code down" />
<TextBlock Classes="hint"
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14">
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
+142 -31
View File
@@ -1,6 +1,7 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
/// </para>
/// <para>
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
/// keyboard to a host list nobody can see.
/// </para>
/// </remarks>
private IInputElement KeyboardHome => shell?.Screen switch
private IInputElement KeyboardHome => shell switch
{
ShellScreen.Vault => VaultPane.KeyboardTarget,
ShellScreen.Hosts => Hosts.KeyboardTarget,
{ IsTerminalShowing: true } => Terminal,
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
_ => this,
};
/// <summary>
/// Asks for the terminal to take the keyboard, once layout has run.
/// </summary>
/// <remarks>
/// <para>
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
/// </para>
/// <para>
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
/// </para>
/// <para>
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
/// into a collapsed WebView would leave the window with nothing focused at all.
/// </para>
/// </remarks>
private void FocusTerminalWhenLaidOut() =>
Dispatcher.UIThread.Post(
() =>
{
if (shell is { IsTerminalShowing: true })
{
Terminal.Focus();
}
},
DispatcherPriority.Loaded);
/// <summary>
/// Where the keyboard belongs once the vault is no longer open.
/// </summary>
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
}
/// <remarks>
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
/// not replayed when it is revealed.
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
/// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
/// reliably is at this instant. A session can now be opened from any screen, so this event routinely
/// arrives in the same turn that revealed the WebView. Hence the post; see
/// <see cref="FocusTerminalWhenLaidOut"/>.
/// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
/// point at which "does this one return early" stops being obvious to a reader.
/// </remarks>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (shell is not { } viewModel)
@@ -175,7 +223,34 @@ internal sealed partial class MainWindow : Window
return;
}
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
switch (e.PropertyName)
{
case nameof(MainWindowViewModel.IsUnlocked):
OnVaultOpenedOrClosed(viewModel);
break;
case nameof(MainWindowViewModel.IsSearching):
OnPaletteToggled(viewModel);
break;
// One arm for both, deliberately. They mean the same thing to this handler — what the window is
// showing may have changed — and answering them separately would make the order of two
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
case nameof(MainWindowViewModel.Surface):
case nameof(MainWindowViewModel.Screen):
OnShowingSomethingElse(viewModel);
break;
case nameof(MainWindowViewModel.SelectedTab):
OnSelectedTabChanged(viewModel);
break;
default:
break;
}
}
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
{
var unlocked = viewModel.IsUnlocked;
@@ -187,42 +262,78 @@ internal sealed partial class MainWindow : Window
}
wasUnlocked = unlocked;
}
/// <remarks>
/// 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
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
/// that is still collapsed is a no-op that is not replayed when it is revealed.
/// </remarks>
private void OnPaletteToggled(MainWindowViewModel viewModel)
{
if (viewModel.IsSearching)
{
return;
}
// 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))
// Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
// focus rather than the immediate one.
if (viewModel.IsTerminalShowing)
{
if (!viewModel.IsSearching)
FocusTerminalWhenLaidOut();
}
else
{
ReleaseKeyboardTo(KeyboardHome);
}
}
/// <summary>
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
/// </summary>
/// <remarks>
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
/// are both one click away at all times.
/// <para>
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
/// </para>
/// </remarks>
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
{
if (!viewModel.IsUnlocked)
{
return;
}
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
&& viewModel.IsUnlocked)
if (viewModel.IsTerminalShowing)
{
KeyboardHome.Focus();
return;
FocusTerminalWhenLaidOut();
}
else
{
ReleaseKeyboardTo(KeyboardHome);
}
}
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
// native focus, so without this the pane looks selected and every keystroke goes to the button
// instead of the shell until the user clicks inside the terminal by hand.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
/// <remarks>
/// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
/// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
/// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
/// native focus, so without this the pane looks selected and every keystroke goes to the button instead
/// of the shell until the user clicks inside the terminal by hand.
/// </remarks>
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
{
Terminal.Focus();
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
{
FocusTerminalWhenLaidOut();
}
}
+33 -9
View File
@@ -5,7 +5,7 @@
x:DataType="vm:MainWindowViewModel">
<!--
Five destinations down the left edge.
Six destinations down the left edge.
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
@@ -16,6 +16,12 @@
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
anything. Buttons carry no state and cannot disagree with the screen that is showing.
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
back — and a rail entry lit while a terminal filled the window would be pointing at a screen that is not
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
mark, in the strip, and two "you are here" marks is one too many.
-->
<Border Width="54" Background="{StaticResource Chrome}"
@@ -23,26 +29,44 @@
<DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}"
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and the terminals open on them" />
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
ToolTip.Tip="Your hosts, and what is known about the one you have selected" />
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
ToolTip.Tip="Move files to and from a host over SFTP" />
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
<Button Classes="flat nav" Content="KEYS" Classes.active="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}"
ToolTip.Tip="SSH keys, stored passwords, and the host keys you have approved" />
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamScreen}"
ToolTip.Tip="Your keychain: SSH keys, stored passwords, and the host keys you have approved" />
<!--
PINS, not HOST KEYS. The rail is 54 pixels wide at mono FontSize 9, which is five characters —
and "pins" is what this codebase calls them everywhere else anyway.
-->
<Button Classes="flat nav" Content="PINS" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one" />
<!-- SNIPS, for the same five-character reason as PINS above. -->
<Button Classes="flat nav" Content="SNIPS" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal" />
<!-- LOGS, four characters, so it needs no abbreviating at all. -->
<Button Classes="flat nav" Content="LOGS" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain" />
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}"
ToolTip.Tip="Shared vaults and the people in them. Not built yet — see the screen for what is missing." />
ToolTip.Tip="Shared keychains and the people in them. Not built yet — see the screen for what is missing." />
</StackPanel>
<Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS"
Classes.active="{Binding IsPreferencesScreen}"
Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
ToolTip.Tip="Preferences, and this machine's device key" />
@@ -34,7 +34,7 @@
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}"
@@ -54,20 +54,20 @@
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
Text="This machine has nowhere to keep a device key, so the vault will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="VAULT" FontSize="13" FontWeight="SemiBold"
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Lock the vault" Foreground="{StaticResource Text}" FontSize="12"
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Closes the vault and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the vault, not this machine's access to your hosts." />
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
</Grid>
@@ -77,7 +77,7 @@
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
@@ -87,6 +87,18 @@
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Import}" />
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
@@ -100,7 +112,7 @@
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel>
<!--
Hidden rather than disabled while the confirmation is up, because the card below carries the
@@ -137,7 +149,7 @@
<TextBlock Classes="gap"
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
<TextBlock Classes="gap"
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the vault." />
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
<TextBlock Classes="gap"
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
<TextBlock Classes="gap"
@@ -28,7 +28,7 @@
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="10,8"
@@ -0,0 +1,174 @@
<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.SnippetsScreen"
x:DataType="vm:SnippetsViewModel">
<!--
Commands somebody has saved, and how to get one into a terminal.
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
the editor and the insert over the top. See SnippetsViewModel.
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
that a decision taken once while writing it rather than a button beside every one of them.
-->
<Grid ColumnDefinitions="*,300">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
The command is searched as well as the name: half of what anybody remembers about a saved
command is a word that was inside it.
-->
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
</Grid>
</Border>
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
ItemsSource="{Binding Visible}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<!--
The flag, where the decision is made. A snippet that presses Enter for you is not the
same kind of thing as one that does not, and the list is where somebody chooses between
them.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
<TextBlock Text="runs immediately" FontSize="8.5" />
</Border>
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
text reads as a single command, which is the thing being decided about on this row.
-->
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" />
</StackPanel>
</Border>
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="8">
<!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
waiting for one that never comes.
-->
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
FontSize="11" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
Content="Press Enter after inserting this" />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
</StackPanel>
</StackPanel>
<!-- ============ The selected snippet ============ -->
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
<TextBlock Classes="hint" FontSize="11"
Text="Choose a snippet to see it in full and put it into a terminal."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The button names the tab it will type into. This screen is not the terminal — the strip
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The commands this keychain has saved.
/// </summary>
/// <remarks>
/// Its data context is a <c>SnippetsViewModel</c>, a screen-scoped wrapper over the vault rather than an
/// owner of anything: the list, the storage and the push all still belong to <c>VaultViewModel</c>.
/// </remarks>
internal sealed partial class SnippetsScreen : UserControl
{
public SnippetsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
/// keychain with nothing saved yet, where the list is empty and <c>Focus()</c> on it would be a no-op
/// nothing replays.
/// </remarks>
internal IInputElement KeyboardTarget => SnippetFilter;
}
+70 -34
View File
@@ -5,17 +5,20 @@
x:DataType="vm:MainWindowViewModel">
<!--
The tab strip above the terminal.
The tab strip, above every screen.
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session.
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see
docs/design-import-gaps.md.
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
@@ -24,10 +27,15 @@
<Border Height="34" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<!--
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
disappears would move every screen up and down by 34 pixels each time the last tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -36,12 +44,25 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="flat tab"
<!--
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
for the tab to its right.
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
event handled — so a left press on the cross does not also select the tab. It deliberately
does not handle any other button, which is exactly what lets a middle press bubble out of
the cross and reach the handler below.
-->
<Button Classes="flat tab"
Classes.active="{Binding IsSelected}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
Classes.active="{Binding IsSelected}">
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
@@ -51,42 +72,57 @@
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
looking for it.
-->
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="flat close" Width="20"
VerticalAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell.">
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!--
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
honestly, and there is one way to start a connection rather than two that have to agree.
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
dropping into it composites above a native child window depends on whether Avalonia gives it its
own platform window. docs/platform-flags.md records what this project already paid for treating a
rendering claim as settled without a screenshot. The palette has no such question — opening it
collapses the terminal outright.
-->
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<!--
Nothing open, and this is where that is said. It is also the only place near the terminal that can
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="9.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis" MaxWidth="280"
IsVisible="{Binding HasTabs}" />
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -1,9 +1,56 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The tab strip above the terminal.</summary>
/// <summary>The tab strip, above every screen.</summary>
internal sealed partial class TerminalTabs : UserControl
{
public TerminalTabs() => InitializeComponent();
/// <summary>
/// Closes a tab on a middle click.
/// </summary>
/// <remarks>
/// <para>
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
/// </para>
/// <para>
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
/// and for every press during a middle drag. The question here is which button caused this press, and
/// that is the one thing only <c>PointerUpdateKind</c> answers.
/// </para>
/// <para>
/// On press rather than on release, which is what every browser and every terminal does. Matching a
/// release to its press would need capture tracking, to buy the ability to change your mind about a
/// middle click — a gesture nobody makes by accident and nobody aborts.
/// </para>
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
e.Handled = true;
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
// it. Its failures are the workspace's to report, not this strip's.
shell.CloseTabCommand.Execute(tab);
}
}
@@ -36,15 +36,32 @@
<!-- ============ 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">
<Grid ColumnDefinitions="Auto,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}"
<!--
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
the reason is that the two are not interchangeable: a host brings a password box, a host key
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
would mean half this bar appearing and disappearing with the selection.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
IsVisible="{Binding !IsConnected}">
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}" />
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
</StackPanel>
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
@@ -58,26 +75,43 @@
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsBucketPicker}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" 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="•"
<TextBox Grid.Column="3" 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"
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" 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"
<Button Grid.Column="4" 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"
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" />
</Border>
@@ -93,7 +127,12 @@
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== -->
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
This side takes remote rows only; see TransfersScreen.axaml.cs.
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
@@ -175,6 +214,17 @@
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
<!--
The drop highlight, over the whole pane and last so it is on top.
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
clears, and the drop lands nowhere.
-->
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
</Grid>
<!-- ==== The two directions ==== -->
@@ -197,7 +247,8 @@
</Border>
<!-- ==== The host ==== -->
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
@@ -315,6 +366,24 @@
IsVisible="{Binding IsConnected}" />
</StackPanel>
<!--
Two highlights rather than one, because refusing is worth showing. Something dragged over a
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
Foreground="{StaticResource Danger}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Grid>
@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
/// The two-pane file browser and the transfer queue.
/// </summary>
/// <remarks>
/// <para>
/// 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>.
/// </para>
/// <para>
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
/// by hand — see <c>docs/manual-checks.md</c>.
/// </para>
/// </remarks>
internal sealed partial class TransfersScreen : UserControl
{
/// <summary>
/// How remote rows travel while being dragged.
/// </summary>
/// <remarks>
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
/// as this, so "did this come from our own remote pane" needs no guessing.
/// </remarks>
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
/// <remarks>
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
/// </remarks>
private const double DragThreshold = 4;
private PointerPressedEventArgs? pressed;
private Point pressedAt;
public TransfersScreen()
{
InitializeComponent();
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
LocalList.DoubleTapped += OnLocalActivated;
RemoteList.DoubleTapped += OnRemoteActivated;
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
// still selects, and the drag only begins once the pointer has moved far enough.
foreach (var list in new Control[] { LocalList, RemoteList })
{
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
}
}
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
if (Transfers is { } transfers)
{
transfers.OpenLocalCommand.Execute(null);
}
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
/// </remarks>
private void OnRemoteActivated(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel transfers)
if (Transfers is { } transfers)
{
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
}
}
// ---- Starting a drag ----
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
{
pressed = e;
pressedAt = e.GetPosition(this);
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
/// <remarks>
/// The drag starts here rather than on the press, because a press is also how a row is selected.
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
/// press until either the pointer moves far enough or the button comes back up.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (pressed is not { } origin || Transfers is not { } transfers)
{
return;
}
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
pressed = null;
return;
}
var moved = e.GetPosition(this) - pressedAt;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
pressed = null;
if (ReferenceEquals(sender, RemoteList))
{
StartRemoteDrag(origin, transfers);
}
else
{
_ = StartLocalDragAsync(origin, transfers);
}
}
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedRemoteEntry is not { } row)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
}
/// <remarks>
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
/// nothing on a pointer-moved path can await.
/// </remarks>
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|| TopLevel.GetTopLevel(this) is not { } top)
{
return;
}
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
if (file is null)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.CreateFile(file));
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
}
// ---- Accepting a drop ----
/// <remarks>
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
/// would be a copy from this machine to this machine, which is not what this screen is for.
/// </remarks>
private void OnLocalDragOver(object? sender, DragEventArgs e)
{
var accepted = e.DataTransfer.Contains(RemoteEntries);
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = accepted;
}
e.Handled = true;
}
private void OnLocalDragLeave(object? sender, DragEventArgs e)
{
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = false;
}
}
private void OnLocalDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
transfers.IsLocalDropTarget = false;
e.Handled = true;
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
{
transfers.QueueDownloads(payload.Rows);
}
}
/// <remarks>
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
/// after it.
/// </remarks>
private void OnRemoteDragOver(object? sender, DragEventArgs e)
{
var files = e.DataTransfer.Contains(DataFormat.File);
var connected = Transfers is { IsConnected: true };
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = files && connected;
transfers.IsRemoteDropRefused = files && !connected;
}
e.Handled = true;
}
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
private void OnRemoteDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
ClearRemoteHighlight();
e.Handled = true;
if (e.DataTransfer.TryGetFiles() is not { } files)
{
return;
}
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
// this screen declines rather than a thing it half does.
var paths = files
.Select(file => file.TryGetLocalPath())
.OfType<string>()
.ToList();
transfers.QueueUploads(paths);
}
private void ClearRemoteHighlight()
{
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = false;
transfers.IsRemoteDropRefused = false;
}
}
}
/// <summary>
/// The remote rows carried by one drag.
/// </summary>
/// <remarks>
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
/// keys on the type and a bare <c>IReadOnlyList&lt;T&gt;</c> is too general a key to be sure of.
/// </remarks>
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
@@ -19,7 +19,7 @@
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" />
<TextBlock Classes="heading" Text="Unlock your keychain" />
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
<!--
@@ -33,7 +33,7 @@
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
-->
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
PlaceholderText="vault passphrase" PasswordChar="•">
PlaceholderText="keychain passphrase" PasswordChar="•">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
</TextBox.KeyBindings>
@@ -52,7 +52,7 @@
Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
</StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -73,7 +73,7 @@
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
</StackPanel>
</Border>
@@ -88,7 +88,7 @@
<StackPanel Spacing="6">
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
<Button Classes="ghost" Content="RESET THIS MACHINE"
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
</StackPanel>
+127 -31
View File
@@ -2,25 +2,29 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel">
<!--
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
has approved.
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
shape for every kind, which is what makes the ALL category possible and is why the row projection
exists; see VaultItemRowViewModel.
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
access service, so a rail with three entries would be showing two vaults nothing can open.
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
behind them, so listing them would be two headings that could never have anything under them. Recorded
in docs/design-import-gaps.md.
The SCOPES rail below the categories is the keychain list, which is real and today has one entry in it.
The design shows three, two of them teams; team keychains exist as tables on the server and are refused
by its access service, so a rail with three entries would be showing two nothing can open.
-->
<Grid ColumnDefinitions="176,*,244">
@@ -31,7 +35,7 @@
<ScrollViewer>
<StackPanel Margin="0,12">
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.All}"
@@ -66,13 +70,18 @@
</Grid>
</Button>
<!--
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
is something somebody creates, edits and keeps a secret for, which is what the other two
categories are. A pin is a decision recorded at connect time and is not.
-->
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
Classes.active="{Binding ShowsKnownHosts}">
CommandParameter="{x:Static vm:VaultSection.Buckets}"
Classes.active="{Binding ShowsBuckets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
@@ -93,7 +102,7 @@
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
Text="One keychain, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
<!--
Items that would not decrypt. Shown here rather than only in the status line because this is the
@@ -121,10 +130,18 @@
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<!--
Always offered. Every category left on this screen is one things can be added to — the one
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
either. See KnownHostsScreen.
-->
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
IsVisible="{Binding CanAddToSection}" />
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
IsVisible="{Binding CanAddToSection}" />
ToolTip.Tip="Pastes in a key you already have." />
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
</StackPanel>
</Grid>
</Border>
@@ -218,7 +235,7 @@
empty rows, this says what is missing in one line.
-->
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}">
@@ -226,6 +243,17 @@
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in
every application on the machine.
-->
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
HorizontalAlignment="Left"
IsVisible="{Binding SelectedItemIsKey}"
Command="{Binding CopyPublicKeyCommand}"
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
<!--
The question DELETE asks, in the place those two buttons were. Here rather than over the
screen, because this pane is where the item being deleted is described: the name, the kind and
@@ -238,19 +266,50 @@
<views:ConfirmDeleteCard />
</Border>
</StackPanel>
<!--
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
the one place it can be checked against what the operator published. What it does have is a
way out, because a changed host key is refused outright and a rebuilt server would otherwise
be unreachable for ever.
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
exactly one thing on this screen that writes a key, and it is still SAVE.
-->
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
<TextBlock Classes="hint" FontSize="9.5"
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetPinCommand}"
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
Buttons and a command rather than a selector bound to the algorithm, which is the same
choice the category rail makes and for the same reason: a selector moves its own highlight
before anything can refuse, so it can end up showing a choice nobody made.
-->
<Button Classes="flat choice" Content="ED25519"
Classes.active="{Binding GeneratesEd25519}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
<Button Classes="flat choice" Content="RSA 4096"
Classes.active="{Binding GeneratesRsa}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
</StackPanel>
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
<!--
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
already makes: a passphrase protects a key file on a disk, and this key is never on one.
-->
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
</StackPanel>
</StackPanel>
@@ -273,7 +332,7 @@
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
@@ -307,6 +366,43 @@
</StackPanel>
</StackPanel>
<!-- The bucket editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
<!--
Masked, like a password and for the same reason: a secret access key is one. The access key id
beside it is an identifier and is shown, which is also why the two are separate boxes.
-->
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
PasswordChar="•" />
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
<!--
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
is what makes this work against a self-hosted service.
-->
<TextBox Text="{Binding BucketEditorEndpoint}"
PlaceholderText="endpoint (blank: Amazon S3)" />
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
Content="Address the bucket as a path" />
<!--
Said where the decision is made. Getting this wrong produces a DNS failure whose message
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
-->
<TextBlock Classes="hint" FontSize="9.5"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
@@ -22,6 +22,7 @@ const SERVER_SESSION_OPENED = 2;
const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const SERVER_PASTE = 6;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
@@ -275,6 +276,39 @@ function handleFrame(buffer) {
break;
}
case SERVER_PASTE: {
const session = sessions.get(sessionId);
if (!session || payload.length < 1) {
break;
}
const execute = payload[0] !== 0;
const text = new TextDecoder().decode(payload.subarray(1));
/*
term.paste rather than term.input, and that is the whole reason this frame exists rather than
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
when the remote has turned that mode on xterm tracks \e[?2004h from the output stream, which
is something only this page sees and a shell that receives a multi-line command inside those
markers treats every newline as text. Without them it treats each one as "run this", so a
three-line snippet runs three commands the moment it is inserted.
*/
session.term.paste(text);
/*
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
return, so nothing would run which is the failure that looks like the feature working right
up until somebody wonders why RUN does not.
*/
if (execute) {
session.term.input('\r');
}
break;
}
case SERVER_SESSION_CLOSED: {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
+33 -1
View File
@@ -349,6 +349,21 @@
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
@@ -357,12 +372,14 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
"DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -406,6 +423,21 @@
"NSec.Cryptography": "[26.4.0, )"
}
},
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
@@ -0,0 +1,112 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>What was done to an item.</summary>
public enum ActivityOperation
{
/// <summary>It was created.</summary>
Created = 0,
/// <summary>It was changed.</summary>
Updated = 1,
/// <summary>It was deleted.</summary>
Deleted = 2,
}
/// <summary>
/// One create, edit or delete of a keychain item, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="ChangedFields"/> holds names and never values.</b> That is the rule the whole type is built
/// around, and it is the same one ADR 0006 imposes on the server's own <c>detail</c> column: an audit log
/// that recorded what a password used to be would be a plaintext credential store with a vault drawn around
/// it. "Password" is what somebody needs to see; the old password is what nobody does.
/// </para>
/// <para>
/// <b><see cref="ItemLabel"/> is a copy, taken at the time.</b> Deleting the item is one of the three things
/// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also
/// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed
/// 'prod-db'" because that is what it is called now is not.
/// </para>
/// </remarks>
public sealed record ActivityLogSecret : IVaultSecret
{
/// <summary>What kind of item this was about, as the sync contract names it.</summary>
/// <remarks>
/// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a
/// kind this one does not still reads as something — an unknown name is shown as itself, where an
/// unknown number would have to be shown as a number.
/// </remarks>
public required string ItemKind { get; init; }
/// <summary>The item, so an entry can be traced to what it was about.</summary>
public required Guid ItemId { get; init; }
/// <summary>What the item was called at the time.</summary>
public required string ItemLabel { get; init; }
/// <summary>What was done.</summary>
public ActivityOperation Operation { get; init; }
/// <summary>
/// The names of the fields that changed, separated by <c>", "</c>. Never their values.
/// </summary>
/// <remarks>
/// <para>
/// One string rather than a collection, and the choice is about equality. A plain
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
/// <c>Equals</c>, which is the trap <see cref="JumpChain"/> exists to avoid — and a second type of that
/// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is
/// unambiguous because these are C# property names, which cannot contain one.
/// </para>
/// <para>
/// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all
/// of them went. Empty is also the honest answer when an update's before and after could not be compared,
/// which is why nothing reading this may take empty to mean "nothing changed".
/// </para>
/// </remarks>
public string ChangedFields { get; init; } = string.Empty;
/// <summary>When it happened.</summary>
public required DateTimeOffset At { get; init; }
/// <summary>Which machine it was done from, as that machine calls itself.</summary>
public required string DeviceName { get; init; }
/// <summary>Which account in this organisation did it.</summary>
public Guid ActorUserId { get; init; }
/// <summary>What this entry is called, derived from what it records.</summary>
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
public string Label => $"{Operation} {ItemLabel}";
/// <summary>Whether this is storable, and why not if it is not.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(ItemKind))
{
reason = "An activity log entry needs the kind of item it was about.";
return false;
}
if (ItemId == Guid.Empty)
{
reason = "An activity log entry needs the item it was about.";
return false;
}
if (string.IsNullOrWhiteSpace(DeviceName))
{
reason = "An activity log entry needs the machine it was done from.";
return false;
}
// The label is deliberately not checked. An item somebody created and never named has an empty one,
// and refusing to record that would mean the log's completeness depended on the user's tidiness.
reason = null;
return true;
}
}
@@ -0,0 +1,137 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded activity log payload, together with the schema version it was written at.</summary>
/// <param name="Entry">The entry.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion)
{
/// <inheritdoc cref="ConnectionLogSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside an activity log entry's encrypted payload.
/// </summary>
/// <remarks>
/// <see cref="ActivityLogSecret.ItemKind"/> travels as its name and not its number, which is the one thing
/// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one
/// can still show "PortForward" where a number would leave it showing "9".
/// </remarks>
public static class ActivityLogSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
public static byte[] Encode(ActivityLogSecret entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (!entry.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(entry));
}
var document = new ActivityLogPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
ItemKind = entry.ItemKind,
ItemId = entry.ItemId,
ItemLabel = entry.ItemLabel,
Operation = (int)entry.Operation,
ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields,
At = entry.At,
DeviceName = entry.DeviceName,
ActorUserId = entry.ActorUserId,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ActivityLogSecretDocument? document)
{
document = null;
ActivityLogPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ActivityLogSecret
{
ItemKind = parsed.ItemKind ?? string.Empty,
ItemId = parsed.ItemId,
ItemLabel = parsed.ItemLabel ?? string.Empty,
Operation = Enum.IsDefined((ActivityOperation)parsed.Operation)
? (ActivityOperation)parsed.Operation
: default,
ChangedFields = parsed.ChangedFields ?? string.Empty,
At = parsed.At,
DeviceName = parsed.DeviceName ?? string.Empty,
ActorUserId = parsed.ActorUserId,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ActivityLogPayloadDocument
{
public int SchemaVersion { get; set; }
public string? ItemKind { get; set; }
public Guid ItemId { get; set; }
public string? ItemLabel { get; set; }
public int Operation { get; set; }
/// <remarks>
/// Written as null when empty rather than as <c>""</c>, so that a create and a delete — which have no
/// changed fields by definition — omit the property entirely instead of carrying an empty one.
/// </remarks>
public string? ChangedFields { get; set; }
public DateTimeOffset At { get; set; }
public string? DeviceName { get; set; }
public Guid ActorUserId { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ActivityLogPayloadDocument))]
internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,147 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>How a connection ended.</summary>
public enum ConnectionOutcome
{
/// <summary>The session ran and then ended — by the user, by the remote, or by the process closing.</summary>
/// <remarks>
/// One value for all three, deliberately. From an auditor's side "this person had a shell on that machine
/// for eleven minutes" is the fact; which of the two ends hung up first is not something this client can
/// establish reliably — a tab close and a remote hangup both arrive as the pump finishing — and a field
/// that guessed would be worse than one that does not claim to know.
/// </remarks>
Closed = 0,
/// <summary>The connection was attempted and did not open.</summary>
Failed = 1,
/// <summary>The host key was not the pinned one, so the client refused before authenticating.</summary>
/// <remarks>
/// Its own outcome rather than a kind of <see cref="Failed"/>, because it is the only one that means
/// something about the <em>host</em> rather than about the network or the credentials. A run of these on
/// one machine is the single most interesting thing a connection log can show.
/// </remarks>
Refused = 2,
}
/// <summary>What kind of session a log entry is about.</summary>
public enum ConnectionKind
{
/// <summary>An interactive terminal.</summary>
Terminal = 0,
/// <summary>An SFTP session for moving files.</summary>
/// <remarks>
/// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's
/// own <c>auth.log</c> is concerned, so a log of ours that quietly omitted it would disagree with the
/// host's — and the person comparing the two would be right to trust the host.
/// </remarks>
Sftp = 1,
}
/// <summary>
/// One connection that was made, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b>What is here, and what deliberately is not.</b> The host's label, its item id, the address as dialled,
/// when it started, how long it lasted, how it ended, and which user on which device did it. An audit log
/// with no actor is not an audit log — the whole reason these sync is that an administrator will read them
/// once teams land — so the actor is recorded and the SSH username is not. The two are different questions:
/// "who in this organisation opened a shell" is what an audit answers, and "which account they logged in as"
/// is a detail of the host that the host's own logs already have.
/// </para>
/// <para>
/// <b>Written once, at close.</b> Every field is known by then, so an entry never needs a second write —
/// which is what keeps a synced log from needing a merge, an outbox row per update, or any way to collide
/// with itself. A connection that is still running is not in here at all; it is shown from the workspace's
/// live state, which is the only place that knows.
/// </para>
/// </remarks>
public sealed record ConnectionLogSecret : IVaultSecret
{
/// <summary>What the host was called at the time, or a plain address when nothing named it.</summary>
/// <remarks>
/// A copy rather than a lookup through <see cref="HostId"/>, and that is the point of it: the bookmark
/// can be renamed or deleted, and a history that changed retroactively when somebody tidied their
/// keychain would be a history nobody could rely on.
/// </remarks>
public required string HostLabel { get; init; }
/// <summary>The address as dialled, <c>user@host:port</c> style, or whatever was typed.</summary>
public required string Address { get; init; }
/// <summary>The host item this was, or null when the connection did not come from one.</summary>
public Guid? HostId { get; init; }
/// <summary>Whether this was a terminal or a file-transfer session.</summary>
public ConnectionKind Kind { get; init; }
/// <summary>When it started.</summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>How long it lasted.</summary>
/// <remarks>
/// A duration rather than an end time, because it is the thing anybody reads — and because the two clocks
/// involved are the same one, so storing both would be storing a value and its own arithmetic.
/// </remarks>
public TimeSpan Duration { get; init; }
/// <summary>How it ended.</summary>
public ConnectionOutcome Outcome { get; init; }
/// <summary>Which machine it was made from, as that machine calls itself.</summary>
public required string DeviceName { get; init; }
/// <summary>Which account in this organisation made it.</summary>
public Guid ActorUserId { get; init; }
/// <summary>What this entry is called, derived from what it records.</summary>
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})");
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// A negative duration is refused rather than clamped. It can only come from a payload written elsewhere
/// — nothing here can produce one — and a log that displayed "-3 hours" would leave a reader unable to
/// tell a corrupt entry from a clock they should worry about.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(HostLabel))
{
reason = "A connection log entry needs the host it was about.";
return false;
}
if (string.IsNullOrWhiteSpace(Address))
{
reason = "A connection log entry needs the address that was dialled.";
return false;
}
if (string.IsNullOrWhiteSpace(DeviceName))
{
reason = "A connection log entry needs the machine it was made from.";
return false;
}
if (Duration < TimeSpan.Zero)
{
reason = "A connection cannot have lasted a negative amount of time.";
return false;
}
if (HostId == Guid.Empty)
{
reason = "A host reference cannot be an empty id; use no host instead.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,153 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded connection log payload, together with the schema version it was written at.</summary>
/// <param name="Entry">The entry.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
/// <remarks>
/// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that
/// could drop a newer client's field. It stays because the reconciler asks every kind.
/// </remarks>
public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a connection log entry's encrypted payload.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostSecretCodec"/>. The two enums are written as numbers rather than names,
/// unlike <see cref="ActivityLogSecret.ItemKind"/>: they are closed sets this codec owns, where the item kind
/// is an open one that a newer build may extend.
/// </para>
/// <para>
/// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a
/// newer client that has learned a fourth outcome is still worth showing with its host, its times and its
/// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway.
/// </para>
/// </remarks>
public static class ConnectionLogSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
public static byte[] Encode(ConnectionLogSecret entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (!entry.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(entry));
}
var document = new ConnectionLogPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
HostLabel = entry.HostLabel,
Address = entry.Address,
HostId = entry.HostId,
Kind = (int)entry.Kind,
StartedAt = entry.StartedAt,
DurationMs = (long)entry.Duration.TotalMilliseconds,
Outcome = (int)entry.Outcome,
DeviceName = entry.DeviceName,
ActorUserId = entry.ActorUserId,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ConnectionLogSecretDocument? document)
{
document = null;
ConnectionLogPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ConnectionLogSecret
{
HostLabel = parsed.HostLabel ?? string.Empty,
Address = parsed.Address ?? string.Empty,
HostId = parsed.HostId,
Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default,
StartedAt = parsed.StartedAt,
Duration = TimeSpan.FromMilliseconds(parsed.DurationMs),
Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome)
? (ConnectionOutcome)parsed.Outcome
: default,
DeviceName = parsed.DeviceName ?? string.Empty,
ActorUserId = parsed.ActorUserId,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ConnectionLogPayloadDocument
{
public int SchemaVersion { get; set; }
public string? HostLabel { get; set; }
public string? Address { get; set; }
public Guid? HostId { get; set; }
public int Kind { get; set; }
public DateTimeOffset StartedAt { get; set; }
/// <remarks>
/// Milliseconds as an integer rather than a <see cref="TimeSpan"/>, which <c>System.Text.Json</c> writes
/// as <c>"00:11:03.4560000"</c> — a format whose parsing varies between platforms and whose precision
/// invites a round-trip that is nearly but not exactly the value written.
/// </remarks>
public long DurationMs { get; set; }
public int Outcome { get; set; }
public string? DeviceName { get; set; }
public Guid ActorUserId { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ConnectionLogPayloadDocument))]
internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A folder hosts can be filed under, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
/// </para>
/// <para>
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
/// side winning outright and the other host silently leaving the group it was just put in.
/// </para>
/// <para>
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
/// </para>
/// </remarks>
public sealed record HostGroupSecret : IVaultSecret
{
/// <summary>What the group is called. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
/// cannot tell apart.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A group needs a name.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,101 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded group payload, together with the schema version it was written at.</summary>
/// <param name="Group">The group.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
/// version, which is what lets a later build add a field without every older client silently dropping it on
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>.
/// </remarks>
public static class HostGroupSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a group to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The group is not valid for storage.</exception>
public static byte[] Encode(HostGroupSecret group)
{
ArgumentNullException.ThrowIfNull(group);
if (!group.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(group));
}
var document = new HostGroupPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = group.Label,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out HostGroupSecretDocument? document)
{
document = null;
HostGroupPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
if (!candidate.TryValidate(out _))
{
return false;
}
document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class HostGroupPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostGroupPayloadDocument))]
internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,63 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The group to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostGroupMergeResult(
HostGroupSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a group against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people
/// organising the same vault at the same time never collide here — the only way to reach this code is for two
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
/// </para>
/// <para>
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
/// differed" would leave the user unable to tell which of their two names survived.
/// </para>
/// </remarks>
public static class HostGroupSecretMerge
{
/// <summary>Produces the merged group.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static HostGroupMergeResult Merge(
HostGroupSecret ancestor,
HostGroupSecret local,
HostGroupSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merge = ThreeWayMerge.Scalar(
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
if (merge.IsConflicted)
{
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
// fixed here rather than derived from the outcome.
conflicts.Add(new HostFieldConflict(
nameof(HostGroupSecret.Label),
MergeSide.Local,
merge.Value,
merge.Discarded,
DiscardedWasRemoval: false));
}
return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
}
}
+32
View File
@@ -101,6 +101,32 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public Guid? CredentialId { get; init; }
/// <summary>
/// The group this host is filed under, or null for none.
/// </summary>
/// <remarks>
/// <para>
/// The pointer lives on the host rather than a member list living on the group, and the reason is the
/// merge: filing two different hosts into one group on two machines has to be two writes to two items.
/// Held the other way round it would be two writes to one item, and with no set merge available the
/// collision would resolve by one side winning and the other host quietly leaving the group.
/// </para>
/// <para>
/// <b>Inside the payload, and it did not have to be.</b> <c>SyncPlaintextFields</c> has carried a
/// <c>GroupId</c> since the contract was frozen and the server had a column for it. Nothing ever wrote
/// one, the column is gone, and the server now refuses the field — because what it would hand over is a
/// clustering of the estate, and the one plaintext concession the design allows itself is the relay
/// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
/// </para>
/// <para>
/// <b>The reference may dangle</b>, exactly as <see cref="SshKeyId"/> may: a group deleted on another
/// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
/// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
/// rewriting every host that named it.
/// </para>
/// </remarks>
public Guid? GroupId { get; init; }
/// <summary>
/// Whether this host may be dialled through the server relay.
/// </summary>
@@ -174,6 +200,12 @@ public sealed record HostSecret : IVaultSecret
return false;
}
if (GroupId == Guid.Empty)
{
reason = "A group reference cannot be an empty id; use no group instead.";
return false;
}
reason = null;
return true;
}
+40 -10
View File
@@ -62,8 +62,11 @@ public static class HostSecretCodec
/// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
public const int CredentialIdSchemaVersion = 3;
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
public const int GroupIdSchemaVersion = 4;
/// <summary>The highest schema version this build can write.</summary>
public const int CurrentSchemaVersion = CredentialIdSchemaVersion;
public const int CurrentSchemaVersion = GroupIdSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -95,6 +98,7 @@ public static class HostSecretCodec
RelayEnabled = host.RelayEnabled,
SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId,
GroupId = host.GroupId,
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -120,18 +124,40 @@ public static class HostSecretCodec
/// did not make every host in every vault look like a change to the sync engine.
/// </para>
/// <para>
/// The two bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so this reads as
/// a ladder rather than a maximum. If a future field is <em>not</em> exclusive with an older one, this
/// becomes the maximum over the versions of the fields present, which is the same rule stated more
/// generally.
/// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
/// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
/// while they were the only versioned fields, a <c>switch</c> that returned the first match was
/// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
/// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
/// drop the field on the next save.
/// </para>
/// <para>
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
/// stays correct however the next field relates to these.
/// </para>
/// </remarks>
private static int SchemaVersionFor(HostSecret host) => host switch
private static int SchemaVersionFor(HostSecret host)
{
{ CredentialId: not null } => CredentialIdSchemaVersion,
{ SshKeyId: not null } => SshKeyIdSchemaVersion,
_ => BaseSchemaVersion,
};
var version = BaseSchemaVersion;
if (host.SshKeyId is not null)
{
version = Math.Max(version, SshKeyIdSchemaVersion);
}
if (host.CredentialId is not null)
{
version = Math.Max(version, CredentialIdSchemaVersion);
}
if (host.GroupId is not null)
{
version = Math.Max(version, GroupIdSchemaVersion);
}
return version;
}
/// <summary>
/// Parses a decrypted payload.
@@ -199,6 +225,7 @@ public static class HostSecretCodec
RelayEnabled = parsed.RelayEnabled,
SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId,
};
if (!candidate.TryValidate(out _))
@@ -254,6 +281,9 @@ internal sealed class HostPayloadDocument
/// <inheritdoc cref="SshKeyId" />
public Guid? CredentialId { get; set; }
/// <inheritdoc cref="SshKeyId" />
public Guid? GroupId { get; set; }
}
[JsonSourceGenerationOptions(
+36 -6
View File
@@ -103,10 +103,35 @@ public static class HostSecretMerge
remote.RelayEnabled,
conflicts,
static enabled => enabled ? "enabled" : "disabled"),
};
// The id is shown in a clash rather than redacted. It is not a secret — it names a vault item,
// it is not the key — and hiding it would leave the user unable to tell which of two keys the
// merge dropped.
return new HostMergeResult(
WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
}
/// <summary>
/// Merges the three ids a host can point at: its key, its credential and its group.
/// </summary>
/// <remarks>
/// <para>
/// Split out for length, and they do belong together: each is a reference to another vault item, each
/// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
/// another machine. None of that is the merge's problem — it is handled where the reference is used.
/// </para>
/// <para>
/// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
/// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
/// merge dropped.
/// </para>
/// </remarks>
private static HostSecret WithReferences(
HostSecret merged,
HostSecret ancestor,
HostSecret local,
HostSecret remote,
List<HostFieldConflict> conflicts) =>
merged with
{
SshKeyId = Field(
nameof(HostSecret.SshKeyId),
ancestor.SshKeyId,
@@ -122,10 +147,15 @@ public static class HostSecretMerge
remote.CredentialId,
conflicts,
static id => id?.ToString() ?? "no credential"),
};
return new HostMergeResult(merged, conflicts);
}
GroupId = Field(
nameof(HostSecret.GroupId),
ancestor.GroupId,
local.GroupId,
remote.GroupId,
conflicts,
static id => id?.ToString() ?? "ungrouped"),
};
private static string Text(
string name,
+112
View File
@@ -0,0 +1,112 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged entry, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The entry to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record ConnectionLogMergeResult(
ConnectionLogSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <inheritdoc cref="ConnectionLogMergeResult" />
public sealed record ActivityLogMergeResult(
ActivityLogSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a connection log entry.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists because the item-kind pipeline requires it, and it should never run.</b> A log entry is
/// written once, at the moment a connection closes, and nothing updates one — so there is no second version
/// for a first to diverge from. Reaching this code means two clients wrote different records under one
/// entity id, and entity ids are v7 GUIDs minted independently on each machine.
/// </para>
/// <para>
/// It is still a real merge rather than a throw. The reconciler runs inside a sync pass, and an exception
/// there would strand every item queued behind this one — for a situation that is a bug in some client and
/// not an emergency. So the remote side wins, the difference is recorded like any other, and somebody reads
/// a conflict notice about a log entry, which is the loudest signal this could reasonably give.
/// </para>
/// <para>
/// Nothing is redacted. Every field is already an audit record of something that happened, and a notice that
/// hid which of two records was dropped would defeat the point of noticing.
/// </para>
/// </remarks>
public static class ConnectionLogSecretMerge
{
/// <summary>Produces the merged entry.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static ConnectionLogMergeResult Merge(
ConnectionLogSecret ancestor,
ConnectionLogSecret local,
ConnectionLogSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
// Whole-value, not field by field. The fields of one entry describe one event, and a merge that took
// the host from one side and the duration from the other would invent a connection nobody made —
// which is a worse outcome than losing the record this machine happened to hold.
if (local == remote)
{
return new ConnectionLogMergeResult(remote, []);
}
return new ConnectionLogMergeResult(
remote,
[
new HostFieldConflict(
"Entry",
MergeSide.Local,
remote.Label,
local.Label,
DiscardedWasRemoval: false),
]);
}
}
/// <summary>
/// Merges two divergent versions of an activity log entry.
/// </summary>
/// <inheritdoc cref="ConnectionLogSecretMerge" path="/remarks" />
public static class ActivityLogSecretMerge
{
/// <inheritdoc cref="ConnectionLogSecretMerge.Merge" />
public static ActivityLogMergeResult Merge(
ActivityLogSecret ancestor,
ActivityLogSecret local,
ActivityLogSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
if (local == remote)
{
return new ActivityLogMergeResult(remote, []);
}
return new ActivityLogMergeResult(
remote,
[
new HostFieldConflict(
"Entry",
MergeSide.Local,
remote.Label,
local.Label,
DiscardedWasRemoval: false),
]);
}
}
@@ -0,0 +1,120 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// An S3-compatible bucket and the credentials that reach it, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// Named for the protocol rather than for Amazon, because everything here works the same against MinIO, R2,
/// Backblaze or Ceph — and for those, <see cref="Endpoint"/> is an address on somebody's own network. The
/// interface says S3, which is what people call the protocol; the type says what it is.
/// </para>
/// <para>
/// <b><see cref="SecretAccessKey"/> is a password, and everything this codebase does about passwords applies
/// to it.</b> It is inside the encrypted payload, it never appears in a log line — the activity log records
/// the field's name and not its value — and the merge reports that it differed rather than what it was.
/// </para>
/// </remarks>
public sealed record ObjectStoreSecret : IVaultSecret
{
/// <summary>What the user calls this bucket. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>The bucket.</summary>
public required string Bucket { get; init; }
/// <summary>The access key id.</summary>
public required string AccessKeyId { get; init; }
/// <summary>The secret access key.</summary>
public required string SecretAccessKey { get; init; }
/// <summary>
/// The region, or null to let the endpoint decide.
/// </summary>
/// <remarks>
/// Required by AWS and ignored by several S3-compatible services, which is why it is nullable rather than
/// defaulted to <c>us-east-1</c>. A default would be a guess presented as configuration, and the guess is
/// wrong for exactly the self-hosted case this field exists to support.
/// </remarks>
public string? Region { get; init; }
/// <summary>
/// The service endpoint, or null for Amazon's own.
/// </summary>
/// <remarks>
/// Null means AWS and the SDK resolves the host from <see cref="Region"/>. Anything else is a URL, and it
/// is the field that makes this work against a MinIO in a cupboard.
/// </remarks>
public string? Endpoint { get; init; }
/// <summary>
/// Whether to address the bucket as a path rather than as a subdomain.
/// </summary>
/// <remarks>
/// <c>https://endpoint/bucket/key</c> instead of <c>https://bucket.endpoint/key</c>. Off for AWS, on for
/// nearly every self-hosted service — MinIO in its default configuration has no wildcard DNS, so
/// virtual-host addressing simply does not resolve. It is a setting rather than a guess because getting
/// it wrong produces a name-resolution failure that says nothing about buckets.
/// </remarks>
public bool UsePathStyle { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// The endpoint is checked for being a well-formed absolute URL when it is set at all. A relative one, or
/// a bare hostname, produces an SDK failure at the first request whose message names neither the field
/// nor this bucket — and the person reading it has typically just typed the value.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A bucket needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(Bucket))
{
reason = "A bucket needs the bucket it points at.";
return false;
}
if (string.IsNullOrWhiteSpace(AccessKeyId) || string.IsNullOrWhiteSpace(SecretAccessKey))
{
reason = "A bucket needs an access key id and a secret access key.";
return false;
}
if (Endpoint is not null)
{
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint))
{
reason = "The endpoint has to be a full URL, like https://minio.internal:9000.";
return false;
}
if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
&& !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
{
reason = "The endpoint has to be http or https.";
return false;
}
}
if (Region is null && Endpoint is null)
{
// With neither, the SDK has nothing to resolve a host from and fails at the first request with
// a message about a missing region rather than about this bucket.
reason = "A bucket needs a region, an endpoint, or both.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,129 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded bucket payload, together with the schema version it was written at.</summary>
/// <param name="Store">The bucket.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a bucket item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="CredentialSecretCodec"/>, for the same reasons and with the same guarantees.
/// </remarks>
public static class ObjectStoreSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a bucket to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The bucket is not valid for storage.</exception>
public static byte[] Encode(ObjectStoreSecret store)
{
ArgumentNullException.ThrowIfNull(store);
if (!store.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(store));
}
var document = new ObjectStorePayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = store.Label,
Bucket = store.Bucket,
AccessKeyId = store.AccessKeyId,
SecretAccessKey = store.SecretAccessKey,
Region = store.Region,
Endpoint = store.Endpoint,
UsePathStyle = store.UsePathStyle,
Notes = store.Notes,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ObjectStoreSecretDocument? document)
{
document = null;
ObjectStorePayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ObjectStoreSecret
{
Label = parsed.Label ?? string.Empty,
Bucket = parsed.Bucket ?? string.Empty,
AccessKeyId = parsed.AccessKeyId ?? string.Empty,
SecretAccessKey = parsed.SecretAccessKey ?? string.Empty,
Region = parsed.Region,
Endpoint = parsed.Endpoint,
UsePathStyle = parsed.UsePathStyle,
Notes = parsed.Notes,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ObjectStorePayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Bucket { get; set; }
public string? AccessKeyId { get; set; }
public string? SecretAccessKey { get; set; }
public string? Region { get; set; }
public string? Endpoint { get; set; }
public bool UsePathStyle { get; set; }
public string? Notes { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ObjectStorePayloadDocument))]
internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,107 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged bucket, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The bucket to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record ObjectStoreMergeResult(
ObjectStoreSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a bucket against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Every field is a scalar, so this is <see cref="CredentialSecretMerge"/>'s shape and it reuses
/// <see cref="HostFieldConflict"/> for the same reason.
/// </para>
/// <para>
/// <b>The secret access key never reaches the conflict log</b>, exactly as a password does not: a discarded
/// one is very often still live on the service it belongs to. The access key <em>id</em> is shown, because it
/// is an identifier rather than a secret and knowing which of two key pairs the merge dropped is the whole
/// content of the notice.
/// </para>
/// </remarks>
public static class ObjectStoreSecretMerge
{
/// <summary>Produces the merged bucket.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static ObjectStoreMergeResult Merge(
ObjectStoreSecret ancestor,
ObjectStoreSecret local,
ObjectStoreSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new ObjectStoreSecret
{
// Null-forgiving on the required fields, as the neighbouring merges do: the merge returns one of
// its three inputs, and all three are non-null by construction.
Label = Resolve(
nameof(ObjectStoreSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
Bucket = Resolve(
nameof(ObjectStoreSecret.Bucket), ancestor.Bucket, local.Bucket, remote.Bucket, conflicts)!,
AccessKeyId = Resolve(
nameof(ObjectStoreSecret.AccessKeyId),
ancestor.AccessKeyId,
local.AccessKeyId,
remote.AccessKeyId,
conflicts)!,
SecretAccessKey = Resolve(
nameof(ObjectStoreSecret.SecretAccessKey),
ancestor.SecretAccessKey,
local.SecretAccessKey,
remote.SecretAccessKey,
conflicts,
redact: true)!,
Region = Resolve(
nameof(ObjectStoreSecret.Region), ancestor.Region, local.Region, remote.Region, conflicts),
Endpoint = Resolve(
nameof(ObjectStoreSecret.Endpoint),
ancestor.Endpoint,
local.Endpoint,
remote.Endpoint,
conflicts),
UsePathStyle = ThreeWayMerge
.Scalar(ancestor.UsePathStyle, local.UsePathStyle, remote.UsePathStyle)
.Value,
Notes = Resolve(
nameof(ObjectStoreSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
};
return new ObjectStoreMergeResult(merged, conflicts);
}
private static string? Resolve(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts,
bool redact = false)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
redact ? "(kept the server's value)" : merge.Value ?? "(none)",
redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
return merge.Value;
}
}
@@ -0,0 +1,76 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A saved command, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="RunsOnInsert"/> is the field this type exists to get right.</b> A terminal is one input
/// stream with no notion of "at a prompt": the remote may be inside <c>vi</c>, or at a <c>sudo</c> password
/// prompt with echo off, and without shell integration the client cannot tell. So inserting a snippet is
/// always "type this into whatever is there", never "run this command" — and whether a newline follows the
/// text is the difference between the user reading what appeared and deciding, and something happening.
/// It defaults to <see langword="false"/>, which makes that decision the user's Enter key.
/// </para>
/// <para>
/// <b><see cref="Command"/> is stored verbatim.</b> No trimming, no newline normalisation — the same rule
/// <see cref="SshKeySecret.PrivateKeyPem"/> follows, for a related reason: a heredoc's trailing newline is
/// load-bearing, and a shell that receives a here-document terminator with the whitespace tidied off it hangs
/// waiting for one that never comes.
/// </para>
/// <para>
/// Deliberately not in this version, each with a reason rather than an omission: <b>host scoping</b>, which
/// needs a set merge that <see cref="ThreeWayMerge"/> does not have; <b>tags</b>, which are their own reserved
/// item kind; and <b>parameter substitution</b>, which would make this a template language expanding into a
/// root shell — a second security surface for a feature whose first one is already the hard part.
/// </para>
/// </remarks>
public sealed record SnippetSecret : IVaultSecret
{
/// <summary>What the snippet is called. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>The text to insert. May be several lines.</summary>
public required string Command { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>
/// Whether inserting this also presses Enter.
/// </summary>
/// <remarks>
/// Off unless the user turns it on, per snippet. A vault-wide preference was the alternative and it is
/// worse: the setting belongs to the command, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c>
/// do not want the same answer, and a single switch would eventually be left on by whoever needed it for
/// the first of those.
/// </remarks>
public bool RunsOnInsert { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// <see cref="Command"/> is checked for being blank but for nothing else. What makes a valid command is
/// the remote shell's business, this client does not know which shell that is, and a validator guessing
/// at it would refuse the legitimate cases — a bare <c>\x03</c>, a partial line meant to be completed by
/// hand — while catching nothing that matters.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A snippet needs a name.";
return false;
}
if (string.IsNullOrEmpty(Command))
{
reason = "A snippet needs something to insert.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,121 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded snippet payload, together with the schema version it was written at.</summary>
/// <param name="Snippet">The snippet.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record SnippetSecretDocument(SnippetSecret Snippet, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > SnippetSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a snippet item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="CredentialSecretCodec"/>. The one thing to be careful about here is
/// <see cref="SnippetSecret.RunsOnInsert"/>: it is a <see cref="bool"/>, so a payload that omits it decodes
/// as <see langword="false"/> — which is the safe direction, and deliberately the one a malformed or
/// truncated write falls in.
/// </remarks>
public static class SnippetSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a snippet to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The snippet is not valid for storage.</exception>
public static byte[] Encode(SnippetSecret snippet)
{
ArgumentNullException.ThrowIfNull(snippet);
if (!snippet.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(snippet));
}
var document = new SnippetPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = snippet.Label,
Command = snippet.Command,
Notes = snippet.Notes,
RunsOnInsert = snippet.RunsOnInsert,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out SnippetSecretDocument? document)
{
document = null;
SnippetPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new SnippetSecret
{
Label = parsed.Label ?? string.Empty,
Command = parsed.Command ?? string.Empty,
Notes = parsed.Notes,
RunsOnInsert = parsed.RunsOnInsert,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new SnippetSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class SnippetPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Command { get; set; }
public string? Notes { get; set; }
/// <remarks>
/// Not nullable, so its absence is <see langword="false"/> rather than a third state. The field decides
/// whether inserting a snippet also presses Enter, and "we could not tell" has to resolve to the answer
/// that does nothing.
/// </remarks>
public bool RunsOnInsert { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SnippetPayloadDocument))]
internal sealed partial class SnippetPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,94 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged snippet, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The snippet to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record SnippetMergeResult(
SnippetSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a snippet against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Three strings and a flag, so the shape is <see cref="CredentialSecretMerge"/>'s and it reuses
/// <see cref="HostFieldConflict"/> for the same reason. Nothing is redacted: a snippet is a command somebody
/// wrote down on purpose, and a notice that hid the discarded version would leave the user unable to tell
/// whether the one that survived is the one they wanted to keep.
/// </para>
/// <para>
/// <b><see cref="SnippetSecret.RunsOnInsert"/> cannot conflict, and it is worth knowing why rather than
/// assuming it.</b> A three-way clash needs local and remote each to differ from the ancestor <em>and</em>
/// from one another; with only two possible values, the first two conditions force the third to fail. So this
/// field always resolves to whichever side actually changed it, and a merge can never turn a snippet into one
/// that runs on its own — the outcome the ordinary rule would have made possible if the field had a third
/// state. An earlier draft special-cased it to resolve to <see langword="false"/> on a clash; the branch was
/// unreachable, and unreachable safety code is worse than none, because it reads as protection.
/// </para>
/// </remarks>
public static class SnippetSecretMerge
{
/// <summary>Produces the merged snippet.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static SnippetMergeResult Merge(
SnippetSecret ancestor,
SnippetSecret local,
SnippetSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new SnippetSecret
{
// Null-forgiving on the two required fields, as the neighbouring merges do for the same reason:
// the merge returns one of its three inputs, and all three are non-null by construction.
Label = Text(
nameof(SnippetSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
Command = Text(
nameof(SnippetSecret.Command),
ancestor.Command,
local.Command,
remote.Command,
conflicts)!,
Notes = Text(
nameof(SnippetSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
RunsOnInsert = ThreeWayMerge
.Scalar(ancestor.RunsOnInsert, local.RunsOnInsert, remote.RunsOnInsert)
.Value,
};
return new SnippetMergeResult(merged, conflicts);
}
private static string? Text(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
merge.Value ?? "(none)",
merge.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
return merge.Value;
}
}
@@ -0,0 +1,57 @@
namespace DodoSSH.Client.Domain;
/// <summary>
/// Reads the moment a version 7 identifier was created back out of it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> No vault item carries a timestamp. <c>VaultItem</c> is an id, a secret, a version
/// and three sync flags, and the server's <c>created_at</c> is deliberately not handed back — so a screen
/// that wants to say when something was added has nothing to read. Every id this client mints goes through
/// <see cref="Guid.CreateVersion7()"/>, which is banned-symbol policy rather than preference (see
/// <c>BannedSymbols.txt</c>), and RFC 9562 puts 48 bits of Unix milliseconds in the first six bytes of one.
/// That is a real creation time, already stored, costing nothing.
/// </para>
/// <para>
/// <b>What it is not.</b> It is when the item was <em>created</em>, never when it was last changed — an
/// update keeps the id. A screen showing this has to say so, or it is quietly presenting a creation date as
/// a modification date. And an id minted anywhere else, by an older client or another implementation, is not
/// a v7 at all; that case answers null rather than a number derived from bytes that mean something else.
/// </para>
/// </remarks>
public static class Uuid7Timestamp
{
/// <summary>Where the version nibble lives in the RFC byte order.</summary>
private const int VersionByte = 6;
/// <summary>
/// The creation time recorded in a version 7 identifier, or null if it is not one.
/// </summary>
public static DateTimeOffset? Of(Guid id)
{
Span<byte> bytes = stackalloc byte[16];
// Big-endian, which is the whole reason this is not two lines of shifting. Guid's own layout stores
// its first three fields in the host's byte order, so the little-endian overload scrambles exactly
// the six bytes being read here — and does it silently, producing dates in the year 30000 rather
// than an error.
if (!id.TryWriteBytes(bytes, bigEndian: true, out _))
{
return null;
}
if ((bytes[VersionByte] & 0xF0) != 0x70)
{
return null;
}
long milliseconds = 0;
for (var i = 0; i < 6; i++)
{
milliseconds = (milliseconds << 8) | bytes[i];
}
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Reading an OpenSSH client configuration and turning it into hosts this application can store.
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
different concern with different dependencies, and keeping them apart is what lets the whole of the
parsing be tested with no Avalonia, no SQLite and no disk.
-->
<ItemGroup>
<ProjectReference Include="..\DodoSSH.Client.Domain\DodoSSH.Client.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Import.Tests" />
</ItemGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// One host an <c>ssh_config</c> describes, resolved and ready to be looked at.
/// </summary>
/// <remarks>
/// Deliberately not a <see cref="HostSecret"/>. This is a candidate somebody has not agreed to import yet,
/// and it carries things a stored host has no field for — the identity file's path, the jump alias by name,
/// and the warnings that go beside a row in the preview.
/// </remarks>
/// <param name="Alias">
/// The name from the <c>Host</c> line, which is what the user types after <c>ssh</c> and so the name they
/// will recognise.
/// </param>
/// <param name="Hostname">
/// What <c>HostName</c> said, or the alias when it said nothing — which is OpenSSH's own default and the
/// reason <c>Host db.internal</c> with no other directive works.
/// </param>
/// <param name="Username">What <c>User</c> said, if anything.</param>
/// <param name="Port">What <c>Port</c> said, defaulting to 22.</param>
/// <param name="IdentityFiles">Every <c>IdentityFile</c> path, in the order they were given.</param>
/// <param name="ProxyJump">The <c>ProxyJump</c> value verbatim, if any.</param>
/// <param name="Options">Everything else, as SSH directives.</param>
/// <param name="Warnings">What could not be represented, per host.</param>
public sealed record ImportedHost(
string Alias,
string Hostname,
string? Username,
int Port,
IReadOnlyList<string> IdentityFiles,
string? ProxyJump,
HostOptions Options,
IReadOnlyList<string> Warnings)
{
/// <summary>The address this would dial, for a preview row.</summary>
public string Address => Username is { Length: > 0 } user
? $"{user}@{Hostname}:{Port}"
: $"{Hostname}:{Port}";
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>The identity file becomes a note and a directive, not a key.</b> Reading somebody's
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate,
/// and doing it as a side effect of "import my config" is the wrong default. The path is recorded so it
/// is not lost; importing the material is a separate, per-row choice.
/// </para>
/// <para>
/// <b>ProxyJump records intent and changes nothing about connecting.</b> The SSH layer has no jump
/// hosts — <c>ISshConnection</c> offers <c>OpenShellAsync</c> and nothing else, and
/// <c>SshConnectionRequest</c> has no route field. So it is kept as a directive and a note, and the
/// preview says so; a bastion topology that imported and quietly did not route would be worse than one
/// that was not imported.
/// </para>
/// </remarks>
public HostSecret ToSecret()
{
var options = new List<HostOption>(Options);
var notes = new List<string>();
if (IdentityFiles.Count > 0)
{
options.Add(new HostOption("IdentityFile", IdentityFiles[0]));
notes.Add(IdentityFiles.Count == 1
? $"ssh_config used the key at {IdentityFiles[0]}."
: $"ssh_config listed {IdentityFiles.Count} keys, the first being {IdentityFiles[0]}.");
}
if (ProxyJump is { Length: > 0 } jump)
{
options.Add(new HostOption("ProxyJump", jump));
notes.Add($"ssh_config reached this through {jump}. DodoSSH does not route through a jump host yet.");
}
return new HostSecret
{
Label = Alias,
Hostname = Hostname,
Port = Port,
Username = Username,
Notes = notes.Count == 0 ? null : string.Join(" ", notes),
Options = HostOptions.Create(options),
};
}
}
/// <summary>
/// Everything an <c>ssh_config</c> yielded: the hosts it can offer, and what it could not.
/// </summary>
/// <param name="Hosts">The importable candidates, in file order.</param>
/// <param name="SkippedPatterns">
/// <c>Host</c> patterns that are patterns rather than names. They contribute defaults and are not
/// importable: a bookmark called <c>*.internal</c> is one nothing can dial.
/// </param>
/// <param name="Warnings">Document-level notes, including the parser's own.</param>
public sealed record SshConfigImport(
IReadOnlyList<ImportedHost> Hosts,
IReadOnlyList<string> SkippedPatterns,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,31 @@
namespace DodoSSH.Client.Import;
/// <summary>One <c>Keyword Value</c> line, with the keyword as written.</summary>
/// <param name="Keyword">The directive name. SSH keywords are case-insensitive; the case here is the file's.</param>
/// <param name="Value">Everything after the keyword, unquoted but otherwise verbatim.</param>
public sealed record SshConfigDirective(string Keyword, string Value);
/// <summary>
/// One <c>Host</c> block: the patterns it applies to and the directives under it.
/// </summary>
/// <param name="Patterns">
/// Every token on the <c>Host</c> line. One line can name several — <c>Host web1 web2 web3</c> — and any of
/// them may be a pattern rather than a name.
/// </param>
/// <param name="Directives">The directives under it, in file order.</param>
public sealed record SshConfigBlock(
IReadOnlyList<string> Patterns,
IReadOnlyList<SshConfigDirective> Directives);
/// <summary>
/// A parsed <c>ssh_config</c>, plus what could not be honoured.
/// </summary>
/// <param name="Blocks">Every <c>Host</c> block, in the order OpenSSH would read them.</param>
/// <param name="Warnings">
/// What was skipped or flattened, in the words the preview will show. Everything this parser cannot
/// represent ends up here rather than being dropped quietly — a config that half-imported without saying so
/// is worse than one that refused.
/// </param>
public sealed record SshConfigDocument(
IReadOnlyList<SshConfigBlock> Blocks,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,80 @@
namespace DodoSSH.Client.Import;
/// <summary>
/// Finds and reads the user's OpenSSH client configuration.
/// </summary>
/// <remarks>
/// The only type here that touches a disk, which is what keeps <see cref="SshConfigParser"/> and
/// <see cref="SshConfigResolver"/> testable against strings.
/// </remarks>
public sealed class SshConfigLocator
{
private readonly string sshDirectory;
/// <param name="sshDirectory">
/// Where to look. Defaults to <c>~/.ssh</c>, which is the location on Windows as well as everywhere
/// else — OpenSSH on Windows uses the profile directory, not <c>%APPDATA%</c>.
/// </param>
public SshConfigLocator(string? sshDirectory = null) =>
this.sshDirectory = sshDirectory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".ssh");
/// <summary>The file this would read.</summary>
public string ConfigPath => Path.Combine(sshDirectory, "config");
/// <summary>Whether there is anything to read.</summary>
public bool Exists => File.Exists(ConfigPath);
/// <summary>Reads and resolves the configuration.</summary>
/// <exception cref="FileNotFoundException">There is no configuration file.</exception>
public async Task<SshConfigImport> ReadAsync(CancellationToken cancellationToken)
{
var text = await File.ReadAllTextAsync(ConfigPath, cancellationToken).ConfigureAwait(false);
return SshConfigResolver.Resolve(SshConfigParser.Parse(text, ReadIncluded));
}
/// <summary>
/// Reads every file an <c>Include</c> pattern names.
/// </summary>
/// <remarks>
/// <para>
/// A relative pattern resolves against <c>~/.ssh</c>, which is OpenSSH's rule for the user file. Glob
/// characters are handled by enumerating the directory rather than by matching by hand — a pattern like
/// <c>conf.d/*.conf</c> is the common shape and is what the enumeration overload is for.
/// </para>
/// <para>
/// Everything here swallows its own failures and returns nothing. An <c>Include</c> naming a file that
/// does not exist is not an error to OpenSSH, and an unreadable one is a reason to import less rather
/// than a reason to import nothing — the parser records the shortfall in its warnings either way.
/// </para>
/// </remarks>
private IReadOnlyList<string> ReadIncluded(string pattern)
{
try
{
var rooted = Path.IsPathRooted(pattern) ? pattern : Path.Combine(sshDirectory, pattern);
var directory = Path.GetDirectoryName(rooted);
var mask = Path.GetFileName(rooted);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(mask) || !Directory.Exists(directory))
{
return [];
}
return [.. Directory
.EnumerateFiles(directory, mask, SearchOption.TopDirectoryOnly)
.Order(StringComparer.Ordinal)
.Select(File.ReadAllText)];
}
catch (IOException)
{
return [];
}
catch (UnauthorizedAccessException)
{
return [];
}
}
}
@@ -0,0 +1,295 @@
using System.Globalization;
namespace DodoSSH.Client.Import;
/// <summary>
/// Reads an OpenSSH client configuration into blocks and directives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pure, and takes its include reader as a parameter.</b> That is what makes <c>Include</c> — the one
/// directive whose behaviour depends on the file system — testable without a file system, and it keeps the
/// recursion depth cap and the cycle detection here, next to the recursion, rather than in whatever happens
/// to be doing the reading.
/// </para>
/// <para>
/// <b>Deliberately not a complete implementation of ssh_config, and the gaps are reported rather than
/// hidden.</b> <c>Match</c> blocks are not evaluated: <c>Match exec</c> runs a command, <c>Match host</c>
/// depends on what is being connected to, and <c>Match final</c> depends on the result of everything else —
/// none of which is knowable while looking at a file. Token expansion beyond <c>~</c>,
/// <c>CanonicalizeHostname</c> and negated patterns are all out of scope for the same reason: this is an
/// importer producing bookmarks somebody will check, not a second SSH client.
/// </para>
/// </remarks>
public static class SshConfigParser
{
/// <summary>How deep <c>Include</c> may nest before this gives up.</summary>
/// <remarks>
/// OpenSSH's own limit is 16. Matching it means a config this refuses is one <c>ssh</c> refuses too,
/// which is a better answer than a different arbitrary number.
/// </remarks>
private const int MaximumIncludeDepth = 16;
/// <summary>
/// Parses configuration text.
/// </summary>
/// <param name="text">The file's contents.</param>
/// <param name="includeReader">
/// Resolves an <c>Include</c> pattern to the contents of every file it names, in order. Return an empty
/// sequence for a pattern that matches nothing, which is what OpenSSH does — an <c>Include</c> naming no
/// file is not an error.
/// </param>
public static SshConfigDocument Parse(string text, Func<string, IReadOnlyList<string>>? includeReader = null)
{
ArgumentNullException.ThrowIfNull(text);
var blocks = new List<SshConfigBlock>();
var warnings = new List<string>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
ParseInto(text, includeReader, blocks, warnings, visited, depth: 0);
return new SshConfigDocument(blocks, warnings);
}
private static void ParseInto(
string text,
Func<string, IReadOnlyList<string>>? includeReader,
List<SshConfigBlock> blocks,
List<string> warnings,
HashSet<string> visited,
int depth)
{
List<string>? patterns = null;
var directives = new List<SshConfigDirective>();
// A Match block is "everything until the next Host or Match", and while one is open its directives
// are dropped rather than attributed to whatever block came before — which is what a naive parser
// does, and it silently gives one host another host's settings.
var insideMatch = false;
var matchBlocks = 0;
foreach (var raw in text.Split('\n'))
{
var (keyword, value) = Tokenise(raw);
if (keyword is null)
{
continue;
}
if (Is(keyword, "Host"))
{
Flush(blocks, patterns, directives);
patterns = SplitPatterns(value);
directives = [];
insideMatch = false;
}
else if (Is(keyword, "Match"))
{
Flush(blocks, patterns, directives);
patterns = null;
directives = [];
insideMatch = true;
matchBlocks++;
}
else if (insideMatch)
{
continue;
}
else if (Is(keyword, "Include"))
{
// Flushed first, so the included file's blocks land between this block and the next — which
// is where OpenSSH puts them, and it matters because the first value seen for a keyword is
// the one that wins.
Flush(blocks, patterns, directives);
patterns = null;
directives = [];
Include(value, includeReader, blocks, warnings, visited, depth);
}
else
{
directives.Add(new SshConfigDirective(keyword, value));
}
}
Flush(blocks, patterns, directives);
WarnAboutMatchBlocks(matchBlocks, warnings);
}
/// <remarks>
/// Counted rather than listed. What a reader needs is that some of their file was not honoured and why;
/// naming each <c>Match</c> condition would be repeating the file back at them.
/// </remarks>
private static void WarnAboutMatchBlocks(int matchBlocks, List<string> warnings)
{
if (matchBlocks == 0)
{
return;
}
warnings.Add(string.Create(
CultureInfo.CurrentCulture,
$"{matchBlocks} Match block(s) were ignored. Whether one applies depends on what is being connected to, or on a command's output, so it cannot be decided from the file alone."));
}
private static bool Is(string keyword, string name) =>
string.Equals(keyword, name, StringComparison.OrdinalIgnoreCase);
private static void Include(
string pattern,
Func<string, IReadOnlyList<string>>? includeReader,
List<SshConfigBlock> blocks,
List<string> warnings,
HashSet<string> visited,
int depth)
{
if (includeReader is null)
{
warnings.Add($"Include {pattern} was skipped: nothing was supplied to read included files.");
return;
}
if (depth >= MaximumIncludeDepth)
{
warnings.Add($"Include {pattern} was skipped: includes are nested more than {MaximumIncludeDepth} deep.");
return;
}
// Cycles are the reason this is a set rather than a counter. A file that includes itself — directly
// or through a chain — would otherwise recurse until the depth cap, importing the same hosts sixteen
// times before stopping, which reads as a bug in the importer rather than in the config.
if (!visited.Add(pattern))
{
warnings.Add($"Include {pattern} was skipped: it is already being read further up.");
return;
}
try
{
foreach (var included in includeReader(pattern))
{
ParseInto(included, includeReader, blocks, warnings, visited, depth + 1);
}
}
finally
{
visited.Remove(pattern);
}
}
private static void Flush(
List<SshConfigBlock> blocks,
List<string>? patterns,
List<SshConfigDirective> directives)
{
if (patterns is { Count: > 0 })
{
blocks.Add(new SshConfigBlock(patterns, directives));
}
}
/// <summary>
/// Splits one line into a keyword and a value, or nothing.
/// </summary>
/// <remarks>
/// OpenSSH accepts <c>Keyword Value</c>, <c>Keyword=Value</c> and <c>Keyword = Value</c>, allows leading
/// whitespace, treats <c>#</c> as a comment, and lets a value be double-quoted. The quoting is what this
/// has to get right rather than approximately right: <c>IdentityFile "~/my keys/id_ed25519"</c> is one
/// path, and splitting it on whitespace produces two that do not exist.
/// </remarks>
private static (string? Keyword, string Value) Tokenise(string line)
{
// A BOM on the first line, and CR on every line of a CRLF file. Both are invisible and both would
// otherwise end up inside the first keyword, where nothing matches them.
var trimmed = line.Trim('', '\r').Trim();
if (trimmed.Length == 0 || trimmed[0] == '#')
{
return (null, string.Empty);
}
var separator = trimmed.AsSpan().IndexOfAny(" \t=");
if (separator < 0)
{
return (trimmed, string.Empty);
}
var keyword = trimmed[..separator];
var rest = trimmed[separator..].TrimStart(' ', '\t');
if (rest.StartsWith('='))
{
rest = rest[1..].TrimStart(' ', '\t');
}
return (keyword, Unquote(rest));
}
private static string Unquote(string value)
{
var trimmed = value.Trim();
return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
? trimmed[1..^1]
: trimmed;
}
/// <remarks>
/// Each token unquoted separately, because <c>Host "my server" other</c> is two patterns and one of them
/// contains a space.
/// </remarks>
private static List<string> SplitPatterns(string value)
{
var patterns = new List<string>();
var span = value.AsSpan();
var index = 0;
while (index < span.Length)
{
while (index < span.Length && char.IsWhiteSpace(span[index]))
{
index++;
}
if (index >= span.Length)
{
break;
}
int end;
if (span[index] == '"')
{
index++;
end = index;
while (end < span.Length && span[end] != '"')
{
end++;
}
patterns.Add(span[index..end].ToString());
index = end + 1;
continue;
}
end = index;
while (end < span.Length && !char.IsWhiteSpace(span[end]))
{
end++;
}
patterns.Add(span[index..end].ToString());
index = end;
}
return patterns;
}
}
@@ -0,0 +1,238 @@
using System.Buffers;
using System.Globalization;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// Turns parsed blocks into the hosts an import can offer.
/// </summary>
/// <remarks>
/// <para>
/// <b>First value wins.</b> That is the actual OpenSSH rule and it is not the intuitive one — a later
/// <c>Host *</c> block supplies defaults for keywords nothing earlier set, and cannot override a keyword an
/// earlier block already set. Getting it backwards produces an import where every host has the wildcard
/// block's username.
/// </para>
/// <para>
/// <b>A block whose patterns are all wildcards contributes defaults and is not itself importable.</b>
/// <c>Host *.internal</c> is a rule about names, not a machine — a bookmark by that name could not be
/// dialled. Those are reported so the preview can say what was used and not imported, rather than leaving
/// somebody to wonder why six blocks produced four hosts.
/// </para>
/// </remarks>
public static class SshConfigResolver
{
private static readonly SearchValues<char> PatternCharacters = SearchValues.Create("*?!");
/// <summary>Resolves every importable host in a parsed configuration.</summary>
public static SshConfigImport Resolve(SshConfigDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var hosts = new List<ImportedHost>();
var skipped = new List<string>();
var warnings = new List<string>(document.Warnings);
foreach (var pattern in document.Blocks.SelectMany(block => block.Patterns).Where(IsPattern))
{
if (!skipped.Contains(pattern, StringComparer.Ordinal))
{
skipped.Add(pattern);
}
}
foreach (var alias in document.Blocks.SelectMany(block => block.Patterns).Where(name => !IsPattern(name)))
{
if (hosts.Any(host => string.Equals(host.Alias, alias, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
hosts.Add(Resolve(alias, document));
}
if (skipped.Count > 0)
{
var named = string.Join(", ", skipped);
warnings.Add(string.Create(
CultureInfo.CurrentCulture,
$"{skipped.Count} pattern block(s) — {named} — supplied defaults but were not imported as hosts. A pattern names a rule, not a machine."));
}
return new SshConfigImport(hosts, skipped, warnings);
}
private static ImportedHost Resolve(string alias, SshConfigDocument document)
{
// Case-insensitive, because SSH keywords are and HostOption.NameComparer already says so. Two
// spellings of ServerAliveInterval reaching HostOptions.Create would be a duplicate-name throw.
var settled = new Dictionary<string, string>(HostOption.NameComparer);
var identityFiles = new List<string>();
var warnings = new List<string>();
Settle(alias, document, settled, identityFiles, warnings);
var port = ResolvePort(settled, warnings);
var hostname = Take(settled, "HostName") ?? alias;
var username = Take(settled, "User");
var proxyJump = Take(settled, "ProxyJump");
if (Take(settled, "ProxyCommand") is { } proxyCommand)
{
// Not put into Options: it would look like a setting that does something. Nothing in this
// application runs a ProxyCommand, and a directive sitting in a host's editor implying otherwise
// is worse than a sentence saying it was dropped.
warnings.Add($"ProxyCommand was dropped: nothing here runs one. It was '{proxyCommand}'.");
}
return new ImportedHost(
alias,
hostname,
username,
port,
identityFiles,
proxyJump,
HostOptions.Create(settled.Select(entry => new HostOption(entry.Key, entry.Value))),
warnings);
}
/// <summary>Walks every block that applies to an alias, keeping the first value for each keyword.</summary>
private static void Settle(
string alias,
SshConfigDocument document,
Dictionary<string, string> settled,
List<string> identityFiles,
List<string> warnings)
{
var duplicates = new HashSet<string>(HostOption.NameComparer);
foreach (var block in document.Blocks.Where(block => block.Patterns.Any(pattern => Matches(pattern, alias))))
{
foreach (var directive in block.Directives)
{
// IdentityFile is the one keyword that legitimately repeats — ssh tries each in turn — so it
// accumulates instead of settling, and is not reported as a duplicate.
if (string.Equals(directive.Keyword, "IdentityFile", StringComparison.OrdinalIgnoreCase))
{
identityFiles.Add(ExpandHome(directive.Value));
continue;
}
if (!settled.TryAdd(directive.Keyword, directive.Value))
{
duplicates.Add(directive.Keyword);
}
}
}
foreach (var keyword in duplicates.Order(HostOption.NameComparer))
{
// HostOptions is unique by name and cannot hold a repeat, which is a stated M1 limitation whose
// own remarks require the import path to surface it rather than quietly keep one. The first is
// kept because that is what ssh would have used.
warnings.Add($"{keyword} was set more than once; the first value was kept.");
}
}
private static int ResolvePort(Dictionary<string, string> settled, List<string> warnings)
{
if (Take(settled, "Port") is not { } portText)
{
return HostSecret.DefaultPort;
}
if (int.TryParse(portText, CultureInfo.InvariantCulture, out var parsed) && parsed is > 0 and <= 65535)
{
return parsed;
}
warnings.Add($"Port '{portText}' is not a usable port number; 22 was used.");
return HostSecret.DefaultPort;
}
/// <remarks>
/// Removed as it is read, so a keyword that maps onto a first-class field does not <em>also</em> end up
/// in <c>Options</c>. A host carrying both a <c>Port</c> of 2222 and a <c>Port</c> directive saying 2222
/// has two places to change it and one of them will be forgotten.
/// </remarks>
private static string? Take(Dictionary<string, string> settled, string keyword)
{
if (!settled.Remove(keyword, out var value))
{
return null;
}
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static bool IsPattern(string name) => name.AsSpan().ContainsAny(PatternCharacters);
/// <summary>
/// Whether a <c>Host</c> pattern applies to an alias.
/// </summary>
/// <remarks>
/// <c>*</c> and <c>?</c> only. Negation is not implemented — a <c>!</c> pattern is treated as not
/// matching, which errs towards importing a host with fewer defaults rather than towards silently
/// applying a block the user had excluded.
/// </remarks>
private static bool Matches(string pattern, string alias)
{
if (pattern.StartsWith('!'))
{
return false;
}
return !pattern.AsSpan().ContainsAny(PatternCharacters)
? string.Equals(pattern, alias, StringComparison.OrdinalIgnoreCase)
: Glob(pattern.AsSpan(), alias.AsSpan());
}
private static bool Glob(ReadOnlySpan<char> pattern, ReadOnlySpan<char> value)
{
if (pattern.IsEmpty)
{
return value.IsEmpty;
}
if (pattern[0] == '*')
{
for (var skip = 0; skip <= value.Length; skip++)
{
if (Glob(pattern[1..], value[skip..]))
{
return true;
}
}
return false;
}
if (value.IsEmpty)
{
return false;
}
return (pattern[0] == '?' || char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0]))
&& Glob(pattern[1..], value[1..]);
}
/// <remarks>
/// Tilde only. <c>%h</c>, <c>%p</c> and the rest are left alone: they are expanded per connection
/// against values this importer does not have, and a path with a literal <c>%h</c> in it is at least
/// visibly unexpanded rather than wrong.
/// </remarks>
private static string ExpandHome(string path)
{
if (!path.StartsWith("~/", StringComparison.Ordinal) && !path.StartsWith("~\\", StringComparison.Ordinal))
{
return path;
}
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, path[2..]);
}
}
@@ -0,0 +1,22 @@
{
"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=="
},
"dodossh.client.domain": {
"type": "Project"
}
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
S3-compatible buckets as a remote in the file browser.
Its own project rather than more of DodoSSH.Client.Transfer, because the two answer different
questions — that one is about moving bytes and what to do when moving them stops halfway, this
one is about one protocol's idea of what a file is — and because the AWS SDK belongs to exactly
one project rather than to the whole client.
It references DodoSSH.Client.Ssh for two types: IRemoteFileStore and SftpEntry. That reads
oddly and is deliberate; the reasoning is on IRemoteFileStore itself, and the short version is
that moving them would rename a record the entire file browser is written against.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="AWSSDK.Core" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.ObjectStore.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// Translating between the paths a file browser uses and the keys a bucket has.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bucket has no directories.</b> It has keys, which are strings, and a convention that <c>/</c> in a
/// key means what it means in a path. Everything in this class is that convention written down in one place,
/// because the alternative is the same three lines of trimming repeated at every call site with one of them
/// subtly different.
/// </para>
/// <para>
/// The browser's side is an absolute POSIX path — <c>/reports/2026/q3.csv</c> — because that is what the
/// screen, the breadcrumb trail and the transfer queue already speak. The bucket's side is a key with no
/// leading slash: <c>reports/2026/q3.csv</c>. The root is <c>/</c> on one side and the empty string on the
/// other, which is the case every one of these methods is really about.
/// </para>
/// </remarks>
internal static class ObjectKeys
{
/// <summary>The path a file browser opens on.</summary>
internal const string Root = "/";
/// <summary>The object key for a browser path.</summary>
internal static string ToKey(string path) => path.TrimStart('/');
/// <summary>The browser path for an object key.</summary>
internal static string ToPath(string key) => Root + key.TrimStart('/');
/// <summary>
/// The prefix that lists one directory's immediate contents.
/// </summary>
/// <remarks>
/// Trailing slash, always, and empty for the root. Without it a listing of <c>/reports</c> would also
/// return <c>/reports-archive</c>, because a prefix match knows nothing about path segments.
/// </remarks>
internal static string ToPrefix(string path)
{
var key = ToKey(path);
return key.Length == 0 || key.EndsWith('/') ? key : key + "/";
}
/// <summary>The last segment of a key, which is what a row shows.</summary>
/// <remarks>
/// Trailing slashes are removed first, so the common prefix <c>reports/2026/</c> yields <c>2026</c>
/// rather than an empty string.
/// </remarks>
internal static string NameOf(string key)
{
var trimmed = key.TrimEnd('/');
var slash = trimmed.LastIndexOf('/');
return slash < 0 ? trimmed : trimmed[(slash + 1)..];
}
}
@@ -0,0 +1,69 @@
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.ObjectStore;
/// <summary>Opens a bucket as a place with files in it.</summary>
/// <remarks>
/// An interface so the file screen can be tested without a bucket, exactly as <c>ISftpSessionFactory</c> is
/// what lets it be tested without a host.
/// </remarks>
public interface IObjectStoreFactory
{
/// <summary>Builds a client for one bucket.</summary>
/// <param name="store">The bucket and its credentials, decrypted.</param>
/// <remarks>
/// Synchronous and cheap: nothing is contacted here. S3 is request-per-operation, so there is no
/// connect step to fail — the first thing that can fail is the first listing, which is where the
/// credentials and the endpoint are actually tested.
/// </remarks>
IRemoteFileStore Open(ObjectStoreSecret store);
}
/// <summary>Opens buckets with the AWS SDK.</summary>
public sealed class S3ObjectStoreFactory : IObjectStoreFactory
{
/// <inheritdoc />
public IRemoteFileStore Open(ObjectStoreSecret store)
{
ArgumentNullException.ThrowIfNull(store);
if (!store.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(store));
}
var config = new AmazonS3Config
{
// On for nearly every self-hosted service and off for AWS. It is a stored setting rather than
// something inferred from the endpoint, because inferring it wrongly produces a DNS failure that
// says nothing about buckets — see ObjectStoreSecret.UsePathStyle.
ForcePathStyle = store.UsePathStyle,
};
if (store.Endpoint is { } endpoint)
{
config.ServiceURL = endpoint;
// Still set when there is one, because SigV4 signs the region into every request and several
// S3-compatible services check it. The ones that do not, ignore it.
if (store.Region is { } named)
{
config.AuthenticationRegion = named;
}
}
else
{
// No endpoint means Amazon, and then the region is what resolves the host. Validation has
// already refused the case where neither is set.
config.RegionEndpoint = RegionEndpoint.GetBySystemName(store.Region!);
}
var credentials = new BasicAWSCredentials(store.AccessKeyId, store.SecretAccessKey);
return new S3FileStore(new AmazonS3Client(credentials, config), store.Bucket);
}
}
@@ -0,0 +1,449 @@
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// One S3-compatible bucket, as a place with files in it.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bucket is not a filesystem, and the three places that matter are documented on the members rather
/// than smoothed over.</b> There are no directories, only keys with slashes in them; an object cannot be
/// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each
/// is refused with a reason or implemented with its cost stated, because a file browser that quietly did
/// something adjacent would be worse than one that said no.
/// </para>
/// <para>
/// <b>Listings are one page.</b> <c>ListObjectsV2</c> returns up to a thousand keys and this asks for one
/// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging
/// the whole way through a bucket with a million objects under one prefix is a request storm behind a
/// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory
/// anybody browses.
/// </para>
/// </remarks>
internal sealed class S3FileStore : IRemoteFileStore
{
/// <summary>
/// The most keys one listing asks for.
/// </summary>
/// <remarks>
/// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
/// not possible.
/// </remarks>
private const int PageSize = 1000;
private readonly IAmazonS3 client;
private readonly string bucket;
private int disposed;
internal S3FileStore(IAmazonS3 client, string bucket)
{
this.client = client;
this.bucket = bucket;
}
/// <summary>
/// Always true, because there is no connection to be up.
/// </summary>
/// <remarks>
/// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering
/// false when the network is down would be a claim this type cannot make without a request of its own,
/// and every operation already reports its own failure.
/// </remarks>
public bool IsConnected => Volatile.Read(ref disposed) == 0;
/// <inheritdoc />
public string HomeDirectory => ObjectKeys.Root;
/// <summary>
/// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
/// </summary>
/// <remarks>
/// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a
/// listing of the root returns every object in the bucket. Common prefixes come back as directories;
/// the marker object some tools write for a "folder" is dropped, because it is the directory itself and
/// showing it would put an empty-named row inside every one.
/// </remarks>
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var prefix = ObjectKeys.ToPrefix(path);
ListObjectsV2Response response;
try
{
response = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix,
Delimiter = "/",
MaxKeys = PageSize,
},
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
return Project(response, prefix);
}
/// <summary>Turns one listing into rows a file browser can show.</summary>
/// <remarks>
/// Directories first and then by name, which is the order every caller of this interface expects and
/// what saves the screen sorting it again.
/// </remarks>
private static IReadOnlyList<SftpEntry> Project(ListObjectsV2Response response, string prefix)
{
var entries = new List<SftpEntry>();
foreach (var common in response.CommonPrefixes ?? [])
{
entries.Add(new SftpEntry(
ObjectKeys.NameOf(common),
ObjectKeys.ToPath(common),
SftpEntryKind.Directory,
Length: 0,
LastWriteTimeUtc: default,
// Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a
// prefix would be a fact this store made up.
Permissions: string.Empty));
}
foreach (var item in response.S3Objects ?? [])
{
// The marker object for this prefix itself, which several tools write to make a folder appear
// in a web console. It is this directory, not something in it.
if (string.Equals(item.Key, prefix, StringComparison.Ordinal))
{
continue;
}
entries.Add(new SftpEntry(
ObjectKeys.NameOf(item.Key),
ObjectKeys.ToPath(item.Key),
SftpEntryKind.File,
item.Size ?? 0,
Utc(item.LastModified),
Permissions: string.Empty));
}
return
[
.. entries
.OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory)
.ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase),
];
}
/// <summary>
/// The SDK's timestamp as an unambiguous instant.
/// </summary>
/// <remarks>
/// Stated rather than converted implicitly. S3 returns <c>Last-Modified</c> in UTC and the SDK hands it
/// over as a <see cref="DateTime"/> whose <c>Kind</c> is not reliably set — so an implicit conversion
/// would read it as local time on some paths and shift every timestamp in the listing by the machine's
/// offset. The file browser shows this column beside an SFTP one.
/// </remarks>
private static DateTimeOffset Utc(DateTime? moment) =>
moment is { } value
? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
: default;
/// <summary>
/// What one path is, or null when nothing is there.
/// </summary>
/// <remarks>
/// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a
/// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether
/// anything lives under it as a prefix. The order matters — a key can be both, and the object is the
/// more specific answer.
/// </remarks>
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var key = ObjectKeys.ToKey(path);
if (key.Length == 0)
{
return new SftpEntry(
string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty);
}
try
{
var head = await client.GetObjectMetadataAsync(
new GetObjectMetadataRequest { BucketName = bucket, Key = key },
cancellationToken).ConfigureAwait(false);
return new SftpEntry(
ObjectKeys.NameOf(key),
ObjectKeys.ToPath(key),
SftpEntryKind.File,
head.ContentLength,
Utc(head.LastModified),
Permissions: string.Empty);
}
catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Not an object. It may still be a prefix with things under it, which is what a browser means
// by a directory.
}
var listing = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = ObjectKeys.ToPrefix(path),
MaxKeys = 1,
},
cancellationToken).ConfigureAwait(false);
return listing.KeyCount > 0
? new SftpEntry(
ObjectKeys.NameOf(key),
ObjectKeys.ToPath(key),
SftpEntryKind.Directory,
0,
default,
string.Empty)
: null;
}
/// <inheritdoc />
public async Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) };
if (offset > 0)
{
// A ranged GET, which is what makes an interrupted download resumable — and the one place where
// a bucket is better at this than SFTP, because the range is part of the protocol rather than a
// seek on an open handle.
request.ByteRange = new ByteRange(offset, long.MaxValue);
}
try
{
var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false);
return response.ResponseStream;
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Opens an object for writing, from the beginning.
/// </summary>
/// <remarks>
/// <para>
/// <b>A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.</b>
/// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart
/// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag
/// across the interruption — state this store would have to persist somewhere, on behalf of a queue that
/// already has its own idea of what resuming means. Refusing with a reason is the honest answer;
/// silently starting from zero would corrupt a resumed file.
/// </para>
/// <para>
/// The returned stream is the writing half of a pipe. A background upload reads the other half and
/// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part
/// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would
/// have cost.
/// </para>
/// </remarks>
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
if (offset != 0)
{
throw new SftpPathException(
path,
"An object cannot be written to from the middle, so an interrupted upload to a bucket "
+ "starts again rather than resuming.");
}
return Task.FromResult<Stream>(
new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
}
/// <summary>
/// Creates the marker object that makes an empty prefix visible.
/// </summary>
/// <remarks>
/// A zero-byte object whose key ends in <c>/</c>, which is the convention every S3 console and most
/// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by
/// itself once real objects live under the prefix, which is why the listing above drops it.
/// </remarks>
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var prefix = ObjectKeys.ToPrefix(path);
if (prefix.Length == 0)
{
throw new SftpPathException(path, "The root of a bucket already exists.");
}
try
{
await client.PutObjectAsync(
new PutObjectRequest
{
BucketName = bucket,
Key = prefix,
ContentBody = string.Empty,
},
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Deletes one object, or an empty prefix's marker.
/// </summary>
/// <remarks>
/// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete
/// against a bucket is the one operation on this screen that can destroy something no undo reaches. A
/// prefix with anything under it is refused and says so.
/// </remarks>
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var key = ObjectKeys.ToKey(path);
if (key.Length == 0)
{
throw new SftpPathException(path, "A bucket cannot delete its own root.");
}
if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory })
{
var listing = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = ObjectKeys.ToPrefix(path),
MaxKeys = 2,
},
cancellationToken).ConfigureAwait(false);
// One key is the marker object for this prefix itself; anything more is contents.
if (listing.KeyCount > 1)
{
throw new SftpPathException(
path, "There are still objects under this prefix, so it was not deleted.");
}
key = ObjectKeys.ToPrefix(path);
}
try
{
await client.DeleteObjectAsync(
new DeleteObjectRequest { BucketName = bucket, Key = key },
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
/// </summary>
/// <remarks>
/// <para>
/// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both
/// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is
/// real and a failure leaves a duplicate rather than a loss, which is the safe direction.
/// </para>
/// <para>
/// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing
/// a rename's clothing, and the failure mode is a half-moved directory.
/// </para>
/// </remarks>
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fromPath);
ArgumentNullException.ThrowIfNull(toPath);
if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false)
is not { Kind: SftpEntryKind.File })
{
throw new SftpPathException(
fromPath,
"Only an object can be renamed in a bucket. A prefix would have to be copied key by key.");
}
try
{
await client.CopyObjectAsync(
new CopyObjectRequest
{
SourceBucket = bucket,
SourceKey = ObjectKeys.ToKey(fromPath),
DestinationBucket = bucket,
DestinationKey = ObjectKeys.ToKey(toPath),
},
cancellationToken).ConfigureAwait(false);
await client.DeleteObjectAsync(
new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) },
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(fromPath, Describe(exception), exception);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 0)
{
client.Dispose();
}
return ValueTask.CompletedTask;
}
/// <summary>
/// What went wrong, in words that name the bucket rather than the protocol.
/// </summary>
/// <remarks>
/// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not
/// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the
/// moment to say which of the two is more likely.
/// </remarks>
private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch
{
System.Net.HttpStatusCode.NotFound => "There is nothing at that key.",
System.Net.HttpStatusCode.Forbidden =>
"The bucket refused that. Check the access key and what it is allowed to do.",
System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" =>
"The bucket is in a different region to the one configured.",
_ => exception.Message,
};
}
@@ -0,0 +1,204 @@
using System.IO.Pipelines;
using Amazon.S3;
using Amazon.S3.Transfer;
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// A stream you write an object into.
/// </summary>
/// <remarks>
/// <para>
/// <b>The direction is the whole problem.</b> The transfer queue asks for somewhere to write and then copies
/// a local file into it; the S3 SDK wants a stream it can read from. Something has to bridge the two, and
/// there are only three ways to do it: buffer the whole object to a temporary file and upload afterwards
/// (correct, and doubles the disk a big upload costs), hold it in memory (correct until somebody uploads a
/// disc image), or run the upload concurrently and hand back the writing half of a pipe.
/// </para>
/// <para>
/// This is the third. <see cref="TransferUtility"/> reads the pipe and splits it into multipart chunks, so
/// memory stays bounded by the part size however large the object is, and nothing lands on disk twice.
/// </para>
/// <para>
/// <b>Completion is on <see cref="DisposeAsync"/>, and it is not optional.</b> The upload is only finished
/// when the pipe is completed and the background task has been awaited — so a caller that abandons this
/// stream without disposing it leaves an upload running against a bucket. That is the same contract every
/// stream has; it is written down because the consequence here is remote rather than local.
/// </para>
/// <para>
/// <b>A failed upload has to surface at the writer.</b> If the service refuses halfway, the reading half
/// stops and this stream's next <c>WriteAsync</c> would otherwise block for ever — so the background task's
/// completion also completes the pipe's reader with the exception, which is what makes the write throw with
/// the real reason rather than hang.
/// </para>
/// </remarks>
internal sealed class S3UploadStream : Stream
{
private readonly Pipe pipe = new();
private readonly Task upload;
private readonly CancellationToken cancellationToken;
private int disposed;
internal S3UploadStream(
IAmazonS3 client,
string bucket,
string key,
CancellationToken cancellationToken)
{
this.cancellationToken = cancellationToken;
upload = UploadAsync(client, bucket, key);
}
/// <inheritdoc />
public override bool CanRead => false;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => Volatile.Read(ref disposed) == 0;
/// <summary>Not answerable: an object's length is not known until it has all been written.</summary>
public override long Length => throw new NotSupportedException();
/// <inheritdoc cref="Length" />
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
/// <inheritdoc />
public override async ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
var result = await pipe.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
if (result.IsCompleted)
{
// The reader has stopped, which means the upload ended — almost always because the service
// refused it. Awaiting the task surfaces that exception here, at the write, instead of leaving
// the caller to discover it at disposal after copying a whole file into nothing.
await upload.ConfigureAwait(false);
}
}
/// <summary>
/// Refused: this stream is asynchronous all the way down.
/// </summary>
/// <remarks>
/// Blocking on the pipe from a synchronous write is a deadlock waiting for a thread-pool starvation to
/// find it — the other half of the pipe is being read by a task that needs a thread to run on. The only
/// caller is the transfer queue, which copies asynchronously, so this is unreachable rather than
/// inconvenient. Throwing says which; blocking would say nothing until a large upload hung.
/// </remarks>
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException(
"An upload to a bucket is written asynchronously; use WriteAsync.");
/// <summary>
/// Nothing, deliberately.
/// </summary>
/// <remarks>
/// A flush cannot mean what a caller would want it to here — the object does not exist until the upload
/// completes, so there is no partial state to make durable. The pipe's own writes are already handed to
/// the reader as they arrive.
/// </remarks>
public override void Flush()
{
}
/// <inheritdoc cref="Flush" />
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
// Completing the writer is what tells the upload there is no more, so it must happen before the
// await — and it must happen even when the caller is abandoning a failed transfer, or the background
// task never ends.
await pipe.Writer.CompleteAsync().ConfigureAwait(false);
try
{
await upload.ConfigureAwait(false);
}
finally
{
await base.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Refused when it would have to finish an upload.
/// </summary>
/// <remarks>
/// <para>
/// Completing this stream means completing the pipe and awaiting the upload, and doing that from a
/// synchronous <c>Dispose</c> is the deadlock the synchronous <c>Write</c> above avoids. The alternative
/// — completing the writer and abandoning the task — silently drops whatever the service was about to
/// say, including a refusal, and reports a transfer as finished that never landed.
/// </para>
/// <para>
/// So a <c>using</c> rather than an <c>await using</c> throws, which is loud, immediate and correct. The
/// only caller already uses <c>await using</c>; this is what stops a second one being written by
/// accident.
/// </para>
/// </remarks>
protected override void Dispose(bool disposing)
{
if (disposing && Volatile.Read(ref disposed) == 0)
{
throw new NotSupportedException(
"An upload to a bucket finishes asynchronously; use await using rather than using.");
}
base.Dispose(disposing);
}
private async Task UploadAsync(IAmazonS3 client, string bucket, string key)
{
using var transfer = new TransferUtility(client);
try
{
await transfer.UploadAsync(
new TransferUtilityUploadRequest
{
BucketName = bucket,
Key = key,
InputStream = pipe.Reader.AsStream(),
// The stream has no length, so the utility has to be told not to look for one. It reads
// until the pipe completes and splits what it read into parts.
AutoCloseStream = false,
},
cancellationToken).ConfigureAwait(false);
await pipe.Reader.CompleteAsync().ConfigureAwait(false);
}
catch (Exception exception)
{
// Completing the reader *with* the exception is what unblocks a writer that is still copying:
// its next write sees a completed pipe and awaits this task, which rethrows this.
await pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
throw;
}
}
}
@@ -0,0 +1,88 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"AWSSDK.Core": {
"type": "Direct",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "Direct",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.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.domain": {
"type": "Project"
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"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"
}
}
}
}
}
@@ -0,0 +1,165 @@
using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session;
/// <summary>
/// Records keychain changes into the vault they happened in, without making the save wait.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ConnectionRecorder"/>'s shape, and the reason is the same one stated a different way: the
/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
/// into every edit. So <see cref="Record"/> posts to a bounded channel and returns, and one background task
/// does the work.
/// </para>
/// <para>
/// <b>Session-scoped, unlike the connection recorder.</b> This one is created with the vault and dies with
/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
/// is recorded. That is why it is owned by <see cref="VaultSession"/> rather than by the shell.
/// </para>
/// <para>
/// <b>Every failure is swallowed.</b> A log write that failed and surfaced would fail a save, and the whole
/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
/// something is one advisory line.
/// </para>
/// </remarks>
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
{
/// <inheritdoc cref="ConnectionRecorder" path="/remarks/para[4]" />
private const int QueueDepth = 512;
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel<ActivityLogSecret> pending = Channel.CreateBounded<ActivityLogSecret>(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly ActivityLogRepository log;
private readonly Guid vaultId;
private readonly Guid actorUserId;
private readonly string deviceName;
private readonly TimeProvider clock;
private readonly CancellationTokenSource lifetime = new();
private readonly Task drain;
private int disposed;
/// <param name="log">Where entries go.</param>
/// <param name="vaultId">The vault they belong to.</param>
/// <param name="actorUserId">Which account is making them.</param>
/// <param name="deviceName">What this machine calls itself.</param>
/// <param name="clock">Time source.</param>
internal ActivityRecorder(
ActivityLogRepository log,
Guid vaultId,
Guid actorUserId,
string deviceName,
TimeProvider clock)
{
this.log = log;
this.vaultId = vaultId;
this.actorUserId = actorUserId;
this.deviceName = deviceName;
this.clock = clock;
drain = DrainAsync(lifetime.Token);
}
/// <inheritdoc />
public void Record(
Guid vaultId,
SyncEntityType kind,
Guid entityId,
string label,
ActivityOperation operation,
IReadOnlyList<string> changedFields)
{
ArgumentNullException.ThrowIfNull(changedFields);
if (vaultId != this.vaultId)
{
// A write to a vault this recorder is not for. Not currently reachable — one session, one active
// vault — and refused rather than filed under the wrong one, because that is the failure that
// would be hardest to notice once shared vaults land.
return;
}
var entry = new ActivityLogSecret
{
// The name rather than the number, so a build that has never heard of a kind still shows
// something a person can read. See ActivityLogSecretCodec.
ItemKind = Enum.GetName(kind) ?? kind.ToString(),
ItemId = entityId,
ItemLabel = label,
Operation = operation,
ChangedFields = string.Join(", ", changedFields),
At = clock.GetUtcNow(),
DeviceName = deviceName,
ActorUserId = actorUserId,
};
pending.Writer.TryWrite(entry);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed. There is no caller left to tell, and the realistic failure is a cache that
// has gone away underneath a session being disposed.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
}
@@ -0,0 +1,410 @@
using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Session;
/// <summary>A connection that has started and has no log entry yet, because it has not ended.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
/// <summary>
/// Records connections into whichever vault is open, without ever making the caller wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>A process-lifetime object with session-scoped contents</b>, exactly like <see cref="VaultKnownHostStore"/>
/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
/// so a recorder created per session would have to be threaded through an object that must not know about
/// vaults at all. <see cref="Open"/> on unlock, <see cref="Close"/> on lock.
/// </para>
/// <para>
/// <b>Nothing on the calling thread does any work.</b> Both interface methods take a lock, touch a
/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
/// writing. That is not tidiness — <c>Closed</c> is called from a <c>finally</c> unwinding on a thread-pool
/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
/// exactly how closing an application comes to take four seconds.
/// </para>
/// <para>
/// <b>A shell can outlive the vault, so close-out has to as well.</b> A tab opened before a lock and closed
/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
/// it was opened against rather than reading whichever one is current. The write then fails if the session
/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
/// never worth surfacing an error over.
/// </para>
/// <para>
/// <b>The queue is bounded and drops the oldest when full.</b> An unbounded one would turn a stuck write into
/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
/// the newest — which is what somebody reading a log actually wants.
/// </para>
/// </remarks>
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
{
/// <summary>
/// How many close-outs may be waiting to be written.
/// </summary>
/// <remarks>
/// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
/// stopped draining — which is the case it exists for.
/// </remarks>
private const int QueueDepth = 256;
/// <summary>How long <see cref="DisposeAsync"/> waits for the queue to be written.</summary>
/// <remarks>
/// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
/// write — and short enough that a stuck cache cannot become a window that will not close.
/// </remarks>
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel<PendingEntry> pending = Channel.CreateBounded<PendingEntry>(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly Dictionary<uint, OpenTicket> tickets = [];
private readonly Lock gate = new();
private readonly TimeProvider clock;
private readonly string deviceName;
private readonly Task drain;
private readonly CancellationTokenSource lifetime = new();
private Binding? binding;
private int disposed;
/// <param name="clock">Time source. Used only for a duration this type did not receive.</param>
/// <param name="deviceName">What this machine calls itself, recorded on every entry.</param>
public ConnectionRecorder(TimeProvider clock, string deviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
this.clock = clock;
this.deviceName = deviceName;
drain = DrainAsync(lifetime.Token);
}
/// <summary>
/// The connections that have opened and not yet been recorded.
/// </summary>
/// <remarks>
/// For the logs screen, which shows these above the finished entries. It reads them from here rather
/// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
/// on that screen appears and disappears in step with the entry that will replace it, rather than in
/// step with a tab, which is a different thing that merely usually agrees.
/// </remarks>
public IReadOnlyList<OpenConnection> Open()
{
lock (gate)
{
return
[
.. tickets.Values
.Select(ticket => new OpenConnection(
ticket.HostLabel, ticket.Address, ticket.StartedAt))
.OrderByDescending(open => open.StartedAt),
];
}
}
/// <summary>Whether a vault is open behind this recorder.</summary>
public bool IsOpen
{
get
{
lock (gate)
{
return binding is not null;
}
}
}
/// <summary>Starts recording into an unlocked vault.</summary>
/// <param name="session">The unlocked session. Its active vault is the one written to.</param>
/// <param name="actorUserId">Which account this is, recorded on every entry.</param>
public void Open(VaultSession session, Guid actorUserId)
{
ArgumentNullException.ThrowIfNull(session);
lock (gate)
{
binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
}
}
/// <summary>
/// Stops recording new connections.
/// </summary>
/// <remarks>
/// Open tickets are deliberately <em>not</em> discarded. Each already holds the repository it was opened
/// against, so a shell still running when the vault locks closes out into the vault it was made from —
/// which is the honest record. What is dropped is the ability to <em>start</em> a ticket, because a
/// connection made while locked has no vault to belong to.
/// </remarks>
public void Close()
{
lock (gate)
{
binding = null;
}
}
/// <inheritdoc />
public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
lock (gate)
{
if (binding is not { } open)
{
return;
}
// The address stands in for the name until Identify supplies one, so a connection made by
// something that never calls it is still recorded — with a worse label, which beats no entry.
tickets[sessionId] = new OpenTicket(
open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
}
}
/// <summary>
/// Names the host an already-open session belongs to.
/// </summary>
/// <param name="sessionId">The session, as the workspace knows it.</param>
/// <param name="hostLabel">What the host is called in the keychain.</param>
/// <param name="hostId">The host item.</param>
/// <remarks>
/// <para>
/// The workspace takes an <c>SshConnectionRequest</c>, which has no notion of a keychain item, so it
/// knows an address and nothing else. The label and the id arrive here instead, from the view model that
/// does know — and as an amendment rather than a second ticket, so the start time stays the one the
/// workspace recorded rather than the slightly later one this call would carry.
/// </para>
/// <para>
/// A session id with no ticket is ignored, which is what a connection made while the vault was locked
/// looks like.
/// </para>
/// </remarks>
public void Identify(uint sessionId, string hostLabel, Guid? hostId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
lock (gate)
{
if (tickets.TryGetValue(sessionId, out var ticket))
{
tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
}
}
}
/// <inheritdoc />
public void Closed(uint sessionId, DateTimeOffset endedAt)
{
OpenTicket ticket;
lock (gate)
{
if (!tickets.Remove(sessionId, out var found))
{
// Never opened, already closed, or opened while the vault was locked. All three mean there
// is nothing to record, and none of them is an error.
return;
}
ticket = found;
}
Queue(ticket, endedAt, ConnectionOutcome.Closed);
}
/// <summary>
/// Records a connection that was never a workspace session.
/// </summary>
/// <param name="address">The address that was dialled.</param>
/// <param name="hostLabel">What the host is called.</param>
/// <param name="hostId">The host item, if there was one.</param>
/// <param name="kind">Which sort of session it was.</param>
/// <param name="startedAt">When it began.</param>
/// <param name="endedAt">When it ended, which is the same instant for an attempt that failed.</param>
/// <param name="outcome">How it ended.</param>
/// <remarks>
/// <para>
/// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
/// a connection that never opened — the workspace throws out of <c>ConnectAsync</c> before an id exists,
/// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
/// entirely and would collide with a terminal's id if it borrowed one.
/// </para>
/// <para>
/// A run of refusals against one host is the single most interesting thing a connection log can show,
/// which is why the failures are recorded at all rather than only the sessions that worked.
/// </para>
/// </remarks>
public void Record(
string address,
string hostLabel,
Guid? hostId,
ConnectionKind kind,
DateTimeOffset startedAt,
DateTimeOffset endedAt,
ConnectionOutcome outcome)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
Binding open;
lock (gate)
{
if (binding is not { } current)
{
return;
}
open = current;
}
Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
}
/// <summary>
/// Closes out every still-open connection and writes what is queued, within a bounded wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>Closing the application is the ordinary way a session ends</b>, and without this every one of them
/// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
/// the vault they would be written into has gone. So the tickets are closed here instead, while there is
/// still something to write to, and the durations run to the moment of exit — which is what actually
/// happened.
/// </para>
/// <para>
/// <b>The wait is bounded and the remainder is dropped.</b> An advisory log is never worth making a
/// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
/// patience.
/// </para>
/// </remarks>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
OpenTicket[] remaining;
lock (gate)
{
remaining = [.. tickets.Values];
tickets.Clear();
binding = null;
}
var at = clock.GetUtcNow();
foreach (var ticket in remaining)
{
Queue(ticket, at, ConnectionOutcome.Closed);
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
{
// A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
// but a machine that resumed from sleep between them can still produce a negative one, and the
// payload refuses those outright.
var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;
var entry = new ConnectionLogSecret
{
HostLabel = ticket.HostLabel,
Address = ticket.Address,
HostId = ticket.HostId,
Kind = ticket.Kind,
StartedAt = ticket.StartedAt,
Duration = duration,
Outcome = outcome,
DeviceName = deviceName,
ActorUserId = ticket.Binding.ActorUserId,
};
// TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
// a bounded channel with DropOldest never refuses anyway.
pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await item.Binding.Log
.CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
// there is no caller left to tell. The realistic failures are a session disposed between
// the queue and the write — a shell closed after the vault locked — and a cache that has
// gone away underneath it. Neither is worth an unobserved exception on a background task.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
/// <summary>Which vault entries go to, and who is making them.</summary>
private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
/// <summary>A connection that has started and not yet been recorded.</summary>
/// <remarks>
/// It carries its own <see cref="Binding"/> rather than reading the current one at close time, which is
/// what lets a session outlive the vault it was opened in without being filed into the next one.
/// </remarks>
private sealed record OpenTicket(
Binding Binding,
string Address,
string HostLabel,
Guid? HostId,
ConnectionKind Kind,
DateTimeOffset StartedAt);
private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
}
@@ -24,6 +24,14 @@
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
<!--
The terminal layer, for one interface: IConnectionLogSink, which ConnectionRecorder implements. The
direction is the point. Client.Terminal references only Client.Ssh and must keep doing so — a workspace
that knew about vaults would be a workspace that could not keep a shell running through a lock — so the
hole is declared down there and filled up here, exactly as VaultKnownHostStore fills IKnownHostStore.
Nothing in Client.Terminal references this project, so the graph stays acyclic.
-->
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
</ItemGroup>
<ItemGroup>
+123
View File
@@ -0,0 +1,123 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Session;
/// <summary>How much log a vault keeps.</summary>
/// <param name="MaxAge">How far back entries are kept.</param>
/// <param name="MaxEntries">How many entries of each kind are kept, whatever their age.</param>
/// <remarks>
/// <para>
/// Two limits rather than one, and whichever bites first wins. An age alone lets somebody who connects two
/// hundred times a day accumulate a log nobody wants to sync; a count alone means a quiet month of work
/// disappears the week somebody has a busy afternoon.
/// </para>
/// <para>
/// <b>Retention is not optional here the way it is for a local log file.</b> These entries sync, so keeping
/// them for ever costs every machine in the vault the bandwidth and the storage — which is the price of the
/// decision that made them auditable in the first place.
/// </para>
/// </remarks>
public sealed record LogRetention(TimeSpan MaxAge, int MaxEntries)
{
/// <summary>Ninety days, or five thousand entries of each kind.</summary>
public static LogRetention Default { get; } = new(TimeSpan.FromDays(90), 5_000);
}
/// <summary>What one pruning pass removed.</summary>
/// <param name="Connections">Connection entries deleted.</param>
/// <param name="Activity">Activity entries deleted.</param>
public sealed record LogPruneResult(int Connections, int Activity)
{
/// <summary>Whether anything went.</summary>
public bool RemovedAnything => Connections > 0 || Activity > 0;
}
/// <summary>
/// Removes log entries a vault has agreed to stop keeping.
/// </summary>
/// <remarks>
/// <para>
/// <b>A real tombstone delete that pushes</b>, because these are synced items — so pruning is not a local
/// tidy-up and cannot be run on a whim. It goes once when a vault opens and at most once per auto-sync tick
/// behind a last-pruned stamp; the alternative, a timer of its own, would be a second thing waking a laptop
/// up to write to a server.
/// </para>
/// <para>
/// <b>Age is read from the entry, not from the item.</b> A connection entry knows when the connection
/// started and an activity entry knows when the change happened, and both are the times a person means. The
/// item id's own v7 timestamp is close but not the same — it is when the entry was <em>written</em>, which
/// for a connection is when it ended.
/// </para>
/// </remarks>
public static class LogPruner
{
/// <summary>Deletes whatever falls outside the retention policy.</summary>
/// <param name="session">The open vault.</param>
/// <param name="retention">What to keep.</param>
/// <param name="now">The moment to measure age from.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// Reads both logs in full, which is what makes the count limit possible at all: neither the server nor
/// the local mirror can order encrypted entries, so the only place that can decide which five thousand
/// to keep is a client that has decrypted them.
/// </remarks>
public static async Task<LogPruneResult> PruneAsync(
VaultSession session,
LogRetention retention,
DateTimeOffset now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(retention);
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(false);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(false);
var cutoff = now - retention.MaxAge;
var staleConnections = Stale(
connections.Items, retention, cutoff, entry => entry.Secret.StartedAt);
var staleActivity = Stale(activity.Items, retention, cutoff, entry => entry.Secret.At);
foreach (var entry in staleConnections)
{
await session.ConnectionLog
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
.ConfigureAwait(false);
}
foreach (var entry in staleActivity)
{
await session.ActivityLog
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
.ConfigureAwait(false);
}
return new LogPruneResult(staleConnections.Count, staleActivity.Count);
}
/// <summary>The ids of the entries that fall outside the policy, newest kept.</summary>
private static IReadOnlyList<Guid> Stale<TSecret>(
IReadOnlyList<VaultItem<TSecret>> entries,
LogRetention retention,
DateTimeOffset cutoff,
Func<VaultItem<TSecret>, DateTimeOffset> at)
where TSecret : class, IVaultSecret
{
var ordered = entries.OrderByDescending(at).ToArray();
return
[
.. ordered
.Where((entry, index) => index >= retention.MaxEntries || at(entry) < cutoff)
.Select(entry => entry.EntityId),
];
}
}
+85 -11
View File
@@ -48,6 +48,16 @@ public sealed class VaultSession : IAsyncDisposable
private readonly VaultKeyring keyring;
private readonly TimeProvider clock;
private readonly SyncOptions options;
/// <summary>
/// Records what is done to this vault's items, for as long as this session lasts.
/// </summary>
/// <remarks>
/// Owned here rather than by the shell, unlike the connection recorder beside it. An edit is finished by
/// the time it is recorded, so nothing about it can outlive the session — where a shell genuinely can.
/// </remarks>
private readonly ActivityRecorder activity;
private bool disposed;
internal VaultSession(
@@ -78,10 +88,23 @@ public sealed class VaultSession : IAsyncDisposable
Vault = new VaultStore(caches, clock);
Unlock = new UnlockStore(caches, clock);
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
// actually stops it; building them first is what lets the recorder exist before the kinds that use
// it. See ActivityRecorder.
ConnectionLog = new ConnectionLogRepository(Items, Outbox, keyring);
ActivityLog = new ActivityLogRepository(Items, Outbox, keyring);
activity = new ActivityRecorder(
ActivityLog, activeVaultId, profile.UserId, Environment.MachineName, clock);
Hosts = new HostRepository(Items, Outbox, keyring, activity);
SshKeys = new SshKeyRepository(Items, Outbox, keyring, activity);
Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
}
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
@@ -114,6 +137,36 @@ public sealed class VaultSession : IAsyncDisposable
/// </remarks>
public KnownHostRepository KnownHosts { get; }
/// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
/// <remarks>
/// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name —
/// which is what makes filing two hosts at once on two machines two independent writes rather than one
/// contested one.
/// </remarks>
public HostGroupRepository HostGroups { get; }
/// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
public SnippetRepository Snippets { get; }
/// <summary>S3-compatible buckets and their credentials, decrypted.</summary>
/// <remarks>
/// Read when the file screen builds its picker, and the object-store client is constructed from the
/// result. Nothing here is on a transfer's data path.
/// </remarks>
public ObjectStoreRepository ObjectStores { get; }
/// <summary>The connections this vault has recorded, decrypted.</summary>
/// <remarks>
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
/// entry is created once, on the teardown path of a session, and encrypting on that thread is how
/// closing the application comes to take four seconds — see that type for the queue that keeps the two
/// apart.
/// </remarks>
public ConnectionLogRepository ConnectionLog { get; }
/// <summary>The keychain changes this vault has recorded, decrypted.</summary>
public ActivityLogRepository ActivityLog { get; }
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
@@ -316,7 +369,8 @@ public sealed class VaultSession : IAsyncDisposable
ArgumentNullException.ThrowIfNull(deviceKeys);
// Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
// needs the thread it was called from to be one that pumps messages. See WindowsDeviceKeyStore.
// needs the thread it was called from to be one that pumps messages. See the desktop head's
// WindowsDeviceKeyStore — this layer only knows it is handed an IDeviceKeyStore.
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);
var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);
@@ -369,33 +423,53 @@ public sealed class VaultSession : IAsyncDisposable
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
}
/// <summary>How many local changes are waiting to be pushed.</summary>
/// <summary>
/// How many local changes the <em>user</em> has made that are waiting to be pushed.
/// </summary>
/// <remarks>
/// <para>
/// <b>Log entries are excluded, and the exclusion is the honest reading rather than a convenience.</b>
/// This number is shown in the titlebar and it answers one question: how much of my work is not yet
/// safe anywhere else. A connection that was recorded is not somebody's work — nobody typed it, nobody
/// would re-enter it if this machine were lost, and an entry queued a moment after a save would leave
/// the titlebar claiming an unsynced change immediately after reporting a successful sync.
/// </para>
/// <para>
/// The entries are still pushed, on the next pass like anything else. What they are kept out of is a
/// count that means something narrower than "rows in the outbox".
/// </para>
/// </remarks>
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
return pending.Count;
return pending.Count(operation => operation.EntityType is not (
SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
}
/// <inheritdoc />
public ValueTask DisposeAsync()
public async ValueTask DisposeAsync()
{
if (disposed)
{
return ValueTask.CompletedTask;
return;
}
disposed = true;
// Before the keys go, and it waits — briefly. Anything queued has to be encrypted under a vault key
// that is about to be zeroed, so a fire-and-forget here would silently lose the last few entries of
// every session. The wait is bounded inside the recorder; locking never stalls on it.
await activity.DisposeAsync().ConfigureAwait(false);
// Order is not important — none of these depend on another — but completeness is. Missing one
// leaves key material in memory for the life of the process, which is the opposite of what
// locking is supposed to mean.
keyring.Dispose();
protector.Dispose();
bundle.Dispose();
return ValueTask.CompletedTask;
}
private static ConflictNotice Describe(StoredConflict conflict)
@@ -141,6 +141,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -163,6 +164,12 @@
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
@@ -7,6 +7,12 @@
<ItemGroup>
<PackageReference Include="SSH.NET" />
<!--
For Ed25519 key generation, which .NET has no primitive for at all. The same reason DodoSSH.Crypto
takes it; see Directory.Packages.props. Nothing else here touches it, and no key generated with it is
part of the DSH1 envelope — this is an SSH file format, not the vault's cryptography.
-->
<PackageReference Include="NSec.Cryptography" />
</ItemGroup>
<ItemGroup>
+211
View File
@@ -0,0 +1,211 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace DodoSSH.Client.Ssh;
/// <summary>
/// Writes the two files <c>ssh-keygen</c> writes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this is hand-rolled.</b> Neither the BCL nor NSec can write an OpenSSH private key. .NET has no
/// Ed25519 at all — that is why NSec is here in the first place — and the <c>openssh-key-v1</c> container is
/// an SSH-specific framing that no general-purpose library emits. The one alternative was PKCS#8 with the
/// Ed25519 OID <c>1.3.101.112</c>, which would also have had to be hand-encoded and which SSH.NET 2025.1.0
/// is not confirmed to parse — its PKCS#8 path historically switches on RSA, DSA and EC OIDs only. This
/// format is the one SSH.NET definitely reads and the one every other tool reads.
/// </para>
/// <para>
/// <b>The container is written unencrypted, on purpose.</b> Encrypting one needs <c>bcrypt_pbkdf</c> —
/// Blowfish with a byte-swizzling quirk — plus AES-256-CTR. .NET has no Blowfish, <c>Rfc2898DeriveBytes</c>
/// is in <c>BannedSymbols.txt</c> and is the wrong primitive anyway, and the only oracle for a hand-written
/// implementation is <c>ssh-keygen</c> itself. That is a standing crypto maintenance cost for a defence the
/// product does not need: a passphrase protects a key file sitting on a disk, and a key generated here goes
/// straight into an end-to-end encrypted keychain and never touches one. See
/// <c>SshKeySecret.Passphrase</c>, which makes the same argument at more length.
/// </para>
/// <para>
/// Everything below is length-prefixed big-endian, which is the whole of the SSH wire format. Getting a
/// prefix wrong yields a file that parses far enough to look plausible and then fails authentication with
/// an error that says nothing about the encoding.
/// </para>
/// </remarks>
internal static class OpenSshKeyWriter
{
private const string Magic = "openssh-key-v1\0";
private const string Ed25519Algorithm = "ssh-ed25519";
private const string RsaAlgorithm = "ssh-rsa";
/// <summary>
/// How wide the base64 body is wrapped.
/// </summary>
/// <remarks>
/// OpenSSH writes 70. Nothing parses by line length — but a key that diffs against one <c>ssh-keygen</c>
/// produced, in a repository or a paste, should differ in its bytes and not in its wrapping.
/// </remarks>
private const int WrapAt = 70;
/// <summary>
/// The armoured private key for an Ed25519 pair, in <c>openssh-key-v1</c> form.
/// </summary>
/// <param name="seed">The 32-byte private scalar seed, as NSec exports it.</param>
/// <param name="publicKey">The 32-byte public point.</param>
/// <param name="comment">The trailing comment, which OpenSSH stores inside the private section.</param>
internal static string WriteEd25519PrivateKey(
ReadOnlySpan<byte> seed,
ReadOnlySpan<byte> publicKey,
string comment)
{
var publicBlob = Ed25519PublicBlob(publicKey);
using var privateSection = new MemoryStream();
// Two copies of the same random value. OpenSSH uses them as a decryption check: after decrypting an
// encrypted key it compares them, and a mismatch is a wrong passphrase. Nothing here is encrypted,
// so nothing checks them — they are written because the format says so, and a parser is entitled to
// insist.
var check = RandomNumberGenerator.GetBytes(4);
privateSection.Write(check);
privateSection.Write(check);
WriteString(privateSection, Ed25519Algorithm);
WriteString(privateSection, publicKey);
// The private field of an Ed25519 OpenSSH key is the seed followed by the public point, 64 bytes,
// not the 32-byte seed alone. A file carrying only the seed loads and then signs with a key whose
// public half nobody agrees on.
Span<byte> expanded = stackalloc byte[64];
seed.CopyTo(expanded);
publicKey.CopyTo(expanded[32..]);
WriteString(privateSection, expanded);
WriteString(privateSection, comment);
Pad(privateSection);
using var container = new MemoryStream();
container.Write(Encoding.ASCII.GetBytes(Magic));
WriteString(container, "none");
WriteString(container, "none");
WriteString(container, ReadOnlySpan<byte>.Empty);
WriteUInt32(container, 1);
WriteString(container, publicBlob);
WriteString(container, privateSection.ToArray());
return Armour("OPENSSH PRIVATE KEY", container.ToArray());
}
/// <summary>The <c>authorized_keys</c> line for an Ed25519 public point.</summary>
internal static string WriteEd25519PublicKey(ReadOnlySpan<byte> publicKey, string comment) =>
PublicLine(Ed25519Algorithm, Ed25519PublicBlob(publicKey), comment);
/// <summary>The <c>authorized_keys</c> line for an RSA key.</summary>
internal static string WriteRsaPublicKey(RSA rsa, string comment) =>
PublicLine(RsaAlgorithm, RsaPublicBlob(rsa), comment);
/// <summary>The raw public key blob, which is what a fingerprint is taken over.</summary>
internal static byte[] Ed25519PublicBlob(ReadOnlySpan<byte> publicKey)
{
using var blob = new MemoryStream();
WriteString(blob, Ed25519Algorithm);
WriteString(blob, publicKey);
return blob.ToArray();
}
/// <inheritdoc cref="Ed25519PublicBlob" />
internal static byte[] RsaPublicBlob(RSA rsa)
{
var parameters = rsa.ExportParameters(includePrivateParameters: false);
using var blob = new MemoryStream();
WriteString(blob, RsaAlgorithm);
WriteMpint(blob, parameters.Exponent!);
WriteMpint(blob, parameters.Modulus!);
return blob.ToArray();
}
private static string PublicLine(string algorithm, byte[] blob, string comment)
{
var line = $"{algorithm} {Convert.ToBase64String(blob)}";
return string.IsNullOrWhiteSpace(comment) ? line : $"{line} {comment.Trim()}";
}
/// <remarks>
/// To a multiple of eight, with the bytes 1, 2, 3… — the block size of the "none" cipher, which OpenSSH
/// applies even though nothing is being blocked. This is the classic place to get an
/// <c>openssh-key-v1</c> writer wrong, because whether it is wrong depends on the length of the comment:
/// a name that happens to land on a boundary produces a file that loads everywhere, and one character
/// more produces one that does not.
/// </remarks>
private static void Pad(Stream destination)
{
var remainder = (int)(destination.Length % 8);
if (remainder == 0)
{
return;
}
for (var i = 1; i <= 8 - remainder; i++)
{
destination.WriteByte((byte)i);
}
}
private static string Armour(string label, byte[] body)
{
var builder = new StringBuilder();
builder.Append("-----BEGIN ").Append(label).Append("-----\n");
var base64 = Convert.ToBase64String(body);
for (var offset = 0; offset < base64.Length; offset += WrapAt)
{
builder.Append(base64.AsSpan(offset, Math.Min(WrapAt, base64.Length - offset))).Append('\n');
}
builder.Append("-----END ").Append(label).Append("-----\n");
return builder.ToString();
}
private static void WriteString(Stream destination, string value) =>
WriteString(destination, Encoding.UTF8.GetBytes(value));
private static void WriteString(Stream destination, ReadOnlySpan<byte> value)
{
WriteUInt32(destination, (uint)value.Length);
destination.Write(value);
}
private static void WriteUInt32(Stream destination, uint value)
{
Span<byte> encoded = stackalloc byte[4];
BinaryPrimitives.WriteUInt32BigEndian(encoded, value);
destination.Write(encoded);
}
/// <remarks>
/// Signed big-endian, so a leading byte with its high bit set needs a zero in front of it or it reads as
/// a negative number. An RSA modulus has that bit set roughly half the time, which is what makes this
/// the kind of bug that ships.
/// </remarks>
private static void WriteMpint(Stream destination, byte[] value)
{
if (value.Length > 0 && (value[0] & 0x80) != 0)
{
var padded = new byte[value.Length + 1];
value.CopyTo(padded, 1);
WriteString(destination, padded);
return;
}
WriteString(destination, value);
}
}
+30 -4
View File
@@ -214,14 +214,40 @@ public static class SftpPath
/// transfer runs is fine, and is the point of not opening a session per transfer.
/// </para>
/// </remarks>
public interface ISftpSession : IAsyncDisposable
public interface ISftpSession : IRemoteFileStore
{
/// <summary>The host key that was accepted for this session.</summary>
HostKeyPresentation HostKey { get; }
}
/// <summary>
/// A remote place with files in it, whatever protocol reaches it.
/// </summary>
/// <remarks>
/// <para>
/// Extracted from <see cref="ISftpSession"/> when buckets arrived, and unchanged in shape — the transfer
/// queue reads, writes, stats and lists, and never once needed anything SSH-specific. What stayed behind on
/// <c>ISftpSession</c> is the one member that could not be answered by a bucket: a host key.
/// </para>
/// <para>
/// <b>It lives in a project called <c>.Ssh</c>, which is a naming debt worth writing down rather than
/// paying.</b> <see cref="SftpEntry"/> is here too and is the type every listing is made of, so moving the
/// interface without moving that would split the vocabulary in half — and moving both means renaming a
/// record that the whole file browser and its tests are written against. The cost of leaving it is a
/// reference that reads oddly from the object-store project; the cost of moving it is a rename with no
/// behaviour in it.
/// </para>
/// <para>
/// <b>Not every implementation can do everything, and the contract says which.</b> An object store has no
/// directories, no rename and no way to resume a half-finished upload; each of those is documented on the
/// member and refused with a reason rather than silently approximated. See <c>S3FileStore</c>.
/// </para>
/// </remarks>
public interface IRemoteFileStore : 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>
+122
View File
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using NSec.Cryptography;
namespace DodoSSH.Client.Ssh;
/// <summary>Which kind of key pair to make.</summary>
public enum SshKeyAlgorithm
{
/// <summary>Ed25519. Small, fast, and what every current OpenSSH prefers.</summary>
Ed25519 = 0,
/// <summary>RSA at 4096 bits, for servers too old to accept the above.</summary>
Rsa4096 = 1,
}
/// <summary>
/// A freshly generated key pair, in the two forms anybody needs it in.
/// </summary>
/// <param name="PrivateKeyArmour">
/// The private half, in the armoured form <c>ssh-keygen</c> writes. Goes straight into
/// <c>SshKeySecret.PrivateKeyPem</c>, which stores it verbatim.
/// </param>
/// <param name="PublicKeyLine">
/// The public half, as one <c>authorized_keys</c> line. This is what gets installed on a host.
/// </param>
/// <param name="Fingerprint">
/// The <c>SHA256:…</c> fingerprint, in the format <c>ssh-keygen -lf</c> prints, so it can be read out to
/// somebody or compared against what a host reports.
/// </param>
public sealed record GeneratedSshKey(string PrivateKeyArmour, string PublicKeyLine, string Fingerprint);
/// <summary>
/// Makes a new SSH key pair without shelling out to <c>ssh-keygen</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the client can do this at all.</b> Every part is already here: NSec does Ed25519 because .NET
/// does not, the BCL does RSA, and the SSH wire encoding is a few length-prefixed strings — see
/// <see cref="OpenSshKeyWriter"/>. What it buys is that the private key is never written to a disk. The
/// alternative flow is "run ssh-keygen, find the file, open it, copy the text, paste it here, remember to
/// delete the file", and the last step is the one nobody does.
/// </para>
/// <para>
/// <b>The armour has no passphrase</b>, and that is a deliberate limitation with its reasoning in
/// <see cref="OpenSshKeyWriter"/>. The key is protected by the keychain it lands in.
/// </para>
/// <para>
/// This lives in the SSH project rather than in <c>DodoSSH.Crypto</c>, which is the normative
/// implementation of <c>docs/crypto.md</c> and has nothing to say about SSH file formats. It is also where
/// <see cref="SshHostKeyFingerprint"/> already lives, and a second <c>SHA256:</c> encoder would be a second
/// thing to get wrong.
/// </para>
/// </remarks>
public static class SshKeyGenerator
{
/// <summary>
/// Generates a key pair.
/// </summary>
/// <param name="algorithm">Which kind.</param>
/// <param name="comment">
/// The trailing comment, conventionally <c>user@machine</c>. It identifies the key in a host's
/// <c>authorized_keys</c> and is the only thing there that will say where it came from.
/// </param>
/// <remarks>
/// Synchronous and CPU-bound. RSA at 4096 bits is seconds of work on an ordinary machine, so a caller on
/// a UI thread has to move this to one of its own — the window would otherwise freeze at exactly the
/// moment somebody is watching it. Ed25519 is effectively instant, and the caller should not have to
/// know which is which.
/// </remarks>
public static GeneratedSshKey Generate(SshKeyAlgorithm algorithm, string comment) => algorithm switch
{
SshKeyAlgorithm.Ed25519 => Ed25519(comment),
SshKeyAlgorithm.Rsa4096 => Rsa4096(comment),
_ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
};
private static GeneratedSshKey Ed25519(string comment)
{
var parameters = new KeyCreationParameters
{
// The seed has to come back out to be written into the file. NSec holds key material in
// libsodium's guarded memory and refuses to export it unless asked at creation time.
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
};
using var key = Key.Create(SignatureAlgorithm.Ed25519, parameters);
var seed = key.Export(KeyBlobFormat.RawPrivateKey);
try
{
var publicKey = key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
return new GeneratedSshKey(
OpenSshKeyWriter.WriteEd25519PrivateKey(seed, publicKey, comment),
OpenSshKeyWriter.WriteEd25519PublicKey(publicKey, comment),
SshHostKeyFingerprint.Format(OpenSshKeyWriter.Ed25519PublicBlob(publicKey)));
}
finally
{
// The one copy of the private scalar this method makes, and it is an ordinary managed array
// outside libsodium's guarded memory. Clearing it does not undo anything the garbage collector
// may already have moved, which is why the export happens once and is used immediately.
CryptographicOperations.ZeroMemory(seed);
}
}
/// <remarks>
/// PKCS#1, which is what <c>ExportRSAPrivateKeyPem</c> writes and what SSH.NET's <c>RSA PRIVATE KEY</c>
/// branch reads. No hand-encoding is needed on this path at all — only the public line, because there is
/// no BCL helper for the SSH wire format.
/// </remarks>
private static GeneratedSshKey Rsa4096(string comment)
{
using var rsa = RSA.Create(4096);
return new GeneratedSshKey(
rsa.ExportRSAPrivateKeyPem() + "\n",
OpenSshKeyWriter.WriteRsaPublicKey(rsa, comment),
SshHostKeyFingerprint.Format(OpenSshKeyWriter.RsaPublicBlob(rsa)));
}
}
+15
View File
@@ -14,6 +14,15 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSec.Cryptography": {
"type": "Direct",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SSH.NET": {
"type": "Direct",
"requested": "[2025.1.0, )",
@@ -42,6 +51,12 @@
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
}
}
}
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a log entry into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostKeyCipher"/> exactly, including the rule that a payload is sealed at the
/// version the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// In practice a log entry is only ever sealed at version 1, because nothing updates one; the general rule
/// is used anyway, so that this cipher does not become the one place where a different one applies.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> <c>SyncEntityType.ActivityLogEntry</c> and
/// <c>CryptoSpec.AadResourceType.ActivityLogEntry</c> deliberately differ, as every pair in this folder does, and the
/// two log kinds sit next to each other in both enums — so a copied cipher with one constant left behind
/// seals a connection record under the resource type for an activity record. That encrypts perfectly,
/// decrypts perfectly on the machine that wrote it, and violates docs/crypto.md everywhere else.
/// </para>
/// </remarks>
public static class ActivityLogCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ActivityLogEntry;
/// <summary>Encrypts an entry.</summary>
/// <param name="entry">The entry. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
ActivityLogSecret entry,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = ActivityLogSecretCodec.Encode(entry);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload here. Nothing in a log entry is a credential, and what it does
// hold — which machines this person reaches, and when — is the aggregate the whole item is
// encrypted to keep, so leaving it in a pooled buffer would be an odd place to stop caring.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts an entry.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static ActivityLogSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return ActivityLogSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a log entry into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostKeyCipher"/> exactly, including the rule that a payload is sealed at the
/// version the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// In practice a log entry is only ever sealed at version 1, because nothing updates one; the general rule
/// is used anyway, so that this cipher does not become the one place where a different one applies.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> <c>SyncEntityType.ConnectionLogEntry</c> and
/// <c>CryptoSpec.AadResourceType.ConnectionLogEntry</c> deliberately differ, as every pair in this folder does, and the
/// two log kinds sit next to each other in both enums — so a copied cipher with one constant left behind
/// seals a connection record under the resource type for an activity record. That encrypts perfectly,
/// decrypts perfectly on the machine that wrote it, and violates docs/crypto.md everywhere else.
/// </para>
/// </remarks>
public static class ConnectionLogCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ConnectionLogEntry;
/// <summary>Encrypts an entry.</summary>
/// <param name="entry">The entry. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
ConnectionLogSecret entry,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = ConnectionLogSecretCodec.Encode(entry);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload here. Nothing in a log entry is a credential, and what it does
// hold — which machines this person reaches, and when — is the aggregate the whole item is
// encrypted to keep, so leaving it in a pooled buffer would be an odd place to stop caring.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts an entry.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static ConnectionLogSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return ConnectionLogSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -18,10 +18,14 @@ namespace DodoSSH.Client.Sync;
/// interface reads a listing once per reload rather than holding one open.
/// </para>
/// </remarks>
public sealed class CredentialRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
public sealed class CredentialRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<CredentialSecret> credentials =
new(CredentialKind.Instance, items, outbox, keyring);
new(CredentialKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<CredentialSecret>> ListAsync(
+130
View File
@@ -0,0 +1,130 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a host group into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostKeyCipher"/> exactly, including the rule that a payload is sealed at the
/// version the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> <c>SyncEntityType.HostGroup</c> is 4 and
/// <c>CryptoSpec.AadResourceType.HostGroup</c> is 7, because the crypto enum carries None, User, Device and
/// Vault ahead of the item types. Casting one to the other would seal a group under the resource type for a
/// <em>host</em> — which encrypts perfectly, decrypts perfectly on the machine that wrote it, and is a
/// specification violation nothing would notice until an interoperating client refused the item.
/// </para>
/// </remarks>
public static class HostGroupCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.HostGroup;
/// <summary>Encrypts a group.</summary>
/// <param name="group">The group. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
HostGroupSecret group,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(group);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = HostGroupSecretCodec.Encode(group);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload in this folder, and here for the smallest reason of all: the
// buffer holds one name somebody chose for a folder. It is wiped anyway, because the rule this
// folder follows is that plaintext does not outlive the call that made it, and an exception for
// the case that seems harmless is how the rule stops being one.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a group.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static HostGroupSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return HostGroupSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,54 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The groups in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// <para>
/// The fifth facade over the same generic repository, and like the fourth it needed no new sync logic at all.
/// </para>
/// <para>
/// <b>Deleting a group does not touch the hosts in it.</b> There is deliberately no <c>DeleteAsync</c>
/// overload that unfiles its members: one user action would become N host writes, N outbox rows and N chances
/// to merge against an edit nobody made, and the group's own tombstone can still lose a merge — by which time
/// the membership it was clearing is gone. Hosts left holding a dangling id fall under the ungrouped heading,
/// which is where the interface handles it. See <see cref="HostSecret.GroupId"/>.
/// </para>
/// </remarks>
public sealed class HostGroupRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<HostGroupSecret> groups =
new(HostGroupKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<HostGroupSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
groups.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
HostGroupSecret group,
CancellationToken cancellationToken) =>
groups.CreateAsync(vaultId, group, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
HostGroupSecret group,
CancellationToken cancellationToken) =>
groups.UpdateAsync(vaultId, entityId, group, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
groups.DeleteAsync(vaultId, entityId, cancellationToken);
}
+6 -2
View File
@@ -13,10 +13,14 @@ namespace DodoSSH.Client.Sync;
/// generic is internal to this assembly — exposing it would make the encoding and merge of every item
/// type part of the public surface for the sake of a constructor argument.
/// </remarks>
public sealed class HostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
public sealed class HostRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<HostSecret> hosts =
new(HostKind.Instance, items, outbox, keyring);
new(HostKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<HostSecret>> ListAsync(Guid vaultId, CancellationToken cancellationToken) =>
@@ -0,0 +1,64 @@
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Somewhere to record that a keychain item was created, changed or deleted.
/// </summary>
/// <remarks>
/// <para>
/// <b>Hooked into <see cref="VaultItemRepository{TSecret}"/> rather than into the view models.</b> That
/// repository is the single generic funnel every kind's create, update and delete goes through, so one
/// write site covers all of them and picks up a new kind for free. Hooking the view models instead would
/// miss <c>VaultKnownHostStore</c>, which writes pins programmatically at connect time and never touches a
/// screen — and those are exactly the writes an audit trail must not be blind to.
/// </para>
/// <para>
/// Three rules govern the call, and they are properties of where it sits rather than of what it does:
/// </para>
/// <list type="number">
/// <item>
/// <b>After the outbox queue, never before.</b> A crash between the two loses an advisory line; the reverse
/// records a change that never happened.
/// </item>
/// <item>
/// <b>Every exception swallowed by the implementation.</b> A failing log write must never fail a save — the
/// entire point of the outbox is that saving works offline and cannot be refused.
/// </item>
/// <item>
/// <b>Not in a transaction with the outbox</b>, or a log failure rolls back a change the user made.
/// </item>
/// </list>
/// <para>
/// Note the deliberate asymmetry with the outbox itself, because it reads as a discrepancy otherwise: the
/// outbox <em>coalesces</em> two edits of one item into a single pending row, and this does not — two edits
/// are two lines. The outbox describes what still has to be sent; this describes what somebody did.
/// </para>
/// </remarks>
public interface IActivityLogSink
{
/// <summary>Records one write.</summary>
/// <param name="vaultId">Which vault it happened in.</param>
/// <param name="kind">Which sort of item, as the wire contract names it.</param>
/// <param name="entityId">The item.</param>
/// <param name="label">What the item was called at the time.</param>
/// <param name="operation">What was done.</param>
/// <param name="changedFields">
/// The names of the fields that differ, and never their values. Empty for a create and a delete, and
/// also when the previous version could not be read — which is why an empty list must not be taken to
/// mean nothing changed.
/// </param>
/// <remarks>
/// Returns <see langword="void"/> by contract, for the reason <c>IConnectionLogSink</c> does: the caller
/// is a save the user is waiting on, and an encrypt-and-write on that path would put the cost of the log
/// into every keystroke that reaches a Save button.
/// </remarks>
void Record(
Guid vaultId,
SyncEntityType kind,
Guid entityId,
string label,
ActivityOperation operation,
IReadOnlyList<string> changedFields);
}
+557
View File
@@ -1,6 +1,7 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.FieldChange;
namespace DodoSSH.Client.Sync;
@@ -85,6 +86,35 @@ internal interface IItemKind<TSecret>
/// <summary>Merges two divergent versions against the version they both started from.</summary>
MergedItem<TSecret> Merge(TSecret ancestor, TSecret local, TSecret remote);
/// <summary>
/// The names of the fields that differ between two versions of an item.
/// </summary>
/// <remarks>
/// <para>
/// For the activity log, which records what changed and never what it changed to. Written per kind
/// rather than derived by reflection, for two reasons: this project's serialisation is source-generated
/// precisely to keep reflection out of it, and the names here are read by a person — so each kind gets
/// to say "Passphrase" rather than whatever a property happens to be called.
/// </para>
/// <para>
/// <b>Never a value.</b> A log that recorded an old password would be a plaintext credential store with
/// a vault drawn around it; see ADR 0006, which imposes the same rule on the server's own detail column.
/// </para>
/// </remarks>
IReadOnlyList<string> Changes(TSecret before, TSecret after);
/// <summary>
/// Whether writing one of these is worth a line in the activity log.
/// </summary>
/// <remarks>
/// False for the log kinds themselves, and that is not a preference: the activity hook sits in the one
/// generic repository every kind goes through, so a log entry that logged itself would produce an entry
/// per entry, for ever. It is stated per kind rather than special-cased at the call site so that a
/// future kind with the same shape — anything written by the machine rather than by a person — cannot
/// re-enter the loop by being forgotten.
/// </remarks>
bool IsAudited => true;
/// <summary>The same item under a new name, for a resurrection.</summary>
TSecret Relabel(TSecret secret, string label);
}
@@ -120,6 +150,23 @@ internal static class ItemKinds
(SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<KnownHostSecret>(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostGroupSecret>(HostGroupKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ConnectionLogSecret>(
ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ActivityLogSecret>(
ActivityLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
];
/// <summary>The types to ask the server for, in a fixed order.</summary>
@@ -141,6 +188,24 @@ internal static class ItemKinds
entry => entry.Create(outbox, conflicts, keyring));
}
/// <summary>What the per-kind field comparisons share.</summary>
/// <remarks>
/// One line per field at each call site, which is the point: the alternative was reflection, and this
/// project keeps reflection out of its serialisation on purpose. Comparison is <see cref="object.Equals(object)"/>
/// on the values, which is why the collection-shaped fields on a host are types with structural equality —
/// <c>JumpChain</c> and <c>HostOptions</c> — rather than plain lists.
/// </remarks>
internal static class FieldChange
{
internal static void Note<T>(List<string> changed, string name, T before, T after)
{
if (!EqualityComparer<T>.Default.Equals(before, after))
{
changed.Add(name);
}
}
}
/// <summary>Hosts.</summary>
internal sealed class HostKind : IItemKind<HostSecret>
{
@@ -184,6 +249,29 @@ internal sealed class HostKind : IItemKind<HostSecret>
return new MergedItem<HostSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostSecret before, HostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Hostname", before.Hostname, after.Hostname);
Note(changed, "Port", before.Port, after.Port);
Note(changed, "Username", before.Username, after.Username);
Note(changed, "Notes", before.Notes, after.Notes);
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
Note(changed, "Options", before.Options, after.Options);
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
Note(changed, "Credential", before.CredentialId, after.CredentialId);
Note(changed, "Group", before.GroupId, after.GroupId);
return changed;
}
/// <inheritdoc />
public HostSecret Relabel(HostSecret secret, string label)
{
@@ -247,6 +335,27 @@ internal sealed class SshKeyKind : IItemKind<SshKeySecret>
return new MergedItem<SshKeySecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// The private key is compared and never reported by value, which is the whole rule. "Private key"
/// appearing in a log is the fact somebody needs; the key itself is what nobody does.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(SshKeySecret before, SshKeySecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Private key", before.PrivateKeyPem, after.PrivateKeyPem);
Note(changed, "Passphrase", before.Passphrase, after.Passphrase);
Note(changed, "Public key", before.PublicKey, after.PublicKey);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public SshKeySecret Relabel(SshKeySecret secret, string label)
{
@@ -312,6 +421,22 @@ internal sealed class CredentialKind : IItemKind<CredentialSecret>
return new MergedItem<CredentialSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(CredentialSecret before, CredentialSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Password", before.Password, after.Password);
Note(changed, "Username", before.Username, after.Username);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public CredentialSecret Relabel(CredentialSecret secret, string label)
{
@@ -397,6 +522,28 @@ internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
/// the pin is about a different host. The resurrected item still gets its own id and still produces a
/// conflict notice, so the event is visible — the notice simply names the pin the same way twice.
/// </remarks>
/// <remarks>
/// In practice only the fingerprint can change: the store rewrites one or creates a new item, and never
/// re-addresses an existing pin. The other three are compared anyway, because a payload from elsewhere
/// is untrusted input and a silently unreported change to what a pin is <em>about</em> is the one thing
/// an audit trail must not miss.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(KnownHostSecret before, KnownHostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Host", before.Host, after.Host);
Note(changed, "Port", before.Port, after.Port);
Note(changed, "Algorithm", before.Algorithm, after.Algorithm);
Note(changed, "Fingerprint", before.Fingerprint, after.Fingerprint);
return changed;
}
/// <inheritdoc />
public KnownHostSecret Relabel(KnownHostSecret secret, string label)
{
@@ -405,3 +552,413 @@ internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
return secret;
}
}
/// <summary>Host groups.</summary>
internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
{
internal static HostGroupKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.HostGroup;
/// <inheritdoc />
public string Noun => "group";
/// <inheritdoc />
public OpenedItem<HostGroupSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<HostGroupSecret>(document.Group, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
HostGroupSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and the field it declines to send is the one named after this type.
/// </summary>
/// <remarks>
/// <c>SyncPlaintextFields.GroupId</c> exists, the server had a column for it, and no client ever wrote
/// one. What it would have handed over is a clustering of the estate — which machines this user files
/// together — for a column nothing in the product reads. The server now refuses the field outright, on
/// hosts as well as here. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
/// <inheritdoc />
public MergedItem<HostGroupSecret> Merge(
HostGroupSecret ancestor,
HostGroupSecret local,
HostGroupSecret remote)
{
var merged = HostGroupSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<HostGroupSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostGroupSecret before, HostGroupSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
return changed;
}
/// <inheritdoc />
public HostGroupSecret Relabel(HostGroupSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Snippets.</summary>
internal sealed class SnippetKind : IItemKind<SnippetSecret>
{
internal static SnippetKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Snippet;
/// <inheritdoc />
public string Noun => "snippet";
/// <inheritdoc />
public OpenedItem<SnippetSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<SnippetSecret>(document.Snippet, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SnippetSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. A command is a description of the estate as precise as a hostname is.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SnippetSecret secret) => null;
/// <inheritdoc />
public MergedItem<SnippetSecret> Merge(
SnippetSecret ancestor,
SnippetSecret local,
SnippetSecret remote)
{
var merged = SnippetSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SnippetSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(SnippetSecret before, SnippetSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Command", before.Command, after.Command);
Note(changed, "Notes", before.Notes, after.Notes);
// Named for what it means rather than after the property, because this is the line somebody
// reviewing a log actually needs to see: a snippet that has been turned into one that runs.
Note(changed, "Runs on insert", before.RunsOnInsert, after.RunsOnInsert);
return changed;
}
/// <inheritdoc />
public SnippetSecret Relabel(SnippetSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Connection log entries.</summary>
internal sealed class ConnectionLogEntryKind : IItemKind<ConnectionLogSecret>
{
internal static ConnectionLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "Log entry" and not "connection". A user told that "this connection could not be decrypted" would go
/// looking at a machine they cannot reach; the item is the <em>record</em> of one, and the noun has to
/// say so.
/// </remarks>
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ConnectionLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ConnectionLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ConnectionLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is a timestamp rather than an address.
/// </summary>
/// <remarks>
/// A plaintext <c>startedAt</c> would let the server order and prune a log without a client's help, which
/// is genuinely useful and is exactly the wrong trade: a timestamp column on this table is a record of
/// when each user works, assembled for free. The client sorts and prunes its own log. See ADR 0004.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ConnectionLogSecret> Merge(
ConnectionLogSecret ancestor,
ConnectionLogSecret local,
ConnectionLogSecret remote)
{
var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ConnectionLogSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>Never asked, because a log entry is never audited or updated.</summary>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ConnectionLogSecret before, ConnectionLogSecret after) => [];
/// <summary>
/// False, and this is the guard that stops the log logging itself.
/// </summary>
/// <remarks>
/// The activity hook lives in the one generic repository every kind writes through, so without this a
/// connection entry would produce an activity entry, which would produce another, without end. It is a
/// property of the kind rather than a check at the call site so that the next machine-written kind
/// cannot re-enter the loop by being overlooked.
/// </remarks>
public bool IsAudited => false;
/// <summary>
/// The entry unchanged, because an entry has no name of its own to change.
/// </summary>
/// <remarks>
/// As for a pinned host key: the label is derived from what the entry records, so renaming it would mean
/// claiming the connection was to a different machine. A resurrected entry still gets its own id and
/// still produces a conflict notice, so the event stays visible.
/// </remarks>
/// <inheritdoc />
public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Activity log entries.</summary>
internal sealed class ActivityLogEntryKind : IItemKind<ActivityLogSecret>
{
internal static ActivityLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry;
/// <inheritdoc />
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ActivityLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ActivityLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ActivityLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. <c>SyncPlaintextFields.Kind</c> would fit and is refused for it.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ActivityLogSecret> Merge(
ActivityLogSecret ancestor,
ActivityLogSecret local,
ActivityLogSecret remote)
{
var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ActivityLogSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc cref="ConnectionLogEntryKind.Changes" />
public IReadOnlyList<string> Changes(ActivityLogSecret before, ActivityLogSecret after) => [];
/// <inheritdoc cref="ConnectionLogEntryKind.IsAudited" />
public bool IsAudited => false;
/// <inheritdoc cref="ConnectionLogEntryKind.Relabel" />
public ActivityLogSecret Relabel(ActivityLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Buckets.</summary>
internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
{
internal static ObjectStoreKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ObjectStore;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "Bucket" rather than "object store", because that is the word on the screen and in every service's own
/// documentation. The type is named for the protocol; the noun is named for what people say.
/// </remarks>
public string Noun => "bucket";
/// <inheritdoc />
public OpenedItem<ObjectStoreSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ObjectStoreSecret>(document.Store, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ObjectStoreSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and for this type there was never a candidate.
/// </summary>
/// <remarks>
/// The endpoint is the field somebody might reach for, and it is the one that must not go: for everybody
/// self-hosting it is an address on their own network, which is exactly what the host table only holds in
/// the clear when the relay cannot work without it. Nothing on the server dials a bucket.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null;
/// <inheritdoc />
public MergedItem<ObjectStoreSecret> Merge(
ObjectStoreSecret ancestor,
ObjectStoreSecret local,
ObjectStoreSecret remote)
{
var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ObjectStoreSecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// The secret access key is compared and never reported by value, which is the rule every credential-like
/// field in this file follows. The access key id is shown: it is an identifier, not a secret.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ObjectStoreSecret before, ObjectStoreSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Bucket", before.Bucket, after.Bucket);
Note(changed, "Access key id", before.AccessKeyId, after.AccessKeyId);
Note(changed, "Secret access key", before.SecretAccessKey, after.SecretAccessKey);
Note(changed, "Region", before.Region, after.Region);
Note(changed, "Endpoint", before.Endpoint, after.Endpoint);
Note(changed, "Path-style addressing", before.UsePathStyle, after.UsePathStyle);
Note(changed, "Notes", before.Notes, after.Notes);
return changed;
}
/// <inheritdoc />
public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
@@ -19,10 +19,14 @@ namespace DodoSSH.Client.Sync;
/// the handshake from an in-memory snapshot.
/// </para>
/// </remarks>
public sealed class KnownHostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
public sealed class KnownHostRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<KnownHostSecret> knownHosts =
new(KnownHostKeyKind.Instance, items, outbox, keyring);
new(KnownHostKeyKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<KnownHostSecret>> ListAsync(
@@ -0,0 +1,68 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The connections this vault has recorded, decrypted, with unpushed local entries laid over them.
/// </summary>
/// <remarks>
/// <para>
/// The seventh facade over the same generic repository, and the first whose <c>UpdateAsync</c> is missing on
/// purpose. A connection log entry is written once, at close, and never edited — see
/// <c>VaultConnectionLogEntry</c> for why that is what makes a synced log tractable at all — so an update
/// method here would be an invitation to break the property the whole design rests on.
/// </para>
/// <para>
/// <see cref="DeleteAsync"/> stays, because retention needs it. It is the only thing that deletes an entry.
/// </para>
/// </remarks>
public sealed class ConnectionLogRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
{
private readonly VaultItemRepository<ConnectionLogSecret> entries =
new(ConnectionLogEntryKind.Instance, items, outbox, keyring);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<ConnectionLogSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
entries.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
ConnectionLogSecret entry,
CancellationToken cancellationToken) =>
entries.CreateAsync(vaultId, entry, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
entries.DeleteAsync(vaultId, entityId, cancellationToken);
}
/// <summary>
/// The keychain changes this vault has recorded, decrypted, with unpushed local entries laid over them.
/// </summary>
/// <inheritdoc cref="ConnectionLogRepository" path="/remarks" />
public sealed class ActivityLogRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
{
private readonly VaultItemRepository<ActivityLogSecret> entries =
new(ActivityLogEntryKind.Instance, items, outbox, keyring);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<ActivityLogSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
entries.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
ActivityLogSecret entry,
CancellationToken cancellationToken) =>
entries.CreateAsync(vaultId, entry, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
entries.DeleteAsync(vaultId, entityId, cancellationToken);
}
@@ -0,0 +1,129 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a bucket into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="CredentialCipher"/> exactly, including the rule that a payload is sealed at the version
/// the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> <c>SyncEntityType.ObjectStore</c> is 13 and
/// <c>CryptoSpec.AadResourceType.ObjectStore</c> is 16, because the crypto enum carries None, User, Device
/// and Vault ahead of the item types and then closed a hole at 12 and 13. Casting one to the other would
/// seal a bucket's keys under the resource type for a <em>host-to-credential association</em> — which
/// encrypts perfectly, decrypts perfectly on the machine that wrote it, and violates docs/crypto.md
/// everywhere else.
/// </para>
/// </remarks>
public static class ObjectStoreCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ObjectStore;
/// <summary>Encrypts a bucket.</summary>
/// <param name="store">The bucket. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
ObjectStoreSecret store,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = ObjectStoreSecretCodec.Encode(store);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// The same reason a credential's buffer is wiped: this one holds a secret access key, which is a
// password by another name and is live on the service it belongs to.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a bucket.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static ObjectStoreSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return ObjectStoreSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,47 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The buckets in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// The eighth facade over the same generic repository, and the pattern has not needed a change since the
/// fourth — which is the point of the item-kind seam. Nothing here is on a transfer's data path: a bucket is
/// read when the file screen's picker is built, and the object-store client is constructed from the result.
/// </remarks>
public sealed class ObjectStoreRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<ObjectStoreSecret> stores =
new(ObjectStoreKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<ObjectStoreSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
stores.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
ObjectStoreSecret store,
CancellationToken cancellationToken) =>
stores.CreateAsync(vaultId, store, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
ObjectStoreSecret store,
CancellationToken cancellationToken) =>
stores.UpdateAsync(vaultId, entityId, store, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
stores.DeleteAsync(vaultId, entityId, cancellationToken);
}
+128
View File
@@ -0,0 +1,128 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a snippet into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostKeyCipher"/> exactly, including the rule that a payload is sealed at the
/// version the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy.</b> <c>SyncEntityType.Snippet</c> is 8 and
/// <c>CryptoSpec.AadResourceType.Snippet</c> is 9 — a difference of one, which is the most dangerous kind,
/// because a cast that is wrong by one still produces a defined value and seals the item under the resource
/// type for a <em>tag</em>. That round-trips on the machine that wrote it and violates docs/crypto.md
/// everywhere else.
/// </para>
/// </remarks>
public static class SnippetCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Snippet;
/// <summary>Encrypts a snippet.</summary>
/// <param name="snippet">The snippet. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
SnippetSecret snippet,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(snippet);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = SnippetSecretCodec.Encode(snippet);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// A snippet is not a credential, and it is closer to one than it looks: people paste tokens into
// commands, and the command that unlocks a service is worth as much as the password it carries.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a snippet.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static SnippetSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return SnippetSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,47 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The snippets in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// The sixth facade over the same generic repository. Nothing here is on the terminal's write path: a snippet
/// is read when the screen that lists them opens, and inserting one hands text to the renderer rather than
/// coming back through the vault.
/// </remarks>
public sealed class SnippetRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<SnippetSecret> snippets =
new(SnippetKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<SnippetSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
snippets.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
SnippetSecret snippet,
CancellationToken cancellationToken) =>
snippets.CreateAsync(vaultId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
SnippetSecret snippet,
CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
}

Some files were not shown because too many files have changed in this diff Show More