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; /// One pinned host key, as a row in the list. /// /// /// 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 trust 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. /// /// /// 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. /// /// internal sealed class KnownHostRowViewModel( VaultItem pin, bool isDialledByAHost, Guid vaultId, string vaultName) { /// Which vault this pin lives in. See . internal Guid VaultId => vaultId; /// The vault's display name. 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; /// The endpoint and algorithm, which is what a pin actually identifies. internal string Label => pin.Secret.Label; /// The fingerprint, in full. /// /// 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. /// internal string Fingerprint => pin.Secret.Fingerprint; /// /// Whether any host in this vault actually dials the endpoint this pin is for. /// /// /// 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. /// 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"; /// /// When this pin was approved, as far as anything here can tell. /// /// /// Derived from the entity id, which this client mints with — see /// . 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. /// internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped ? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture) : "—"; } /// /// The host keys this keychain has approved, and how to withdraw one. /// /// /// /// A wrapper over the vault rather than a view model of its own. Everything about a pin — reading /// them, forgetting one, pushing the change — already lives on , 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. /// /// /// The filter matches fingerprints, deliberately. 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. /// /// internal sealed partial class KnownHostsViewModel : ObservableObject { private readonly VaultViewModel vault; private readonly Action? onBack; /// Where the pins, the reload and the withdrawal all actually live. /// /// What the v5c header's own back arrow does — a delegate rather than a reference up to /// MainWindowViewModel, on the same reasoning ImportViewModel's own onCancel is one: /// this type has no business knowing ShellScreen exists. Null in a layout test that builds this /// directly makes the button a no-op rather than a crash. /// 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(); } /// The pins this filter admits, in the order the vault produced them. /// /// 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. /// internal ObservableCollection VisiblePins { get; } = []; [ObservableProperty] private string filter = string.Empty; /// The row the list has selected, mirrored onto the vault so its command can act on it. /// /// Pushed down rather than duplicated: ForgetPinCommand reads VaultViewModel.SelectedKnownHost /// and there is no reason for it to learn about this screen. /// [ObservableProperty] private KnownHostRowViewModel? selected; /// /// The pins from vaults this machine is showing, before the filter box narrows them. /// /// /// Every count and every sentence on this screen is taken from here rather than from /// vault.KnownHostPins, 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. /// /// The vault's own list stays whole and this is a projection of it, which is the rule stated on /// VaultViewModel.IsVaultShown: the trust the SSH handshake consults is read straight out of /// VaultKnownHostStore and has never come through either list. /// /// private IEnumerable Shown => vault.KnownHostPins.Where(pin => vault.IsVaultShown(pin.VaultId)); internal bool HasPins => Shown.Any(); /// /// 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 reads off /// rather than : it is a fact about the list, not about /// whatever somebody last typed into the filter. /// internal int Count => Shown.Count(); internal bool HasVisiblePins => VisiblePins.Count > 0; internal bool HasSelection => Selected is not null; /// What the whole list amounts to, in one line. /// /// 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. /// 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"); } } /// /// 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. /// 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.", }; /// Withdraws trust in the selected pin. /// /// 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. /// [RelayCommand] private async Task ForgetSelectedAsync() { if (Selected is null) { return; } await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true); } /// Puts the selected pin's fingerprint on the clipboard. Forwarded, like . [RelayCommand] private async Task CopyFingerprintAsync() { if (Selected is null) { return; } await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true); } /// The header's own back arrow: to the Keychain screen this list was pulled out of. [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); }