using System.Globalization; namespace DodoSSH.Client.Domain; /// /// A value the merge had to override, kept so the user can see it and put it back. /// /// /// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only /// acceptable because the other side is preserved verbatim and surfaced; without that, a /// field-level merge is just last-writer-wins with extra steps. /// /// /// Which field, as a path. A directive reads Options[ServerAliveInterval] so the user is told /// which one rather than merely that "options" changed. /// /// Whose intent was overridden. /// The value that survives, rendered for display. /// The value that lost, rendered for display. /// /// True when what lost was a deletion rather than a different value. /// public sealed record HostFieldConflict( string Field, MergeSide DiscardedSide, string? Kept, string? Discarded, bool DiscardedWasRemoval); /// The merged host, and everything that had to be overridden to produce it. /// The host to store and push. /// Empty when the two sides were reconcilable field by field. public sealed record HostMergeResult( HostSecret Merged, IReadOnlyList Conflicts) { /// Whether anything had to be overridden. public bool HasConflicts => Conflicts.Count > 0; } /// /// Merges two divergent versions of a host against the version they both started from. /// /// /// /// Called when a pull brings down a change to an item that also has a local edit pending, and again /// when a push comes back Conflict carrying the server's current row. Both paths need the /// same answer, so both go through here. /// /// /// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value, /// because its order is its meaning. Directives merge per name, which is what lets two people each /// add one and both keep it. See for why the remote side wins. /// /// public static class HostSecretMerge { /// /// Produces the merged host. /// /// /// The version both sides branched from — the ciphertext the client retained when it queued its /// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a /// revert and so cannot avoid resurrecting deleted values. /// /// The pending local version. /// The server's current version. public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote) { ArgumentNullException.ThrowIfNull(ancestor); ArgumentNullException.ThrowIfNull(local); ArgumentNullException.ThrowIfNull(remote); var conflicts = new List(); var merged = new HostSecret { Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts), Hostname = Text( nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts), Port = Field( nameof(HostSecret.Port), ancestor.Port, local.Port, remote.Port, conflicts, static port => port.ToString(CultureInfo.InvariantCulture)), Username = Text( nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts), Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts), JumpHostIds = Field( nameof(HostSecret.JumpHostIds), ancestor.JumpHostIds, local.JumpHostIds, remote.JumpHostIds, conflicts, FormatChain), Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts), RelayEnabled = Field( nameof(HostSecret.RelayEnabled), ancestor.RelayEnabled, local.RelayEnabled, remote.RelayEnabled, conflicts, static enabled => enabled ? "enabled" : "disabled"), }; return new HostMergeResult( WithReferences(merged, ancestor, local, remote, conflicts), conflicts); } /// /// Merges the three ids a host can point at: its key, its credential and its group. /// /// /// /// Split out for length, and they do belong together: each is a reference to another vault item, each /// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on /// another machine. None of that is the merge's problem — it is handled where the reference is used. /// /// /// The ids are shown in a clash rather than redacted. An id is not a secret — it names a vault /// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the /// merge dropped. /// /// private static HostSecret WithReferences( HostSecret merged, HostSecret ancestor, HostSecret local, HostSecret remote, List conflicts) => merged with { SshKeyId = Field( nameof(HostSecret.SshKeyId), ancestor.SshKeyId, local.SshKeyId, remote.SshKeyId, conflicts, static id => id?.ToString() ?? "no key"), CredentialId = Field( nameof(HostSecret.CredentialId), ancestor.CredentialId, local.CredentialId, remote.CredentialId, conflicts, static id => id?.ToString() ?? "no credential"), GroupId = Field( nameof(HostSecret.GroupId), ancestor.GroupId, local.GroupId, remote.GroupId, conflicts, static id => id?.ToString() ?? "ungrouped"), }; private static string Text( string name, string? ancestor, string? local, string? remote, List conflicts) => Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!; /// /// /// A scalar clash always overrides the local side — see — so the /// discarded side is fixed here rather than derived. /// /// /// The formatter is handed the discarded value even when that value is null, and the null-forgiving /// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a /// nullable field whose discarded value was "unset" rather than a missing one. Short-circuiting on null /// instead — which this did — meant the formatter never ran for exactly that case, so a field whose /// absence has a name could not report it and the conflict log showed an empty string in its place. /// /// private static T Field( string name, T ancestor, T local, T remote, List conflicts, Func format, IEqualityComparer? comparer = null) { var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer); if (merge.IsConflicted) { conflicts.Add(new HostFieldConflict( name, MergeSide.Local, format(merge.Value), format(merge.Discarded!), DiscardedWasRemoval: false)); } return merge.Value; } private static HostOptions MergeOptions( HostOptions ancestor, HostOptions local, HostOptions remote, List conflicts) { var merge = ThreeWayMerge.Map( ancestor.ToNameMap(), local.ToNameMap(), remote.ToNameMap(), HostOption.NameComparer, StringComparer.Ordinal); foreach (var conflict in merge.Conflicts) { conflicts.Add(new HostFieldConflict( $"{nameof(HostSecret.Options)}[{conflict.Key}]", conflict.DiscardedSide, conflict.Kept, conflict.Discarded, conflict.DiscardedWasRemoval)); } // The merged map is keyed by the same comparer, so uniqueness already holds and Create // cannot throw here. return HostOptions.Create( merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value))); } private static string FormatChain(JumpChain chain) => chain.Count == 0 ? "(none)" : string.Join(" → ", chain); }