Compare commits

...
2 Commits
Author SHA1 Message Date
jaap-jan 36b6ded63e Merge pull request 'Connections: drop the manual box's view-model state now that both heads ask through the prompt' (#24) from claude/charming-cerf-ae57b6 into main
ci / build and test (push) Successful in 3m46s
ci / api image (push) Successful in 10s
ci / deploy ssh.dodotech.cloud (push) Successful in 12s
ci / android head (push) Successful in 4m28s
ci / desktop nightly (push) Successful in 3m27s
Reviewed-on: #24
2026-10-16 12:32:14 +00:00
jaap-jan fa85ad1eff Connections: drop the manual box's view-model state now that both heads ask through the prompt
ci / build and test (pull_request) Successful in 2m32s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m41s
The phone's Connections screen no longer draws the user@host + password box, and the desktop never
did, so nothing bound VaultViewModel.ManualTarget, ManualPassword, ManualStatus or
ConnectManuallyCommand any more, nor MainWindowViewModel.ConnectToRecentCommand, whose address branch
wrote into that box. They are gone, and so is VaultViewModel.AskAboutHost, whose one caller was
ConnectToRecent. TryParseManualTarget, ManualEndpoint and ConnectToEndpointAsync stay: the prompt, the
desktop's Ctrl+K palette and the phone's hosts search all use them. The reasoning the removed members
carried moved onto ConnectToEndpointAsync and ChoosePromptRecentAsync.

The tests whose subject is still live now go through the prompt instead of the box: a typed address
dialled exactly as typed, port 22 by default, the refusal sentences, the host-key retry replaying the
attempt that raised it, the host-key card staying over the surface it was typed on, the session facts
for a typed password, and no forwards for a typed address. The recent-row cases now drive
ChoosePromptRecentCommand. Two cases existing prompt tests already covered were deleted. Manual check
11.8 now describes recent rows landing on the prompt.
2026-10-16 14:21:10 +02:00
7 changed files with 142 additions and 313 deletions

No files matched your search

+9 -7
View File
@@ -2099,14 +2099,16 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
With at least one keychain host and one machine connected to by hand in the log, open Connections with With at least one keychain host and one machine connected to by hand in the log, open Connections with
nothing running. nothing running.
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready **Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and name would — its password line, or straight into a terminal for a host with a key or a saved password.
leaves the password box **empty**. Neither one dials anything on its own. Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
given.
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should **Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives carrying a host id it should not have. If the password came back, something is storing it — it never was.
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through And if the keychain one shows the logged address rather than the host's name, the host id is not being
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press. matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
### 11.9 The keychain and snippets are the vault's, and back walks out of them ### 11.9 The keychain and snippets are the vault's, and back walks out of them
@@ -170,7 +170,7 @@
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword, PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in. and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in.
--> -->
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}"> <StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
/// <summary>The keychain host this was, if it was one.</summary> /// <summary>The keychain host this was, if it was one.</summary>
/// <remarks> /// <remarks>
/// Null for a connection made to a typed address, and that is a real distinction rather than missing /// Null for a connection made to a typed address, and that is a real distinction rather than missing
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer /// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own /// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
/// authentication behind it, and an address has only the box it was typed into. /// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
/// </remarks> /// </remarks>
internal Guid? HostId => entry.Secret.HostId; internal Guid? HostId => entry.Secret.HostId;
@@ -1857,9 +1857,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// button needs a command. /// button needs a command.
/// </para> /// </para>
/// <para> /// <para>
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — the /// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
/// phone's terminal screen draws its manual box there, and the desktop its prompt; see /// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
/// <see cref="IsConnectPromptShowing"/> — which is worth being able to reach deliberately. /// able to reach deliberately.
/// </para> /// </para>
/// </remarks> /// </remarks>
[RelayCommand] [RelayCommand]
@@ -1946,57 +1946,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasRecentConnections)); OnPropertyChanged(nameof(HasRecentConnections));
} }
/// <summary>Goes back to a machine that has been connected to before.</summary>
/// <remarks>
/// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
/// path that had to answer all of that again.
/// </para>
/// <para>
/// â—† <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
/// </para>
/// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
/// manual path, and a field that filled itself in would be claiming otherwise.
/// </para>
/// <para>
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
/// out — arriving at the hosts screen with nothing selected and no explanation.
/// </para>
/// </remarks>
[RelayCommand]
private void ConnectToRecent(ConnectionLogRowViewModel row)
{
if (row is null || Vault is not { } vault)
{
return;
}
if (row.HostId is { } hostId
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
vault.AskAboutHostCommand.Execute(known);
ShowScreen(ShellScreen.Hosts);
return;
}
vault.ManualTarget = row.Address;
vault.ManualStatus = string.Empty;
}
// ---- The rail's own page grouping ---- // ---- The rail's own page grouping ----
/// <summary> /// <summary>
@@ -2602,10 +2551,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// A click on one of the recent connections printed under the prompt. /// A click on one of the recent connections printed under the prompt.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A row naming a host still in the keychain goes to that host, with whatever the keychain knows about /// <para>
/// it; one that names none — or a host deleted since — is the address that was dialled, and asks for its /// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
/// password the way a typed one does. The same split <see cref="ConnectToRecent"/> makes, and for its /// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
/// reason, except that here the answer is a line of the prompt rather than another screen. /// password, its refusals — exactly as typing its name would. One that names none was typed in as an
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
/// password was never stored, which is the whole point of that path.
/// </para>
/// <para>
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
/// the rest of the application is deliberately not showing.
/// </para>
/// </remarks> /// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)] [RelayCommand(AllowConcurrentExecutions = true)]
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row) private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
@@ -2807,13 +2766,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <para> /// <para>
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a /// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
/// saved host, and a box somebody has just been given the address of had no way in on this head short of /// saved host, and a box somebody has just been given the address of had no way in on this head short of
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already /// saving it first. The query already is a box, and <c>user@host</c> is the grammar people type into
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather /// one, so the palette reads it rather than growing a second field.
/// than growing a second field.
/// </para> /// </para>
/// <para> /// <para>
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so /// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the connect prompt's parser and
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences. /// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses the rest
/// with the same sentences.
/// </para> /// </para>
/// <para> /// <para>
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a /// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
@@ -2884,7 +2843,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary> /// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
/// <remarks> /// <remarks>
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box /// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the /// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
/// last one's password already in the box. /// last one's password already in the box.
/// </remarks> /// </remarks>
@@ -3001,8 +2960,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own /// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the connect prompt's own path,
/// path, so a machine reached from here gets the same tab, the same host-key question and the same /// so a machine reached from here gets the same tab, the same host-key question and the same
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust /// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is /// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
/// exactly the one whose key nobody has seen before. /// exactly the one whose key nobody has seen before.
@@ -4251,48 +4251,21 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsAddButton))] [NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAskingForConnectPassword; private bool isAskingForConnectPassword;
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
/// <remarks>
/// One box rather than four, because this is the form of an address people already have: it is what a
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
/// would make the ordinary case three taps between three keyboards on a phone.
/// </remarks>
[ObservableProperty]
private string manualTarget = string.Empty;
/// <inheritdoc cref="ConnectPassword" />
/// <remarks>
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
/// not a password offered on the other.
/// </remarks>
[ObservableProperty]
private string manualPassword = string.Empty;
/// <summary>Why the manual box refused, if it did.</summary>
/// <remarks>
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
/// the text box, not on a status line that also carries what the sync engine is doing.
/// </remarks>
[ObservableProperty]
private string manualStatus = string.Empty;
/// <summary> /// <summary>
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>. /// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal /// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt on the
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while /// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the /// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so /// always there, the design already calls it "Search or connect…", and <c>user@host</c> is what people
/// it reads the address rather than the screen growing a second field. /// type into a box, so it reads the address rather than the screen growing a second field.
/// </para> /// </para>
/// <para> /// <para>
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an /// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters /// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
/// would arrive on every keystroke. /// three letters would arrive on every keystroke.
/// </para> /// </para>
/// </remarks> /// </remarks>
[ObservableProperty] [ObservableProperty]
@@ -4318,10 +4291,9 @@ internal sealed partial class VaultViewModel(
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary> /// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
/// <remarks> /// <remarks>
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not /// Its own box rather than <see cref="ConnectPassword"/>: these are different screens, and a password
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a /// typed on one is not a password offered on the other. Cleared when the address changes, so the next
/// password offered on the other. Cleared when the address changes, so the next machine typed never /// machine typed never arrives with the last one's password already in the box.
/// arrives with the last one's password already in the box.
/// </remarks> /// </remarks>
[ObservableProperty] [ObservableProperty]
private string filterAddressPassword = string.Empty; private string filterAddressPassword = string.Empty;
@@ -7820,40 +7792,6 @@ internal sealed partial class VaultViewModel(
IsHostPaneOpen = true; IsHostPaneOpen = true;
} }
/// <summary>
/// Raises whatever the head in front of the user uses to ask about one host.
/// </summary>
/// <param name="row">The machine, which the caller is holding.</param>
/// <remarks>
/// <para>
/// â—† <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
/// that is a different piece of state, so a caller that only opened the pane would leave this head
/// arriving at a list with nothing on it to press.
/// </para>
/// <para>
/// Both are set rather than branching on which head is running, because neither view model knows and
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
/// phone has no drawer.
/// </para>
/// <para>
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
/// act both flags exist to tell apart from browsing.
/// </para>
/// </remarks>
[RelayCommand]
private void AskAboutHost(HostRowViewModel? row)
{
if (row is null)
{
return;
}
OpenHostPane(row);
ChooseHost(row);
}
/// <summary> /// <summary>
/// Puts the drawer away, whichever of the three panels is in it. /// Puts the drawer away, whichever of the three panels is in it.
/// </summary> /// </summary>
@@ -11529,62 +11467,13 @@ internal sealed partial class VaultViewModel(
cancellationToken).ConfigureAwait(true); cancellationToken).ConfigureAwait(true);
} }
/// <summary>
/// Opens a terminal on somewhere that is not in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
/// been given the address of.
/// </para>
/// <para>
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
/// a machine reached this way is exactly the one whose key nobody has seen before.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
/// <inheritdoc cref="ConnectManuallyAsync()" />
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
{
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
{
ManualStatus = refusal;
return;
}
if (ManualPassword.Length == 0)
{
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
return;
}
ManualStatus = string.Empty;
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
}
/// <summary> /// <summary>
/// Connects to the address typed into the hosts search box, with the password typed under it. /// Connects to the address typed into the hosts search box, with the password typed under it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log /// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the /// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
/// keychain. /// keychain.
/// </para> /// </para>
/// <para> /// <para>
@@ -11654,14 +11543,27 @@ internal sealed partial class VaultViewModel(
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's /// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into — /// else starts from a keychain item, and that is still the way a host anybody uses twice should be
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands /// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
/// over what it read rather than writing into the phone's fields to have them read back here. /// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
/// been given the address of.
/// </para> /// </para>
/// <para> /// <para>
/// The caller has already refused an unreadable address and an empty password, which is why neither is /// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// checked again: both refusals are sentences about a box, and only the caller knows which box. /// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
/// a machine reached this way is exactly the one whose key nobody has seen before.
/// </para>
/// <para>
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. The caller has
/// already refused an unreadable address and an empty password, which is why neither is checked again:
/// both refusals are sentences about a box, and only the caller knows which box.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal Task ConnectToEndpointAsync( internal Task ConnectToEndpointAsync(
@@ -12375,7 +12277,7 @@ internal sealed partial class VaultViewModel(
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked // Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear. // for must not wait on a vault write to appear.
// //
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing // Only for a target that came from a keychain host. A machine typed in as an address has nothing
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the // to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
// screen it is typed on says so. // screen it is typed on says so.
if (target is { Row: { } row, MayRememberPassword: true }) if (target is { Row: { } row, MayRememberPassword: true })
@@ -12702,7 +12604,7 @@ internal sealed partial class VaultViewModel(
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is /// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
/// reached with a key the item names, and a machine typed into the manual box has neither. /// reached with a key the item names, and a machine typed in as an address has neither.
/// </remarks> /// </remarks>
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null); internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
} }
@@ -152,13 +152,11 @@ public sealed partial class ShellFlowTests
[Fact] [Fact]
public async Task ATypedInAddress_StartsNoForwards() public async Task ATypedInAddress_StartsNoForwards()
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@10.0.0.9"; await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty(); shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
ssh.Forwards.ShouldBeEmpty(); ssh.Forwards.ShouldBeEmpty();
+69 -101
View File
@@ -1040,19 +1040,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// The one connection this application makes to a machine that is not in the keychain. What is worth /// The one connection this application makes to a machine that is not in the keychain. What is worth
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and /// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
/// the port all come out of the one box. /// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
/// them.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task AManualTarget_IsDialledExactlyAsItWasTyped() public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = " deploy@build.internal:2222 "; await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem(); var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal"); request.Host.ShouldBe("build.internal");
@@ -1065,71 +1063,55 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
} }
[Fact] [Fact]
public async Task AManualTargetWithNoPort_TakesTwentyTwo() public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box"; await ConnectAtThePromptAsync("root@box", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22); ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
} }
/// <remarks> /// <remarks>
/// <para>
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which /// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
/// is never a login on anything, so the guess would fail at the remote as "authentication failed" /// is never a login on anything, so the guess would fail at the remote as "authentication failed"
/// rather than here as a sentence about the box that was typed into. /// rather than here as a sentence about the line that was typed into.
/// </para>
/// <para>
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
/// address — which is why their sentences are the prompt's own and not the address parser's.
/// </para>
/// </remarks> /// </remarks>
[Theory] [Theory]
[InlineData("", "Type a machine")] [InlineData("", "Type the name of a saved host")]
[InlineData("build.internal", "Say who to log in as")] [InlineData("build.internal", "No saved host is called")]
[InlineData("deploy@", "Say who to log in as")] [InlineData("deploy@", "Say who to log in as")]
[InlineData("@build.internal", "Say who to log in as")] [InlineData("@build.internal", "Say who to log in as")]
[InlineData("deploy@build.internal:70000", "between 1 and 65535")] [InlineData("deploy@build.internal:70000", "between 1 and 65535")]
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")] [InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
[InlineData("deploy@[fe80::1]", "bracketed IPv6")] [InlineData("deploy@[fe80::1]", "bracketed IPv6")]
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled( public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
string typed, string typed,
string because) string because)
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = typed; shell.ShowTerminalCommand.Execute(null);
vault.ManualPassword = "hunter2"; shell.PromptText = typed;
await vault.ConnectManuallyCommand.ExecuteAsync(null); await shell.SubmitPromptCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain(because); shell.PromptRefusal.ShouldContain(because);
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
ssh.Requests.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it"); shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
} }
/// <remarks>
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
/// </remarks>
[Fact]
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "root@box";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.ManualStatus.ShouldContain("password");
ssh.Requests.ShouldBeEmpty();
}
/// <remarks> /// <remarks>
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c> /// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
/// and for nothing else. A plain search must not grow one on every keystroke. /// and for nothing else. A plain search must not grow one on every keystroke.
@@ -1188,8 +1170,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the /// An empty password is refused under the box rather than sent, as the connect prompt refuses one — and
/// address stays where it was typed, so filling the password in is all that is left to do. /// the address stays where it was typed, so filling the password in is all that is left to do.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress() public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
@@ -1429,8 +1411,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// An empty password is refused on the prompt rather than sent, for the reason the manual box gives: the /// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
/// server's "authentication failed" would be a sentence about the machine when the problem is the box. /// be a sentence about the machine when the problem is the box. A password is the only thing a typed
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
/// copy of.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task ThePrompt_RefusesAnEmptyPassword() public async Task ThePrompt_RefusesAnEmptyPassword()
@@ -1464,6 +1449,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.PromptText.ShouldBe("deploy@build.internal:2222"); shell.PromptText.ShouldBe("deploy@build.internal:2222");
shell.IsPromptAskingForPassword.ShouldBeTrue(); shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:"); shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
shell.PromptPassword.ShouldBeEmpty("the log never held one");
ssh.Requests.ShouldBeEmpty();
} }
/// <remarks> /// <remarks>
@@ -1485,7 +1472,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was /// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a /// the only way to connect; with a typed address it would answer "do you trust this key" by dialling a
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to /// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and /// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
/// the assertion would still catch it. /// the assertion would still catch it.
@@ -1502,10 +1489,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
ssh.Failure = new SshHostKeyUnknownException( ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown")); new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222"; await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue(); vault.HasPendingHostKey.ShouldBeTrue();
@@ -1520,46 +1504,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The /// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar /// keychain half takes the host as typing its name would, so its key, its saved password and its
/// is where its key, its password box and its refusals already live. /// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
/// with nothing dialled yet.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen() public async Task ARecentHostOnThePrompt_TakesThatHost()
{ {
var vault = await ReadyToConnectAsync(); var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0]; var host = vault.Hosts[0];
vault.SelectedHost = null;
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
shell.IsHostsShowing.ShouldBeTrue();
vault.SelectedHost.ShouldBe(host);
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
}
/// <remarks>
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
/// so it goes straight back in — without the password, which was never stored and whose absence is the
/// point of that path rather than a gap in it.
/// </remarks>
[Fact]
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
{
var vault = await ReadyToConnectAsync();
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
// screen the application happens to open on.
shell.ShowTerminalCommand.Execute(null); shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute( await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
vault.ManualTarget.ShouldBe("deploy@build.internal:2222"); shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
vault.ManualPassword.ShouldBeEmpty(); shell.IsPromptAskingForPassword.ShouldBeTrue();
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface"); shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
ssh.Requests.ShouldBeEmpty();
} }
/// <remarks> /// <remarks>
@@ -1567,17 +1530,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// about it, so the address is the honest answer rather than a tap that does nothing. /// about it, so the address is the honest answer rather than a tap that does nothing.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress() public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
shell.ShowTerminalCommand.Execute(null); shell.ShowTerminalCommand.Execute(null);
shell.ConnectToRecentCommand.Execute( await shell.ChoosePromptRecentCommand.ExecuteAsync(
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7())); Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
vault.ManualTarget.ShouldBe("root@prod-db:22"); shell.PromptText.ShouldBe("root@prod-db:22");
shell.IsTerminalSurface.ShouldBeTrue(); shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
ssh.Requests.ShouldBeEmpty();
} }
/// <summary>One row of the connection log, built by hand.</summary> /// <summary>One row of the connection log, built by hand.</summary>
@@ -3599,8 +3563,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
/// <remarks> /// <remarks>
/// The connection with no host, which is the case the old navigation was worst for: it took the box that /// The connection with no host, which is the case the old navigation was worst for: it took the box that
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The /// was typed into away and asked about the machine on a list the machine is deliberately not on. The
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet — /// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
/// and what the user comes back to whichever way they answer. /// what the user comes back to whichever way they answer.
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto() public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
@@ -3609,19 +3573,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
shell.ShowTerminalCommand.Execute(null);
ssh.Failure = new SshHostKeyUnknownException( ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown")); new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
vault.ManualTarget = "deploy@build.internal:2222"; await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
vault.HasPendingHostKey.ShouldBeTrue(); vault.HasPendingHostKey.ShouldBeTrue();
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away"); shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
shell.IsHostKeyDecisionShowing.ShouldBeTrue(); shell.IsHostKeyDecisionShowing.ShouldBeTrue();
} }
@@ -8592,14 +8551,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
[Fact] [Fact]
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity() public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
{ {
var vault = await ReadyToConnectAsync(); await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ManualTarget = "deploy@build.internal:2222"; await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
vault.ManualPassword = "hunter2";
await vault.ConnectManuallyCommand.ExecuteAsync(null);
shell.IsSessionConnected.ShouldBeTrue(); shell.IsSessionConnected.ShouldBeTrue();
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519"); shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
@@ -11399,6 +11355,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
return vault; return vault;
} }
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
private async Task ConnectAtThePromptAsync(string typed, string password)
{
shell.ShowTerminalCommand.Execute(null);
shell.PromptText = typed;
await shell.SubmitPromptCommand.ExecuteAsync(null);
shell.PromptPassword = password;
await shell.SubmitPromptCommand.ExecuteAsync(null);
}
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary> /// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
private async Task<FakeSftpSession> ConnectedTransfersAsync() private async Task<FakeSftpSession> ConnectedTransfersAsync()
{ {