Public Access
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.
The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.
One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.
The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.
The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.
That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.
Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.
ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.
Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.
The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.
Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.
It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.
Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.
Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.
The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.
845 tests green. Zero warnings, dotnet format clean.
Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
501 lines
18 KiB
C#
501 lines
18 KiB
C#
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, cursors that are opaque to the client, the pull filter, and the per-type rules about which
|
|
/// plaintext columns an item may carry. 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>
|
|
/// <para>
|
|
/// Rows are keyed on the entity type as well as the id, as the server's separate tables are and as the
|
|
/// client's cache is. Keying on the id alone would work for every test that uses one item type and would
|
|
/// silently make a host and a key with the same id the same row.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class FakeVaultServer : ISyncApi
|
|
{
|
|
/// <summary>The item types this fake knows, mirroring the server's own registry.</summary>
|
|
private static readonly SyncEntityType[] Supported =
|
|
[
|
|
SyncEntityType.Host,
|
|
SyncEntityType.SshKey,
|
|
SyncEntityType.Credential,
|
|
SyncEntityType.KnownHostKey,
|
|
];
|
|
|
|
private readonly Dictionary<(SyncEntityType Type, Guid EntityId), 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>The entity-type filter of the last pull, so a test can assert what was asked for.</summary>
|
|
internal IReadOnlyList<SyncEntityType>? LastPullTypes { 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);
|
|
|
|
LastPullTypes = request.EntityTypes;
|
|
|
|
// Empty or absent means every type, as the contract says.
|
|
var wanted = request.EntityTypes is { Count: > 0 } types ? types : null;
|
|
|
|
var page = log
|
|
.Where(entry => entry.Sequence > after)
|
|
.Where(entry => wanted is null || wanted.Contains(entry.EntityType))
|
|
.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,
|
|
SyncEntityType entityType = SyncEntityType.Host)
|
|
{
|
|
var result = Apply(new SyncPushOperation(
|
|
Guid.CreateVersion7(),
|
|
entityType,
|
|
entityId,
|
|
SyncOperation.Upsert,
|
|
rows.TryGetValue((entityType, entityId), out var existing) && !existing.IsDeleted
|
|
? existing.Version
|
|
: null,
|
|
payload,
|
|
fields));
|
|
|
|
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, SyncEntityType entityType = SyncEntityType.Host)
|
|
{
|
|
var existing = rows[(entityType, entityId)];
|
|
|
|
var result = Apply(new SyncPushOperation(
|
|
Guid.CreateVersion7(),
|
|
entityType,
|
|
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, SyncEntityType entityType = SyncEntityType.Host) =>
|
|
rows.TryGetValue((entityType, 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 (!Supported.Contains(operation.EntityType))
|
|
{
|
|
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.EntityType, 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 (!ValidateFields(operation.EntityType, fields, out var fieldError))
|
|
{
|
|
return Invalid(operation, fieldError);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>The per-type rules about which plaintext columns an item may carry.</summary>
|
|
/// <remarks>
|
|
/// One method per type, as the server has one class per type, because the differences are the interesting
|
|
/// part. Everything except a host is stricter rather than merely different: the relay concession belongs
|
|
/// to hosts alone, so anything else arriving with an address is a client bug and is refused with a reason
|
|
/// instead of being quietly dropped.
|
|
/// </remarks>
|
|
private static bool ValidateFields(
|
|
SyncEntityType entityType,
|
|
SyncPlaintextFields fields,
|
|
out string error) => entityType switch
|
|
{
|
|
SyncEntityType.SshKey => ValidateKeyFields(fields, out error),
|
|
SyncEntityType.Credential => ValidateCredentialFields(fields, out error),
|
|
SyncEntityType.KnownHostKey => ValidateKnownHostFields(fields, out error),
|
|
_ => ValidateHostFields(fields, out error),
|
|
};
|
|
|
|
private static bool ValidateKeyFields(SyncPlaintextFields fields, out string error)
|
|
{
|
|
error = string.Empty;
|
|
|
|
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
|
{
|
|
error = "An SSH key has no relay target; relay fields may only be set on a host.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool ValidateCredentialFields(SyncPlaintextFields fields, out string error)
|
|
{
|
|
error = string.Empty;
|
|
|
|
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
|
{
|
|
error = "A credential has no relay target; relay fields may only be set on a host.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A credential has no public key.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The type that does hold an address, and holds it inside the ciphertext. A pin arriving with one in the
|
|
/// clear would be the server being handed the list of endpoints a user reaches.
|
|
/// </remarks>
|
|
private static bool ValidateKnownHostFields(SyncPlaintextFields fields, out string error)
|
|
{
|
|
error = string.Empty;
|
|
|
|
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
|
{
|
|
error = "A known host key is not something the server dials; its address stays encrypted.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.PublicKeyFingerprint is not null)
|
|
{
|
|
error = "A known host key's fingerprint stays inside its payload.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool ValidateHostFields(SyncPlaintextFields fields, out string error)
|
|
{
|
|
error = string.Empty;
|
|
|
|
if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
|
|
{
|
|
error = "An address may only be supplied when relay is enabled.";
|
|
return false;
|
|
}
|
|
|
|
if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null))
|
|
{
|
|
error = "Relay-enabled hosts require both a hostname and a port.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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.EntityType, 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.EntityType, row.EntityId, change, row.Version, Now));
|
|
rows[(row.EntityType, 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.EntityType, 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(
|
|
row.EntityType,
|
|
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(
|
|
SyncEntityType EntityType,
|
|
Guid EntityId,
|
|
int Version,
|
|
long ChangeSequence,
|
|
EncryptedPayload Payload,
|
|
SyncPlaintextFields Fields,
|
|
bool IsDeleted);
|
|
|
|
private sealed record LogEntry(
|
|
long Sequence,
|
|
SyncEntityType EntityType,
|
|
Guid EntityId,
|
|
SyncOperation Operation,
|
|
int Revision,
|
|
DateTimeOffset OccurredAt);
|
|
|
|
private sealed record Receipt(int Version, long Sequence);
|
|
}
|