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