using System.Collections.ObjectModel;
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;
/// 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;
/// 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) : 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";
/// 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 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));
}
}