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"),
};
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);
}