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.
This commit is contained in:
2026-07-29 10:27:37 +02:00
parent a878c2b6bb
commit 8d2416a602
72 changed files with 11313 additions and 30 deletions
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The decrypted shape of a vault item, and the three-way merge over it.
Deliberately dependency-free: no Contracts, no Crypto, no EF. This is the one project that
knows what a host *means*, and it is the only place the merge rules live. Keeping it free of
the wire format and the cipher is what lets the conflict matrix be a pure, fast unit suite
with no ciphertext or database in sight.
-->
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Domain.Tests" />
</ItemGroup>
</Project>
+221
View File
@@ -0,0 +1,221 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// One SSH configuration directive on a host.
/// </summary>
/// <remarks>
/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
/// clients that resolved the same merge could end up holding <c>ServerAliveInterval</c> and
/// <c>serveraliveinterval</c>, compare their hosts as different, and push over each other forever
/// while agreeing on every actual value.
/// </remarks>
/// <param name="Name">Directive name, for example <c>ServerAliveInterval</c>.</param>
/// <param name="Value">Directive value, verbatim and case-sensitive.</param>
public sealed record HostOption(string Name, string Value)
{
/// <summary>Defines directive identity: SSH keywords are case-insensitive.</summary>
public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;
/// <inheritdoc />
public bool Equals(HostOption? other) =>
other is not null
&& NameComparer.Equals(Name, other.Name)
&& string.Equals(Value, other.Value, StringComparison.Ordinal);
/// <inheritdoc />
public override int GetHashCode() =>
HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
}
/// <summary>
/// A host's SSH directives: unique by name, held in name order.
/// </summary>
/// <remarks>
/// <para>
/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
/// is what lets two people add different directives to the same host and both survive — a
/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
/// engine does not push a spurious update on every pass.
/// </para>
/// <para>
/// <b>The cost, stated plainly:</b> real <c>ssh_config</c> permits a directive to repeat, and for
/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
/// surface it rather than quietly keeping one of the duplicates.
/// </para>
/// </remarks>
public sealed class HostOptions : IReadOnlyList<HostOption>, IEquatable<HostOptions>
{
private readonly HostOption[] items;
private readonly int hash;
private HostOptions(HostOption[] items)
{
this.items = items;
hash = ComputeHash(items);
}
/// <summary>No directives.</summary>
public static HostOptions Empty { get; } = new([]);
/// <inheritdoc />
public int Count => items.Length;
/// <inheritdoc />
public HostOption this[int index] => items[index];
/// <summary>
/// Builds a canonical collection, sorting by name.
/// </summary>
/// <exception cref="ArgumentException">A name repeats, or a name is blank.</exception>
public static HostOptions Create(IEnumerable<HostOption> options)
{
if (!TryCreate(options, out var result, out var error))
{
throw new ArgumentException(error, nameof(options));
}
return result;
}
/// <summary>
/// Builds a canonical collection, reporting rather than throwing on bad input.
/// </summary>
/// <remarks>
/// The non-throwing overload exists because these values arrive from two places neither of which
/// is trusted: a decrypted payload written by another client, and an imported
/// <c>ssh_config</c>. Neither should be able to raise an exception from inside a sync pass.
/// </remarks>
public static bool TryCreate(
IEnumerable<HostOption> options,
[NotNullWhen(true)] out HostOptions? result,
[NotNullWhen(false)] out string? error)
{
ArgumentNullException.ThrowIfNull(options);
result = null;
var ordered = options.ToArray();
if (!Validate(ordered, out error))
{
return false;
}
Array.Sort(
ordered,
static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));
result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
return true;
}
/// <summary>Looks up a directive by name, case-insensitively as SSH treats keywords.</summary>
public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
{
foreach (var option in items)
{
if (HostOption.NameComparer.Equals(option.Name, name))
{
value = option.Value;
return true;
}
}
value = null;
return false;
}
/// <inheritdoc />
public bool Equals(HostOptions? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
if (other is null || other.items.Length != items.Length || other.hash != hash)
{
return false;
}
return items.AsSpan().SequenceEqual(other.items);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as HostOptions);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<HostOption> GetEnumerator() => ((IEnumerable<HostOption>)items).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(HostOptions) is the named alternate.")]
public static bool operator ==(HostOptions? left, HostOptions? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right);
/// <summary>Projects to a name-keyed map, for the per-directive merge.</summary>
internal Dictionary<string, string> ToNameMap()
{
var map = new Dictionary<string, string>(items.Length, HostOption.NameComparer);
foreach (var option in items)
{
map[option.Name] = option.Value;
}
return map;
}
private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
{
foreach (var option in ordered)
{
if (option is null || string.IsNullOrWhiteSpace(option.Name))
{
error = "An SSH directive must have a name.";
return false;
}
}
var duplicate = ordered
.GroupBy(o => o.Name, HostOption.NameComparer)
.FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null)
{
error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
return false;
}
error = null;
return true;
}
private static int ComputeHash(HostOption[] items)
{
var accumulator = new HashCode();
accumulator.Add(items.Length);
foreach (var option in items)
{
accumulator.Add(option);
}
return accumulator.ToHashCode();
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A host as the user sees it: everything the server never gets to read.
/// </summary>
/// <remarks>
/// <para>
/// The whole of this record lives inside the item's encrypted payload. In particular there is no
/// plaintext label anywhere in the system — access-control administration runs on the client, which
/// can decrypt names, so the server never needs a searchable title.
/// </para>
/// <para>
/// <see cref="Hostname"/> and <see cref="Port"/> are here <em>and</em> may additionally appear as
/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
/// the one deliberate privacy concession in the design: the relay must resolve its target
/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
/// supplies only when relay is enabled. See ADR 0004.
/// </para>
/// <para>
/// Structural equality holds across every field, including the collections, which is what the merge
/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
/// </para>
/// </remarks>
public sealed record HostSecret
{
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
public const int DefaultPort = 22;
/// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; }
/// <summary>Hostname or address to connect to.</summary>
public required string Hostname { get; init; }
/// <summary>TCP port.</summary>
public int Port { get; init; } = DefaultPort;
/// <summary>Login user, when the host pins one.</summary>
public string? Username { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>
/// The jump chain, nearest hop first, as host item ids.
/// </summary>
/// <remarks>
/// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
/// chain changes which machine is reached through which, and a set union of two different chains
/// would produce a route neither user asked for.
/// </remarks>
public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;
/// <summary>SSH directives, unique by name.</summary>
public HostOptions Options { get; init; } = HostOptions.Empty;
/// <summary>
/// Whether this host may be dialled through the server relay.
/// </summary>
/// <remarks>
/// <para>
/// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
/// keeps. It has to: it is the flag that decides whether <see cref="Hostname"/> and
/// <see cref="Port"/> are copied out into those columns, and a setting the merge cannot see is a
/// setting two clients can silently disagree about — one of them re-exposing an address the other
/// had just withdrawn.
/// </para>
/// <para>
/// The plaintext copy is derived from this, in one place, so the address can only ever leave the
/// payload as a consequence of the user turning this on. See ADR 0004.
/// </para>
/// </remarks>
public bool RelayEnabled { get; init; }
/// <summary>
/// Checks the fields that must hold before this can be stored.
/// </summary>
/// <remarks>
/// Separate from construction on purpose. A view model binds directly to these properties and
/// passes through empty and half-typed states on the way to a valid one; a constructor that threw
/// would make the editor unusable. The sync layer validates before sealing, and the codec
/// validates on decode, which are the two points where an invalid host would become durable.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? error)
{
if (string.IsNullOrWhiteSpace(Label))
{
error = "A host needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(Hostname))
{
error = "A host needs a hostname or address.";
return false;
}
if (Port is < 1 or > 65535)
{
error = $"Port must be between 1 and 65535, not {Port}.";
return false;
}
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
{
error = "A jump chain cannot contain an empty host id.";
return false;
}
error = null;
return true;
}
}
@@ -0,0 +1,211 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded host payload, together with the schema version it was written at.</summary>
/// <param name="Host">The host.</param>
/// <param name="SchemaVersion">
/// The version the writing client used. May exceed
/// <see cref="HostSecretCodec.CurrentSchemaVersion"/>, which is the case this type exists to make
/// visible.
/// </param>
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
{
/// <summary>
/// Whether this payload was written by a newer client than the one reading it.
/// </summary>
/// <remarks>
/// <para>
/// Such an item is safe to <em>read</em> — every field this build knows about decodes normally —
/// but must not be re-encoded, because fields added by the newer schema are not represented here
/// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
/// class of bug that makes people stop trusting a synced vault.
/// </para>
/// <para>
/// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
/// fields through a round trip was the alternative and it is worse — it means carrying opaque
/// JSON inside the domain model, which then has no usable structural equality and so breaks the
/// merge.
/// </para>
/// </remarks>
public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
/// </summary>
/// <remarks>
/// <para>
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
/// grows a field without a migration — and the one thing that must not happen is an old client
/// quietly dropping a field a new one wrote, which is what <see cref="HostSecretDocument.IsReadOnly"/>
/// prevents.
/// </para>
/// <para>
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
/// a codec that produced different bytes for the same host would make every pass look like a change.
/// </para>
/// </remarks>
public static class HostSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
public static byte[] Encode(HostSecret host)
{
ArgumentNullException.ThrowIfNull(host);
if (!host.TryValidate(out var error))
{
throw new ArgumentException(error, nameof(host));
}
var options = new SortedDictionary<string, string>(HostOption.NameComparer);
foreach (var option in host.Options)
{
options[option.Name] = option.Value;
}
var document = new HostPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = host.Label,
Hostname = host.Hostname,
Port = host.Port,
Username = host.Username,
Notes = host.Notes,
JumpHostIds = [.. host.JumpHostIds],
Options = options,
RelayEnabled = host.RelayEnabled,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostPayloadJsonContext.Default.HostPayloadDocument);
}
/// <summary>
/// Parses a decrypted payload.
/// </summary>
/// <remarks>
/// Returns <see langword="false"/> rather than throwing on anything malformed. These bytes
/// authenticated under a key only vault members hold, so a failure here is not an attack — it is
/// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
/// rather than an exception that aborts the whole sync pass and strands every other change.
/// </remarks>
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out HostSecretDocument? document)
{
document = null;
HostPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, HostPayloadJsonContext.Default.HostPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
if (!TryBuild(parsed, out var host))
{
return false;
}
document = new HostSecretDocument(host, parsed.SchemaVersion);
return true;
}
private static bool TryBuild(
HostPayloadDocument parsed,
[NotNullWhen(true)] out HostSecret? host)
{
host = null;
var directives = (parsed.Options ?? [])
.Select(entry => new HostOption(entry.Key, entry.Value));
if (!HostOptions.TryCreate(directives, out var options, out _))
{
return false;
}
var candidate = new HostSecret
{
Label = parsed.Label ?? string.Empty,
Hostname = parsed.Hostname ?? string.Empty,
Port = parsed.Port,
Username = parsed.Username,
Notes = parsed.Notes,
JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
Options = options,
RelayEnabled = parsed.RelayEnabled,
};
if (!candidate.TryValidate(out _))
{
return false;
}
host = candidate;
return true;
}
}
/// <summary>
/// The serialised shape. Mutable and nullable because it models untrusted input.
/// </summary>
/// <remarks>
/// Deliberately separate from <see cref="HostSecret"/>. A single type would force the domain model to
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
/// everything — and would let a decode failure produce a half-built host that looks valid to
/// everything downstream.
/// </remarks>
internal sealed class HostPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Hostname { get; set; }
public int Port { get; set; }
public string? Username { get; set; }
public string? Notes { get; set; }
public Guid[]? JumpHostIds { get; set; }
/// <remarks>
/// Sorted, so serialisation order is defined by the type rather than by insertion order — a
/// plain <see cref="Dictionary{TKey,TValue}"/> does not guarantee enumeration order, and this
/// encoding has to be reproducible.
/// </remarks>
public SortedDictionary<string, string>? Options { get; set; }
public bool RelayEnabled { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// An unknown member means a newer client wrote a field this build has no concept of. Skipping it
// is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostPayloadDocument))]
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,178 @@
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A value the merge had to override, kept so the user can see it and put it back.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="Field">
/// Which field, as a path. A directive reads <c>Options[ServerAliveInterval]</c> so the user is told
/// which one rather than merely that "options" changed.
/// </param>
/// <param name="DiscardedSide">Whose intent was overridden.</param>
/// <param name="Kept">The value that survives, rendered for display.</param>
/// <param name="Discarded">The value that lost, rendered for display.</param>
/// <param name="DiscardedWasRemoval">
/// True when what lost was a deletion rather than a different value.
/// </param>
public sealed record HostFieldConflict(
string Field,
MergeSide DiscardedSide,
string? Kept,
string? Discarded,
bool DiscardedWasRemoval);
/// <summary>The merged host, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The host to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostMergeResult(
HostSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a host against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>Conflict</c> carrying the server's current row. Both paths need the
/// same answer, so both go through here.
/// </para>
/// <para>
/// 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 <see cref="ThreeWayMerge"/> for why the remote side wins.
/// </para>
/// </remarks>
public static class HostSecretMerge
{
/// <summary>
/// Produces the merged host.
/// </summary>
/// <param name="ancestor">
/// 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.
/// </param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
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(merged, conflicts);
}
private static string Text(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts) =>
Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;
/// <remarks>
/// A scalar clash always overrides the local side — see <see cref="ThreeWayMerge"/> — so the
/// discarded side is fixed here rather than derived.
/// </remarks>
private static T Field<T>(
string name,
T ancestor,
T local,
T remote,
List<HostFieldConflict> conflicts,
Func<T, string?> format,
IEqualityComparer<T>? comparer = null)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
format(merge.Value),
merge.Discarded is null ? null : format(merge.Discarded),
DiscardedWasRemoval: false));
}
return merge.Value;
}
private static HostOptions MergeOptions(
HostOptions ancestor,
HostOptions local,
HostOptions remote,
List<HostFieldConflict> 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);
}
+108
View File
@@ -0,0 +1,108 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// An ordered route to a host: the intermediate hosts to tunnel through, nearest hop first.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list of ids, for two reasons. It compares by contents, which the
/// merge depends on — a plain <see cref="IReadOnlyList{T}"/> on a record gets reference equality from
/// the compiler-generated <c>Equals</c>, so every host would read as changed on every sync pass and
/// two identical edits would register as a conflict. And it names the thing: the order here is the
/// route, so this is not a set and must never be merged as one.
/// </para>
/// <para>
/// Duplicate and empty hops are not rejected at construction. They arrive from a decrypted payload
/// written by another client, and a constructor that threw would turn one bad item into a failed sync
/// pass for every other item behind it. <see cref="HostSecret.TryValidate"/> is where that is caught.
/// </para>
/// </remarks>
public sealed class JumpChain : IReadOnlyList<Guid>, IEquatable<JumpChain>
{
private readonly Guid[] hops;
private readonly int hash;
private JumpChain(Guid[] hops)
{
this.hops = hops;
hash = ComputeHash(hops);
}
/// <summary>A direct connection: no intermediate hosts.</summary>
public static JumpChain Empty { get; } = new([]);
/// <inheritdoc />
public int Count => hops.Length;
/// <inheritdoc />
public Guid this[int index] => hops[index];
/// <summary>Copies a sequence of hops, preserving order.</summary>
public static JumpChain Create(IEnumerable<Guid> hops)
{
ArgumentNullException.ThrowIfNull(hops);
var copy = hops.ToArray();
return copy.Length == 0 ? Empty : new JumpChain(copy);
}
/// <summary>Copies a span of hops, preserving order.</summary>
public static JumpChain Create(ReadOnlySpan<Guid> hops) =>
hops.IsEmpty ? Empty : new JumpChain(hops.ToArray());
/// <inheritdoc />
public bool Equals(JumpChain? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
return other is not null
&& other.hash == hash
&& hops.AsSpan().SequenceEqual(other.hops);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as JumpChain);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<Guid> GetEnumerator() => ((IEnumerable<Guid>)hops).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => hops.GetEnumerator();
/// <summary>The hops, without copying.</summary>
public ReadOnlySpan<Guid> AsSpan() => hops;
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(JumpChain) is the named alternate.")]
public static bool operator ==(JumpChain? left, JumpChain? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(JumpChain? left, JumpChain? right) => !(left == right);
private static int ComputeHash(Guid[] hops)
{
// Order-sensitive, because reordering a route changes which machine is reached through which.
var accumulator = new HashCode();
accumulator.Add(hops.Length);
foreach (var hop in hops)
{
accumulator.Add(hop);
}
return accumulator.ToHashCode();
}
}
+272
View File
@@ -0,0 +1,272 @@
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;
}
}
}
}
@@ -0,0 +1,19 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
}
}
}
}