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) ?? "the group's port"), 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), TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts), PinnedPaths = MergePinnedPaths( ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts), PortForwards = MergePortForwards( ancestor.PortForwards, local.PortForwards, remote.PortForwards, 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"), AsksForPassword = Field( nameof(HostSecret.AsksForPassword), ancestor.AsksForPassword, local.AsksForPassword, remote.AsksForPassword, conflicts, static asked => asked is true ? "a typed password" : "the group's binding"), // Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to // the credential holding its key — and it merges as one value for the reason the jump chain does: // an API address from one side and a server id from the other would be a route neither user // chose, to a server that may not exist on that Spelunk. Spelunk = Field( nameof(HostSecret.Spelunk), ancestor.Spelunk, local.Spelunk, remote.Spelunk, conflicts, static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"), }; 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))); } /// /// Merges the tag set per tag, so two people each adding a different one both keep theirs. /// /// /// /// The reason exposes a map at all. A whole-value merge here would take one side's /// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west" /// would silently lose one of the two, which is the single most visible difference between a field-level /// merge and last-writer-wins. /// /// /// No conflict is reachable, and the loop below is kept anyway. The value in the map is the key, /// so a tag can only be present or absent — and running that through /// leaves nothing to disagree about. Where both sides hold the tag or /// neither does they agree; where exactly one side moved, the other still matches the ancestor and the /// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this /// map cannot express. A set is the one collection shape that merges without ever asking the user. /// /// /// The loop stays because that proof depends on keying by the value, which /// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that /// nothing records. Unreachable code that costs a foreach over an empty list is a cheaper way to /// hold that invariant than a comment alone. /// /// private static TagSet MergeTags( TagSet ancestor, TagSet local, TagSet remote, List conflicts) { var merge = ThreeWayMerge.Map( ancestor.ToIdMap(), local.ToIdMap(), remote.ToIdMap(), EqualityComparer.Default); foreach (var conflict in merge.Conflicts) { conflicts.Add(new HostFieldConflict( $"{nameof(HostSecret.TagIds)}[{conflict.Key}]", conflict.DiscardedSide, conflict.Kept == Guid.Empty ? "not tagged" : "tagged", conflict.DiscardedWasRemoval ? "not tagged" : "tagged", conflict.DiscardedWasRemoval)); } return TagSet.Create(merge.Merged.Keys); } /// /// Merges the pinned paths per path, so two people each pinning a different one both keep theirs. /// /// /// /// The same reasoning as , and the same shape: a whole-value merge would take /// one side's list entire and drop the other's, so a colleague pinning /var/log while you pinned /// /var/www/app would silently lose one of the two. /// /// /// Unlike a tag set, order survives. is built in list /// order rather than a canonical one, and unions keys ancestor first, /// then local, then remote — so the merged keys come out base order, then this machine's additions, /// then the server's, which is what then /// preserves rather than re-sorting. /// /// /// No conflict is reachable here either, for the reason gives. The value /// in the map is the key, so a path can only be present or absent, and the "both sides moved /// differently" branch of the keyed merge needs one key to hold two values — which this map cannot /// express. The loop stays for the same reason it stays there: the proof depends on /// keying by value, and that is one edit away from no longer /// being true. /// /// private static PinnedPathList MergePinnedPaths( PinnedPathList ancestor, PinnedPathList local, PinnedPathList remote, List conflicts) { var merge = ThreeWayMerge.Map( ancestor.ToPathMap(), local.ToPathMap(), remote.ToPathMap(), StringComparer.Ordinal); foreach (var conflict in merge.Conflicts) { conflicts.Add(new HostFieldConflict( $"{nameof(HostSecret.PinnedPaths)}[{conflict.Key}]", conflict.DiscardedSide, conflict.Kept is null ? "not pinned" : "pinned", conflict.DiscardedWasRemoval ? "not pinned" : "pinned", conflict.DiscardedWasRemoval)); } return PinnedPathList.Create(merge.Merged.Keys); } /// /// Merges the forwards per forward, so two people each adding one both keep theirs. /// /// /// /// Keyed by id and ordered the way is: existing forwards, then this /// machine's additions, then the server's. /// /// /// Unlike a tag or a pinned path, a conflict is reachable here. The value under a key is the whole /// rule, so two machines changing one forward differently is a genuine clash. The server's version wins, /// as every scalar's does, and the other is recorded in OpenSSH's notation so the user can read what was /// lost. An edit on one side beats a removal on the other, which is the map merge's standing rule. /// /// private static PortForwardList MergePortForwards( PortForwardList ancestor, PortForwardList local, PortForwardList remote, List conflicts) { var merge = ThreeWayMerge.Map( ancestor.ToIdMap(), local.ToIdMap(), remote.ToIdMap(), EqualityComparer.Default); foreach (var conflict in merge.Conflicts) { conflicts.Add(new HostFieldConflict( $"{nameof(HostSecret.PortForwards)}[{conflict.Key}]", conflict.DiscardedSide, conflict.Kept?.Notation ?? "removed", conflict.Discarded?.Notation ?? "removed", conflict.DiscardedWasRemoval)); } return PortForwardList.Create(merge.Merged.Values); } private static string FormatChain(JumpChain chain) => chain.Count == 0 ? "(none)" : string.Join(" → ", chain); }