Public Access
Three new client projects, and the wire-contract fix they needed. DodoSSH.Client.Domain holds the decrypted item model and the three-way merge, with no I/O at all — so the suite that decides whether a credential can be lost runs in milliseconds with nothing to mock. Scalars defer to the server on a genuine clash so every replica resolves the same triple identically and two clients cannot ping-pong; directives merge per name so two people each adding one both keep theirs; the jump chain merges as a whole value because its order is the route. Whatever loses is returned rather than dropped. DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are already ciphertext, so an encrypted file would protect protected bytes at the cost of a native dependency. It keeps the server's state and the outbox in separate tables, which is what preserves the common ancestor a merge needs. One pending operation per item, enforced by a unique index. DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts — a change with no local work pending is plumbed as ciphertext — so a first sync of thousands of items does not run twice as many AEAD operations for nothing. Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The specification has required a per-item data key since crypto.md §3, the columns have existed since the first migration and DshAad.ItemPayload binds the id, but this record had nowhere to put either — so a spec-compliant item could not be transmitted at all. Found by writing the client that has to produce one. Also closes a hole in AadResourceType, which had no value for the HostTag and HostCredential that SyncEntityType has always listed. Four bugs the tests found, not review: - SQLite refuses to order or compare its own DateTimeOffset mapping, and throws at execution rather than model build. Collecting tombstones and listing conflicts are both that shape, so this was a crash waiting for the first user with a deleted host. Timestamps are integers now, by convention so a later field cannot be the one left unconverted. - SQLitePCLRaw 2.1.11, which EF resolves, is covered by GHSA-2m69-gcr7-jv3q. Pinned forward as a family. - Resurrecting content from a remote deletion cleared the original before queueing the copy. Two transactions, so a crash between them lost the work; reversed, and the rescued id is derived from the tombstone so a replay coalesces instead of duplicating. - Several equality assertions went through Shouldly's ShouldBe, which compares IEnumerable element-wise and so tested nothing about the Equals these types exist to provide. Corrected; the falsification that caught it went from 2 failures to 6. The push response's cursor is deliberately ignored. It sits after this client's own writes, so adopting it skips anything another client committed at a lower sequence in the window between a pull and a push — permanently. Re-reading one's own writes is idempotent and costs a page. The Contracts doc that invited the shortcut now says so. 593 tests, up from 448. The delete-versus-edit rules, the ancestor retention, the fresh operation id on coalesce and the cursor safeguard were each verified by breaking them and watching the right test fail.
273 lines
10 KiB
C#
273 lines
10 KiB
C#
using System.Runtime.InteropServices;
|
|
|
|
namespace DodoSSH.Client.Domain;
|
|
|
|
/// <summary>Which of the two diverging replicas a value came from.</summary>
|
|
public enum MergeSide
|
|
{
|
|
/// <summary>The edit made on this machine.</summary>
|
|
Local = 0,
|
|
|
|
/// <summary>The edit that arrived from the server.</summary>
|
|
Remote = 1,
|
|
}
|
|
|
|
/// <summary>How a single field was resolved.</summary>
|
|
public enum MergeDecision
|
|
{
|
|
/// <summary>
|
|
/// Both sides hold the same value — either neither changed it, or both made the identical
|
|
/// change. Distinguishing those two is not useful: the outcome is the same and no one is
|
|
/// surprised.
|
|
/// </summary>
|
|
Agreed = 0,
|
|
|
|
/// <summary>Only this machine changed it.</summary>
|
|
TookLocal = 1,
|
|
|
|
/// <summary>Only the server side changed it.</summary>
|
|
TookRemote = 2,
|
|
|
|
/// <summary>Both changed it, differently. One value survives and the other is reported.</summary>
|
|
Conflicted = 3,
|
|
}
|
|
|
|
/// <summary>The outcome of merging one field.</summary>
|
|
/// <typeparam name="T">The field's type.</typeparam>
|
|
/// <param name="Value">The value to keep.</param>
|
|
/// <param name="Decision">How it was resolved.</param>
|
|
/// <param name="Discarded">
|
|
/// The value that lost, meaningful only when <paramref name="Decision"/> is
|
|
/// <see cref="MergeDecision.Conflicted"/>. Never simply dropped: the caller is expected to record it.
|
|
/// </param>
|
|
[StructLayout(LayoutKind.Auto)]
|
|
public readonly record struct FieldMerge<T>(T Value, MergeDecision Decision, T? Discarded)
|
|
{
|
|
/// <summary>Whether both sides changed this field to different values.</summary>
|
|
public bool IsConflicted => Decision == MergeDecision.Conflicted;
|
|
}
|
|
|
|
/// <summary>A key whose value both sides changed, or which one side removed while the other edited.</summary>
|
|
/// <typeparam name="TKey">Key type.</typeparam>
|
|
/// <typeparam name="TValue">Value type.</typeparam>
|
|
/// <param name="Key">The key in question.</param>
|
|
/// <param name="Kept">The value that survives, or <see langword="default"/> if the key is removed.</param>
|
|
/// <param name="DiscardedSide">Which replica's intent was overridden.</param>
|
|
/// <param name="Discarded">
|
|
/// The value that lost, or <see langword="default"/> when what lost was a removal.
|
|
/// </param>
|
|
/// <param name="DiscardedWasRemoval">
|
|
/// True when the overridden intent was to remove the key rather than to set it to a different value.
|
|
/// </param>
|
|
[StructLayout(LayoutKind.Auto)]
|
|
public readonly record struct MapConflict<TKey, TValue>(
|
|
TKey Key,
|
|
TValue? Kept,
|
|
MergeSide DiscardedSide,
|
|
TValue? Discarded,
|
|
bool DiscardedWasRemoval);
|
|
|
|
/// <summary>The outcome of merging a keyed collection.</summary>
|
|
/// <typeparam name="TKey">Key type.</typeparam>
|
|
/// <typeparam name="TValue">Value type.</typeparam>
|
|
/// <param name="Merged">The resulting collection.</param>
|
|
/// <param name="Conflicts">Every key where the two sides disagreed.</param>
|
|
[StructLayout(LayoutKind.Auto)]
|
|
public readonly record struct MapMerge<TKey, TValue>(
|
|
IReadOnlyDictionary<TKey, TValue> Merged,
|
|
IReadOnlyList<MapConflict<TKey, TValue>> Conflicts)
|
|
where TKey : notnull;
|
|
|
|
/// <summary>
|
|
/// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two
|
|
/// divergent versions.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why
|
|
/// a conflicting push comes back with the server's current row rather than being resolved for us,
|
|
/// and why this code is the last line of defence against losing a credential.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why the remote side wins a genuine clash.</b> It has to be one of them, and it has to be the
|
|
/// same one on every replica. If each client kept its own value, two clients would resolve the same
|
|
/// triple in opposite directions, each push would conflict with the other's, and they would ping-pong
|
|
/// forever without converging. Deferring to the value already on the server converges in one round.
|
|
/// </para>
|
|
/// <para>
|
|
/// The losing value is <em>never</em> discarded silently. Every primitive returns it, the item-level
|
|
/// merge collects them, and the sync engine writes them to a conflict log the user can act on. This
|
|
/// is the whole point: a merge that quietly drops the password someone just typed is worse than one
|
|
/// that refuses to merge at all.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ThreeWayMerge
|
|
{
|
|
/// <summary>
|
|
/// Resolves one field.
|
|
/// </summary>
|
|
/// <param name="ancestor">The value both sides started from.</param>
|
|
/// <param name="local">This machine's value.</param>
|
|
/// <param name="remote">The server's value.</param>
|
|
/// <param name="comparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
|
public static FieldMerge<T> Scalar<T>(
|
|
T ancestor,
|
|
T local,
|
|
T remote,
|
|
IEqualityComparer<T>? comparer = null)
|
|
{
|
|
comparer ??= EqualityComparer<T>.Default;
|
|
|
|
// Checked first, so two people making the identical edit is agreement rather than a
|
|
// conflict they have to be bothered about.
|
|
if (comparer.Equals(local, remote))
|
|
{
|
|
return new FieldMerge<T>(local, MergeDecision.Agreed, default);
|
|
}
|
|
|
|
if (comparer.Equals(local, ancestor))
|
|
{
|
|
return new FieldMerge<T>(remote, MergeDecision.TookRemote, default);
|
|
}
|
|
|
|
if (comparer.Equals(remote, ancestor))
|
|
{
|
|
return new FieldMerge<T>(local, MergeDecision.TookLocal, default);
|
|
}
|
|
|
|
return new FieldMerge<T>(remote, MergeDecision.Conflicted, local);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a keyed collection key by key.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Per-key rather than whole-collection, which is the difference between two people each adding
|
|
/// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the
|
|
/// single most visible benefit of a field-level merge over last-writer-wins.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An edit beats a removal.</b> Where one side deleted a key and the other changed its value,
|
|
/// the value survives and the removal is reported. The asymmetry is deliberate and it is not a
|
|
/// preference: re-applying a removal costs one click, while a discarded value may be the only
|
|
/// copy of something the user cannot reconstruct.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="ancestor">The state both sides started from.</param>
|
|
/// <param name="local">This machine's state.</param>
|
|
/// <param name="remote">The server's state.</param>
|
|
/// <param name="keyComparer">Defines key identity.</param>
|
|
/// <param name="valueComparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
|
public static MapMerge<TKey, TValue> Map<TKey, TValue>(
|
|
IReadOnlyDictionary<TKey, TValue> ancestor,
|
|
IReadOnlyDictionary<TKey, TValue> local,
|
|
IReadOnlyDictionary<TKey, TValue> remote,
|
|
IEqualityComparer<TKey> keyComparer,
|
|
IEqualityComparer<TValue>? valueComparer = null)
|
|
where TKey : notnull
|
|
{
|
|
ArgumentNullException.ThrowIfNull(ancestor);
|
|
ArgumentNullException.ThrowIfNull(local);
|
|
ArgumentNullException.ThrowIfNull(remote);
|
|
ArgumentNullException.ThrowIfNull(keyComparer);
|
|
|
|
valueComparer ??= EqualityComparer<TValue>.Default;
|
|
|
|
var merged = new Dictionary<TKey, TValue>(keyComparer);
|
|
var conflicts = new List<MapConflict<TKey, TValue>>();
|
|
|
|
foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer))
|
|
{
|
|
var a = Slot.For(ancestor, key);
|
|
var l = Slot.For(local, key);
|
|
var r = Slot.For(remote, key);
|
|
|
|
var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts);
|
|
|
|
if (resolved.Present)
|
|
{
|
|
merged[key] = resolved.Value!;
|
|
}
|
|
}
|
|
|
|
return new MapMerge<TKey, TValue>(merged, conflicts);
|
|
}
|
|
|
|
/// <summary>One key's state on one replica: present with a value, or absent.</summary>
|
|
[StructLayout(LayoutKind.Auto)]
|
|
private readonly record struct Slot<TValue>(bool Present, TValue? Value)
|
|
{
|
|
internal bool Matches(in Slot<TValue> other, IEqualityComparer<TValue> comparer) =>
|
|
Present == other.Present
|
|
&& (!Present || comparer.Equals(Value!, other.Value!));
|
|
}
|
|
|
|
private static class Slot
|
|
{
|
|
internal static Slot<TValue> For<TKey, TValue>(
|
|
IReadOnlyDictionary<TKey, TValue> source,
|
|
TKey key) =>
|
|
source.TryGetValue(key, out var value)
|
|
? new Slot<TValue>(true, value)
|
|
: new Slot<TValue>(false, default);
|
|
}
|
|
|
|
private static Slot<TValue> ResolveKey<TKey, TValue>(
|
|
TKey key,
|
|
in Slot<TValue> ancestor,
|
|
in Slot<TValue> local,
|
|
in Slot<TValue> remote,
|
|
IEqualityComparer<TValue> valueComparer,
|
|
List<MapConflict<TKey, TValue>> conflicts)
|
|
{
|
|
if (local.Matches(remote, valueComparer))
|
|
{
|
|
return local;
|
|
}
|
|
|
|
if (local.Matches(ancestor, valueComparer))
|
|
{
|
|
return remote;
|
|
}
|
|
|
|
if (remote.Matches(ancestor, valueComparer))
|
|
{
|
|
return local;
|
|
}
|
|
|
|
// Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal;
|
|
// where both hold one, defer to the server so every replica converges the same way.
|
|
var winner = remote.Present ? remote : local;
|
|
var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote;
|
|
var loser = remote.Present ? local : remote;
|
|
|
|
conflicts.Add(new MapConflict<TKey, TValue>(
|
|
key,
|
|
winner.Value,
|
|
loserSide,
|
|
loser.Present ? loser.Value : default,
|
|
DiscardedWasRemoval: !loser.Present));
|
|
|
|
return winner;
|
|
}
|
|
|
|
private static IEnumerable<TKey> UnionOfKeys<TKey, TValue>(
|
|
IReadOnlyDictionary<TKey, TValue> ancestor,
|
|
IReadOnlyDictionary<TKey, TValue> local,
|
|
IReadOnlyDictionary<TKey, TValue> remote,
|
|
IEqualityComparer<TKey> keyComparer)
|
|
where TKey : notnull
|
|
{
|
|
var seen = new HashSet<TKey>(keyComparer);
|
|
|
|
foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys))
|
|
{
|
|
if (seen.Add(key))
|
|
{
|
|
yield return key;
|
|
}
|
|
}
|
|
}
|
|
}
|