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,377 @@
using System.Globalization;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// An in-memory vault server with the real sync semantics.
/// </summary>
/// <remarks>
/// <para>
/// A faithful reimplementation of <c>DodoSSH.Api.Features.Sync.SyncService</c>'s decision table: the
/// version check, the tombstone-beats-late-upsert rule, idempotent deletes, operation receipts, the
/// change log, and cursors that are opaque to the client. It is not a stub that returns canned answers —
/// if it were, none of the conflict tests would mean anything, because the interesting behaviour is
/// exactly the server's refusal to apply a stale write.
/// </para>
/// <para>
/// The duplication against the real service is deliberate and is the point of the exercise: two
/// independent expressions of the same rules, and <c>SyncEndpointTests</c> checks the other one against
/// real Postgres. A shared implementation would let a misreading of the protocol pass on both sides.
/// </para>
/// </remarks>
internal sealed class FakeVaultServer : ISyncApi
{
private readonly Dictionary<Guid, Row> rows = [];
private readonly List<LogEntry> log = [];
private readonly Dictionary<Guid, Receipt> receipts = [];
internal FakeVaultServer(Guid vaultId, uint keyGeneration = 1)
{
VaultId = vaultId;
KeyGeneration = keyGeneration;
}
internal Guid VaultId { get; }
internal uint KeyGeneration { get; set; }
/// <summary>The server's clock, so a test can create skew deliberately.</summary>
internal DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(1_750_000_000);
/// <summary>Pull pages are capped here, as the real server clamps a client's requested limit.</summary>
internal int MaxPullLimit { get; set; } = 500;
/// <summary>Forces the next push to answer <see cref="SyncOperationStatus.Forbidden"/>.</summary>
internal bool DenyWrites { get; set; }
/// <summary>Pushes received, so a test can prove a retry did or did not happen.</summary>
internal int PushCount { get; private set; }
/// <summary>
/// Runs just before a push is applied, so a test can land another client's write in the window
/// between one client's pull and its push. That window is the whole subject of the cursor-gap test.
/// </summary>
internal Action? OnPush { get; set; }
internal int RowCount => rows.Count(entry => !entry.Value.IsDeleted);
/// <inheritdoc />
public Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var after = DecodeCursor(request.Cursor);
var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit);
var page = log.Where(entry => entry.Sequence > after).Take(limit + 1).ToList();
var hasMore = page.Count > limit;
if (hasMore)
{
page.RemoveAt(page.Count - 1);
}
// When nothing came back the cursor must not move, or a write landing between this read and the
// next would be skipped for ever.
var next = page.Count > 0 ? page[^1].Sequence : after;
return Task.FromResult(new SyncPullResponse(
[.. page.Select(entry => Hydrate(entry))],
EncodeCursor(next),
hasMore,
Now,
KeyGeneration));
}
/// <inheritdoc />
public Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken)
{
PushCount++;
var interleaved = OnPush;
OnPush = null;
interleaved?.Invoke();
var results = new List<SyncPushResult>(request.Operations.Count);
foreach (var operation in request.Operations)
{
results.Add(Apply(operation));
}
return Task.FromResult(new SyncPushResponse(results, EncodeCursor(Head)));
}
/// <summary>Applies a change as if another client had made it.</summary>
internal int ExternalUpsert(Guid entityId, EncryptedPayload payload, SyncPlaintextFields? fields)
{
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
rows.TryGetValue(entityId, out var existing) && !existing.IsDeleted
? existing.Version
: null,
payload,
fields ?? new SyncPlaintextFields()));
if (result.Status != SyncOperationStatus.Applied)
{
throw new InvalidOperationException(
$"The external write was not applied: {result.Status} — {result.Detail}.");
}
return result.Version!.Value;
}
/// <summary>Deletes as if another client had done it.</summary>
internal void ExternalDelete(Guid entityId)
{
var existing = rows[entityId];
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Host,
entityId,
SyncOperation.Delete,
existing.Version,
null,
null));
if (result.Status != SyncOperationStatus.Applied)
{
throw new InvalidOperationException($"The external delete was not applied: {result.Status}.");
}
}
internal Row? Find(Guid entityId) => rows.TryGetValue(entityId, out var row) ? row : null;
private long Head => log.Count == 0 ? 0 : log[^1].Sequence;
// ---- The decision table ----
private SyncPushResult Apply(SyncPushOperation operation)
{
if (operation.EntityType != SyncEntityType.Host)
{
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
}
if (receipts.TryGetValue(operation.OperationId, out var receipt))
{
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Duplicate,
receipt.Version,
receipt.Sequence,
null,
null);
}
if (DenyWrites)
{
return new SyncPushResult(
operation.OperationId, SyncOperationStatus.Forbidden, null, null, null, null);
}
rows.TryGetValue(operation.EntityId, out var existing);
return operation.Operation == SyncOperation.Delete
? ApplyDelete(operation, existing)
: ApplyUpsert(operation, existing);
}
private SyncPushResult ApplyUpsert(SyncPushOperation operation, Row? existing)
{
if (operation.Payload is null)
{
return Invalid(operation, "An upsert requires a payload.");
}
if (operation.Payload.WrappedDataKey.Length == 0 || operation.Payload.DataKeyId == Guid.Empty)
{
return Invalid(operation, "A payload requires its data key.");
}
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
{
return Invalid(operation, "An address may only be supplied when relay is enabled.");
}
if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null))
{
return Invalid(operation, "Relay-enabled hosts require both a hostname and a port.");
}
if (existing is null || existing.IsDeleted)
{
return Create(operation, existing, fields);
}
if (operation.ExpectedVersion != existing.Version)
{
return Conflict(operation, existing);
}
var updated = existing with
{
Version = existing.Version + 1,
Payload = operation.Payload,
Fields = fields,
IsDeleted = false,
};
return Commit(operation, updated, SyncOperation.Upsert);
}
private SyncPushResult Create(SyncPushOperation operation, Row? existing, SyncPlaintextFields fields)
{
// A tombstone beats a late upsert. The client is told so it can resurrect the item deliberately
// under a new id rather than silently undoing someone else's delete.
if (existing?.IsDeleted == true)
{
return Conflict(operation, existing);
}
if (operation.ExpectedVersion is not null)
{
// The client believes it is updating something that does not exist here.
return Conflict(operation, existing: null);
}
var created = new Row(operation.EntityId, 1, 0, operation.Payload!, fields, false);
return Commit(operation, created, SyncOperation.Upsert);
}
private SyncPushResult ApplyDelete(SyncPushOperation operation, Row? existing)
{
if (existing is null)
{
return Invalid(operation, "Cannot delete an item that does not exist.");
}
if (existing.IsDeleted)
{
// Idempotent: a client retrying a delete it is unsure about should not have to tell these
// two situations apart.
return new SyncPushResult(
operation.OperationId,
SyncOperationStatus.Applied,
existing.Version,
existing.ChangeSequence,
null,
null);
}
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
{
return Conflict(operation, existing);
}
var tombstone = existing with
{
Version = existing.Version + 1,
IsDeleted = true,
// The address goes with the item, or the server stays able to resolve a host the user
// believes they deleted.
Fields = new SyncPlaintextFields(),
};
return Commit(operation, tombstone, SyncOperation.Delete);
}
private SyncPushResult Commit(SyncPushOperation operation, Row row, SyncOperation change)
{
var sequence = Head + 1;
log.Add(new LogEntry(sequence, row.EntityId, change, row.Version, Now));
rows[row.EntityId] = row with { ChangeSequence = sequence };
receipts[operation.OperationId] = new Receipt(row.Version, sequence);
return new SyncPushResult(
operation.OperationId, SyncOperationStatus.Applied, row.Version, sequence, null, null);
}
private SyncPushResult Conflict(SyncPushOperation operation, Row? existing) =>
new(
operation.OperationId,
SyncOperationStatus.Conflict,
existing?.Version,
existing?.ChangeSequence,
existing is null ? null : ToChange(existing),
null);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new(operation.OperationId, SyncOperationStatus.Invalid, null, null, null, detail);
private SyncChange Hydrate(LogEntry entry)
{
var row = rows[entry.EntityId];
return ToChange(row, entry.Sequence, entry.Revision, entry.OccurredAt);
}
private SyncChange ToChange(Row row, long? sequence = null, int? version = null, DateTimeOffset? at = null) =>
new(
SyncEntityType.Host,
row.EntityId,
row.IsDeleted ? SyncOperation.Delete : SyncOperation.Upsert,
version ?? row.Version,
sequence ?? row.ChangeSequence,
// A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete
// ciphertext would undermine the point of the tombstone.
row.IsDeleted ? null : row.Payload,
row.IsDeleted ? null : row.Fields,
at ?? Now);
// ---- Cursors ----
/// <remarks>
/// Prefixed and non-numeric so a client that tried to compute one would produce something this
/// rejects. The real server HMAC-tags them; the property that matters to the client is only that it
/// must round-trip what it is given.
/// </remarks>
private static string EncodeCursor(long sequence) =>
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
private static long DecodeCursor(string? cursor)
{
if (string.IsNullOrEmpty(cursor))
{
return 0;
}
if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal)
|| !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence))
{
throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'.");
}
return sequence;
}
internal sealed record Row(
Guid EntityId,
int Version,
long ChangeSequence,
EncryptedPayload Payload,
SyncPlaintextFields Fields,
bool IsDeleted);
private sealed record LogEntry(
long Sequence,
Guid EntityId,
SyncOperation Operation,
int Revision,
DateTimeOffset OccurredAt);
private sealed record Receipt(int Version, long Sequence);
}