Files
DodoSSH/src/DodoSSH.Client.Storage/SyncStateStore.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

111 lines
4.2 KiB
C#

using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// Where each vault's pull has reached.
/// </summary>
/// <remarks>
/// <para>
/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted.
/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to
/// resume from a position the server never granted, and a tampered cursor is rejected rather than
/// silently mis-serving a range.
/// </para>
/// <para>
/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been
/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is
/// not.
/// </para>
/// </remarks>
public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> contexts)
{
/// <summary>
/// Reads a vault's position, or a fresh one starting from the beginning.
/// </summary>
/// <remarks>
/// Never returns null. An unknown vault is not an error — it is a vault this client has not synced
/// yet — and a caller forced to handle a null here would most likely handle it by starting from the
/// beginning anyway.
/// </remarks>
public async Task<StoredSyncState> ReadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<SyncStateRow>()
.AsNoTracking()
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null
? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0)
: new StoredSyncState(
row.VaultId,
row.Cursor,
row.KeyGeneration,
row.LastPulledAtUtc,
row.LastPushedAtUtc,
row.ServerTimeSkewMs);
}
/// <summary>Records a vault's position.</summary>
public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<SyncStateRow>()
.SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new SyncStateRow { VaultId = state.VaultId };
context.Add(row);
}
row.Cursor = state.Cursor;
row.KeyGeneration = state.KeyGeneration;
row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Forgets a vault's position so the next pull starts over.
/// </summary>
/// <remarks>
/// <para>
/// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or
/// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about
/// which half of the cache is still valid.
/// </para>
/// <para>
/// <b>The outbox is deliberately not cleared.</b> Those rows are the only copy of changes the user
/// made and the server has not accepted; discarding them here would turn a recoverable cache
/// problem into lost work. They re-push against the re-pulled state, conflicting and merging where
/// they must.
/// </para>
/// </remarks>
public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Set<SyncStateRow>()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.Set<CachedItemRow>()
.Where(r => r.VaultId == vaultId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}