using System.Collections; using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// One SSH configuration directive on a host. /// /// /// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two /// clients that resolved the same merge could end up holding ServerAliveInterval and /// serveraliveinterval, compare their hosts as different, and push over each other forever /// while agreeing on every actual value. /// /// Directive name, for example ServerAliveInterval. /// Directive value, verbatim and case-sensitive. public sealed record HostOption(string Name, string Value) { /// Defines directive identity: SSH keywords are case-insensitive. public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase; /// public bool Equals(HostOption? other) => other is not null && NameComparer.Equals(Name, other.Name) && string.Equals(Value, other.Value, StringComparison.Ordinal); /// public override int GetHashCode() => HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal)); } /// /// A host's SSH directives: unique by name, held in name order. /// /// /// /// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which /// is what lets two people add different directives to the same host and both survive — a /// whole-collection comparison would make that a conflict and discard one side. Name order makes the /// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync /// engine does not push a spurious update on every pass. /// /// /// The cost, stated plainly: real ssh_config permits a directive to repeat, and for /// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1 /// limitation rather than an oversight — a repeated key has no merge key — and the import path must /// surface it rather than quietly keeping one of the duplicates. /// /// public sealed class HostOptions : IReadOnlyList, IEquatable { private readonly HostOption[] items; private readonly int hash; private HostOptions(HostOption[] items) { this.items = items; hash = ComputeHash(items); } /// No directives. public static HostOptions Empty { get; } = new([]); /// public int Count => items.Length; /// public HostOption this[int index] => items[index]; /// /// Builds a canonical collection, sorting by name. /// /// A name repeats, or a name is blank. public static HostOptions Create(IEnumerable options) { if (!TryCreate(options, out var result, out var error)) { throw new ArgumentException(error, nameof(options)); } return result; } /// /// Builds a canonical collection, reporting rather than throwing on bad input. /// /// /// The non-throwing overload exists because these values arrive from two places neither of which /// is trusted: a decrypted payload written by another client, and an imported /// ssh_config. Neither should be able to raise an exception from inside a sync pass. /// public static bool TryCreate( IEnumerable options, [NotNullWhen(true)] out HostOptions? result, [NotNullWhen(false)] out string? error) { ArgumentNullException.ThrowIfNull(options); result = null; var ordered = options.ToArray(); if (!Validate(ordered, out error)) { return false; } Array.Sort( ordered, static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name)); result = ordered.Length == 0 ? Empty : new HostOptions(ordered); return true; } /// Looks up a directive by name, case-insensitively as SSH treats keywords. public bool TryGetValue(string name, [NotNullWhen(true)] out string? value) { foreach (var option in items) { if (HostOption.NameComparer.Equals(option.Name, name)) { value = option.Value; return true; } } value = null; return false; } /// public bool Equals(HostOptions? other) { if (ReferenceEquals(this, other)) { return true; } if (other is null || other.items.Length != items.Length || other.hash != hash) { return false; } return items.AsSpan().SequenceEqual(other.items); } /// public override bool Equals(object? obj) => Equals(obj as HostOptions); /// public override int GetHashCode() => hash; /// public IEnumerator GetEnumerator() => ((IEnumerable)items).GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator(); /// Contents equality, tolerating nulls on either side. [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Equals(HostOptions) is the named alternate.")] public static bool operator ==(HostOptions? left, HostOptions? right) => left is null ? right is null : left.Equals(right); /// Contents inequality. public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right); /// Projects to a name-keyed map, for the per-directive merge. internal Dictionary ToNameMap() { var map = new Dictionary(items.Length, HostOption.NameComparer); foreach (var option in items) { map[option.Name] = option.Value; } return map; } private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error) { foreach (var option in ordered) { if (option is null || string.IsNullOrWhiteSpace(option.Name)) { error = "An SSH directive must have a name."; return false; } } var duplicate = ordered .GroupBy(o => o.Name, HostOption.NameComparer) .FirstOrDefault(g => g.Count() > 1); if (duplicate is not null) { error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names."; return false; } error = null; return true; } private static int ComputeHash(HostOption[] items) { var accumulator = new HashCode(); accumulator.Add(items.Length); foreach (var option in items) { accumulator.Add(option); } return accumulator.ToHashCode(); } }