Files
DodoSSH/src/DodoSSH.Client.Sync/ItemReconciler.cs
T
jaap-jan f332d9a56f Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- The outbox coalesces a new save into the row a push already read, under a new operation id, but
  Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
  deleted the row holding the later one and the edit was gone without a word. They now take the
  PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
  just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
  added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
  sync_state.pulled_types records the list a cursor was read under, and a different list starts the
  pull over once. Migration AddSyncStatePulledTypes.
2026-10-02 13:16:47 +02:00

562 lines
22 KiB
C#

using System.Buffers.Binary;
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Derives the id a resurrected item takes.
/// </summary>
/// <remarks>
/// Deterministic, from the original id and the version of the tombstone that displaced it. That matters
/// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied,
/// so a process that dies in between re-applies them on the next start. A random id would resurrect the
/// same item twice and leave the user with duplicates to sort out; this way the second attempt produces
/// the same id and coalesces into the same outbox row.
/// <para>
/// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id
/// carries is meaningless for a copy created to rescue content from a deletion.
/// </para>
/// </remarks>
internal static class ResurrectionId
{
internal static Guid For(Guid entityId, int tombstoneVersion)
{
Span<byte> input = stackalloc byte[19 + 16 + sizeof(int)];
"dsh1/resurrect/v1"u8.CopyTo(input);
var offset = 17;
input[offset++] = 0;
input[offset++] = 0;
if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _))
{
throw new InvalidOperationException("Failed to write the entity id.");
}
offset += 16;
BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion);
Span<byte> digest = stackalloc byte[32];
SHA256.HashData(input, digest);
return new Guid(digest[..16], bigEndian: true);
}
}
/// <summary>
/// Reconciles one item type, with the secret type erased so the engine can hold a table of them.
/// </summary>
/// <remarks>
/// The engine never needs the concrete type — it dispatches on the entity type a change carries and lets
/// the reconciler do the rest — so this interface is what it stores. The two members are the two places
/// the push and pull paths need type-specific crypto.
/// </remarks>
internal interface IItemReconciler
{
/// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
Task ReconcileAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken);
/// <summary>Re-seals a queued change as a create, for a server that says it has no such row.</summary>
/// <returns>Null on success, or the reason the change could not be re-offered.</returns>
Task<string?> ReofferAsCreateAsync(
Guid vaultId,
PendingOperation pending,
CancellationToken cancellationToken);
}
/// <summary>
/// Decides what happens when a remote change collides with an unpushed local one.
/// </summary>
/// <remarks>
/// <para>
/// Shared by the pull and the push paths, because both meet the same six situations and must answer them
/// identically — a pull that merged one way and a push that merged the other would make the outcome
/// depend on which side happened to notice first. Shared across item types for the same reason: a host
/// and an SSH key meet those six situations in exactly the same way, and the only differences —
/// encoding, merge, plaintext columns, what to call the thing — arrive through
/// <see cref="IItemKind{TSecret}"/>.
/// </para>
/// <para>
/// <b>The governing rule is that nothing is discarded silently.</b> Where the two sides can be
/// reconciled field by field, they are. Where they cannot, one value survives, the other is written to
/// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives:
/// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot
/// reconstruct.
/// </para>
/// </remarks>
/// <remarks>
/// Takes no <see cref="ItemStore"/>, which is worth noticing rather than reading as an omission: nothing
/// here writes the mirror. Reconciling only ever revises the outbox and records conflicts, and the
/// server's own version of an item is written by <see cref="ItemMirror"/> before this is called.
/// </remarks>
internal sealed class ItemReconciler<TSecret>(
IItemKind<TSecret> kind,
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring) : IItemReconciler
where TSecret : class, IVaultSecret
{
/// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
public Task ReconcileAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(remote);
ArgumentNullException.ThrowIfNull(pending);
if (remote.Operation == SyncOperation.Delete)
{
return pending.Operation == SyncOperation.Delete
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
? outbox.CompleteAsync(pending, cancellationToken)
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
}
return pending.Operation == SyncOperation.Delete
? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken)
: MergeAsync(vaultId, remote, pending, report, cancellationToken);
}
/// <summary>
/// Re-seals a queued change as a create, for a server that says it has no such row.
/// </summary>
/// <remarks>
/// The payload has to be re-sealed rather than re-sent: it was sealed at the version this client
/// predicted, and a create produces version 1, which the AAD binds.
/// </remarks>
/// <returns>Null on success, or the reason the change could not be re-offered.</returns>
public async Task<string?> ReofferAsCreateAsync(
Guid vaultId,
PendingOperation pending,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(pending);
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|| pending.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey))
{
return "This item has no usable vault key.";
}
// Opened under the generation it was queued at and re-sealed under the current one. Those
// differ whenever a rotation lands between an offline edit and its push, and re-sealing is
// the point: what goes back to the server has to be readable by everybody holding the new key.
var local = kind.TryOpen(
pending.Payload,
queuedKey.Span,
pending.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
if (local is null)
{
return "The queued change could not be decrypted, so it could not be re-offered.";
}
await outbox.ReviseAsync(
pending,
SyncOperation.Upsert,
expectedVersion: null,
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
kind.Fields(local.Secret),
ancestor: null,
cancellationToken).ConfigureAwait(false);
return null;
}
/// <summary>
/// Reconciles a pending create that the server says already exists.
/// </summary>
/// <remarks>
/// In practice this means an earlier push of the same create did land and its acknowledgement was
/// lost — a timeout, a dropped connection — after which the local row may also have been edited. The
/// resolution adopts the server's row as the base and re-offers the local content as an update, so
/// the newer local state wins and no duplicate item appears. A genuine id collision between two
/// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's
/// values would be in the conflict log rather than gone.
/// </remarks>
internal async Task AdoptRemoteAsBaseAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
.ConfigureAwait(false);
if (opened is null)
{
return;
}
var (local, remoteSecret, vaultKey, generation) = opened.Value;
// Through the comparer, not ==. Both secrets are records with value equality, but TSecret is a
// type parameter, so == would bind to reference equality at compile time and never be true —
// turning "our own create coming back" into a conflict record on every single pass.
if (EqualityComparer<TSecret>.Default.Equals(local, remoteSecret))
{
// Field for field the same item: this is our own create coming back. Nothing to do but stop
// trying to send it again.
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
return;
}
await ReviseAsUpdateAsync(
vaultId, remote, pending, local, vaultKey, generation, cancellationToken)
.ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
remote.EntityId,
ConflictKind.FieldOverridden,
ConflictDetails.Encode(
$"An item with this id already existed on the server at version {remote.Version}. "
+ "The version from this machine was kept; the server's values are recorded here."),
cancellationToken).ConfigureAwait(false);
report.Merged++;
}
/// <summary>Merges two divergent edits of the same item.</summary>
private async Task MergeAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (pending.Ancestor is null)
{
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
.ConfigureAwait(false);
return;
}
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
.ConfigureAwait(false);
if (opened is null)
{
return;
}
var (local, remoteSecret, vaultKey, generation) = opened.Value;
// The ancestor is the version the server last confirmed, so it carries its own generation —
// typically the oldest of the three when a rotation has happened since.
var ancestor =
keyring.TryGetAt(vaultId, pending.Ancestor.Payload.KeyGeneration, out var ancestorKey)
? kind.TryOpen(
pending.Ancestor.Payload,
ancestorKey.Span,
remote.EntityId,
pending.Ancestor.Version)
: null;
if (ancestor is null)
{
// The base is unreadable, so a three-way merge is not possible. Falling back to a two-way
// one would have to guess which side changed what, so the honest move is to keep the local
// state as an update over the server's and record what was overridden.
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
.ConfigureAwait(false);
return;
}
var merged = kind.Merge(ancestor.Secret, local, remoteSecret);
await ReviseAsUpdateAsync(
vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken)
.ConfigureAwait(false);
if (merged.Conflicts.Count > 0)
{
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
remote.EntityId,
ConflictKind.FieldOverridden,
ConflictDetails.Encode(
$"'{merged.Merged.Label}' was edited in two places at once. "
+ $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
merged.Conflicts),
cancellationToken).ConfigureAwait(false);
}
report.Merged++;
}
/// <summary>
/// Keeps local content that a remote deletion would otherwise take with it.
/// </summary>
/// <remarks>
/// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late
/// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user
/// can see what happened. That is the whole of "never silently drop an item": the original goes, the
/// work does not.
/// </remarks>
private async Task ResurrectAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation))
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return;
}
var local = pending.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
? null
: kind.TryOpen(
pending.Payload,
queuedKey.Span,
remote.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
if (local is null || local.IsReadOnly)
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return;
}
var restoredId = ResurrectionId.For(remote.EntityId, remote.Version);
var restored = kind.Relabel(local.Secret, $"{local.Secret.Label} (restored)");
// Queued before the original is cleared, and that order matters. These are two separate
// transactions, so a process that dies between them has to fail in the direction that keeps the
// work: this way the original stays pending and the next pass resurrects again — landing on the
// same deterministic id, which coalesces into the row already queued. The other order would
// leave the tombstone accepted and the local content gone.
await outbox.QueueAsync(
new QueuedChange(
vaultId,
kind.EntityType,
restoredId,
SyncOperation.Upsert,
ExpectedVersion: null,
kind.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1),
kind.Fields(restored),
Ancestor: null),
cancellationToken).ConfigureAwait(false);
// Now the tombstone can stand.
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
remote.EntityId,
ConflictKind.RemoteDeleteResurrected,
ConflictDetails.Encode(
$"'{local.Secret.Label}' was deleted elsewhere while this machine had unsaved changes. "
+ $"The deletion stands and the local version was kept as '{restored.Label}'."),
cancellationToken).ConfigureAwait(false);
report.Resurrected++;
}
/// <summary>Drops a local deletion because the other side edited the item instead.</summary>
private async Task AbandonLocalDeleteAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
remote.EntityId,
ConflictKind.LocalDeleteOverridden,
ConflictDetails.Encode(
$"This {kind.Noun} was edited elsewhere after it was deleted here, so the deletion was "
+ "not applied. Delete it again if that is still what you want."),
cancellationToken).ConfigureAwait(false);
report.DeletesAbandoned++;
}
/// <summary>Re-offers an item as an update against the server's current version.</summary>
private async Task ReviseAsUpdateAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
TSecret secret,
ReadOnlyMemory<byte> vaultKey,
uint generation,
CancellationToken cancellationToken)
{
var nextVersion = SyncVersions.NextVersion(remote.Version);
await outbox.ReviseAsync(
pending,
SyncOperation.Upsert,
expectedVersion: remote.Version,
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
kind.Fields(secret),
new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields),
cancellationToken).ConfigureAwait(false);
}
/// <summary>Opens both sides of a collision, parking the operation if either will not open.</summary>
private async Task<(TSecret Local, TSecret Remote, ReadOnlyMemory<byte> VaultKey, uint Generation)?>
OpenPairAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|| pending.Payload is null
|| remote.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
|| !keyring.TryGetAt(vaultId, remote.Payload.KeyGeneration, out var remoteKey))
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return null;
}
// Each side under its own generation. The two genuinely differ after a rotation: what the
// server holds was sealed before it, and the queued edit after — or the other way round, for a
// client that rotated while this one was offline.
var local = kind.TryOpen(
pending.Payload,
queuedKey.Span,
remote.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
var remoteSecret = kind.TryOpen(
remote.Payload, remoteKey.Span, remote.EntityId, remote.Version);
if (local is null || remoteSecret is null)
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return null;
}
if (local.IsReadOnly || remoteSecret.IsReadOnly)
{
await ParkAsTooNewAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return null;
}
return (local.Secret, remoteSecret.Secret, vaultKey, generation);
}
/// <summary>
/// Leaves an item alone because a newer client wrote it.
/// </summary>
/// <remarks>
/// Re-encoding would drop fields this build cannot represent, so the item waits until this client is
/// updated. Parked rather than merged-and-hoped: the dropped field could be the one that matters.
/// </remarks>
private async Task ParkAsTooNewAsync(
Guid vaultId,
Guid entityId,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.ParkAsync(
pending,
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
entityId,
ConflictKind.TooNewToEdit,
ConflictDetails.Encode(
$"This {kind.Noun} was written by a newer version of DodoSSH. It can be read but not "
+ "merged here, because saving it would discard fields this version does not know "
+ "about."),
cancellationToken).ConfigureAwait(false);
report.Parked++;
}
private async Task ParkAsync(
Guid vaultId,
Guid entityId,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.ParkAsync(
pending,
$"The local or the server copy of this {kind.Noun} could not be decrypted.",
cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync(
vaultId,
kind.EntityType,
entityId,
ConflictKind.Undecryptable,
ConflictDetails.Encode(
$"This {kind.Noun} could not be decrypted, so the change made here could not be merged. "
+ "The vault key may have been rotated, or the stored payload may not belong to this "
+ "item."),
cancellationToken).ConfigureAwait(false);
report.Unreadable++;
report.Parked++;
}
}
/// <summary>Writes the server's version of an item into the local mirror.</summary>
/// <remarks>
/// Type-agnostic on purpose, and separate from the reconcilers for that reason: mirroring copies
/// ciphertext into a row and never decrypts, so there is nothing here for an item kind to decide. Making
/// it a method on a reconciler would have meant picking one arbitrarily, or having the engine look one up
/// for a change it can mirror without knowing anything about.
/// </remarks>
internal static class ItemMirror
{
internal static Task WriteAsync(
ItemStore items,
Guid vaultId,
SyncChange change,
CancellationToken cancellationToken) =>
items.SaveAsync(
new StoredItem(
vaultId,
change.EntityType,
change.EntityId,
change.Version,
change.ChangeSequence,
change.Payload,
change.PlaintextFields,
change.Operation == SyncOperation.Delete,
change.UpdatedAt),
cancellationToken);
}