Make a host take what its group lends it, everywhere it is read

Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host
against its groups; nothing asked it to. This is the wiring, and it is mostly
one change repeated: read the resolved host, not the stored one.

TryBuildAuthentication and TryBuildConnectionRequest now take the resolved
value beside the stored one, which is where group context was being lost. It
is the only authentication resolution in the product — both heads and both
transports come through it — so a host inheriting its binding would otherwise
have been offered a password prompt on every screen at once. The
credential-username fallback becomes three levels, and Complete still refuses
an empty username, but now only after the chain has been walked; refusing
before it would refuse exactly the hosts inheritance exists to serve.

HostRowViewModel carries its ResolvedHost, resolved once when the list is
built. Address, Authentication and Dialled read it, so a row cannot disagree
with itself about what it dials — and MainWindowViewModel.Rank searches
Address, so a host inheriting 2222 that displayed 22 would have been
unfindable by the port it actually answers on.

HostsBoundTo counts over the resolved binding, which is the difference between
a warning and a silence: a key bound once on a group and inherited by twenty
hosts named nobody, would have been deleted, and would then have refused all
twenty at connect time.

HostFields.From is answered by a refusal rather than by threading a group list
through the sync engine. A relay host may not inherit its port. The reason is
stronger than the convenience: a plaintext column is a derived duplicate the
client supplies when it pushes *this* host, so an inherited port would make it
depend on another item — editing a group would change what the relay dials for
every host beneath it, except that nothing re-pushes those hosts, so the server
would keep dialling the old port until each was next touched for some unrelated
reason. A stale wire on the relay path connects the user to the wrong service.

The editor distinguishes unset from explicit in both directions. An empty port
box means "take the group's" and shows what that will be as a placeholder,
following the group picker as it moves — a pre-filled 2222 would have been
indistinguishable from one the user typed, and saving would have pinned it. The
authentication picker gains a fourth entry, offered only to a host in a group,
because for an ungrouped host it would behave exactly like the first.

Which found a real defect while the tests were being written. Filing an
ungrouped host into a group silently pinned it to a typed password: the picker
had no "Inherit" entry when it opened, so it sat on "Password (ask each time)",
and saving wrote that as a decision — the host would have been pinned to a
prompt nobody asked for and the group's key would never have reached it. Two
guards now: the picker is rebuilt when the group changes, and BuildHost writes
AsksForPassword only for a host that had the alternative on offer.

The group editor is here too, and the plan never assigned it a step. Without it
no group can carry a default, so every line above would have been unreachable.
It grows a parent picker that leaves out the group itself and everything
beneath it — a courtesy rather than the guarantee, since a cycle assembled from
two offline re-parents was never offered that list — and three defaults beside
the name, each of which may be left empty because "lend nothing" is an answer.

Tags are stored and not editable. TagIds merges, encodes and resolves; no
screen can set one yet, and the editor carries the set through a save untouched
so a client that can set them does not lose them to somebody editing a port.

Eight new tests, and they dial. That is the point of them: a resolved value
that never reaches SshConnectionRequest is a label, and every one of these
failures would be silent — a host connecting to the wrong port, or being asked
for a password it does not need, with nothing on screen admitting it.

Verified by the whole suite: 1390 tests over nineteen projects, none failing.
Both heads build. Nothing on the phone has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:41:28 +02:00
co-authored by Claude Opus 5
parent 8c04ba60b0
commit b7335743d9
9 changed files with 913 additions and 71 deletions
+15 -5
View File
@@ -4,17 +4,27 @@ The phone can read a keychain and connect through it. It cannot put anything in
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
ordered work, and the traps that are already known. ordered work, and the traps that are already known.
> **Status: steps 13 built, 46 not started.** The domain is done and the phone has not been touched. Each > **Status: steps 14 built, 56 not started.** The domain and the desktop are done and the phone has not
> built step compiles with the whole suite green, which is the rule the ordering below sets. > been touched. Each built step compiles with the whole suite green, which is the rule the ordering below
> sets.
> >
> | Step | State | Notes | > | Step | State | Notes |
> | --- | --- | --- | > | --- | --- | --- |
> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. | > | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. | > | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. | > | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
> | 4. The shared view model | Not started | The five port call sites already route through the resolver; the rest of the list below does not. | > | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
> | 5. The phone | Not started | | > | 5. The phone | Not started | Nothing in `Views/HostsScreen.axaml`, `Theme/Phone.axaml` or `PhoneShell` has changed. |
> | 6. Tests and false prose | Partly done as it went | The guards steps 13 tripped are fixed. `docs/design-import-gaps.md` and the three phone files are untouched. | > | 6. Tests and false prose | Partly done as it went | The guards each step tripped are fixed and the connect path is covered. `docs/design-import-gaps.md` and the three phone files still say the `+` is deliberately absent. |
>
> **Tags are stored but not editable.** `TagIds` merges, encodes and resolves, and no screen can put one on a
> host yet — the desktop editor carries the set through a save untouched so that a client which can set them
> does not lose them. The chips belong with the phone's editors in step 5.
>
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
> derived from a *different* item goes stale when that item is edited and nothing re-pushes the hosts beneath
> it, so the relay would keep dialling the old port — which is worse than the restriction.
> >
> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not > **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer, > three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
@@ -177,10 +177,17 @@
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" /> <TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" /> <TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<!--
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset". The
watermark is what the host will actually use if it is left that way, which is why it is bound
rather than literal: it changes when the group picker below moves.
-->
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535" <NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" /> FormatString="0" ShowButtonSpinner="False"
<TextBox Grid.Column="2" Text="{Binding EditorUsername}" PlaceholderText="username" /> PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
</Grid> </Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True" <TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" /> Height="48" TextWrapping="Wrap" />
+47 -2
View File
@@ -224,14 +224,59 @@
</ListBox> </ListBox>
</ScrollViewer> </ScrollViewer>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}"> <!--
A group is no longer only a name, so this is no longer only a box. The four fields under the
name are what the hosts inside inherit when they say nothing themselves; every one of them may
be left empty, and empty means "lend nothing" rather than "unset". The parent picker leaves out
this group and everything beneath it, so a cycle cannot be made here — which is a courtesy
rather than the guarantee, because one assembled offline on two machines was never offered this
list. See HostInheritance.
-->
<StackPanel Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180" <TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" /> FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorParentChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedParent}"
MinWidth="150" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<NumericUpDown Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" PlaceholderText="default port"
Width="120" FontSize="11" MinHeight="26" />
<TextBox Text="{Binding Vault.GroupEditorDefaultUsername}"
PlaceholderText="default username" Width="150"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}"
MinWidth="180" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}" <Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" /> Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" /> <Button Classes="ghost" Content="EDIT SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" /> <Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel> </StackPanel>
</StackPanel>
<!-- <!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
+20
View File
@@ -229,6 +229,20 @@ public sealed record HostSecret : IVaultSecret
/// The plaintext copy is derived from this, in one place, so the address can only ever leave the /// The plaintext copy is derived from this, in one place, so the address can only ever leave the
/// payload as a consequence of the user turning this on. See ADR 0004. /// payload as a consequence of the user turning this on. See ADR 0004.
/// </para> /// </para>
/// <para>
/// <b>A relay host may not inherit its port</b>, and <see cref="TryValidate"/> refuses one that tries.
/// The reason is what a plaintext column is: a derived duplicate the client supplies when it pushes
/// <em>this</em> host. An inherited port would make that duplicate depend on a different item, so
/// editing a group would silently change what the relay dials for every host beneath it — except that
/// nothing re-pushes those hosts, so the server would keep dialling the old port until each was next
/// touched for some unrelated reason. Copying a value that can change elsewhere into a column nothing
/// refreshes is a stale wire, and a stale wire on the relay path connects the user to the wrong service.
/// </para>
/// <para>
/// The cost is one field on the hosts that opt into the relay, which is the same set of hosts already
/// opting to publish their address. It is what lets the plaintext columns be derived from a
/// <see cref="HostSecret"/> alone, in one place, with no group in reach — see <c>HostFields</c>.
/// </para>
/// </remarks> /// </remarks>
public bool RelayEnabled { get; init; } public bool RelayEnabled { get; init; }
@@ -322,6 +336,12 @@ public sealed record HostSecret : IVaultSecret
return false; return false;
} }
if (RelayEnabled && Port is null)
{
reason = "A host dialled through the relay needs a port of its own, not its group's.";
return false;
}
reason = null; reason = null;
return true; return true;
} }
@@ -214,10 +214,30 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer /// 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. /// to import a duplicate of something already stored under another name.
/// </remarks> /// </remarks>
/// <summary>
/// Whether this block describes a machine the vault already has.
/// </summary>
/// <remarks>
/// <para>
/// <b>Compared against the resolved host, not the stored one.</b> A stored host that takes its port and
/// username from its group is the same machine as an imported block naming them outright — and comparing
/// the stored fields would leave it unmatched, so the import screen would offer to add a duplicate of
/// every host that inherits anything. Duplicates offered by a screen whose whole job is to say what is
/// new are worse than a missed match: they get accepted.
/// </para>
/// <para>
/// <b>An imported block with no <c>Port</c> still pins 22 rather than inheriting</b>, which
/// <c>SshConfigResolver</c> already does and this deliberately leaves alone. Nothing imported is filed
/// into a group — there is no group picker here — so an inherited port would resolve to 22 anyway, and
/// the two would differ only in which of them a later edit to some group could change underneath the
/// user. An absent <c>Port</c> in an ssh_config means 22; storing that is the faithful reading.
/// </para>
/// </remarks>
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing => private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase) string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
&& existing.Host.Port == host.Port && existing.Resolved.Port.Value == host.Port
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase)); && string.Equals(
existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase));
private void RaiseListState() private void RaiseListState()
{ {
@@ -419,8 +419,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal bool HasHostKeyMismatch => HostKeyMismatch is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
/// <summary>Whether the chosen host will want something typed into the password box.</summary> /// <summary>Whether the chosen host will want something typed into the password box.</summary>
/// <remarks>
/// The resolved binding, for the reason its counterpart on <c>VaultViewModel</c> gives: a host naming
/// neither a key nor a credential used to mean "type one" and now means "take the group's". This screen
/// has its own password box and so needs its own copy of the question — but it must give the same
/// answer, or one screen would ask for a password the other knew was not wanted.
/// </remarks>
internal bool SelectedHostAsksForAPassword => internal bool SelectedHostAsksForAPassword =>
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } }; ShowsHostPicker
&& (SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
// ---- The remote pane ---- // ---- The remote pane ----
@@ -136,6 +136,7 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
/// </remarks> /// </remarks>
internal sealed partial class HostRowViewModel( internal sealed partial class HostRowViewModel(
VaultItem<HostSecret> host, VaultItem<HostSecret> host,
ResolvedHost resolved,
Guid vaultId, Guid vaultId,
string vaultName) : ObservableObject, ISidebarRow string vaultName) : ObservableObject, ISidebarRow
{ {
@@ -170,11 +171,38 @@ internal sealed partial class HostRowViewModel(
internal HostSecret Host => host.Secret; internal HostSecret Host => host.Secret;
/// <summary>
/// The same host with its group chain applied: what it dials, not what was typed into it.
/// </summary>
/// <remarks>
/// Resolved once, when the list is built, rather than per property. Every label on this row wants the
/// same three answers, and re-walking the chain for each of them would be a walk per property per redraw.
/// It also means a row cannot disagree with itself — the address the list shows and the port the connect
/// command dials come from one value.
/// </remarks>
internal ResolvedHost Resolved => resolved;
internal string Label => host.Secret.Label; internal string Label => host.Secret.Label;
/// <summary>
/// What this row dials, as one string.
/// </summary>
/// <remarks>
/// The <em>resolved</em> address, which is not decoration: <c>MainWindowViewModel.Rank</c> searches this,
/// so a host inheriting 2222 that displayed 22 would be unfindable by the port it actually answers on —
/// and the user would be searching for the number the machine really uses.
/// </remarks>
internal string Address => string.Create( internal string Address => string.Create(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"{host.Secret.Username ?? ""}@{host.Secret.Hostname}:{host.Secret.Port}"); $"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
/// <remarks>
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
/// with a message asking for one.
/// </remarks>
private string DisplayUsername =>
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges; internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
@@ -184,15 +212,24 @@ internal sealed partial class HostRowViewModel(
/// <summary>How this host authenticates, in one word.</summary> /// <summary>How this host authenticates, in one word.</summary>
/// <remarks> /// <remarks>
/// <para>
/// Worth a word in the list because the three behave differently at the moment of connecting: only one of /// Worth a word in the list because the three behave differently at the moment of connecting: only one of
/// them needs the password box filled in, and a user staring at an empty password box on a /// them needs the password box filled in, and a user staring at an empty password box on a
/// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which /// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which
/// is why the stored kind is "credential" rather than a second sort of password. /// is why the stored kind is "credential" rather than a second sort of password.
/// </para>
/// <para>
/// Read from the resolved binding rather than the host's own two ids, because the question this answers
/// is "will the password box be used" — and a host that inherits its group's key would otherwise say
/// "password" while quietly not needing one. Where the binding came from a group the word is the same;
/// the group is named in <see cref="VaultViewModel.SelectedHostAuthenticationNote"/>, which has room for
/// a sentence rather than a word.
/// </para>
/// </remarks> /// </remarks>
internal string Authentication => host.Secret switch internal string Authentication => resolved.Binding.Kind switch
{ {
{ CredentialId: not null } => "credential", ResolvedBindingKind.Credential => "credential",
{ SshKeyId: not null } => "key", ResolvedBindingKind.SshKey => "key",
_ => "password", _ => "password",
}; };
@@ -229,6 +266,17 @@ internal enum AuthenticationKind
/// <summary>A username and password in this vault.</summary> /// <summary>A username and password in this vault.</summary>
Credential, Credential,
/// <summary>
/// Whatever the group above this host says, or a typed password if it says nothing.
/// </summary>
/// <remarks>
/// The entry that arrived with inheritance, and the reason <see cref="HostSecret.AsksForPassword"/>
/// exists. Naming neither a key nor a credential used to be the way to say "type one each time"; it now
/// means this, so the old meaning needs a value of its own — otherwise a host under a group that binds a
/// key could not opt out of it.
/// </remarks>
Inherited,
} }
/// <summary>An entry in the host editor's authentication picker.</summary> /// <summary>An entry in the host editor's authentication picker.</summary>
@@ -270,6 +318,26 @@ internal sealed record AuthenticationChoice(
internal static AuthenticationChoice Typed { get; } = internal static AuthenticationChoice Typed { get; } =
new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty); new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty);
/// <summary>The "whatever the group says" entry, offered only to a host that is in one.</summary>
/// <remarks>
/// Named for where the answer comes from rather than for what it will be, because what it will be
/// changes when somebody edits the group — which is the point of it. It is deliberately not offered to
/// an ungrouped host: with nothing above it this and <see cref="Typed"/> do the same thing, and two
/// entries that behave identically are two entries a user has to guess between.
/// </remarks>
internal static AuthenticationChoice Inherited { get; } =
new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty);
/// <summary>The "this group lends no binding" entry, first in the group editor's picker.</summary>
/// <remarks>
/// A sentinel rather than a null selection, for the reason <see cref="Typed"/> is one: a picker showing
/// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is
/// not <see cref="Typed"/> under another name — a group cannot assert "everything under here types its
/// password", because that is already what a host gets when the chain lends nothing.
/// </remarks>
internal static AuthenticationChoice NoDefault { get; } =
new(AuthenticationKind.Typed, null, "No default binding", string.Empty);
/// <summary>An SSH key that is in the vault.</summary> /// <summary>An SSH key that is in the vault.</summary>
internal static AuthenticationChoice ForKey(Guid entityId, string label) => internal static AuthenticationChoice ForKey(Guid entityId, string label) =>
new(AuthenticationKind.SshKey, entityId, label, "SSH key"); new(AuthenticationKind.SshKey, entityId, label, "SSH key");
@@ -812,8 +880,7 @@ internal sealed partial class VaultViewModel(
/// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host /// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
/// would be one allocation per row per redraw. /// would be one allocation per row per redraw.
/// </remarks> /// </remarks>
private IReadOnlyDictionary<Guid, HostGroupSecret> groupsById = private Dictionary<Guid, HostGroupSecret> groupsById = [];
new Dictionary<Guid, HostGroupSecret>();
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary> /// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
private readonly HashSet<Guid> collapsedGroups = []; private readonly HashSet<Guid> collapsedGroups = [];
@@ -976,6 +1043,43 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private string groupEditorLabel = string.Empty; private string groupEditorLabel = string.Empty;
/// <summary>
/// The port hosts in this group take when they state none, empty for no default.
/// </summary>
/// <remarks>
/// Empty is a real answer here rather than a missing one: a group that states no port lends none, and
/// the hosts beneath it walk further up. Distinguishing that from 22 is the difference between "these
/// machines are on 22" and "these machines have not been told".
/// </remarks>
[ObservableProperty]
private int? groupEditorDefaultPort;
/// <summary>The user hosts in this group log in as when they state none.</summary>
/// <inheritdoc cref="GroupEditorDefaultPort" path="/remarks" />
[ObservableProperty]
private string groupEditorDefaultUsername = string.Empty;
/// <summary>
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
/// </summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
[ObservableProperty]
private GroupChoice? groupEditorSelectedParent;
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
/// <remarks>
/// The host picker's list without its first entry. A group cannot default to "ask for a password each
/// time": that is what a host beneath it gets when nothing in the chain lends a binding, so the entry
/// would be indistinguishable from leaving this alone — and two controls that do the same thing is one
/// control and a guess. "No default" is the sentinel instead.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> GroupEditorAuthenticationChoices { get; } = [];
[ObservableProperty]
private AuthenticationChoice? groupEditorSelectedAuthentication;
/// <summary>The group being renamed, or null when the box would create one.</summary> /// <summary>The group being renamed, or null when the box would create one.</summary>
[ObservableProperty] [ObservableProperty]
private Guid? editingGroupId; private Guid? editingGroupId;
@@ -1176,8 +1280,18 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private string editorHostname = string.Empty; private string editorHostname = string.Empty;
/// <summary>
/// The port box, empty when the host is to take its group's.
/// </summary>
/// <remarks>
/// Nullable rather than defaulting to 22, which is the difference between a form that states a port and
/// one that leaves it to the group. An empty box shows <see cref="EditorPortPlaceholder"/>, so the form
/// says what leaving it blank will get you rather than making the user guess — and a new host under a
/// group that says 2222 is created wanting 2222, without anybody typing it.
/// </remarks>
[ObservableProperty] [ObservableProperty]
private int editorPort = HostSecret.DefaultPort; [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
private int? editorPort;
[ObservableProperty] [ObservableProperty]
private string editorUsername = string.Empty; private string editorUsername = string.Empty;
@@ -1207,8 +1321,92 @@ internal sealed partial class VaultViewModel(
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = []; internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
private GroupChoice? editorSelectedGroup; private GroupChoice? editorSelectedGroup;
/// <summary>
/// Keeps the authentication picker in step with whether there is a group to inherit from.
/// </summary>
/// <remarks>
/// <para>
/// <b>Filing a host into a group must not pin it to a typed password, and without this it did.</b> An
/// ungrouped host is offered no "Inherit from group" entry — with nothing above it the two entries would
/// behave identically — so its picker sits on "Password (ask each time)". Choose a group and save, and
/// that selection would be written as <see cref="HostSecret.AsksForPassword"/>: the host would be pinned
/// to a prompt it never asked for, by a user who was only filing it, and the group's key would never
/// reach it.
/// </para>
/// <para>
/// The selection moves to "Inherit from group" rather than staying, because for an ungrouped host the
/// two are the same thing and the stored value was "nothing stated". Reading a deliberate refusal into
/// a choice the user could not have made differently would be inventing an intent; inheriting is what
/// the record already said.
/// </para>
/// </remarks>
partial void OnEditorSelectedGroupChanged(GroupChoice? value)
{
var grouped = value?.EntityId is not null;
if (grouped == EditorAuthenticationChoices.Contains(AuthenticationChoice.Inherited))
{
return;
}
var selected = EditorSelectedAuthentication;
BuildAuthenticationChoices(
Bound(AuthenticationKind.SshKey),
Bound(AuthenticationKind.Credential),
asksForPassword: !grouped && selected?.Kind == AuthenticationKind.Typed,
grouped);
}
/// <summary>What an empty port box will dial.</summary>
/// <remarks>
/// <para>
/// Recomputed when the group picker moves, which is the whole point of it being a placeholder rather
/// than a pre-filled value: filing a host into a group is supposed to visibly change what leaving the
/// box empty means. A pre-filled 2222 would have been indistinguishable from a port the user typed, and
/// saving it would have pinned it.
/// </para>
/// <para>
/// Reads the group chain from the picker's current selection rather than from the host being edited,
/// because the two differ for exactly as long as the editor is open and unsaved — which is when this is
/// read.
/// </para>
/// </remarks>
internal string EditorPortPlaceholder =>
InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture))
?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture);
/// <summary>What an empty username box will log in as, or a prompt when nothing supplies one.</summary>
/// <inheritdoc cref="EditorPortPlaceholder" path="/remarks" />
internal string EditorUsernamePlaceholder =>
InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username";
/// <remarks>
/// Walks through <see cref="HostInheritance.Chain"/> rather than looking only at the selected group, so
/// a placeholder shows the value that will actually be used — which may come from three levels up — and
/// so that a cycle assembled elsewhere cannot hang the editor either.
/// </remarks>
private string? InheritedFromEditorGroup(Func<HostGroupSecret, string?> read) =>
HostInheritance
.Chain(EditorSelectedGroup?.EntityId, groupsById)
.Select(entry => read(entry.Group))
.FirstOrDefault(value => value is not null);
/// <summary>
/// The tags the host being edited already wears, carried through a save untouched.
/// </summary>
/// <remarks>
/// Not a box on this head — neither head can put a tag on a host yet — and held anyway, because
/// <see cref="BuildHost"/> rebuilds the whole record from the editor's state. Left out, editing a port
/// would strip every tag a teammate had added from a client that can set them, which is the same class
/// of silent loss that <c>HostSecretDocument.IsReadOnly</c> exists to prevent.
/// </remarks>
private TagSet editorTagIds = TagSet.Empty;
/// <summary>The item being edited, or null when creating.</summary> /// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId; private Guid? editingEntityId;
@@ -1447,29 +1645,57 @@ internal sealed partial class VaultViewModel(
/// Whether the selected host will want something typed into the password box. /// Whether the selected host will want something typed into the password box.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// True with nothing selected, which is deliberate: the box is the resting state of that corner of the /// True with nothing selected, which is deliberate: the box is the resting state of that corner of the
/// window, and an empty terminal column with no password box in it reads as a column that is still /// window, and an empty terminal column with no password box in it reads as a column that is still
/// loading. /// loading.
/// </para>
/// <para>
/// The resolved binding, not the host's own two ids. A host that names neither used to mean "type one",
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
/// every host under a group that binds a key, and the box would do nothing.
/// </para>
/// </remarks> /// </remarks>
internal bool SelectedHostAsksForAPassword => internal bool SelectedHostAsksForAPassword =>
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } }; SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
/// <summary> /// <summary>
/// What the terminal column says in place of the password box, or nothing when the box is showing. /// What the terminal column says in place of the password box, or nothing when the box is showing.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs /// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs
/// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two /// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two
/// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this /// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this
/// said nothing, and a key cannot. /// said nothing, and a key cannot.
/// </para>
/// <para>
/// <b>An inherited binding says so.</b> This is the one place with room for the sentence, and it is worth
/// spending: a user looking at a host that says nothing about keys, being told it authenticates with one,
/// would go looking in the wrong editor. Naming the group points at the record that can actually be
/// changed.
/// </para>
/// </remarks> /// </remarks>
internal string SelectedHostAuthenticationNote => SelectedHost?.Host switch internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
{ {
{ CredentialId: not null } => "This host uses a password stored in your keychain.", { Kind: ResolvedBindingKind.Credential } binding =>
{ SshKeyId: not null } => "This host authenticates with its SSH key.", $"This host uses a password stored in your keychain{From(binding)}.",
{ Kind: ResolvedBindingKind.SshKey } binding =>
$"This host authenticates with an SSH key{From(binding)}.",
_ => string.Empty, _ => string.Empty,
}; };
/// <remarks>
/// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
/// search. A group that has since been deleted leaves the binding dangling, which the connect path
/// reports on its own — this only has to avoid claiming a name it cannot read.
/// </remarks>
private string From(ResolvedBinding binding) =>
binding.FromGroupId is { } groupId && groupsById.TryGetValue(groupId, out var group)
? $", from the group {group.Label}"
: string.Empty;
[ObservableProperty] [ObservableProperty]
private HostKeyPresentation? pendingHostKey; private HostKeyPresentation? pendingHostKey;
@@ -1641,8 +1867,10 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable; unreadable += listing.Unreadable;
// Resolved here, which is why ReloadGroupsAsync runs before this: a host resolved against a
// stale group list would show one port and dial another.
rows.AddRange(listing.Items.Select( rows.AddRange(listing.Items.Select(
item => new HostRowViewModel(item, vault.VaultId, vault.Name) item => new HostRowViewModel(item, Resolve(item.Secret), vault.VaultId, vault.Name)
{ {
// Only when there is something to tell apart. A badge on every row of a // Only when there is something to tell apart. A badge on every row of a
// single-vault list is noise that says the same thing on all of them. // single-vault list is noise that says the same thing on all of them.
@@ -2518,15 +2746,25 @@ internal sealed partial class VaultViewModel(
editingHostVaultId = TargetVaultId; editingHostVaultId = TargetVaultId;
EditorLabel = string.Empty; EditorLabel = string.Empty;
EditorHostname = string.Empty; EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
// Empty rather than 22, so a new host under a group that says 2222 is created wanting 2222 without
// anybody typing it — and one under no group still dials 22, because that is where the chain ends.
EditorPort = null;
EditorUsername = string.Empty; EditorUsername = string.Empty;
EditorNotes = string.Empty; EditorNotes = string.Empty;
EditorRelayEnabled = false; EditorRelayEnabled = false;
BuildAuthenticationChoices(boundKeyId: null, boundCredentialId: null); editorTagIds = TagSet.Empty;
// A new host opens in whichever group is selected beside the list, if one is, because adding three // A new host opens in whichever group is selected beside the list, if one is, because adding three
// machines to the group somebody has just made is the ordinary case. // machines to the group somebody has just made is the ordinary case. Before the picker, because
// whether there is a group to inherit from decides whether the picker offers to.
BuildGroupChoices(SelectedGroup?.EntityId); BuildGroupChoices(SelectedGroup?.EntityId);
BuildAuthenticationChoices(
boundKeyId: null,
boundCredentialId: null,
asksForPassword: false,
grouped: EditorSelectedGroup?.EntityId is not null);
IsEditing = true; IsEditing = true;
Status = "Adding a host."; Status = "Adding a host.";
} }
@@ -2552,15 +2790,24 @@ internal sealed partial class VaultViewModel(
editingHostVaultId = row.VaultId; editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label; EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname; EditorHostname = row.Host.Hostname;
// The resolved port rather than the stored one, so a host that inherits opens showing what it
// actually dials rather than an empty box. The editor cannot yet express "leave this to the group", // The stored port, not the resolved one, and the difference is the whole feature: an inheriting host
// so saving pins whatever is shown — which is what it did before any of this existed. // opens with an empty box showing its group's value as a placeholder. Loading the resolved value
EditorPort = Resolve(row.Host).Port.Value; // instead would fill the box, and saving would then pin what the host had deliberately left open.
EditorPort = row.Host.Port;
EditorUsername = row.Host.Username ?? string.Empty; EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled; EditorRelayEnabled = row.Host.RelayEnabled;
BuildAuthenticationChoices(row.Host.SshKeyId, row.Host.CredentialId); editorTagIds = row.Host.TagIds;
BuildGroupChoices(row.Host.GroupId); BuildGroupChoices(row.Host.GroupId);
BuildAuthenticationChoices(
row.Host.SshKeyId,
row.Host.CredentialId,
row.Host.AsksForPassword is true,
grouped: row.Host.GroupId is not null);
IsEditing = true; IsEditing = true;
Status = $"Editing {row.Label}."; Status = $"Editing {row.Label}.";
} }
@@ -2645,7 +2892,17 @@ internal sealed partial class VaultViewModel(
[RelayCommand] [RelayCommand]
private async Task SaveGroupAsync(CancellationToken cancellationToken) private async Task SaveGroupAsync(CancellationToken cancellationToken)
{ {
var group = new HostGroupSecret { Label = GroupEditorLabel.Trim() }; var group = new HostGroupSecret
{
Label = GroupEditorLabel.Trim(),
ParentId = GroupEditorSelectedParent?.EntityId,
DefaultPort = GroupEditorDefaultPort,
DefaultUsername = string.IsNullOrWhiteSpace(GroupEditorDefaultUsername)
? null
: GroupEditorDefaultUsername.Trim(),
DefaultSshKeyId = GroupBound(AuthenticationKind.SshKey),
DefaultCredentialId = GroupBound(AuthenticationKind.Credential),
};
if (!group.TryValidate(out var reason)) if (!group.TryValidate(out var reason))
{ {
@@ -2672,12 +2929,11 @@ internal sealed partial class VaultViewModel(
.ConfigureAwait(true); .ConfigureAwait(true);
} }
GroupEditorLabel = string.Empty; ClearGroupEditor();
EditingGroupId = null;
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Renamed to '{group.Label}'."; Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{group.Label}'.";
}).ConfigureAwait(true); }).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
@@ -2700,15 +2956,137 @@ internal sealed partial class VaultViewModel(
EditingGroupId = row.EntityId; EditingGroupId = row.EntityId;
GroupEditorLabel = row.Label; GroupEditorLabel = row.Label;
Status = $"Renaming {row.Label}."; GroupEditorDefaultPort = row.Group.DefaultPort;
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
Status = $"Editing {row.Label}.";
}
/// <summary>The group picker's selection, if it names something of this kind.</summary>
private Guid? GroupBound(AuthenticationKind kind) =>
GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
/// <summary>
/// Fills the parent picker, leaving out the group itself and everything beneath it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Refusing a descendant here is a convenience, not the guarantee.</b> It stops a cycle being made on
/// this machine, which is worth doing because the alternative is a user watching their own sidebar go
/// flat. What it cannot stop is a cycle assembled from two offline re-parents on two machines, neither
/// of which was ever offered this list — so the walk itself carries a visited set. See
/// <see cref="HostInheritance"/>.
/// </para>
/// <para>
/// Descendants are found by walking each candidate <em>upwards</em> rather than this group downwards,
/// because upwards is the direction the pointer goes and <see cref="HostInheritance.Chain"/> already
/// terminates on a cycle. Walking down would need a child index this view model does not keep, and
/// building one that has to survive a cycle is the same problem twice.
/// </para>
/// </remarks>
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
{
GroupEditorParentChoices.Clear();
GroupEditorParentChoices.Add(GroupChoice.None);
foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
{
var descends = HostInheritance
.Chain(candidate.EntityId, groupsById)
.Any(entry => entry.Id == groupId);
if (!descends)
{
GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
}
}
// A parent that is no longer selectable keeps a placeholder, so that editing a group's default port
// cannot unparent it as a side effect — the same reason the host's group picker keeps one.
if (parentId is { } bound && !GroupEditorParentChoices.Any(choice => choice.EntityId == bound))
{
GroupEditorParentChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
}
GroupEditorSelectedParent =
GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == parentId)
?? GroupChoice.None;
}
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
/// <remarks>
/// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
/// one. A group defaults to a key, to a credential, or to nothing — and "nothing" is the sentinel first
/// entry, because a picker with no selection and a group that deliberately lends no binding look
/// identical and are not the same thing.
/// </remarks>
private void BuildGroupAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId)
{
GroupEditorAuthenticationChoices.Clear();
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.NoDefault);
foreach (var key in Keys)
{
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
}
foreach (var credential in Credentials)
{
GroupEditorAuthenticationChoices.Add(
AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
}
AddMissingGroupBinding(AuthenticationKind.SshKey, boundKeyId);
AddMissingGroupBinding(AuthenticationKind.Credential, boundCredentialId);
GroupEditorSelectedAuthentication = (boundKeyId, boundCredentialId) switch
{
({ } key, _) => FindGroupBinding(AuthenticationKind.SshKey, key),
(_, { } credential) => FindGroupBinding(AuthenticationKind.Credential, credential),
_ => AuthenticationChoice.NoDefault,
};
}
private void AddMissingGroupBinding(AuthenticationKind kind, Guid? boundId)
{
if (boundId is { } bound
&& !GroupEditorAuthenticationChoices.Any(
choice => choice.Kind == kind && choice.EntityId == bound))
{
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
}
}
private AuthenticationChoice FindGroupBinding(AuthenticationKind kind, Guid entityId) =>
GroupEditorAuthenticationChoices
.FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
?? AuthenticationChoice.NoDefault;
/// <summary>Empties every box in the group editor, so the next open starts from nothing.</summary>
/// <remarks>
/// One method rather than a line per field at each of the three places that clear it. A group editor
/// left holding the last group's default key would lend it to the next group somebody created without
/// anybody choosing it, which is the quiet kind of wrong.
/// </remarks>
private void ClearGroupEditor()
{
EditingGroupId = null;
GroupEditorLabel = string.Empty;
GroupEditorDefaultPort = null;
GroupEditorDefaultUsername = string.Empty;
BuildGroupParentChoices(Guid.Empty, parentId: null);
BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
} }
/// <summary>Abandons a rename, leaving the box ready to create one instead.</summary> /// <summary>Abandons a rename, leaving the box ready to create one instead.</summary>
[RelayCommand] [RelayCommand]
private void CancelGroupEdit() private void CancelGroupEdit()
{ {
EditingGroupId = null; ClearGroupEditor();
GroupEditorLabel = string.Empty;
Status = string.Empty; Status = string.Empty;
} }
@@ -3193,7 +3571,7 @@ internal sealed partial class VaultViewModel(
$"Delete the SSH key '{row.Label}'?", $"Delete the SSH key '{row.Label}'?",
HowFarADeletionGoes("The private key, its passphrase and everything saved with them") HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
+ " If this key is not on disk anywhere else, this is the only copy.", + " If this key is not on disk anywhere else, this is the only copy.",
HostsBoundTo(host => host.SshKeyId, row.EntityId)); HostsBoundTo(ResolvedBindingKind.SshKey, row.EntityId));
} }
/// <summary>Queues a tombstone for the key that was agreed to.</summary> /// <summary>Queues a tombstone for the key that was agreed to.</summary>
@@ -3523,7 +3901,7 @@ internal sealed partial class VaultViewModel(
row.EntityId, row.EntityId,
$"Delete the password '{row.Label}'?", $"Delete the password '{row.Label}'?",
HowFarADeletionGoes("The password and the account saved with it"), HowFarADeletionGoes("The password and the account saved with it"),
HostsBoundTo(host => host.CredentialId, row.EntityId)); HostsBoundTo(ResolvedBindingKind.Credential, row.EntityId));
} }
/// <summary>Queues a tombstone for the credential that was agreed to.</summary> /// <summary>Queues a tombstone for the credential that was agreed to.</summary>
@@ -3614,15 +3992,25 @@ internal sealed partial class VaultViewModel(
/// What the hosts that authenticate with an item would be left with, or nothing when none do. /// What the hosts that authenticate with an item would be left with, or nothing when none do.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Counted rather than warned about in general terms. The number is the difference between a sentence /// Counted rather than warned about in general terms. The number is the difference between a sentence
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound /// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a /// to something the vault no longer has is refused at connect time rather than quietly falling back to a
/// typed password — see <see cref="TryBuildAuthentication" />. /// typed password — see <see cref="TryBuildAuthentication" />.
/// </para>
/// <para>
/// <b>Counted over the resolved binding, so a group's default is counted too.</b> Reading each host's own
/// ids would miss a key that only a group names — which is the worst case rather than an edge one: a key
/// bound once on a group and inherited by twenty hosts would warn about nobody, be deleted, and then
/// refuse all twenty at connect time.
/// </para>
/// </remarks> /// </remarks>
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId) private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
{ {
var bound = Hosts var bound = Hosts
.Where(row => binding(row.Host) == entityId) .Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
.Select(row => row.Label) .Select(row => row.Label)
.ToArray(); .ToArray();
@@ -3742,7 +4130,8 @@ internal sealed partial class VaultViewModel(
// Refused rather than quietly falling back to the password box. A host set up for key-only access // Refused rather than quietly falling back to the password box. A host set up for key-only access
// that silently starts offering a password is the failure worth ruling out — the user asked for one // that silently starts offering a password is the failure worth ruling out — the user asked for one
// thing and got another, and the host is the last place that would say so. // thing and got another, and the host is the last place that would say so.
if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal)) if (!TryBuildAuthentication(
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
{ {
Status = refusal; Status = refusal;
return; return;
@@ -4072,14 +4461,16 @@ internal sealed partial class VaultViewModel(
/// <summary>The address as actually dialled.</summary> /// <summary>The address as actually dialled.</summary>
/// <remarks> /// <remarks>
/// Built from what was dialled rather than from the host's own fields, because a bound credential can /// Built from what was dialled rather than from the host's own fields, because neither half of it need
/// supply the username — so a host saved with no username of its own still has one here, and it is the /// come from the host. A bound credential can supply the username, and a group can supply the port and
/// one the remote saw. /// the username both — so a host saved with neither still has both here, and they are the ones the
/// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
/// naming a port nothing dialled is worse than no log.
/// </remarks> /// </remarks>
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) => private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
string.Create( string.Create(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}"); $"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}");
/// <summary> /// <summary>
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours. /// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
@@ -4201,7 +4592,11 @@ internal sealed partial class VaultViewModel(
[NotNullWhen(true)] out SshConnectionRequest? request, [NotNullWhen(true)] out SshConnectionRequest? request,
[NotNullWhen(false)] out string? reason) [NotNullWhen(false)] out string? reason)
{ {
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason)) ArgumentNullException.ThrowIfNull(host);
var resolved = Resolve(host);
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
{ {
request = null; request = null;
return false; return false;
@@ -4209,53 +4604,77 @@ internal sealed partial class VaultViewModel(
request = new SshConnectionRequest( request = new SshConnectionRequest(
host.Hostname, host.Hostname,
Resolve(host).Port.Value, resolved.Port.Value,
authentication.Username, authentication.Username,
authentication.Credential); authentication.Credential);
return true; return true;
} }
/// <summary>
/// Turns a host into a key, a password or a prompt, or says why it cannot.
/// </summary>
/// <remarks>
/// <para>
/// <b>Takes the resolved host as well as the stored one, and this is where group context used to be
/// lost.</b> Both callers of this and of <see cref="TryBuildConnectionRequest"/> used to hand over a
/// bare <see cref="HostSecret"/>, which answers "what did the user type into this host" rather than
/// "what happens when it is connected". It is the only authentication resolution in the product — both
/// heads and both transports come through here — so a host inheriting its binding would otherwise have
/// been offered a password prompt on every screen at once.
/// </para>
/// <para>
/// The refusal messages name the group when the binding came from one. A user told that "this host
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
/// nothing about keys.
/// </para>
/// </remarks>
private bool TryBuildAuthentication( private bool TryBuildAuthentication(
HostSecret host, HostSecret host,
ResolvedHost resolved,
string typedPassword, string typedPassword,
[NotNullWhen(true)] out HostAuthentication? authentication, [NotNullWhen(true)] out HostAuthentication? authentication,
[NotNullWhen(false)] out string? reason) [NotNullWhen(false)] out string? reason)
{ {
if (host.CredentialId is { } credentialId) var binding = resolved.Binding;
var where = binding.IsInherited ? $"'{host.Label}' inherits" : $"'{host.Label}' authenticates";
var repair = binding.IsInherited
? "Edit the group it is filed under, or bind the host itself."
: "Edit the host to choose another one, or set it back to a typed password.";
if (binding is { Kind: ResolvedBindingKind.Credential, EntityId: { } credentialId })
{ {
if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential) if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
{ {
return Refuse( return Refuse(
$"'{host.Label}' authenticates with a credential that is not in this keychain any more. " $"{where} a credential that is not in this keychain any more. {repair}",
+ "Edit the host to choose another one, or set it back to a typed password.",
out authentication, out authentication,
out reason); out reason);
} }
// The credential's username wins where it has one, which is the whole reason it can carry one: one // The credential's username wins where it has one, which is the whole reason it can carry one: one
// account on twenty machines is described once. Falling back to the host's covers the ordinary // account on twenty machines is described once. Falling back to the resolved username covers the
// case of a shared password used under each machine's own account. // ordinary case of a shared password used under each machine's own account — and it is the
// resolved one rather than the host's, so the fallback has three levels rather than two.
return Complete( return Complete(
credential.Credential.Username ?? host.Username, credential.Credential.Username ?? resolved.Username.Value,
new SshPasswordCredential(credential.Credential.Password), new SshPasswordCredential(credential.Credential.Password),
out authentication, out authentication,
out reason); out reason);
} }
if (host.SshKeyId is { } keyId) if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId })
{ {
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key) if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
{ {
return Refuse( return Refuse(
$"'{host.Label}' authenticates with an SSH key that is not in this keychain any more. " $"{where} an SSH key that is not in this keychain any more. {repair}",
+ "Edit the host to choose another key, or set it back to a password.",
out authentication, out authentication,
out reason); out reason);
} }
return Complete( return Complete(
host.Username, resolved.Username.Value,
new SshPrivateKeyCredential( new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase), Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
out authentication, out authentication,
@@ -4263,17 +4682,27 @@ internal sealed partial class VaultViewModel(
} }
return Complete( return Complete(
host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason); resolved.Username.Value,
new SshPasswordCredential(typedPassword),
out authentication,
out reason);
} }
/// <summary> /// <summary>
/// The last thing every branch has to agree on: there is somebody to log in as. /// The last thing every branch has to agree on: there is somebody to log in as.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> because the answer depends /// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> because the answer depends
/// on which branch was taken — a host with no username of its own is perfectly usable through a credential /// on which branch was taken — a host with no username of its own is perfectly usable through a credential
/// that carries one, and refusing it up front would have made the credential's most useful property /// that carries one, and refusing it up front would have made the credential's most useful property
/// unreachable. /// unreachable.
/// </para>
/// <para>
/// It is also why every caller passes a username the group chain has already been consulted for. Refusing
/// before the chain is walked would refuse exactly the hosts inheritance exists to serve: the twenty
/// machines filed under one group that says <c>deploy</c> once.
/// </para>
/// </remarks> /// </remarks>
private static bool Complete( private static bool Complete(
string? username, string? username,
@@ -4309,11 +4738,33 @@ internal sealed partial class VaultViewModel(
{ {
Label = EditorLabel.Trim(), Label = EditorLabel.Trim(),
Hostname = EditorHostname.Trim(), Hostname = EditorHostname.Trim(),
Port = EditorPort,
// Empty means "take the group's" in both directions, which is what makes the placeholder honest:
// what the box showed while empty is what the host will use. A relay host is the exception —
// HostSecret.TryValidate refuses one with no port of its own, so the box is pre-filled and
// required there; see EditorRelayEnabled.
Port = EditorRelayEnabled ? EditorPort ?? HostSecret.DefaultPort : EditorPort,
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(), Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes, Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled, RelayEnabled = EditorRelayEnabled,
// Only ever true or null, never false: the two would mean the same thing, and writing false would
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
//
// And only for a host in a group, because only then was there another entry to choose instead —
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
// host in the vault the first time it was edited.
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
&& EditorSelectedGroup?.EntityId is not null
? true
: null,
// Carried through rather than edited here. Nothing on this head can put a tag on a host yet, and
// rebuilding the host from the editor's boxes alone would strip the tags a teammate had added on
// a machine that can. See docs/adding-hosts-on-the-phone.md.
TagIds = editorTagIds,
// Both read off the one picker, including the id of something that has gone missing. Reading them // Both read off the one picker, including the id of something that has gone missing. Reading them
// from the picker rather than carrying the originals through is what lets a binding be removed at // from the picker rather than carrying the originals through is what lets a binding be removed at
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One // all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
@@ -4336,17 +4787,36 @@ internal sealed partial class VaultViewModel(
/// </summary> /// </summary>
/// <param name="boundKeyId">The key the host names, if any.</param> /// <param name="boundKeyId">The key the host names, if any.</param>
/// <param name="boundCredentialId">The credential the host names, if any.</param> /// <param name="boundCredentialId">The credential the host names, if any.</param>
/// <param name="asksForPassword">Whether the host is pinned to a typed password.</param>
/// <param name="grouped">Whether the host is filed under a group, and so has anything to inherit.</param>
/// <remarks> /// <remarks>
/// <para>
/// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped. /// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped.
/// Without one the picker would open on "Password (ask each time)", and someone editing the host's port /// Without one the picker would open on "Password (ask each time)", and someone editing the host's port
/// would convert it to a typed password by saving — which is the quiet version of the failure the connect /// would convert it to a typed password by saving — which is the quiet version of the failure the connect
/// path refuses outright. /// path refuses outright.
/// </para>
/// <para>
/// <b>Four entries where there were three, and only for a host in a group.</b> The three states two
/// nullable ids could carry became four when naming neither came to mean "inherit"; see
/// <see cref="AuthenticationKind.Inherited"/>. For an ungrouped host the fourth would behave exactly like
/// the first, so it is left out rather than offered and then explained.
/// </para>
/// </remarks> /// </remarks>
private void BuildAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId) private void BuildAuthenticationChoices(
Guid? boundKeyId,
Guid? boundCredentialId,
bool asksForPassword,
bool grouped)
{ {
EditorAuthenticationChoices.Clear(); EditorAuthenticationChoices.Clear();
EditorAuthenticationChoices.Add(AuthenticationChoice.Typed); EditorAuthenticationChoices.Add(AuthenticationChoice.Typed);
if (grouped)
{
EditorAuthenticationChoices.Add(AuthenticationChoice.Inherited);
}
foreach (var key in Keys) foreach (var key in Keys)
{ {
EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label)); EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
@@ -4362,7 +4832,8 @@ internal sealed partial class VaultViewModel(
AddMissing(AuthenticationKind.SshKey, boundKeyId); AddMissing(AuthenticationKind.SshKey, boundKeyId);
AddMissing(AuthenticationKind.Credential, boundCredentialId); AddMissing(AuthenticationKind.Credential, boundCredentialId);
EditorSelectedAuthentication = Selected(boundKeyId, boundCredentialId); EditorSelectedAuthentication =
Selected(boundKeyId, boundCredentialId, asksForPassword, grouped);
} }
private void AddMissing(AuthenticationKind kind, Guid? boundId) private void AddMissing(AuthenticationKind kind, Guid? boundId)
@@ -4375,16 +4846,28 @@ internal sealed partial class VaultViewModel(
} }
/// <remarks> /// <remarks>
/// <para>
/// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the /// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the
/// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a /// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a
/// key when the host named a credential the day the two ever shared an id. /// key when the host named a credential the day the two ever shared an id.
/// </para>
/// <para>
/// The last two arms are where the third state has to be told from the fourth. A host that names neither
/// binding and does not ask for a password is inheriting; one that asks is not. An ungrouped host has no
/// "Inherit" entry to select, so it falls to the typed one — which is what it resolves to anyway.
/// </para>
/// </remarks> /// </remarks>
private AuthenticationChoice Selected(Guid? boundKeyId, Guid? boundCredentialId) => private AuthenticationChoice Selected(
Guid? boundKeyId,
Guid? boundCredentialId,
bool asksForPassword,
bool grouped) =>
(boundKeyId, boundCredentialId) switch (boundKeyId, boundCredentialId) switch
{ {
({ } key, _) => Find(AuthenticationKind.SshKey, key), ({ } key, _) => Find(AuthenticationKind.SshKey, key),
(_, { } credential) => Find(AuthenticationKind.Credential, credential), (_, { } credential) => Find(AuthenticationKind.Credential, credential),
_ => AuthenticationChoice.Typed, _ when asksForPassword || !grouped => AuthenticationChoice.Typed,
_ => AuthenticationChoice.Inherited,
}; };
private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) => private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) =>
+11
View File
@@ -162,10 +162,21 @@ internal static class SyncVersions
/// Derives the plaintext columns the server needs from a host. /// Derives the plaintext columns the server needs from a host.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it /// The single point at which a hostname can leave the encrypted payload, which is the whole reason it
/// is a function rather than something each call site assembles. The address is emitted only when the /// is a function rather than something each call site assembles. The address is emitted only when the
/// user has turned the relay on for that host; with relay off, the server learns nothing but that an /// user has turned the relay on for that host; with relay off, the server learns nothing but that an
/// item exists. See ADR 0004 for why the relay cannot work any other way. /// item exists. See ADR 0004 for why the relay cannot work any other way.
/// </para>
/// <para>
/// <b>The port is the host's own, and it is allowed to be, because a relay host may not inherit one.</b>
/// This runs from inside the generic write path — see <c>IItemKind{TSecret}.Fields</c> — which holds one
/// secret and has no group list to walk, and threading one in would put the group chain inside the sync
/// engine for the sake of a single column. It does not have to: <see cref="HostSecret.TryValidate"/>
/// refuses a relay host with no port of its own, so the branch below cannot be reached by a host that
/// inherits. That refusal exists for a stronger reason than this convenience — a plaintext column derived
/// from another item goes stale when that item is edited, and nothing re-pushes the hosts beneath it.
/// </para>
/// </remarks> /// </remarks>
internal static class HostFields internal static class HostFields
{ {
@@ -3325,6 +3325,244 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved"); vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
} }
// ---- What a host takes from its group ----
//
// The tests below dial. That is the point of them: a resolved value that never reaches
// SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting
// to the wrong port, or being asked for a password it does not need, with nothing on screen admitting
// it. AddHostAsync gives its host a username, so each of these clears what it is about first.
[Fact]
public async Task AHostThatStatesNoPort_DialsItsGroups()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", port: 2222);
await FileAsync(vault, "prod-db", "production");
vault.SelectedHost = Host(vault, "prod-db");
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
// factory is ever reached, and this would pass no matter what port was resolved.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(2222);
}
[Fact]
public async Task AHostThatPinsItsOwnPort_KeepsItUnderAGroupThatSaysOtherwise()
{
// The other direction, and the one that decides whether inheritance is safe to turn on: a host that
// was explicit must not start dialling somewhere else because somebody edited a group.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", port: 2222);
await FileAsync(vault, "prod-db", "production");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorPort = 22;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "prod-db");
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
// factory is ever reached, and this would pass no matter what port was resolved.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
}
[Fact]
public async Task AHostThatStatesNoUsername_LogsInAsItsGroups()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", username: "deploy");
await FileAsync(vault, "prod-db", "production");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorUsername = string.Empty;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "prod-db");
// The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
// factory is ever reached, and this would pass no matter what port was resolved.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
}
[Fact]
public async Task AGroupsDefaultPort_ShowsAsThePlaceholderInTheHostEditor()
{
// What makes an empty box honest. Without this the form asks the user to leave a field blank and
// says nothing about what blank will get them.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorPortPlaceholder.ShouldBe("22", "an ungrouped host falls to the end of the chain");
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
vault.EditorPortPlaceholder.ShouldBe(
"2222", "the placeholder follows the group picker, or it describes the wrong group");
vault.EditorUsernamePlaceholder.ShouldBe("deploy");
}
[Fact]
public async Task AHostUnderAGroupThatBindsAKey_DoesNotAskForAPassword()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
await FileAsync(vault, "prod-db", "production");
vault.SelectedHost = Host(vault, "prod-db");
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
vault.SelectedHostAuthenticationNote.ShouldContain("production");
Host(vault, "prod-db").Authentication.ShouldBe("key");
}
[Fact]
public async Task AHostPinnedToATypedPassword_KeepsAskingUnderAGroupThatBindsAKey()
{
// The failure worth ruling out above every other one here. A host deliberately set back to a typed
// password must not start authenticating with the fleet's key because somebody set a group default.
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
await FileAsync(vault, "prod-db", "production");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication.ShouldBe(
AuthenticationChoice.Inherited, "a host that binds nothing under a group is inheriting, not typing");
vault.EditorSelectedAuthentication = AuthenticationChoice.Typed;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "prod-db");
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
Host(vault, "prod-db").Host.AsksForPassword.ShouldBe(true);
}
[Fact]
public async Task AKeyBoundOnlyByAGroup_WarnsAboutTheHostsBeneathItBeforeItIsDeleted()
{
// Counting each host's own ids would warn about nobody here, and then refuse every host beneath the
// group at connect time. The warning is the only thing standing between the two.
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await SetGroupDefaultsAsync(vault, "production", key: "deploy");
await FileAsync(vault, "prod-db", "production");
vault.SelectedKey = vault.Keys.Single();
vault.DeleteKeyCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().Usage.ShouldContain("prod-db");
}
[Fact]
public async Task AGroupsParentPicker_LeavesOutItselfAndEverythingBeneathIt()
{
// A cycle cannot be made here. It can still arrive from two offline re-parents, which is why the
// walk carries a visited set — this only keeps a user from doing it to themselves.
await UnlockedAsync();
var vault = shell.Vault!;
await AddGroupAsync(vault, "estate");
await AddGroupAsync(vault, "production");
await SetGroupParentAsync(vault, "production", "estate");
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorParentChoices
.Select(choice => choice.Label)
.ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
}
private static async Task SetGroupDefaultsAsync(
VaultViewModel vault,
string group,
int? port = null,
string? username = null,
string? key = null)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorDefaultPort = port;
vault.GroupEditorDefaultUsername = username ?? string.Empty;
if (key is not null)
{
vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
choice => string.Equals(choice.Label, key, StringComparison.Ordinal));
}
await vault.SaveGroupCommand.ExecuteAsync(null);
}
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
}
// ---- Snippets ---- // ---- Snippets ----
/// <remarks> /// <remarks>