namespace DodoSSH.Client.Domain;
/// The merged credential, and everything that had to be overridden to produce it.
/// The credential to store and push.
/// Empty when the two sides were reconcilable field by field.
public sealed record CredentialMergeResult(
CredentialSecret Merged,
IReadOnlyList Conflicts)
{
/// Whether anything had to be overridden.
public bool HasConflicts => Conflicts.Count > 0;
}
///
/// Merges two divergent versions of a credential against the version they both started from.
///
///
///
/// Every field is a scalar, so this is the same shape as and reuses
/// for the same reason: the conflict log, the storage behind it and the
/// interface that shows it are shared, and a parallel record with identical members would have to be mapped
/// at every boundary.
///
///
/// The password never reaches the conflict log. Reported as having differed and nothing more, exactly
/// as SshKeySecretMerge does for key material — and here the case for it is if anything plainer, since
/// a discarded password is very often still the live password on some other system. The user loses nothing
/// they could act on: nobody reconciles two passwords by reading them side by side.
///
///
/// The username is shown, because it is not a secret and knowing which of two accounts the merge dropped is
/// exactly what makes the notice useful.
///
///
public static class CredentialSecretMerge
{
/// Produces the merged credential.
/// The version both sides branched from.
/// The pending local version.
/// The server's current version.
public static CredentialMergeResult Merge(
CredentialSecret ancestor,
CredentialSecret local,
CredentialSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List();
var merged = new CredentialSecret
{
// Null-forgiving on the two required fields, as the host and key merges do for the same reason:
// the merge returns one of its three inputs, and all three are non-null by construction.
Label = Resolve(
nameof(CredentialSecret.Label),
ancestor.Label,
local.Label,
remote.Label,
conflicts,
redact: false)!,
Password = Resolve(
nameof(CredentialSecret.Password),
ancestor.Password,
local.Password,
remote.Password,
conflicts,
redact: true)!,
Username = Resolve(
nameof(CredentialSecret.Username),
ancestor.Username,
local.Username,
remote.Username,
conflicts,
redact: false),
Notes = Resolve(
nameof(CredentialSecret.Notes),
ancestor.Notes,
local.Notes,
remote.Notes,
conflicts,
redact: false),
};
return new CredentialMergeResult(merged, conflicts);
}
private static string? Resolve(
string name,
string? ancestor,
string? local,
string? remote,
List conflicts,
bool redact)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
redact ? "(kept the server's value)" : merge.Value ?? "(none)",
redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
return merge.Value;
}
}