Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
T

307 lines
13 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One pinned host key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
/// What was wrong was that nothing ever showed them.
/// </para>
/// <para>
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
/// of pinning one is to compare it with what they published.
/// </para>
/// </remarks>
internal sealed class KnownHostRowViewModel(
VaultItem<KnownHostSecret> pin,
bool isDialledByAHost,
Guid vaultId,
string vaultName)
{
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal Guid EntityId => pin.EntityId;
internal KnownHostSecret Pin => pin.Secret;
internal string Host => pin.Secret.Host;
internal int Port => pin.Secret.Port;
internal string Algorithm => pin.Secret.Algorithm;
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
internal string Label => pin.Secret.Label;
/// <summary>The fingerprint, in full.</summary>
/// <remarks>
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
/// this whole mechanism exists to replace.
/// </remarks>
internal string Fingerprint => pin.Secret.Fingerprint;
/// <summary>
/// Whether any host in this vault actually dials the endpoint this pin is for.
/// </summary>
/// <remarks>
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
/// worth deleting on the user's behalf.
/// </remarks>
internal bool IsDialledByAHost { get; } = isDialledByAHost;
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
internal string Badge => IsDialledByAHost
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
: "no host uses this";
/// <summary>
/// When this pin was approved, as far as anything here can tell.
/// </summary>
/// <remarks>
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
/// renders as a dash rather than as a guess.
/// </remarks>
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "—";
}
/// <summary>
/// The host keys this keychain has approved, and how to withdraw one.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
/// it produces, neither of which the vault has any use for.
/// </para>
/// <para>
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
/// answer a question nobody is asking.
/// </para>
/// </remarks>
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Action? onBack;
/// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
/// <param name="onBack">
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
/// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
/// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
/// directly makes the button a no-op rather than a crash.
/// </param>
internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
{
this.vault = vault;
this.onBack = onBack;
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
// copy of a trust decision is the one kind of staleness that matters here.
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
Rebuild();
}
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
/// <remarks>
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
/// — host, then port, then algorithm — is the one worth keeping.
/// </remarks>
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
/// <remarks>
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
/// and there is no reason for it to learn about this screen.
/// </remarks>
[ObservableProperty]
private KnownHostRowViewModel? selected;
/// <summary>
/// The pins from vaults this machine is showing, before the filter box narrows them.
/// </summary>
/// <remarks>
/// Every count and every sentence on this screen is taken from here rather than from
/// <c>vault.KnownHostPins</c>, so none of them can describe a pin the list is not drawing — a summary
/// saying "3 that no host dials" over two rows would send somebody looking for a third.
/// <para>
/// The vault's own list stays whole and this is a projection of it, which is the rule stated on
/// <c>VaultViewModel.IsVaultShown</c>: the trust the SSH handshake consults is read straight out of
/// <c>VaultKnownHostStore</c> and has never come through either list.
/// </para>
/// </remarks>
private IEnumerable<KnownHostRowViewModel> Shown =>
vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId));
internal bool HasPins => Shown.Any();
/// <summary>
/// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
/// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
/// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
/// whatever somebody last typed into the filter.
/// </summary>
internal int Count => Shown.Count();
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
/// <summary>What the whole list amounts to, in one line.</summary>
/// <remarks>
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
/// might want to act on, and counting them is cheaper than reading a badge column.
/// </remarks>
internal string Summary
{
get
{
var total = Shown.Count();
if (total == 0)
{
return string.Empty;
}
var unused = Shown.Count(pin => !pin.IsDialledByAHost);
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
return unused == 0
? pins
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
}
}
/// <remarks>
/// The hidden-vault case is its own sentence rather than falling into "nothing approved yet", which
/// would be a screen telling somebody they have never approved a host key while the keys they approved
/// sit in a vault they switched off in a menu.
/// </remarks>
internal string EmptyMessage => (HasPins, vault.KnownHostPins.Count) switch
{
(true, _) => "No approved host key matches that.",
(false, > 0) =>
"Every approved host key here is in a vault you have switched off. Press the ⌄ beside Vaults in "
+ "the tab strip to switch one back on.",
_ =>
"Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you "
+ "to check — approving it puts it here.",
};
/// <summary>Withdraws trust in the selected pin.</summary>
/// <remarks>
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
/// server are the other ones.
/// </remarks>
[RelayCommand]
private async Task ForgetSelectedAsync()
{
if (Selected is null)
{
return;
}
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
[RelayCommand]
private async Task CopyFingerprintAsync()
{
if (Selected is null)
{
return;
}
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
[RelayCommand]
private void Back() => onBack?.Invoke();
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(KnownHostRowViewModel? value)
{
vault.SelectedKnownHost = value;
OnPropertyChanged(nameof(HasSelection));
}
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
VisiblePins.Clear();
foreach (var pin in Shown.Where(Matches))
{
VisiblePins.Add(pin);
}
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(Count));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
}
private bool Matches(KnownHostRowViewModel pin)
{
if (string.IsNullOrWhiteSpace(Filter))
{
return true;
}
var needle = Filter.Trim();
return Contains(pin.Host, needle)
|| Contains(pin.Algorithm, needle)
|| Contains(pin.Fingerprint, needle)
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
}
private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}