Public Access
Merge branch 'claude/gallant-brahmagupta-1f8244'
Writes down that locking the vault leaves shells running, and shows the count on the unlock screen rather than leaving it to be inferred. Conflict resolution: - ShellFlowTests' fixture keeps main's FakeSshConnectionFactory. The branch added an IdleSshConnectionFactory for exactly what main's fake already does — a shell that is open, silent and never closes on its own — so FakeSshConnections.cs is dropped rather than merged, leaving one fake SSH stack in the suite instead of two that would drift apart. - MainWindowViewModel and TerminalWorkspace: both sides added their own members, so both are kept. - TerminalWorkspaceTests was added by both branches, with the renderer gate on one side and session lifetime on the other. Merged into one class over one set of helpers; the gate tests now use FakeConnectionFactory rather than an NSubstitute stub, since the suite already has the fake. gallant's polling Timeout constant is PollTimeout, which no longer reads as the renderer's. - platform-flags.md keeps main's measured focus section and drops the short "nothing hands the terminal keyboard focus" entry the branch still carried, which that section supersedes. One genuine disagreement between the branches, left visible rather than flattened: this branch measured that a collapsed WebView cannot be typed into and attributed it to a hidden WS_CHILD window being ineligible for keyboard focus, while main's focus work measured Win32 focus still held by that hidden window and added a lock path that moves the keyboard off it. Both results stand; the mechanism sentence now defers to the focus entry, which makes the input barrier something the lock path maintains rather than something the platform guarantees. Full suite green, including the container-backed SSH tests.
This commit is contained in:
@@ -25,13 +25,21 @@ zero-knowledge.
|
||||
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
|
||||
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
|
||||
|
||||
Two consequences worth knowing before you read further:
|
||||
Three consequences worth knowing before you read further:
|
||||
|
||||
- **Revocation is not retroactive.** A removed member keeps what they already downloaded. The
|
||||
real remediation is rotating the SSH credential, so offboarding is built around a rotation
|
||||
checklist rather than a button that implies more than it delivers.
|
||||
- **No session recording in relay mode.** The relay forwards SSH ciphertext, so it cannot see
|
||||
commands. That is the cost of the relay not being able to read your traffic.
|
||||
- **Locking the vault does not close your shells.** Lock closes the vault and zeroes every key it
|
||||
held; a session that authenticated before it keeps running, because the remote host never
|
||||
consulted the vault and the credential was already spent. That is deliberate — you lock when you
|
||||
walk away from the machine, which is exactly when a long upgrade or transfer is most likely to be
|
||||
in flight, and an idle auto-lock that killed it would be worse than the exposure it removed. The
|
||||
honest reading is that *locked* describes the vault and not this machine's access to your hosts.
|
||||
The unlock screen therefore shows how many shells are still connected, and quitting DodoSSH is
|
||||
what ends them.
|
||||
|
||||
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
|
||||
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
|
||||
|
||||
@@ -499,5 +499,13 @@ server, its operators, its backups and the network. It does **not** address:
|
||||
- metadata — item counts, sizes, timestamps, access patterns and the sharing graph are
|
||||
visible, as are host addresses for relay-enabled hosts;
|
||||
- a weak passphrase — §2 parameters and passphrase entropy are the whole defence;
|
||||
- **a locked vault on a machine with open sessions** — locking zeroes the identity keys, the vault
|
||||
keys and the cache key, so nothing on disk can be read again without the passphrase. It does not
|
||||
touch an SSH channel that is already open: that channel was authorised at connect time by a
|
||||
credential the remote host verified itself, and no vault key participates in keeping it alive.
|
||||
Sessions therefore survive lock **by design** (the client says so on its unlock screen, and the
|
||||
README explains why), which means a locked client can still hold authenticated access to remote
|
||||
hosts. Ending that is quitting the client, or rotating the credential — the same non-retroactive
|
||||
limit as revocation, one layer down;
|
||||
- supply chain — a server can serve a backdoored client. Sign releases with a key the server
|
||||
does not hold. In a self-hosted E2EE product this is the largest practical hole.
|
||||
|
||||
@@ -158,6 +158,53 @@ headless test renders and focuses correctly and would confirm the wrong belief.
|
||||
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
|
||||
not, and that locking stops the forwarding.
|
||||
|
||||
**The lock/unlock cycle does not resize the pane at all, and the 40 px guard is not what makes it safe.**
|
||||
Measured on Windows with a live shell, against a real `sshd` in a container, in a harness mirroring
|
||||
`MainWindow.axaml`'s `340,*` grid: with the `NativeWebView` collapsed by `IsVisible=false`, the page still
|
||||
reports `paneWidth: 840, paneHeight: 760`, unchanged `cols`/`rows`, and `visibilityState: "visible"`.
|
||||
Hiding is `SetWindowPos(holder, …, SWP_HIDEWINDOW)`, which does not resize the holder, so no
|
||||
`ResizeObserver` callback fires, no fit runs, and **no `window-change` reaches the remote** — before,
|
||||
during or after the cycle. `stty size` on the remote answered `50 118` both before locking and after
|
||||
unlocking, and the renderer's own buffer came back byte for byte, wrapped lines included.
|
||||
|
||||
The guard's irrelevance here was established rather than assumed: the same run with
|
||||
`MINIMUM_FITTABLE_PIXELS` patched to `0` — the guard fully disabled — produced an identical clean result.
|
||||
So the guard is still worth keeping for the paths it was written for, minimising and a splitter dragged to
|
||||
the edge, but it is **not** on the lock path and must not be cited as the reason locking is safe. It was
|
||||
described that way when it landed.
|
||||
|
||||
Two further results from the same harness, both about the deliberate decision that shells outlive a lock
|
||||
(README, `MainWindowViewModel.LockAsync`):
|
||||
|
||||
- **A collapsed WebView is not typed into.** With the harness confirmed as the foreground window and all
|
||||
twelve injected `SendInput` events accepted, not one character of the probe reached the remote pty, and a
|
||||
`Ctrl-U` afterwards answered `BEL` — nothing was sitting in the remote's line editor either. So the lock
|
||||
screen is a real input barrier even though the session behind it is live, and that is what makes
|
||||
surviving the lock defensible rather than merely convenient. The *mechanism* is not what this run
|
||||
concluded: it read the result as a hidden `WS_CHILD` window being ineligible for keyboard focus, but the
|
||||
focus entry above measured Win32 focus still held by the hidden holder window, and the lock path now
|
||||
moves the keyboard off it deliberately. Take the barrier as measured here and the reason from there —
|
||||
which also means the barrier is something the lock path maintains, not something the platform guarantees.
|
||||
- **The session survives the cycle in the real control, not only in tests.** `LiveSessionCount` was 1
|
||||
before, during and after, and the shell accepted a command again immediately on unlock.
|
||||
|
||||
*Suspected, seen once, not reproduced:* on the first run — before the harness learned to wait for the
|
||||
window's scale to settle — the window opened at 2558x1367 px and the page reported a 2202x1328 pane
|
||||
(312x88 characters) for a window 1180 logical units wide, which looks like physical pixels arriving where
|
||||
CSS pixels were expected. A later re-push to 1177x672 then reflowed the wrapped line and split it in two.
|
||||
Both events straddled a DPI settle rather than the lock, and three later runs at `RenderScaling 1.00`
|
||||
never showed it. If a user reports mangled scrollback after moving the window between displays of
|
||||
different scale, start here.
|
||||
|
||||
**WebView2 will not initialise when the host executable sits under a very long path.**
|
||||
`CreateCoreWebView2Environment` fails with `COMException 0x80080005 CO_E_SERVER_EXEC_FAILURE` ("Server
|
||||
execution failed") and the terminal never appears. Hit while building the harness above: the same binary
|
||||
that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not
|
||||
established and the mechanism is unconfirmed — the user data folder is created beside the executable by
|
||||
default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious
|
||||
suspect. Relevant to packaging: an installer that lands under a deep per-user path would break the
|
||||
terminal with an error that names nothing.
|
||||
|
||||
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
||||
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
||||
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
|
||||
|
||||
@@ -139,6 +139,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>
|
||||
/// Shells that were left running when the vault was locked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private int liveSessionCount;
|
||||
|
||||
internal bool HasLiveSessions => LiveSessionCount > 0;
|
||||
|
||||
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
|
||||
internal string LiveSessionSummary => LiveSessionCount == 1
|
||||
? "1 shell is still connected and still running."
|
||||
: $"{LiveSessionCount} shells are still connected and still running.";
|
||||
|
||||
/// <summary>Where the embedded browser should navigate.</summary>
|
||||
internal Uri TerminalPageUrl => workspace.PageUrl;
|
||||
|
||||
@@ -351,7 +367,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Closes the vault and forgets every key it held.</summary>
|
||||
/// <summary>
|
||||
/// Closes the vault and forgets every key it held. Open shells keep running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
|
||||
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
|
||||
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
|
||||
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
|
||||
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
|
||||
/// terminated a running job would be worse than the exposure it removes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
|
||||
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
|
||||
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
|
||||
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
|
||||
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
|
||||
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
|
||||
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
|
||||
/// inferred from a terminal that the lock screen hides.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
|
||||
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
|
||||
/// warning of this kind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task LockAsync()
|
||||
{
|
||||
@@ -361,6 +404,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = "Locked.";
|
||||
}
|
||||
@@ -500,6 +545,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
|
||||
TerminalSessionOpened?.Invoke(this, e);
|
||||
|
||||
partial void OnLiveSessionCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasLiveSessions));
|
||||
OnPropertyChanged(nameof(LiveSessionSummary));
|
||||
}
|
||||
|
||||
partial void OnStateChanged(ShellState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||
<Button Content="Lock" Command="{Binding LockCommand}" />
|
||||
<!--
|
||||
The tooltip carries the policy to the point of action, because the button's name implies
|
||||
the opposite of what it does to a running shell.
|
||||
-->
|
||||
<Button Content="Lock" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Closes the vault and forgets its keys. Open shells keep running and reappear when you unlock." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -319,6 +324,23 @@
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="#1b2432" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="#bcd2ea"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
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." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -175,12 +175,17 @@ function activate(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Below this, a pane is not being looked at — it is minimised, dragged to nothing, or the host has
|
||||
// hidden its window. Fitting anyway would be actively harmful rather than merely useless: the fit addon
|
||||
// floors its proposal at 2 columns by 1 row, so a degenerate viewport reflows the *remote* pty to 2x1
|
||||
// through window-change, and the wrapped scrollback that produces cannot be recovered when the pane comes
|
||||
// back. A guard rather than a fix for one caller, because several paths reach here — a minimised window, a
|
||||
// splitter dragged to the edge, and a host that hides the WebView while the vault is locked.
|
||||
// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
|
||||
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
|
||||
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
|
||||
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
|
||||
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
||||
// once splits land.
|
||||
//
|
||||
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
|
||||
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
|
||||
// does not change, no observer fires and this function is never called — measured with a live shell, and
|
||||
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
|
||||
const MINIMUM_FITTABLE_PIXELS = 40;
|
||||
|
||||
function resize(session, sessionId) {
|
||||
|
||||
@@ -31,10 +31,19 @@ public sealed class TerminalWorkspaceOptions
|
||||
/// Owns the loopback data plane and every live terminal session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One data plane and one renderer page for the whole application, with a session id per terminal.
|
||||
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
|
||||
/// twenty renderer processes and several hundred megabytes for a working set a user would call
|
||||
/// ordinary. Splits and tabs are layout inside the single page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A session's lifetime is the application's, not the vault's.</b> This object is composed once at
|
||||
/// startup and outlives every lock, deliberately: locking the vault zeroes keys, and a shell needs no
|
||||
/// vault key to keep running, so a job started before the lock keeps running through it. That is a
|
||||
/// policy rather than an oversight — <c>MainWindowViewModel.LockAsync</c> says why, and the shell shows
|
||||
/// <see cref="LiveSessionCount"/> on the unlock screen so it is not a hidden state.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
{
|
||||
@@ -69,6 +78,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// <summary>Where the WebView should navigate.</summary>
|
||||
public Uri PageUrl => dataPlane.PageUrl;
|
||||
|
||||
/// <summary>
|
||||
/// How many terminals still have a live shell behind them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
||||
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
||||
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
||||
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
||||
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
||||
/// claims a shell is still running when it is not would be the same class of dishonesty the number
|
||||
/// is there to prevent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int LiveSessionCount => sessions.Values.Count(session => !session.Run.IsCompleted);
|
||||
|
||||
/// <summary>Starts the loopback listener.</summary>
|
||||
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
|
||||
|
||||
@@ -130,7 +158,15 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/// <summary>Closes one terminal.</summary>
|
||||
/// <summary>
|
||||
/// Closes one terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reached only from <see cref="DisposeAsync"/> today, which is a consequence of the lifetime policy
|
||||
/// above rather than an accident: nothing else in the application ends a session, because locking
|
||||
/// deliberately does not and there is no per-tab close in the interface yet. It is here, and tested,
|
||||
/// because closing one terminal without taking the process down is what a tab close needs.
|
||||
/// </remarks>
|
||||
public async Task CloseSessionAsync(uint sessionId)
|
||||
{
|
||||
if (!sessions.Remove(sessionId, out var session))
|
||||
|
||||
@@ -644,6 +644,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.State.ShouldBe(ShellState.Unlocked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deliberate half of what Lock does: the vault closes, the shells do not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a
|
||||
/// person does when they walk away from the machine, and that is exactly when a long job is most
|
||||
/// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock
|
||||
/// would do it unattended. <c>MainWindowViewModel.LockAsync</c> carries the full argument.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The disclosure is asserted along with the behaviour, because the two are the same decision. A
|
||||
/// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while
|
||||
/// authenticated SSH channels stay open is only defensible if it says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
// Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
|
||||
// satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
|
||||
// not how it came to exist, and going through the gate would only add a way for this to fail.
|
||||
await workspace.OpenSessionAsync(
|
||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||
TerminalSize.Default,
|
||||
Token);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Locked);
|
||||
shell.Vault.ShouldBeNull("the vault's keys are gone");
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");
|
||||
|
||||
shell.HasLiveSessions.ShouldBeTrue();
|
||||
shell.LiveSessionCount.ShouldBe(1);
|
||||
shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");
|
||||
|
||||
// And it survives the unlock too, so the session outlives the whole cycle rather than merely
|
||||
// outliving the disposal.
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked);
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockingWithNoOpenShells_DisclosesNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.LiveSessionCount.ShouldBe(0);
|
||||
shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
@@ -101,6 +101,65 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands out <see cref="FakeShellSession"/>s, so a workspace can be driven with no network.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exists for the session-lifetime tests. Everything else in this suite works on a pump directly; the
|
||||
/// workspace is the layer that decides when a session is over, and that decision is what needs a
|
||||
/// connection whose shell can be made to end on cue.
|
||||
/// </remarks>
|
||||
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory
|
||||
{
|
||||
/// <summary>Connections handed out, in order.</summary>
|
||||
internal List<FakeConnection> Connections { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = new FakeConnection(request, bytesPerShell);
|
||||
Connections.Add(connection);
|
||||
|
||||
return Task.FromResult<ISshConnection>(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
|
||||
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
|
||||
/// <summary>The shell this connection opened, if it opened one.</summary>
|
||||
internal FakeShellSession? Shell { get; private set; }
|
||||
|
||||
/// <summary>Whether the connection was disposed, which is what closing a session must do.</summary>
|
||||
internal bool IsDisposed { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
{
|
||||
Shell = new FakeShellSession(bytesPerShell);
|
||||
|
||||
return Task.FromResult<ISshShellSession>(Shell);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
IsDisposed = true;
|
||||
IsConnected = false;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
||||
internal sealed class RecordingTransport : ITerminalTransport
|
||||
{
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The renderer gate: the one place the workspace waits on something outside the process.
|
||||
/// What the workspace waits for outside the process, and how long a session lives once it has one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The gate itself is not in question. <see cref="TerminalDataPlane.SendAsync"/> drops frames when nothing
|
||||
/// is attached, so a session opened before the renderer arrives loses its <c>SessionOpened</c> frame and
|
||||
/// streams output at a terminal that was never created — and that it opens when a renderer does attach is
|
||||
/// covered by <see cref="TerminalDataPlaneTests.TheRenderer_Attaches"/>. What is worth a test here is the
|
||||
/// <para>
|
||||
/// The renderer gate itself is not in question. <see cref="TerminalDataPlane.SendAsync"/> drops frames when
|
||||
/// nothing is attached, so a session opened before the renderer arrives loses its <c>SessionOpened</c> frame
|
||||
/// and streams output at a terminal that was never created — and that it opens when a renderer does attach
|
||||
/// is covered by <see cref="TerminalDataPlaneTests.TheRenderer_Attaches"/>. What is worth a test here is the
|
||||
/// half that used to be missing: waiting for a renderer that never arrives has to end.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The session count is here for a different reason. It is shown to a user on the unlock screen, as the
|
||||
/// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug:
|
||||
/// it either hides a live connection or invents one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TerminalWorkspaceTests
|
||||
{
|
||||
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
||||
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
||||
{
|
||||
@@ -23,7 +31,9 @@ public sealed class TerminalWorkspaceTests
|
||||
// runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this
|
||||
// side they are identical and all look like the listener being up with nothing ever connecting
|
||||
// to it. Before the wait was bounded this test would have hung instead of failing.
|
||||
await using var workspace = CreateWorkspace(TimeSpan.FromMilliseconds(250));
|
||||
await using var workspace = CreateWorkspace(
|
||||
new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250));
|
||||
|
||||
workspace.Start();
|
||||
|
||||
await Should.ThrowAsync<TimeoutException>(async () =>
|
||||
@@ -36,7 +46,9 @@ public sealed class TerminalWorkspaceTests
|
||||
// The timeout is the backstop; the caller's token is what makes a Connect the user gave up on
|
||||
// return at once rather than sitting out the rest of the wait. The timeout here is long enough
|
||||
// that only cancellation can end this.
|
||||
await using var workspace = CreateWorkspace(TimeSpan.FromMinutes(5));
|
||||
await using var workspace = CreateWorkspace(
|
||||
new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5));
|
||||
|
||||
workspace.Start();
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
@@ -47,20 +59,132 @@ public sealed class TerminalWorkspaceTests
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await wait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOpenSessionIsReportedAsLive()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(0);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The connection factory is never reached: every test here stops at the gate, and reaching a real
|
||||
/// host would make this a network test.
|
||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
|
||||
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
|
||||
/// is finished — so counting entries would report a shell that exited as still running. A user
|
||||
/// deciding whether it is safe to walk away is the person that lie is told to.
|
||||
/// </remarks>
|
||||
private static TerminalWorkspace CreateWorkspace(TimeSpan rendererTimeout) =>
|
||||
[Fact]
|
||||
public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive()
|
||||
{
|
||||
// A shell with no output to give: its first read returns 0, which is a remote closing the
|
||||
// channel, so the pump finishes on its own with nobody asking it to.
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.CloseSessionAsync(sessionId);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(0);
|
||||
connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Disposal is the one path that does close sessions, because it is process shutdown. Asserted so
|
||||
/// that the SSH connections are known to be released rather than assumed to be.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DisposingTheWorkspaceClosesEverySession()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
var workspace = CreateWorkspace(connections);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.DisposeAsync();
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(0);
|
||||
connections.Connections.Count.ShouldBe(2);
|
||||
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <param name="connections">
|
||||
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
||||
/// take a fake anyway, because reaching a real host from here would make this a network test.
|
||||
/// </param>
|
||||
/// <param name="rendererTimeout">The gate's bound, or null for the shipped default.</param>
|
||||
private static TerminalWorkspace CreateWorkspace(
|
||||
ISshConnectionFactory connections,
|
||||
TimeSpan? rendererTimeout = null) =>
|
||||
new(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||
{
|
||||
[TerminalDataPlane.PagePath] = new(
|
||||
"text/html; charset=utf-8",
|
||||
Encoding.UTF8.GetBytes("<html><body></body></html>")),
|
||||
}),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
StubAssets(),
|
||||
connections,
|
||||
TimeProvider.System,
|
||||
new TerminalWorkspaceOptions { RendererTimeout = rendererTimeout });
|
||||
rendererTimeout is { } timeout
|
||||
? new TerminalWorkspaceOptions { RendererTimeout = timeout }
|
||||
: null);
|
||||
|
||||
private static InMemoryTerminalAssetProvider StubAssets() =>
|
||||
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||
{
|
||||
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
||||
});
|
||||
|
||||
private static SshConnectionRequest Request() =>
|
||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||
|
||||
/// <remarks>
|
||||
/// Polled rather than awaited on a task, because the point is what an observer of the property
|
||||
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
|
||||
/// telling it to.
|
||||
/// </remarks>
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
var deadline = TimeProvider.System.GetUtcNow() + PollTimeout;
|
||||
|
||||
while (TimeProvider.System.GetUtcNow() < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(20, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
throw new TimeoutException($"The condition was still false after {PollTimeout}.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user