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