using System.Collections.ObjectModel; using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Domain; using DodoSSH.Client.Import; namespace DodoSSH.Client.Shell.ViewModels; /// One host an import is about to store, with the key file it named if that is coming too. /// /// The key travels beside the host rather than inside it because the two are separate vault items: a host /// carries an SshKeyId, and the id does not exist until the key has been written. So the screen says /// which file a host named and the vault decides what id that file ends up with — see /// VaultViewModel.ImportHostsAsync, which is where one key per file is enforced. /// /// The host as it would be stored, with no binding on it yet. /// The key read off the disk, or null when none was asked for or none could be read. /// /// The file came out of. It is what two hosts naming one key are compared on, so it /// travels even though nothing stores it. /// internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath); /// What an import stored. /// Hosts written. /// Private keys written. Counts files read, not hosts bound to one. internal sealed record ImportOutcome(int Hosts, int Keys); /// One host an ssh_config offered, as a row somebody decides about. /// /// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list /// and pressed the button, which is what makes reading a file out of the user's home directory an offer /// rather than an action. /// internal sealed partial class ImportRowViewModel : ObservableObject { private readonly ImportedHost host; internal ImportRowViewModel(ImportedHost host, bool alreadyPresent, bool importsItsKey) { this.host = host; AlreadyPresent = alreadyPresent; this.importsItsKey = importsItsKey && HasKeyFile; // A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark // for one machine is a thing people genuinely want — but it should take a click rather than be the // default. IsSelected = !alreadyPresent; } internal ImportedHost Host => host; internal string Alias => host.Alias; internal string Address => host.Address; /// What HostName said, on its own — the v5c table's own column, beside /// and rather than folded into . internal string Hostname => host.Hostname; /// What User said, or an em dash where the entry named none. internal string User => host.Username is { Length: > 0 } user ? user : "—"; /// What Port said, defaulting to 22 the same way does. internal string Port => host.Port.ToString(CultureInfo.InvariantCulture); /// Whether a host with this address is already in the keychain. internal bool AlreadyPresent { get; } internal string Badge => AlreadyPresent ? "already here" : string.Empty; internal bool HasBadge => AlreadyPresent; /// /// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually /// carries — nothing this screen cannot back up. Skipped patterns (a wildcard Host block) never /// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host /// warnings, from , are the amber case instead — a flattened ProxyJump or /// a dropped directive is exactly the kind of thing "quieter than the file" that ImportScreen.axaml's /// own remark says has to be told before it looks like data loss. /// internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host"; /// The warned case wins over "already here" — a warning is the more actionable of the two facts. internal bool IsMeaningWarned => HasWarnings; internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent; internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent; /// Whether this row's ssh_config entry named a key at all. /// /// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than /// asked of at each call site so that "this row has a key file" is one /// sentence in one place. /// internal bool HasKeyFile => host.IdentityFiles.Count > 0; /// What this row's IdentityFile named, or empty where it named none. internal string KeyPath => HasKeyFile ? host.IdentityFiles[0] : string.Empty; /// How this would authenticate, in the terms the preview can honestly offer. /// /// /// "a key on disk" rather than "a key" when the material is staying where it is, because nothing is /// imported then: the path is recorded and the host will ask for a password until somebody binds it to a /// keychain key. Saying "key" there would promise a connection that does not work. /// /// /// ◆ With the tick on it says the opposite, and has to: the same row now means a key that will be read, /// stored and bound, and leaving it reading "on disk" would understate the one thing on this screen /// somebody explicitly agreed to. /// /// internal string Authentication => (host.IdentityFiles.Count, ImportsItsKey) switch { (0, _) => "password", (_, true) => $"a key, imported · {host.IdentityFiles[0]}", (1, _) => $"a key on disk · {host.IdentityFiles[0]}", var (count, _) => $"{count} keys on disk · {host.IdentityFiles[0]}", }; internal bool HasWarnings => host.Warnings.Count > 0; internal string Warnings => string.Join(" ", host.Warnings); [ObservableProperty] private bool isSelected; /// Whether the import will read this row's key file as well as its host. /// /// Set from the one tick above the list rather than owned per row. A key file is very often shared by a /// dozen entries, so a per-row choice would be a dozen ticks deciding one thing — and the decision being /// made is about reading ~/.ssh at all, which is a decision about the directory rather than about /// any host in it. /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(Authentication))] private bool importsItsKey; } /// /// Reading ~/.ssh/config and offering what it found. /// /// /// /// Two steps, and the first one writes nothing. Scanning reads the file and shows what it means; /// importing is a separate press. That split is the feature: an ssh_config is a file this /// application did not write and may contain forty entries for machines that no longer exist, so the /// interesting question is not "can it be parsed" but "which of these did you actually want". /// /// /// ◆ Private keys are read only if somebody ticks the box, and it starts unticked. Pulling a /// ~/.ssh/id_ed25519 into a keychain is exactly the act this product exists to make deliberate, and /// doing it as a side effect of "import my config" would be the wrong default — so it stays a default nobody /// gets by accident, and the sentence beside the tick names the directory it will read. What changed is that /// it is now possible: an import that recorded a path and left every host asking for a password was /// an import whose result did not connect, and the answer to that was a manual paste per key. /// /// /// With the tick on, each host's first IdentityFile is read, stored as a keychain key and bound to /// the host. One key per file however many hosts named it, and a file already in the keychain is bound to /// rather than stored twice; both of those live in VaultViewModel.ImportHostsAsync. What cannot be /// read off a disk is a passphrase, so a protected key arrives without one and the screen says which — see /// . /// /// internal sealed partial class ImportViewModel( VaultViewModel vault, SshConfigLocator locator, Action? onCancel = null) : ObservableObject { internal ObservableCollection Rows { get; } = []; /// What was skipped or flattened, at document level. internal ObservableCollection Warnings { get; } = []; /// The file this would read, shown so nobody has to guess which one it means. internal string ConfigPath => locator.ConfigPath; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private bool hasScanned; [ObservableProperty] private bool isBusy; /// /// Whether the import should also read the key files the config names. /// /// /// /// Off, and it has to be the sort of off that survives somebody not reading the screen. This is the only /// control in the application that reads private key material out of a directory the user did not point /// at file by file, and the whole of what makes that acceptable is that it took a deliberate press. /// /// /// Not persisted in ClientSettings, deliberately. A remembered "yes" would make the next import — /// possibly on another machine, possibly of a colleague's config — read ~/.ssh without asking /// again, which is the answer to a question that was never put twice. /// /// [ObservableProperty] private bool importsKeys; /// What happened to the key files, one line each. Empty until an import has run. /// /// Reported after the fact rather than previewed, because previewing means reading them — and a screen /// that read every key in order to tell you it had not imported them would be doing the thing the tick /// exists to gate. So the list is what the import found: which were stored, which are protected by a /// passphrase this cannot know, and which were not there at all. /// internal ObservableCollection KeyReport { get; } = []; internal bool HasRows => Rows.Count > 0; internal bool HasWarnings => Warnings.Count > 0; internal bool HasKeyReport => KeyReport.Count > 0; /// Whether anything in the scan named a key file at all. /// /// The tick is drawn only when it would do something. A config of forty password hosts has no keys to /// offer, and a permanently visible offer to read ~/.ssh on a screen where it would read nothing /// is a control that teaches people to ignore it. /// internal bool HasKeyFiles => Rows.Any(row => row.HasKeyFile); internal int SelectedCount => Rows.Count(row => row.IsSelected); internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS"; /// Whether every row is ticked — what the v5c table's header tick-all box shows. internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count; /// /// The v5c header's own mono status line: the file this reads, and whether it has been read yet. /// /// /// Two real facts and nothing invented — and . The /// fuller narrative belongs to , which this does not replace: what happened on a scan /// or an import is a sentence, not a fact this header line has room to state honestly in a handful of /// words. /// internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet"; /// /// The key-material card's own always-visible sentence, ahead of the tick. /// /// /// The count is hosts naming a key file, not raw IdentityFile lines — a fact /// actually carries, where a literal line count would not survive a host that names more than one and is /// only ever bound to the first. The rest of the sentence is ImportViewModel's own long-standing /// claim, restated in the design's words after checking it against SshConfigLocator: this type is /// the only place in the application that reads a private key out of a directory nobody pointed at file /// by file, and is the only place that ever calls /// — never — so nothing is read until /// IMPORT is pressed. /// internal string KeyMaterialIntro { get { var count = Rows.Count(row => row.HasKeyFile); var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath; var noun = count == 1 ? "host names" : "hosts name"; return $"The scan found {count} {noun} a key file in {directory}. This is the only control in " + "DodoSSH that opens key material from a directory you did not point at file by file — " + "nothing is read until Import is pressed."; } } /// The vault every import lands in — see . /// /// Fixed rather than offered as a picker: the import goes through the same /// session.ActiveVaultId every other bulk write does, and there is no per-import target choice to /// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown. /// internal string VaultName => vault.VaultName; /// The v5c footer's own sentence: how many are ticked, out of how many, and where they land. internal string SelectionSummary { get { var noun = Rows.Count == 1 ? "entry" : "entries"; return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}"; } } /// Reads the file and shows what it found. Writes nothing. [RelayCommand] private async Task ScanAsync(CancellationToken cancellationToken) { Rows.Clear(); Warnings.Clear(); KeyReport.Clear(); HasScanned = false; if (!locator.Exists) { Status = $"There is no {locator.ConfigPath} on this machine."; RaiseListState(); return; } IsBusy = true; try { var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true); foreach (var host in import.Hosts) { Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host), ImportsKeys)); } foreach (var warning in import.Warnings) { Warnings.Add(warning); } HasScanned = true; Status = Rows.Count == 0 ? "Nothing in that file could be imported as a host." : $"Found {Rows.Count} host(s). Nothing is stored until you press the button below."; } catch (IOException failure) { Status = $"Could not read {locator.ConfigPath}: {failure.Message}"; } catch (UnauthorizedAccessException failure) { Status = $"Could not read {locator.ConfigPath}: {failure.Message}"; } finally { IsBusy = false; RaiseListState(); } } /// Stores the ticked hosts, and the keys they name if that was asked for. /// /// The key files are read here rather than during the scan, and that ordering is the tick's whole /// meaning: until this press nothing has opened a private key, so changing your mind after scanning /// costs nothing and leaves nothing read. /// [RelayCommand] private async Task ImportAsync(CancellationToken cancellationToken) { var chosen = Rows.Where(row => row.IsSelected).ToList(); if (chosen.Count == 0) { Status = "Nothing is ticked."; return; } IsBusy = true; try { KeyReport.Clear(); var requests = chosen.Select(BuildRequest).ToList(); var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true); // Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes // pressing the button twice harmless and visible rather than harmless and confusing. foreach (var row in Rows.ToList()) { Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host), ImportsKeys); } // This screen's own sentence rather than the vault's. The vault writes one too and then pushes, // and the push overwrites it with a sync report — which is right on a screen somebody is about // to leave, and wrong on the one they are still standing on. Status = Describe(outcome); } finally { IsBusy = false; RaiseListState(); } } /// What the import did, in this screen's own words. /// /// The keys are counted separately and named only when there are any, because reading key material is /// the half of this somebody agreed to rather than the half they asked for. The count is files, not /// hosts: a dozen entries sharing one key is one key, and saying twelve would be describing the config /// rather than what was stored. /// private static string Describe(ImportOutcome outcome) { var material = outcome.Keys switch { 0 => string.Empty, 1 => " 1 private key came with them, and every host that named it is bound to it.", _ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.", }; return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}"; } /// /// Turns a ticked row into what the vault stores, reading its key file where that was agreed to. /// /// /// /// The first IdentityFile only, which is the same one records /// as a directive. A host may list several and ssh tries each in turn; a keychain host binds one key, so /// importing all of them would be storing keys nothing is bound to and calling it authentication. /// /// /// Every outcome is a line in rather than a failure, including the good one. A /// key that could not be read leaves the host importable and unbound, which is exactly what the import /// did for every host before the tick existed. /// /// private ImportedHostRequest BuildRequest(ImportRowViewModel row) { var host = row.Host.ToSecret(); if (!ImportsKeys || !row.HasKeyFile) { return new ImportedHostRequest(host, Key: null, KeyPath: null); } var identity = locator.ReadIdentity(row.KeyPath); if (!identity.WasRead) { KeyReport.Add($"{row.Alias}: {identity.Path} was not imported — {identity.Failure}."); return new ImportedHostRequest(host, Key: null, KeyPath: null); } // Named for the file rather than for the host, because one file is very often a dozen hosts and a // key called "web-01" that four other machines also use is a name that misleads on five screens. var key = new SshKeySecret { Label = Path.GetFileName(identity.Path), PrivateKeyPem = identity.PrivateKeyPem, PublicKey = identity.PublicKey, Notes = $"Imported from {identity.Path} with {locator.ConfigPath}.", }; KeyReport.Add(identity.IsEncrypted ? $"{row.Alias}: {identity.Path} was imported and is protected by a passphrase. Nothing on " + "disk says what it is, so add it to the key on the keychain screen or the host will not " + "connect." : $"{row.Alias}: {identity.Path} was imported."); return new ImportedHostRequest(host, key, identity.Path); } /// Ticks or unticks everything at once. [RelayCommand] private void ToggleAll() { var target = SelectedCount < Rows.Count; foreach (var row in Rows) { row.IsSelected = target; } RaiseListState(); } internal void NoteSelectionChanged() => RaiseListState(); /// The footer's own Cancel button: back to the Preferences page, nothing stored. /// /// A delegate rather than a reference up to MainWindowViewModel, on the same reasoning /// VaultViewModel's own copyToClipboard is one: this type has no business knowing settings /// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes /// the button a no-op rather than a crash. /// [RelayCommand] private void Cancel() => onCancel?.Invoke(); /// /// The rows carry the answer as well as the view model, because each one says what it will authenticate /// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a /// property of the screen it is in without a parent binding, and that is markup for something the list /// already has to be walked for. /// partial void OnImportsKeysChanged(bool value) { foreach (var row in Rows) { row.ImportsItsKey = value && row.HasKeyFile; } } /// /// Matched on where a host points rather than on what it is called. Two entries with different aliases /// for one machine are the ordinary shape of an ssh_config, and matching on the name would offer /// to import a duplicate of something already stored under another name. /// /// /// Whether this block describes a machine the vault already has. /// /// /// /// Compared against the resolved host, not the stored one. 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. /// /// /// An imported block with no Port still pins 22 rather than inheriting, which /// SshConfigResolver 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 Port in an ssh_config means 22; storing that is the faithful reading. /// /// private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing => string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase) && existing.Resolved.Port.Value == host.Port && string.Equals( existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase)); private void RaiseListState() { OnPropertyChanged(nameof(HasRows)); OnPropertyChanged(nameof(HasWarnings)); OnPropertyChanged(nameof(HasKeyFiles)); OnPropertyChanged(nameof(HasKeyReport)); OnPropertyChanged(nameof(SelectedCount)); OnPropertyChanged(nameof(ImportLabel)); OnPropertyChanged(nameof(AllTicked)); OnPropertyChanged(nameof(KeyMaterialIntro)); OnPropertyChanged(nameof(SelectionSummary)); } /// The header's own status line is a function of alone. partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus)); }