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.
This commit is contained in:
2026-07-29 10:27:37 +02:00
parent a878c2b6bb
commit 8d2416a602
72 changed files with 11313 additions and 30 deletions
@@ -0,0 +1,524 @@
using DodoSSH.Client.Storage;
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Two machines, one vault, every way they can disagree.
/// </summary>
/// <remarks>
/// The highest-value suite in the product, because this is the only place data can be lost. Every case
/// asserts two things: that the two devices converge on the same state, and that whatever the merge had
/// to override is recorded rather than gone. A merge that quietly drops the password someone just typed
/// is worse than one that refuses to merge at all.
/// </remarks>
public sealed class ConflictMatrixTests : IAsyncLifetime
{
private SyncHarness harness = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync() => harness = await CreateAsync();
/// <inheritdoc />
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
// ---- The uncontested paths ----
[Fact]
public async Task ACreatedHost_ReachesTheOtherMachine()
{
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "primary"));
await harness.SettleAsync();
var seen = await harness.Second.FindAsync(entityId);
seen.Host.Label.ShouldBe("prod-db");
seen.Host.Notes.ShouldBe("primary");
seen.HasUnsyncedChanges.ShouldBeFalse();
harness.Server.RowCount.ShouldBe(1);
}
[Fact]
public async Task AHostCreatedOffline_IsVisibleLocallyBeforeAnySync()
{
// The reason the outbox exists. A host typed in on a plane has to be usable on that plane.
var entityId = await harness.First.CreateAsync(Host("prod-db"));
var local = await harness.First.FindAsync(entityId);
local.Host.Label.ShouldBe("prod-db");
local.HasUnsyncedChanges.ShouldBeTrue();
harness.Server.RowCount.ShouldBe(0);
}
[Fact]
public async Task ALocalOnlyEdit_IsPushed()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "rotate quarterly"));
await harness.SettleAsync();
(await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("rotate quarterly");
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
}
[Fact]
public async Task ARemoteOnlyEdit_IsPulledWithoutAConflict()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres"));
await harness.SettleAsync();
(await harness.First.FindAsync(entityId)).Host.Username.ShouldBe("postgres");
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
}
// ---- Both edited ----
[Fact]
public async Task BothEditedDifferentFields_BothSurvive()
{
// The payoff for a field-level merge. Last-writer-wins would lose one of these.
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop"));
await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres"));
await harness.SettleAsync();
var merged = (await harness.First.FindAsync(entityId)).Host;
merged.Notes.ShouldBe("from the laptop");
merged.Username.ShouldBe("postgres");
(await harness.Second.FindAsync(entityId)).Host.ShouldBe(merged);
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
}
[Fact]
public async Task BothAddedADifferentDirective_BothSurvive()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateAsync(
entityId, Host("prod-db", options: [("Compression", "yes")]));
await harness.Second.UpdateAsync(
entityId, Host("prod-db", options: [("ServerAliveInterval", "30")]));
await harness.SettleAsync();
var merged = (await harness.First.FindAsync(entityId)).Host;
merged.Options.Count.ShouldBe(2);
merged.Options.TryGetValue("Compression", out _).ShouldBeTrue();
merged.Options.TryGetValue("ServerAliveInterval", out _).ShouldBeTrue();
}
[Fact]
public async Task BothEditedTheSameField_OneValueWinsAndTheOtherIsRecorded()
{
// A genuine clash. Whichever side loses, its value has to be retrievable — that is the entire
// justification for resolving automatically instead of blocking.
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "original"));
await harness.SettleAsync();
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop"));
await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "from the desktop"));
await harness.SettleAsync();
var first = (await harness.First.FindAsync(entityId)).Host;
var second = (await harness.Second.FindAsync(entityId)).Host;
first.ShouldBe(second);
var winner = first.Notes.ShouldNotBeNull();
var lost = string.Equals(winner, "from the laptop", StringComparison.Ordinal)
? "from the desktop"
: "from the laptop";
// One of the two, and the same one on both machines. Which is not the point; that the other is
// retrievable is.
new[] { "from the laptop", "from the desktop" }
.Contains(winner, StringComparer.Ordinal)
.ShouldBeTrue();
(await ConflictsAcrossDevicesAsync())
.ShouldContain(kind => kind == ConflictKind.FieldOverridden);
(await DiscardedValuesAsync())
.ShouldContain(
detail => detail.Contains(lost, StringComparison.Ordinal),
"the overridden value must be recoverable from the conflict log");
}
[Fact]
public async Task BothMadeTheSameEdit_IsNotAConflict()
{
// Two people fixing the same typo must not be asked to arbitrate.
var entityId = await harness.First.CreateAsync(Host("prod-db", hostname: "db.internl"));
await harness.SettleAsync();
await harness.First.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal"));
await harness.Second.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal"));
await harness.SettleAsync();
(await harness.First.FindAsync(entityId)).Host.Hostname.ShouldBe("db.internal");
(await ConflictsAcrossDevicesAsync()).ShouldBeEmpty();
}
// ---- Deletes ----
[Fact]
public async Task ADeletedHost_DisappearsEverywhere()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.DeleteAsync(entityId);
await harness.SettleAsync();
(await harness.First.ListAsync()).Hosts.ShouldBeEmpty();
(await harness.Second.ListAsync()).Hosts.ShouldBeEmpty();
harness.Server.RowCount.ShouldBe(0);
}
[Fact]
public async Task BothDeleted_IsNotAConflict()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.DeleteAsync(entityId);
await harness.Second.DeleteAsync(entityId);
await harness.SettleAsync();
(await harness.First.ListAsync()).Hosts.ShouldBeEmpty();
(await harness.Second.ListAsync()).Hosts.ShouldBeEmpty();
(await ConflictsAcrossDevicesAsync()).ShouldBeEmpty();
}
[Fact]
public async Task DeletedElsewhereWhileEditedHere_TheLocalWorkSurvivesUnderANewName()
{
// The case where naive handling loses data outright. The tombstone has to stand — arguing with it
// conflicts for ever — so the edit is preserved as a separate host instead of being dropped.
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
// The laptop syncs first, so its delete is what reaches the server; the desktop's edit is the
// one that has to be rescued. Which side loses is decided by who gets there first, and both
// orderings are covered — see EditedElsewhereWhileDeletedHere for the mirror image.
await harness.First.DeleteAsync(entityId);
await harness.Second.UpdateAsync(
entityId, Host("prod-db", notes: "credentials rotated, do not delete"));
await harness.SettleAsync();
var listing = await harness.First.ListAsync();
var restored = listing.Hosts.ShouldHaveSingleItem();
restored.EntityId.ShouldNotBe(entityId);
restored.Host.Label.ShouldBe("prod-db (restored)");
restored.Host.Notes.ShouldBe("credentials rotated, do not delete");
(await ConflictsAcrossDevicesAsync())
.ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected);
// And the other machine sees it too, so the rescue is not local-only.
(await harness.Second.ListAsync()).Hosts.ShouldHaveSingleItem()
.EntityId.ShouldBe(restored.EntityId);
}
[Fact]
public async Task ReplayingAPulledDeletion_DoesNotDuplicateTheRescuedCopy()
{
// Applying a pulled change is at-least-once — the cursor is saved after the page is applied — so
// a whole page can arrive twice. This checks the replay is harmless end to end; the deterministic
// id it relies on is pinned by ResurrectionIdTests.
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.DeleteAsync(entityId);
await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "keep me"));
await harness.First.SyncAsync();
// Rewind the desktop's cursor, so the deletion arrives a second time and it tries to rescue the
// same content twice.
await harness.Second.SyncAsync();
await harness.Second.SyncState.ResetAsync(VaultId, TestContext.Current.CancellationToken);
await harness.Second.SyncAsync();
await harness.SettleAsync();
(await harness.First.ListAsync()).Hosts.Count.ShouldBe(1);
harness.Server.RowCount.ShouldBe(1);
}
[Fact]
public async Task EditedElsewhereWhileDeletedHere_TheDeleteIsAbandonedAndReported()
{
// The mirror image, and resolved the same way round: an edit outlives a removal. Re-deleting
// costs a click; a discarded edit may be the only copy of something.
var entityId = await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "still needed"));
await harness.Second.DeleteAsync(entityId);
await harness.SettleAsync();
var survivor = await harness.First.FindAsync(entityId);
survivor.Host.Notes.ShouldBe("still needed");
(await ConflictsAcrossDevicesAsync())
.ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden);
}
// ---- Ordering, retries and idempotence ----
[Fact]
public async Task OfflineChanges_ArePushedInTheOrderTheyWereMade()
{
var first = await harness.First.CreateAsync(Host("a-bastion"));
var second = await harness.First.CreateAsync(Host("b-database"));
var third = await harness.First.CreateAsync(Host("c-cache"));
await harness.SettleAsync();
var order = (await harness.Second.ListAsync()).Hosts
.OrderBy(host => host.Version)
.ThenBy(host => host.Host.Label, StringComparer.Ordinal)
.Select(host => host.EntityId)
.ToArray();
order.ShouldBe([first, second, third], ignoreOrder: false);
}
[Fact]
public async Task ASecondSyncPass_ChangesNothing()
{
// Idempotence, which is what makes re-reading a client's own writes a safe way to avoid the
// cursor-gap hazard in the push response.
await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
var before = await harness.First.HostsSortedAsync();
var pushesBefore = harness.Server.PushCount;
var report = await harness.First.SyncAsync();
report.Pushed.ShouldBe(0);
harness.Server.PushCount.ShouldBe(pushesBefore);
(await harness.First.HostsSortedAsync()).ShouldBe(before);
}
[Fact]
public async Task AnEditWhileAnEarlierPushIsUnacknowledged_KeepsTheNewerValueWithoutDuplicating()
{
// A create that reached the server and whose answer did not come back, followed by another edit.
// The newer value has to win and there must be exactly one host afterwards. Two mechanisms keep
// that true: the pull sees the server's row and re-bases the queued edit onto it, and a coalesced
// row carries a fresh operation id so the server cannot answer Duplicate — "already applied" —
// for an operation whose contents have since changed. The second is pinned directly by
// OutboxStoreTests.ACoalescedEdit_GetsAFreshOperationId; here they are exercised together.
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "first"));
// The create lands on the server, but the acknowledgement never reaches the laptop.
await PushBehindTheEnginesBackAsync(entityId);
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "second"));
await harness.SettleAsync();
harness.Server.RowCount.ShouldBe(1);
(await harness.First.FindAsync(entityId)).Host.Notes.ShouldBe("second");
(await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("second");
}
[Fact]
public async Task AConcurrentWriteDuringAPush_IsNotSkipped()
{
// The cursor-gap hazard. The push response carries a cursor sitting after this client's own
// changes; adopting it would skip anything another client committed at a lower sequence in the
// window between this client's pull and its push. The engine keeps its own cursor instead.
var mine = await harness.First.CreateAsync(Host("mine"));
var theirs = Guid.CreateVersion7();
harness.Server.OnPush = () => harness.Server.ExternalUpsert(theirs, ForeignPayload(), null);
await harness.First.SyncAsync();
var ids = (await harness.First.Items
.ListAsync(VaultId, Contracts.SyncEntityType.Host, false, TestContext.Current.CancellationToken))
.Select(item => item.EntityId)
.ToArray();
ids.ShouldContain(mine);
ids.ShouldContain(theirs, "a change committed during the push was skipped");
}
[Fact]
public async Task AForbiddenWrite_IsParkedRatherThanRetriedForever()
{
var entityId = await harness.First.CreateAsync(Host("prod-db"));
harness.Server.DenyWrites = true;
var report = await harness.First.SyncAsync();
report.Parked.ShouldBe(1);
(await harness.First.Outbox.ListParkedAsync(VaultId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().EntityId.ShouldBe(entityId);
// A parked operation is not retried, so a second pass sends nothing.
var pushes = harness.Server.PushCount;
await harness.First.SyncAsync();
harness.Server.PushCount.ShouldBe(pushes);
(await harness.First.ConflictsAsync()).ShouldContain(c => c.Kind == ConflictKind.Rejected);
}
[Fact]
public async Task AParkedChange_IsStillWhatTheUserSees()
{
// Hiding it because the server refused would show the old values and look like the edit was lost.
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "mine"));
harness.Server.DenyWrites = true;
await harness.First.SyncAsync();
var host = await harness.First.FindAsync(entityId);
host.Host.Notes.ShouldBe("mine");
host.IsBlocked.ShouldBeTrue();
host.HasUnsyncedChanges.ShouldBeTrue();
}
[Fact]
public async Task ARekeyedVault_IsReportedRatherThanShowingAnEmptyList()
{
// After a rekey this client's grant is stale, so items pulled meanwhile cannot be read. Silently
// showing nothing would look exactly like an empty vault.
await harness.First.CreateAsync(Host("prod-db"));
await harness.SettleAsync();
harness.Server.KeyGeneration = 2;
var report = await harness.First.SyncAsync();
report.RekeyRequired.ShouldBeTrue();
report.ServerKeyGeneration.ShouldBe(2u);
report.NeedsAttention.ShouldBeTrue();
}
// ---- The overall property ----
[Fact]
public async Task AfterAnInterleavedSession_BothMachinesAgree()
{
// Convergence, over a fixed script that exercises creates, edits, a delete and a resurrection at
// once. Two devices that ended up with different host lists would be the worst possible outcome
// for a synced vault, and no single-case test rules it out.
var shared = await harness.First.CreateAsync(Host("a-shared"));
var doomed = await harness.First.CreateAsync(Host("b-doomed"));
await harness.SettleAsync();
await harness.First.UpdateAsync(shared, Host("a-shared", notes: "laptop note"));
await harness.Second.UpdateAsync(shared, Host("a-shared", username: "desktop-user"));
await harness.First.DeleteAsync(doomed);
await harness.Second.UpdateAsync(doomed, Host("b-doomed", notes: "still wanted"));
await harness.First.CreateAsync(Host("c-laptop-only"));
await harness.Second.CreateAsync(Host("d-desktop-only"));
await harness.SettleAsync();
await harness.SettleAsync();
var first = await harness.First.HostsSortedAsync();
var second = await harness.Second.HostsSortedAsync();
first.ShouldBe(second);
first.Count.ShouldBe(4);
first.Select(host => host.Label).ShouldBe(
["a-shared", "b-doomed (restored)", "c-laptop-only", "d-desktop-only"]);
// The merged host kept both sides' contributions.
var merged = first.Single(host => string.Equals(host.Label, "a-shared", StringComparison.Ordinal));
merged.Notes.ShouldBe("laptop note");
merged.Username.ShouldBe("desktop-user");
// And nothing is still queued anywhere.
(await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
.ShouldBeEmpty();
(await harness.Second.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
.ShouldBeEmpty();
}
// ---- Helpers ----
private static Contracts.EncryptedPayload ForeignPayload() =>
// Deliberately not decryptable: this stands in for another user's item, and the point of the test
// is only that the change is not skipped. Pulling never decrypts, so nothing here needs to open.
new([1, 2, 3], [4, 5], Guid.CreateVersion7(), 1, Crypto.CryptoSpec.CurrentAadVersion);
/// <summary>
/// Sends a device's queued operation straight to the server, leaving the outbox row in place.
/// </summary>
/// <remarks>
/// Reproduces the one situation the engine cannot reach on its own: a push that the server applied
/// and whose answer never came back. That window is where an idempotency key either saves the newer
/// edit or destroys it.
/// </remarks>
private async Task PushBehindTheEnginesBackAsync(Guid entityId)
{
var pending = await harness.First.Outbox.FindAsync(
VaultId, Contracts.SyncEntityType.Host, entityId, TestContext.Current.CancellationToken);
pending.ShouldNotBeNull();
await harness.Server.SyncPushAsync(
VaultId,
new Contracts.SyncPushRequest(
[
new Contracts.SyncPushOperation(
pending.OperationId,
pending.EntityType,
pending.EntityId,
pending.Operation,
pending.ExpectedVersion,
pending.Payload,
pending.Fields),
]),
TestContext.Current.CancellationToken);
}
private async Task<IReadOnlyList<ConflictKind>> ConflictsAcrossDevicesAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return [.. first.Concat(second).Select(conflict => conflict.Kind)];
}
private async Task<IReadOnlyList<string>> DiscardedValuesAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return
[
.. first.Concat(second)
.Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)),
];
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
The conflict matrix, and the rest of the sync policy.
Driven through ISyncApi by an in-memory server that reproduces the real version checks, change-log
sequences, tombstone rules and operation receipts. That is the point: a stubbed transport would only
prove the right bytes were sent, whereas the question these tests exist to answer is what happens to
a credential when two people edit one host at once.
Real SQLite caches and real DSH1 crypto on both sides — no fakes below this line — so a test that
says nothing was lost is saying it about the code that will ship.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,377 @@
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, and cursors that are opaque to the client. 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>
/// </remarks>
internal sealed class FakeVaultServer : ISyncApi
{
private readonly Dictionary<Guid, 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>
/// 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);
var page = log.Where(entry => entry.Sequence > after).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)
{
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
rows.TryGetValue(entityId, out var existing) && !existing.IsDeleted
? existing.Version
: null,
payload,
fields ?? new SyncPlaintextFields()));
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)
{
var existing = rows[entityId];
var result = Apply(new SyncPushOperation(
Guid.CreateVersion7(),
SyncEntityType.Host,
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) => rows.TryGetValue(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 (operation.EntityType != SyncEntityType.Host)
{
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.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 (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
{
return Invalid(operation, "An address may only be supplied when relay is enabled.");
}
if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null))
{
return Invalid(operation, "Relay-enabled hosts require both a hostname and a port.");
}
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);
}
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.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.EntityId, change, row.Version, Now));
rows[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.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(
SyncEntityType.Host,
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(
Guid EntityId,
int Version,
long ChangeSequence,
EncryptedPayload Payload,
SyncPlaintextFields Fields,
bool IsDeleted);
private sealed record LogEntry(
long Sequence,
Guid EntityId,
SyncOperation Operation,
int Revision,
DateTimeOffset OccurredAt);
private sealed record Receipt(int Version, long Sequence);
}
@@ -0,0 +1,204 @@
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Sealing and opening a host payload.
/// </summary>
/// <remarks>
/// Mostly negative tests, and deliberately so. docs/crypto.md §4.4 claims a server holding every
/// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair
/// one item's envelope with another's key wrap. Those claims are only worth making if something checks
/// them at the layer that actually assembles the AAD.
/// </remarks>
public sealed class HostCipherTests
{
private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly byte[] vaultKey = VaultKeys.Create();
private readonly byte[] otherVaultKey = VaultKeys.Create();
[Fact]
public void AHost_RoundTrips()
{
var host = Host();
var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1);
var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1);
opened.ShouldNotBeNull();
opened.Host.ShouldBe(host);
opened.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void EverySeal_UsesAFreshDataKey()
{
// One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes
// rather than rewriting content.
var host = Host();
var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
first.DataKeyId.ShouldNotBe(second.DataKeyId);
first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey);
first.Envelope.ShouldNotBe(second.Envelope);
}
[Fact]
public void APayload_CannotBeReadAsAnotherItem()
{
// The property that stops a server pasting one host's payload onto another row.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void APayload_CannotBeReadAtAnotherVersion()
{
// The sharpest edge in this layer. A payload is sealed at the version the server will assign, so
// getting that prediction wrong produces something that encrypts cleanly and never decrypts. The
// binding is what turns a silent corruption into a visible failure.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2);
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull();
}
[Fact]
public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1);
// The generation travels with the payload, so a server rewriting the column to 1 changes the AAD
// the client recomputes and the tag fails.
var rolledBack = payload with { KeyGeneration = 1 };
HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void APayload_CannotBeReadWithAnotherVaultsKey()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap()
{
// What content_key_id is in the AAD for. Without it the two halves of a payload would be
// interchangeable and a server could mix them.
var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1);
var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1);
var mixed = first with { WrappedDataKey = second.WrappedDataKey };
HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void ATamperedEnvelope_DoesNotOpen()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
var tampered = payload.Envelope.ToArray();
tampered[^1] ^= 0xFF;
HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull();
}
[Fact]
public void ASubstitutedDataKeyId_DoesNotOpen()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1)
.ShouldBeNull();
}
[Fact]
public void AMissingDataKey_IsRefusedRatherThanThrowing()
{
// What a row written before the data key existed in the contract would look like. It must degrade
// to one unreadable item, not to an exception inside a sync pass.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull();
}
[Fact]
public void Seal_RefusesAVersionBelowOne()
{
// Versions start at 1, and a zero would silently produce a payload no push could ever match.
Should.Throw<ArgumentOutOfRangeException>(
() => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0));
}
[Fact]
public void Seal_RefusesAHostThatCannotBeStored()
{
Should.Throw<ArgumentException>(
() => HostCipher.Seal(Host(label: " "), vaultKey, HostA, 1, 1));
}
[Fact]
public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced()
{
// The prediction both the sealing and the opening side depend on. If these two ever disagreed the
// result would be an item that encrypts and never decrypts, so they share one definition.
SyncVersions.NextVersion(null).ShouldBe(1);
SyncVersions.NextVersion(1).ShouldBe(2);
SyncVersions.NextVersion(41).ShouldBe(42);
}
[Fact]
public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes()
{
// The single point at which a hostname can leave the payload. With relay off the server learns
// only that an item exists; see ADR 0004.
var off = HostFields.From(Host(relayEnabled: false));
off.RelayEnabled.ShouldBeFalse();
off.Hostname.ShouldBeNull();
off.Port.ShouldBeNull();
var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true));
on.RelayEnabled.ShouldBeTrue();
on.Hostname.ShouldBe("bastion.internal");
on.Port.ShouldBe(2222);
}
[Fact]
public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip()
{
// It has to be, or two clients could silently disagree about it and one would re-expose an
// address the other had just withdrawn.
var host = Host(relayEnabled: true);
var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue();
}
private static HostSecret Host(
string label = "prod-db",
string hostname = "db.internal",
int port = 22,
bool relayEnabled = false) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = "deploy",
Options = HostOptions.Create([new HostOption("Compression", "yes")]),
RelayEnabled = relayEnabled,
};
}
@@ -0,0 +1,46 @@
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// The id a rescued item takes.
/// </summary>
/// <remarks>
/// Determinism here is what makes the rescue crash-safe. The reconciler queues the restored copy before
/// it clears the original, and those are two separate transactions — so a process that dies between them
/// leaves the original pending and resurrects again on the next pass. Landing on the same id means that
/// second attempt coalesces into the row already queued instead of leaving the user with duplicates.
/// </remarks>
public sealed class ResurrectionIdTests
{
private static readonly Guid Original = Guid.Parse("0192f0c8-1234-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid Other = Guid.Parse("0192f0c8-5678-7c3d-8e4f-5a6b7c8d9e0f");
[Fact]
public void TheSameTombstone_AlwaysYieldsTheSameId()
{
ResurrectionId.For(Original, 3).ShouldBe(ResurrectionId.For(Original, 3));
}
[Fact]
public void ADifferentItem_YieldsADifferentId()
{
ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Other, 3));
}
[Fact]
public void ADifferentTombstoneVersion_YieldsADifferentId()
{
// An item deleted, restored, and deleted again must produce a second rescue rather than
// colliding with the first.
ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Original, 4));
}
[Fact]
public void TheIdIsNotTheOriginal()
{
// The tombstone stands, so the rescued copy has to be a different item. Reusing the id would
// conflict against the tombstone for ever.
ResurrectionId.For(Original, 1).ShouldNotBe(Original);
ResurrectionId.For(Original, 1).ShouldNotBe(Guid.Empty);
}
}
@@ -0,0 +1,168 @@
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// The mechanics of a pass: paging, batching, bounds, and what the cursor is allowed to be.
/// </summary>
/// <remarks>
/// Separate from the conflict matrix because the failure modes are different. Here a mistake shows up as
/// a sync that never finishes, or one that quietly stops halfway and reports success.
/// </remarks>
public sealed class SyncEngineTests
{
[Fact]
public async Task APullLargerThanOnePage_ReadsEveryChange()
{
// The server clamps a client's requested limit, so a client that trusted one response to be the
// whole story would silently see part of a vault.
using var harness = await CreateAsync(
new SyncOptions { PullPageSize = 2, MaxOperationsPerPush = 100 });
harness.Server.MaxPullLimit = 2;
for (var index = 0; index < 7; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
await harness.First.SyncAsync();
var report = await harness.Second.SyncAsync();
report.Pulled.ShouldBe(7);
(await harness.Second.ListAsync()).Hosts.Count.ShouldBe(7);
}
[Fact]
public async Task MoreQueuedChangesThanOneBatch_AreAllPushed()
{
using var harness = await CreateAsync(new SyncOptions { MaxOperationsPerPush = 2 });
for (var index = 0; index < 5; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
var report = await harness.First.SyncAsync();
report.Pushed.ShouldBe(5);
harness.Server.RowCount.ShouldBe(5);
// Three rounds of two, so the drain loop genuinely continued rather than stopping at one batch.
harness.Server.PushCount.ShouldBeGreaterThanOrEqualTo(3);
}
[Fact]
public async Task AnExhaustedPushLoop_SaysSoRatherThanPretendingItFinished()
{
// A bound is necessary — each round advances, but against a vault someone else writes to
// continuously a pass could keep finding work. Reporting it is what stops that looking like
// success.
using var harness = await CreateAsync(
new SyncOptions { MaxOperationsPerPush = 1, MaxPushRounds = 2 });
for (var index = 0; index < 5; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
var report = await harness.First.SyncAsync();
report.RoundsExhausted.ShouldBeTrue();
report.Pushed.ShouldBe(2);
// And the rest is still queued, not lost.
(await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
.Count.ShouldBe(3);
// A further pass picks up where this one stopped.
await harness.First.SyncAsync();
await harness.First.SyncAsync();
harness.Server.RowCount.ShouldBe(5);
}
[Fact]
public async Task TheCursor_IsWhateverTheServerIssued()
{
// Opaque and integrity-tagged. The fake server rejects a cursor it did not mint, so a client that
// computed one would fail here rather than quietly resuming from a position it invented.
using var harness = await CreateAsync();
await harness.First.CreateAsync(Host("prod-db"));
await harness.First.SyncAsync();
var state = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
state.Cursor.ShouldNotBeNull();
state.Cursor.ShouldStartWith("fake-v1:");
}
[Fact]
public async Task AnEmptyPull_DoesNotMoveTheCursor()
{
// If it did, a write landing between this read and the next would be skipped for ever.
using var harness = await CreateAsync();
await harness.First.SyncAsync();
var before = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
await harness.First.SyncAsync();
var after = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
after.Cursor.ShouldBe(before.Cursor);
}
[Fact]
public async Task ClockSkew_IsRecordedAndNotActedOn()
{
// Recorded because a user should be able to see it. Not acted on because the merge decides by
// version and retained ancestor — a skewed clock must not be able to pick a winner.
using var harness = await CreateAsync();
harness.Server.Now = TimeProvider.System.GetUtcNow().AddHours(3);
var entityId = await harness.First.CreateAsync(Host("prod-db"));
var report = await harness.First.SyncAsync();
report.ServerTimeSkewMs.ShouldBeGreaterThan(2 * 60 * 60 * 1000);
// The item still round-trips, so nothing downstream depended on the timestamp.
(await harness.First.FindAsync(entityId)).Host.Label.ShouldBe("prod-db");
}
[Fact]
public async Task ASyncWithNothingToDo_TouchesTheServerOnceAndReportsNothing()
{
using var harness = await CreateAsync();
var report = await harness.First.SyncAsync();
report.Pulled.ShouldBe(0);
report.Pushed.ShouldBe(0);
report.NeedsAttention.ShouldBeFalse();
harness.Server.PushCount.ShouldBe(0);
}
[Fact]
public async Task AVaultWithNoUsableGrant_IsReportedRatherThanRead()
{
// A grant awaiting re-wrap after a rekey. The vault is temporarily unreadable and saying so is
// the only honest answer — showing an empty host list would be indistinguishable from an empty
// vault.
using var harness = await CreateAsync();
var unknown = Guid.CreateVersion7();
harness.First.Keyring.CanRead(unknown).ShouldBeFalse();
await Should.ThrowAsync<VaultUnreadableException>(
async () => await harness.First.Hosts.ListAsync(
unknown, TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,252 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// One machine: its own cache, its own outbox, its own view of the vault.
/// </summary>
/// <remarks>
/// A separate SQLite database per device, because the whole subject of these tests is two caches
/// diverging and being reconciled. Sharing one would make every conflict test vacuous.
/// </remarks>
internal sealed class SyncDevice : IDisposable
{
private readonly ClientCacheFactory factory;
private readonly MasterKey master;
private readonly LocalCacheProtector protector;
private SyncDevice(
string name,
ClientCacheFactory factory,
MasterKey master,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
SyncOptions options)
{
Name = name;
this.factory = factory;
this.master = master;
this.protector = protector;
Keyring = keyring;
Items = new ItemStore(factory, protector);
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
SyncState = new SyncStateStore(factory);
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
Hosts = new HostRepository(Items, Outbox, keyring);
Engine = new SyncEngine(
server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
}
internal string Name { get; }
internal VaultKeyring Keyring { get; }
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal HostRepository Hosts { get; }
internal SyncEngine Engine { get; }
internal static async Task<SyncDevice> CreateAsync(
string name,
UserSecretBundle bundle,
StoredVault vault,
FakeVaultServer server,
SyncOptions options)
{
var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}");
try
{
await cache.MigrateAsync(TestContext.Current.CancellationToken);
var derived = MasterKey.Derive(
$"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile);
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
var keyring = VaultKeyring.Open(bundle, [vault]);
return new SyncDevice(
name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options);
}
catch
{
cache.Dispose();
throw;
}
}
internal Task<SyncReport> SyncAsync() =>
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal Task<HostListing> ListAsync() =>
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task<IReadOnlyList<HostSecret>> HostsSortedAsync()
{
var listing = await ListAsync();
return [.. listing.Hosts.Select(h => h.Host).OrderBy(h => h.Label, StringComparer.Ordinal)];
}
internal async Task<VaultHost> FindAsync(Guid entityId)
{
var listing = await ListAsync();
return listing.Hosts.SingleOrDefault(host => host.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see host {entityId}.");
}
internal Task<Guid> CreateAsync(HostSecret host) =>
Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken);
internal Task UpdateAsync(Guid entityId, HostSecret host) =>
Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken);
internal Task DeleteAsync(Guid entityId) =>
Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
internal Task<IReadOnlyList<StoredConflict>> ConflictsAsync() =>
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
/// <inheritdoc />
public void Dispose()
{
Keyring.Dispose();
protector.Dispose();
master.Dispose();
factory.Dispose();
}
}
/// <summary>
/// One user, one vault, two machines and a server.
/// </summary>
/// <remarks>
/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop
/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is
/// also the cheapest realistic setup in which every conflict case can be produced.
/// </remarks>
internal sealed class SyncHarness : IDisposable
{
internal static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly UserSecretBundle bundle;
private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second)
{
this.bundle = bundle;
Server = server;
First = first;
Second = second;
}
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f");
internal FakeVaultServer Server { get; }
/// <summary>The laptop.</summary>
internal SyncDevice First { get; }
/// <summary>The desktop.</summary>
internal SyncDevice Second { get; }
internal static async Task<SyncHarness> CreateAsync(SyncOptions? options = null)
{
var effective = options ?? SyncOptions.Default;
var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
try
{
var vaultKey = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1);
// The plaintext key is not retained: each device unwraps the grant itself, as it would after
// an ordinary unlock.
System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey);
var vault = new StoredVault(
VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1,
Permissions: 31, wrapped, RekeyRequired: false);
var server = new FakeVaultServer(VaultId);
var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective);
try
{
var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective);
return new SyncHarness(identity, server, first, second);
}
catch
{
first.Dispose();
throw;
}
}
catch
{
identity.Dispose();
throw;
}
}
/// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
/// <remarks>
/// Twice because one pass per device is not enough for a change made on one to be merged on the
/// other and then pushed back. Asserting on a settled state rather than on an intermediate one is
/// what makes "the two devices converge" a meaningful claim.
/// </remarks>
internal async Task SettleAsync()
{
for (var round = 0; round < 2; round++)
{
await First.SyncAsync();
await Second.SyncAsync();
}
}
/// <inheritdoc />
public void Dispose()
{
First.Dispose();
Second.Dispose();
bundle.Dispose();
}
// ---- Builders ----
internal static HostSecret Host(
string label,
string hostname = "db.internal",
int port = 22,
string? username = "deploy",
string? notes = null,
(string Name, string Value)[]? options = null,
bool relayEnabled = false) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = username,
Notes = notes,
Options = options is null
? HostOptions.Empty
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
RelayEnabled = relayEnabled,
};
}
@@ -0,0 +1,447 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
"resolved": "3.0.134",
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"NSubstitute": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"Shouldly": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
"dependencies": {
"DiffEngine": "11.3.0",
"EmptyFiles": "4.4.0"
}
},
"xunit.v3": {
"type": "Direct",
"requested": "[3.2.2, )",
"resolved": "3.2.2",
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
"dependencies": {
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
"dependencies": {
"System.Diagnostics.EventLog": "6.0.0"
}
},
"DiffEngine": {
"type": "Transitive",
"resolved": "11.3.0",
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
"dependencies": {
"EmptyFiles": "4.4.0",
"System.Management": "6.0.1"
}
},
"EmptyFiles": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
},
"Microsoft.ApplicationInsights": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.10",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
},
"Microsoft.Testing.Extensions.Telemetry": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
"dependencies": {
"Microsoft.ApplicationInsights": "2.23.0",
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Testing.Platform": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
},
"Microsoft.Testing.Platform.MSBuild": {
"type": "Transitive",
"resolved": "1.9.1",
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
"dependencies": {
"Microsoft.Testing.Platform": "1.9.1"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
},
"System.Management": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
"dependencies": {
"System.CodeDom": "6.0.0"
}
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
},
"xunit.v3.assert": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
},
"xunit.v3.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
}
},
"xunit.v3.core.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
"dependencies": {
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
"Microsoft.Testing.Platform": "1.9.1",
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.inproc.console": "[3.2.2]"
}
},
"xunit.v3.extensibility.core": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
"dependencies": {
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.mtp-v1": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
"dependencies": {
"xunit.analyzers": "1.27.0",
"xunit.v3.assert": "[3.2.2]",
"xunit.v3.core.mtp-v1": "[3.2.2]"
}
},
"xunit.v3.runner.common": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
"dependencies": {
"Microsoft.Win32.Registry": "[5.0.0]",
"xunit.v3.common": "[3.2.2]"
}
},
"xunit.v3.runner.inproc.console": {
"type": "Transitive",
"resolved": "3.2.2",
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
"dependencies": {
"xunit.v3.extensibility.core": "[3.2.2]",
"xunit.v3.runner.common": "[3.2.2]"
}
},
"dodossh.client.api": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.auth": {
"type": "Project"
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )",
"EFCore.NamingConventions": "[10.0.1, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
}
},
"dodossh.client.sync": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"Microsoft.EntityFrameworkCore": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
}
},
"SQLitePCLRaw.core": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
}
}
}
}