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

143 lines
5.1 KiB
C#

using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// What the merge had to override, and what it could not process.
/// </summary>
/// <remarks>
/// <para>
/// This table is what makes automatic merging defensible. The merge picks a winner field by field,
/// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a
/// field-level merge is last-writer-wins with a longer explanation.
/// </para>
/// <para>
/// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately
/// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly
/// as sensitive as the item it came from and is treated that way.
/// </para>
/// </remarks>
public sealed class ConflictStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector,
TimeProvider clock)
{
/// <summary>
/// Records a conflict.
/// </summary>
/// <remarks>
/// The record's own id is generated here and the detail is bound to it, so one conflict's discarded
/// values can never be read back against another's row.
/// </remarks>
public async Task<Guid> RecordAsync(
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
ConflictKind kind,
ReadOnlyMemory<byte> detail,
CancellationToken cancellationToken)
{
if (kind == ConflictKind.Unspecified)
{
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required.");
}
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var id = Guid.CreateVersion7();
context.Add(new ConflictRow
{
Id = id,
VaultId = vaultId,
EntityType = entityType,
EntityId = entityId,
Kind = kind,
Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span),
DetectedAtUtc = clock.GetUtcNow(),
Acknowledged = false,
});
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return id;
}
/// <summary>Reads conflicts for a vault, newest first.</summary>
public async Task<IReadOnlyList<StoredConflict>> ListAsync(
Guid vaultId,
bool includeAcknowledged,
CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var query = context.Set<ConflictRow>()
.AsNoTracking()
.Where(row => row.VaultId == vaultId);
if (!includeAcknowledged)
{
query = query.Where(row => !row.Acknowledged);
}
var rows = await query
.OrderByDescending(row => row.DetectedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
}
/// <summary>Marks a conflict as dealt with.</summary>
/// <remarks>
/// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has
/// dismissed the notification. Someone who clicks past a warning and realises a minute later that
/// they wanted the other value should still be able to get it.
/// </remarks>
public async Task<bool> AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var updated = await context.Set<ConflictRow>()
.Where(row => row.Id == conflictId)
.ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
.ConfigureAwait(false);
return updated > 0;
}
/// <summary>Removes an acknowledged conflict for good.</summary>
public async Task<bool> DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var removed = await context.Set<ConflictRow>()
.Where(row => row.Id == conflictId && row.Acknowledged)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return removed > 0;
}
/// <remarks>
/// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its
/// kind, its item, its timestamp — is still worth showing even when the discarded value has become
/// unreadable, for instance after a passphrase change re-derived the cache key.
/// </remarks>
private StoredConflict ToStored(ConflictRow row) =>
new(
row.Id,
row.VaultId,
row.EntityType,
row.EntityId,
row.Kind,
protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [],
row.DetectedAtUtc,
row.Acknowledged);
}