using System.Buffers.Binary;
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync;
///
/// Derives the id a resurrected item takes.
///
///
/// 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.
///
/// 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.
///
///
internal static class ResurrectionId
{
internal static Guid For(Guid entityId, int tombstoneVersion)
{
Span 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 digest = stackalloc byte[32];
SHA256.HashData(input, digest);
return new Guid(digest[..16], bigEndian: true);
}
}
///
/// Reconciles one item type, with the secret type erased so the engine can hold a table of them.
///
///
/// 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.
///
internal interface IItemReconciler
{
/// Reconciles a remote change against the operation pending for the same item.
Task ReconcileAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken);
/// Re-seals a queued change as a create, for a server that says it has no such row.
/// Null on success, or the reason the change could not be re-offered.
Task ReofferAsCreateAsync(
Guid vaultId,
PendingOperation pending,
CancellationToken cancellationToken);
}
///
/// Decides what happens when a remote change collides with an unpushed local one.
///
///
///
/// 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
/// .
///
///
/// The governing rule is that nothing is discarded silently. 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.
///
///
///
/// Takes no , 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 before this is called.
///
internal sealed class ItemReconciler(
IItemKind kind,
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring) : IItemReconciler
where TSecret : class, IVaultSecret
{
/// Reconciles a remote change against the operation pending for the same item.
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.Sequence, cancellationToken)
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
}
return pending.Operation == SyncOperation.Delete
? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken)
: MergeAsync(vaultId, remote, pending, report, cancellationToken);
}
///
/// Re-seals a queued change as a create, for a server that says it has no such row.
///
///
/// 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.
///
/// Null on success, or the reason the change could not be re-offered.
public async Task ReofferAsCreateAsync(
Guid vaultId,
PendingOperation pending,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(pending);
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) || pending.Payload is null)
{
return "This item has no usable vault key.";
}
var local = kind.TryOpen(
pending.Payload,
vaultKey.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.Sequence,
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;
}
///
/// Reconciles a pending create that the server says already exists.
///
///
/// 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.
///
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.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.Sequence, 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++;
}
/// Merges two divergent edits of the same item.
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;
var ancestor = kind.TryOpen(
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
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++;
}
///
/// Keeps local content that a remote deletion would otherwise take with it.
///
///
/// 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.
///
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
? null
: kind.TryOpen(
pending.Payload,
vaultKey.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.Sequence, 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++;
}
/// Drops a local deletion because the other side edited the item instead.
private async Task AbandonLocalDeleteAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.CompleteAsync(pending.Sequence, 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++;
}
/// Re-offers an item as an update against the server's current version.
private async Task ReviseAsUpdateAsync(
Guid vaultId,
SyncChange remote,
PendingOperation pending,
TSecret secret,
ReadOnlyMemory vaultKey,
uint generation,
CancellationToken cancellationToken)
{
var nextVersion = SyncVersions.NextVersion(remote.Version);
await outbox.ReviseAsync(
pending.Sequence,
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);
}
/// Opens both sides of a collision, parking the operation if either will not open.
private async Task<(TSecret Local, TSecret Remote, ReadOnlyMemory 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)
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return null;
}
var local = kind.TryOpen(
pending.Payload,
vaultKey.Span,
remote.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
var remoteSecret = kind.TryOpen(
remote.Payload, vaultKey.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);
}
///
/// Leaves an item alone because a newer client wrote it.
///
///
/// 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.
///
private async Task ParkAsTooNewAsync(
Guid vaultId,
Guid entityId,
PendingOperation pending,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
await outbox.ParkAsync(
pending.Sequence,
$"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.Sequence,
$"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++;
}
}
/// Writes the server's version of an item into the local mirror.
///
/// 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.
///
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);
}