Files
DodoSSH/src/DodoSSH.Client.Domain/ThreeWayMerge.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
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.
2026-07-29 10:27:37 +02:00

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