Public Access
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:
+6
-1
@@ -139,11 +139,16 @@ dotnet_diagnostic.CA1034.severity = none
|
|||||||
# convention exists so callers can spot awaitables; a test method has no callers.
|
# convention exists so callers can spot awaitables; a test method has no callers.
|
||||||
dotnet_diagnostic.IDE1006.severity = none
|
dotnet_diagnostic.IDE1006.severity = none
|
||||||
|
|
||||||
[src/DodoSSH.Infrastructure/Migrations/*.cs]
|
# Matches every project's Migrations folder, not just Infrastructure's: the client's local cache
|
||||||
|
# is migrated too. A glob rather than one block per project, so a third one is not a build break
|
||||||
|
# for whoever adds it.
|
||||||
|
[src/*/Migrations/*.cs]
|
||||||
# EF Core generates these; do not lint or format them.
|
# EF Core generates these; do not lint or format them.
|
||||||
generated_code = true
|
generated_code = true
|
||||||
dotnet_analyzer_diagnostic.severity = none
|
dotnet_analyzer_diagnostic.severity = none
|
||||||
dotnet_diagnostic.IDE0055.severity = none
|
dotnet_diagnostic.IDE0055.severity = none
|
||||||
|
# IDE style rules are not covered by dotnet_analyzer_diagnostic above and have to be named.
|
||||||
|
dotnet_diagnostic.IDE0161.severity = none
|
||||||
|
|
||||||
[*.{g,g.i,generated,designer}.cs]
|
[*.{g,g.i,generated,designer}.cs]
|
||||||
# Source-generator output. In particular the System.Text.Json generator emits a public
|
# Source-generator output. In particular the System.Text.Json generator emits a public
|
||||||
|
|||||||
@@ -25,6 +25,22 @@
|
|||||||
itself moves off 2.0.0.
|
itself moves off 2.0.0.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Microsoft.EntityFrameworkCore.Sqlite 10.0.10 resolves SQLitePCLRaw 2.1.11, whose bundled
|
||||||
|
SQLite build is covered by GHSA-2m69-gcr7-jv3q (high). 2.1.12 is the fix and is a patch bump
|
||||||
|
inside the minor EF asks for, so nothing needs to move. Pinned as a family: the bundle, the
|
||||||
|
core, the provider and the native library ship in lockstep and a mixed set is a loader error
|
||||||
|
at runtime rather than a build failure.
|
||||||
|
|
||||||
|
SQLitePCLRaw 3.x exists and is deliberately not used here. EF Core 10 is built against 2.1.x,
|
||||||
|
and 3.0 is also where bundle_e_sqlcipher was deprecated — which is one of the reasons the
|
||||||
|
local cache does not use SQLCipher at all. See DodoSSH.Client.Storage.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.core" Version="2.1.12" />
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="2.1.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Persistence">
|
<ItemGroup Label="Persistence">
|
||||||
@@ -45,6 +61,13 @@
|
|||||||
HasColumnName in every IEntityTypeConfiguration: more code, zero risk.
|
HasColumnName in every IEntityTypeConfiguration: more code, zero risk.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
|
<!--
|
||||||
|
The client's local cache. Plain SQLite, deliberately not SQLCipher: the rows are already
|
||||||
|
ciphertext, so an encrypted database file would add a native dependency and a licence
|
||||||
|
obligation to protect bytes that are protected already. SQLitePCLRaw's own
|
||||||
|
bundle_e_sqlcipher is deprecated as of 3.0 besides. See DodoSSH.Client.Storage.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Cryptography">
|
<ItemGroup Label="Cryptography">
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
<Project Path="src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
<Project Path="src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||||
|
<Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||||
|
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||||
|
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|
||||||
@@ -27,7 +30,10 @@
|
|||||||
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Api.Tests/DodoSSH.Api.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
|
||||||
|
<Project Path="tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
|
||||||
|
<Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" />
|
||||||
|
<Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
||||||
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ src/
|
|||||||
DodoSSH.Infrastructure DbContext, configurations, migrations
|
DodoSSH.Infrastructure DbContext, configurations, migrations
|
||||||
DodoSSH.Api the server
|
DodoSSH.Api the server
|
||||||
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
|
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
|
||||||
|
DodoSSH.Client.Api the typed server client, and client-side enrollment
|
||||||
|
DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all
|
||||||
|
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
|
||||||
|
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
|
||||||
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
||||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||||
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
||||||
@@ -100,10 +104,11 @@ off-Windows.
|
|||||||
*Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
|
*Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
|
||||||
enrollment with the identity-provider key binding.
|
enrollment with the identity-provider key binding.
|
||||||
*Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
|
*Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
|
||||||
trust, the terminal data plane, and an Avalonia shell whose terminal works end to end against a real
|
trust, the terminal data plane, an Avalonia shell whose terminal works end to end against a real
|
||||||
`sshd`.
|
`sshd`, and the encrypted local cache with the sync client — hosts, offline unlock, an outbox and a
|
||||||
*Remaining:* the encrypted local cache and the sync client, which are what let the app read hosts
|
field-level three-way merge, with the conflict matrix green.
|
||||||
from the vault instead of a form. The client currently connects to a host you type in.
|
*Remaining:* wiring the shell to the vault, so the host list comes from `HostRepository` rather than
|
||||||
|
from the form the window still shows.
|
||||||
- **M2 — full personal vault**, robust sync, relay.
|
- **M2 — full personal vault**, robust sync, relay.
|
||||||
- **M3 — teams**, sharing, ACLs.
|
- **M3 — teams**, sharing, ACLs.
|
||||||
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
||||||
|
|||||||
+16
-2
@@ -230,12 +230,26 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
|||||||
| 3 | `ItemDataKey` | a data key wrapped under a vault key |
|
| 3 | `ItemDataKey` | a data key wrapped under a vault key |
|
||||||
| 4 | `ItemPayload` | item plaintext under its data key |
|
| 4 | `ItemPayload` | item plaintext under its data key |
|
||||||
| 5 | `ItemMetadata` | encrypted host metadata under its data key |
|
| 5 | `ItemMetadata` | encrypted host metadata under its data key |
|
||||||
| 6 | `LocalCache` | a client's on-disk cache record |
|
| 6 | `LocalCache` | a client's on-disk cache record, bound to its own row |
|
||||||
|
|
||||||
|
> **Changed 2026-07-29:** `LocalCache` binds `resourceType` and the record's own id rather
|
||||||
|
> than the user id. The user is already bound by the key — `LocalCacheKey` derives from that
|
||||||
|
> user's MK — so binding it again in the AAD constrained nothing, and left cache records
|
||||||
|
> interchangeable between rows of the same cache. For a plaintext column such as a
|
||||||
|
> relay-enabled host's address, swapping two rows would aim one host's connection at
|
||||||
|
> another's. No cache has been written, so nothing needs migrating.
|
||||||
|
|
||||||
### 4.3 `resourceType`
|
### 4.3 `resourceType`
|
||||||
|
|
||||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey.
|
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
||||||
|
`13` HostCredential.
|
||||||
|
|
||||||
|
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
||||||
|
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
||||||
|
> either — so an association row's payload had no resource type to bind to, and the first
|
||||||
|
> implementation to need one would have had to invent a value or reuse a neighbour's. Append
|
||||||
|
> only, and no such item has been stored.
|
||||||
|
|
||||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ minimal desktop or inside a Flatpak sandbox — where the portal is the correct
|
|||||||
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
|
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
|
||||||
so a platform-specific opener can be substituted without touching the flow.
|
so a platform-specific opener can be substituted without touching the flow.
|
||||||
|
|
||||||
|
## Local cache
|
||||||
|
|
||||||
|
**The cache file has no location yet.** `ClientCacheFactory.ForFile` takes a full path and the
|
||||||
|
application does not yet choose one, because nothing wires the cache into the shell so far. When it
|
||||||
|
does, the path must be per-OS — `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS,
|
||||||
|
`$XDG_DATA_HOME` or `~/.local/share` on Linux — and it must **not** land in a directory that syncs to
|
||||||
|
a cloud drive. Two machines writing one SQLite file through a file-sync client corrupts it, and the
|
||||||
|
whole point of the outbox is that each machine has its own. `Environment.SpecialFolder.LocalApplicationData`
|
||||||
|
maps correctly on all three, but on Linux it ignores `XDG_DATA_HOME` and returns `~/.local/share`
|
||||||
|
unconditionally. *Unverified off Windows.*
|
||||||
|
|
||||||
|
**SQLite timestamps are stored as integers, deliberately.** EF's default `DateTimeOffset` mapping for
|
||||||
|
SQLite is a text form it then refuses to order or compare, so any query that sorts or filters by time
|
||||||
|
throws at execution rather than at model build. `UnixMillisecondsConverter` is applied as a convention
|
||||||
|
so a timestamp added later cannot be the one left unconverted. This is provider behaviour, not
|
||||||
|
platform behaviour, but it cost a debugging session and will again if the converter is removed.
|
||||||
|
|
||||||
|
**No SQLCipher, on any platform.** The rows are already ciphertext from the server, so an encrypted
|
||||||
|
database file would protect bytes that are protected already at the cost of a native dependency and a
|
||||||
|
licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3.0. The consequence to
|
||||||
|
be honest about: the cache offers no protection against another process running as the same user. See
|
||||||
|
`LocalCacheProtector` for what it does and does not defend against.
|
||||||
|
|
||||||
## Build and CI
|
## Build and CI
|
||||||
|
|
||||||
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
||||||
|
|||||||
@@ -264,6 +264,11 @@ internal sealed class SyncService(
|
|||||||
return Invalid(operation, "An upsert requires a payload.");
|
return Invalid(operation, "An upsert requires a payload.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ValidatePayload(operation.Payload, out var payloadError))
|
||||||
|
{
|
||||||
|
return Invalid(operation, payloadError);
|
||||||
|
}
|
||||||
|
|
||||||
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
||||||
|
|
||||||
if (!ValidateRelayFields(fields, out var relayError))
|
if (!ValidateRelayFields(fields, out var relayError))
|
||||||
@@ -392,6 +397,8 @@ internal sealed class SyncService(
|
|||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
host.Payload = payload.Envelope;
|
host.Payload = payload.Envelope;
|
||||||
|
host.DataKeyWrap = payload.WrappedDataKey;
|
||||||
|
host.ContentKeyId = payload.DataKeyId;
|
||||||
host.KeyGeneration = (int)payload.KeyGeneration;
|
host.KeyGeneration = (int)payload.KeyGeneration;
|
||||||
host.PayloadAadVersion = payload.AadVersion;
|
host.PayloadAadVersion = payload.AadVersion;
|
||||||
host.RelayEnabled = fields.RelayEnabled;
|
host.RelayEnabled = fields.RelayEnabled;
|
||||||
@@ -403,6 +410,41 @@ internal sealed class SyncService(
|
|||||||
host.UpdatedByUserId = actorUserId;
|
host.UpdatedByUserId = actorUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejects a payload missing its data key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The server cannot read any of these bytes, so this is a structural check and nothing more.
|
||||||
|
/// It is still worth making: docs/crypto.md §3 requires a per-item data key, the payload's AAD
|
||||||
|
/// binds <see cref="EncryptedPayload.DataKeyId"/>, and a row stored without a wrap is a row no
|
||||||
|
/// client will ever be able to open. Better to refuse it here — where the client is told which
|
||||||
|
/// operation was wrong — than to store an item that silently reads as corrupt forever.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool ValidatePayload(EncryptedPayload payload, out string error)
|
||||||
|
{
|
||||||
|
error = string.Empty;
|
||||||
|
|
||||||
|
if (payload.Envelope.Length == 0)
|
||||||
|
{
|
||||||
|
error = "A payload envelope cannot be empty.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.WrappedDataKey.Length == 0)
|
||||||
|
{
|
||||||
|
error = "A payload requires its data key, wrapped under the vault key.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.DataKeyId == Guid.Empty)
|
||||||
|
{
|
||||||
|
error = "A payload requires a data key identifier; it is part of the payload's AAD.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
|
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
|
||||||
/// constraint violation surfacing as a 500.
|
/// constraint violation surfacing as a 500.
|
||||||
@@ -566,6 +608,11 @@ internal sealed class SyncService(
|
|||||||
? null
|
? null
|
||||||
: new EncryptedPayload(
|
: new EncryptedPayload(
|
||||||
host.Payload,
|
host.Payload,
|
||||||
|
// Non-null for every row a push can create: ValidatePayload refuses an
|
||||||
|
// operation without them. The columns stay nullable because they are also
|
||||||
|
// the seam for M5's per-item grants.
|
||||||
|
host.DataKeyWrap ?? [],
|
||||||
|
host.ContentKeyId ?? Guid.Empty,
|
||||||
(uint)host.KeyGeneration,
|
(uint)host.KeyGeneration,
|
||||||
(byte)host.PayloadAadVersion),
|
(byte)host.PayloadAadVersion),
|
||||||
PlaintextFields: isDelete || host is null
|
PlaintextFields: isDelete || host is null
|
||||||
|
|||||||
@@ -18,6 +18,31 @@ public interface IAccessTokenProvider
|
|||||||
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
|
||||||
|
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
|
||||||
|
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
|
||||||
|
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
|
||||||
|
/// question that actually matters.
|
||||||
|
/// </remarks>
|
||||||
|
public interface ISyncApi
|
||||||
|
{
|
||||||
|
/// <summary>Reads vault changes after a cursor.</summary>
|
||||||
|
Task<SyncPullResponse> SyncPullAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncPullRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Applies a batch of vault changes.</summary>
|
||||||
|
Task<SyncPushResponse> SyncPushAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncPushRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The typed client for one DodoSSH server.
|
/// The typed client for one DodoSSH server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -33,7 +58,7 @@ public interface IAccessTokenProvider
|
|||||||
/// Everything else carries a bearer token.
|
/// Everything else carries a bearer token.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi
|
||||||
{
|
{
|
||||||
private const string MetaPath = "/api/v1/meta";
|
private const string MetaPath = "/api/v1/meta";
|
||||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The decrypted shape of a vault item, and the three-way merge over it.
|
||||||
|
|
||||||
|
Deliberately dependency-free: no Contracts, no Crypto, no EF. This is the one project that
|
||||||
|
knows what a host *means*, and it is the only place the merge rules live. Keeping it free of
|
||||||
|
the wire format and the cipher is what lets the conflict matrix be a pure, fast unit suite
|
||||||
|
with no ciphertext or database in sight.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Domain.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One SSH configuration directive on a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
|
||||||
|
/// clients that resolved the same merge could end up holding <c>ServerAliveInterval</c> and
|
||||||
|
/// <c>serveraliveinterval</c>, compare their hosts as different, and push over each other forever
|
||||||
|
/// while agreeing on every actual value.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Name">Directive name, for example <c>ServerAliveInterval</c>.</param>
|
||||||
|
/// <param name="Value">Directive value, verbatim and case-sensitive.</param>
|
||||||
|
public sealed record HostOption(string Name, string Value)
|
||||||
|
{
|
||||||
|
/// <summary>Defines directive identity: SSH keywords are case-insensitive.</summary>
|
||||||
|
public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Equals(HostOption? other) =>
|
||||||
|
other is not null
|
||||||
|
&& NameComparer.Equals(Name, other.Name)
|
||||||
|
&& string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode() =>
|
||||||
|
HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A host's SSH directives: unique by name, held in name order.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
|
||||||
|
/// is what lets two people add different directives to the same host and both survive — a
|
||||||
|
/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
|
||||||
|
/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
|
||||||
|
/// engine does not push a spurious update on every pass.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The cost, stated plainly:</b> real <c>ssh_config</c> permits a directive to repeat, and for
|
||||||
|
/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
|
||||||
|
/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
|
||||||
|
/// surface it rather than quietly keeping one of the duplicates.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HostOptions : IReadOnlyList<HostOption>, IEquatable<HostOptions>
|
||||||
|
{
|
||||||
|
private readonly HostOption[] items;
|
||||||
|
private readonly int hash;
|
||||||
|
|
||||||
|
private HostOptions(HostOption[] items)
|
||||||
|
{
|
||||||
|
this.items = items;
|
||||||
|
hash = ComputeHash(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No directives.</summary>
|
||||||
|
public static HostOptions Empty { get; } = new([]);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Count => items.Length;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public HostOption this[int index] => items[index];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a canonical collection, sorting by name.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">A name repeats, or a name is blank.</exception>
|
||||||
|
public static HostOptions Create(IEnumerable<HostOption> options)
|
||||||
|
{
|
||||||
|
if (!TryCreate(options, out var result, out var error))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(error, nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a canonical collection, reporting rather than throwing on bad input.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The non-throwing overload exists because these values arrive from two places neither of which
|
||||||
|
/// is trusted: a decrypted payload written by another client, and an imported
|
||||||
|
/// <c>ssh_config</c>. Neither should be able to raise an exception from inside a sync pass.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool TryCreate(
|
||||||
|
IEnumerable<HostOption> options,
|
||||||
|
[NotNullWhen(true)] out HostOptions? result,
|
||||||
|
[NotNullWhen(false)] out string? error)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
result = null;
|
||||||
|
var ordered = options.ToArray();
|
||||||
|
|
||||||
|
if (!Validate(ordered, out error))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Sort(
|
||||||
|
ordered,
|
||||||
|
static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));
|
||||||
|
|
||||||
|
result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Looks up a directive by name, case-insensitively as SSH treats keywords.</summary>
|
||||||
|
public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
|
||||||
|
{
|
||||||
|
foreach (var option in items)
|
||||||
|
{
|
||||||
|
if (HostOption.NameComparer.Equals(option.Name, name))
|
||||||
|
{
|
||||||
|
value = option.Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Equals(HostOptions? other)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other is null || other.items.Length != items.Length || other.hash != hash)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.AsSpan().SequenceEqual(other.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as HostOptions);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode() => hash;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerator<HostOption> GetEnumerator() => ((IEnumerable<HostOption>)items).GetEnumerator();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
|
||||||
|
|
||||||
|
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||||
|
[SuppressMessage(
|
||||||
|
"Usage",
|
||||||
|
"CA2225:Operator overloads have named alternates",
|
||||||
|
Justification = "Equals(HostOptions) is the named alternate.")]
|
||||||
|
public static bool operator ==(HostOptions? left, HostOptions? right) =>
|
||||||
|
left is null ? right is null : left.Equals(right);
|
||||||
|
|
||||||
|
/// <summary>Contents inequality.</summary>
|
||||||
|
public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right);
|
||||||
|
|
||||||
|
/// <summary>Projects to a name-keyed map, for the per-directive merge.</summary>
|
||||||
|
internal Dictionary<string, string> ToNameMap()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(items.Length, HostOption.NameComparer);
|
||||||
|
|
||||||
|
foreach (var option in items)
|
||||||
|
{
|
||||||
|
map[option.Name] = option.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
|
||||||
|
{
|
||||||
|
foreach (var option in ordered)
|
||||||
|
{
|
||||||
|
if (option is null || string.IsNullOrWhiteSpace(option.Name))
|
||||||
|
{
|
||||||
|
error = "An SSH directive must have a name.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicate = ordered
|
||||||
|
.GroupBy(o => o.Name, HostOption.NameComparer)
|
||||||
|
.FirstOrDefault(g => g.Count() > 1);
|
||||||
|
|
||||||
|
if (duplicate is not null)
|
||||||
|
{
|
||||||
|
error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComputeHash(HostOption[] items)
|
||||||
|
{
|
||||||
|
var accumulator = new HashCode();
|
||||||
|
accumulator.Add(items.Length);
|
||||||
|
|
||||||
|
foreach (var option in items)
|
||||||
|
{
|
||||||
|
accumulator.Add(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A host as the user sees it: everything the server never gets to read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The whole of this record lives inside the item's encrypted payload. In particular there is no
|
||||||
|
/// plaintext label anywhere in the system — access-control administration runs on the client, which
|
||||||
|
/// can decrypt names, so the server never needs a searchable title.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Hostname"/> and <see cref="Port"/> are here <em>and</em> may additionally appear as
|
||||||
|
/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
|
||||||
|
/// the one deliberate privacy concession in the design: the relay must resolve its target
|
||||||
|
/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
|
||||||
|
/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
|
||||||
|
/// supplies only when relay is enabled. See ADR 0004.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Structural equality holds across every field, including the collections, which is what the merge
|
||||||
|
/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record HostSecret
|
||||||
|
{
|
||||||
|
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
|
||||||
|
public const int DefaultPort = 22;
|
||||||
|
|
||||||
|
/// <summary>Display name. The only name this host has anywhere.</summary>
|
||||||
|
public required string Label { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Hostname or address to connect to.</summary>
|
||||||
|
public required string Hostname { get; init; }
|
||||||
|
|
||||||
|
/// <summary>TCP port.</summary>
|
||||||
|
public int Port { get; init; } = DefaultPort;
|
||||||
|
|
||||||
|
/// <summary>Login user, when the host pins one.</summary>
|
||||||
|
public string? Username { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Free-text notes.</summary>
|
||||||
|
public string? Notes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The jump chain, nearest hop first, as host item ids.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
|
||||||
|
/// chain changes which machine is reached through which, and a set union of two different chains
|
||||||
|
/// would produce a route neither user asked for.
|
||||||
|
/// </remarks>
|
||||||
|
public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;
|
||||||
|
|
||||||
|
/// <summary>SSH directives, unique by name.</summary>
|
||||||
|
public HostOptions Options { get; init; } = HostOptions.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this host may be dialled through the server relay.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
|
||||||
|
/// keeps. It has to: it is the flag that decides whether <see cref="Hostname"/> and
|
||||||
|
/// <see cref="Port"/> are copied out into those columns, and a setting the merge cannot see is a
|
||||||
|
/// setting two clients can silently disagree about — one of them re-exposing an address the other
|
||||||
|
/// had just withdrawn.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The plaintext copy is derived from this, in one place, so the address can only ever leave the
|
||||||
|
/// payload as a consequence of the user turning this on. See ADR 0004.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool RelayEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the fields that must hold before this can be stored.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Separate from construction on purpose. A view model binds directly to these properties and
|
||||||
|
/// passes through empty and half-typed states on the way to a valid one; a constructor that threw
|
||||||
|
/// would make the editor unusable. The sync layer validates before sealing, and the codec
|
||||||
|
/// validates on decode, which are the two points where an invalid host would become durable.
|
||||||
|
/// </remarks>
|
||||||
|
public bool TryValidate([NotNullWhen(false)] out string? error)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Label))
|
||||||
|
{
|
||||||
|
error = "A host needs a name.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Hostname))
|
||||||
|
{
|
||||||
|
error = "A host needs a hostname or address.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Port is < 1 or > 65535)
|
||||||
|
{
|
||||||
|
error = $"Port must be between 1 and 65535, not {Port}.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
|
||||||
|
{
|
||||||
|
error = "A jump chain cannot contain an empty host id.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>A decoded host payload, together with the schema version it was written at.</summary>
|
||||||
|
/// <param name="Host">The host.</param>
|
||||||
|
/// <param name="SchemaVersion">
|
||||||
|
/// The version the writing client used. May exceed
|
||||||
|
/// <see cref="HostSecretCodec.CurrentSchemaVersion"/>, which is the case this type exists to make
|
||||||
|
/// visible.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this payload was written by a newer client than the one reading it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Such an item is safe to <em>read</em> — every field this build knows about decodes normally —
|
||||||
|
/// but must not be re-encoded, because fields added by the newer schema are not represented here
|
||||||
|
/// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
|
||||||
|
/// class of bug that makes people stop trusting a synced vault.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
|
||||||
|
/// fields through a round trip was the alternative and it is worse — it means carrying opaque
|
||||||
|
/// JSON inside the domain model, which then has no usable structural equality and so breaks the
|
||||||
|
/// merge.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
|
||||||
|
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
|
||||||
|
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
|
||||||
|
/// grows a field without a migration — and the one thing that must not happen is an old client
|
||||||
|
/// quietly dropping a field a new one wrote, which is what <see cref="HostSecretDocument.IsReadOnly"/>
|
||||||
|
/// prevents.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
|
||||||
|
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
|
||||||
|
/// a codec that produced different bytes for the same host would make every pass look like a change.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class HostSecretCodec
|
||||||
|
{
|
||||||
|
/// <summary>The schema version this build writes.</summary>
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||||
|
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||||
|
public static byte[] Encode(HostSecret host)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
|
||||||
|
if (!host.TryValidate(out var error))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(error, nameof(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = new SortedDictionary<string, string>(HostOption.NameComparer);
|
||||||
|
foreach (var option in host.Options)
|
||||||
|
{
|
||||||
|
options[option.Name] = option.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = new HostPayloadDocument
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
Label = host.Label,
|
||||||
|
Hostname = host.Hostname,
|
||||||
|
Port = host.Port,
|
||||||
|
Username = host.Username,
|
||||||
|
Notes = host.Notes,
|
||||||
|
JumpHostIds = [.. host.JumpHostIds],
|
||||||
|
Options = options,
|
||||||
|
RelayEnabled = host.RelayEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
document, HostPayloadJsonContext.Default.HostPayloadDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a decrypted payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns <see langword="false"/> rather than throwing on anything malformed. These bytes
|
||||||
|
/// authenticated under a key only vault members hold, so a failure here is not an attack — it is
|
||||||
|
/// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
|
||||||
|
/// rather than an exception that aborts the whole sync pass and strands every other change.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> payload,
|
||||||
|
[NotNullWhen(true)] out HostSecretDocument? document)
|
||||||
|
{
|
||||||
|
document = null;
|
||||||
|
|
||||||
|
HostPayloadDocument? parsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parsed = JsonSerializer.Deserialize(
|
||||||
|
payload, HostPayloadJsonContext.Default.HostPayloadDocument);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed is null || parsed.SchemaVersion < 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryBuild(parsed, out var host))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document = new HostSecretDocument(host, parsed.SchemaVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBuild(
|
||||||
|
HostPayloadDocument parsed,
|
||||||
|
[NotNullWhen(true)] out HostSecret? host)
|
||||||
|
{
|
||||||
|
host = null;
|
||||||
|
|
||||||
|
var directives = (parsed.Options ?? [])
|
||||||
|
.Select(entry => new HostOption(entry.Key, entry.Value));
|
||||||
|
|
||||||
|
if (!HostOptions.TryCreate(directives, out var options, out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = new HostSecret
|
||||||
|
{
|
||||||
|
Label = parsed.Label ?? string.Empty,
|
||||||
|
Hostname = parsed.Hostname ?? string.Empty,
|
||||||
|
Port = parsed.Port,
|
||||||
|
Username = parsed.Username,
|
||||||
|
Notes = parsed.Notes,
|
||||||
|
JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
|
||||||
|
Options = options,
|
||||||
|
RelayEnabled = parsed.RelayEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!candidate.TryValidate(out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
host = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The serialised shape. Mutable and nullable because it models untrusted input.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately separate from <see cref="HostSecret"/>. A single type would force the domain model to
|
||||||
|
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
|
||||||
|
/// everything — and would let a decode failure produce a half-built host that looks valid to
|
||||||
|
/// everything downstream.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class HostPayloadDocument
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; set; }
|
||||||
|
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public string? Hostname { get; set; }
|
||||||
|
|
||||||
|
public int Port { get; set; }
|
||||||
|
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public Guid[]? JumpHostIds { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Sorted, so serialisation order is defined by the type rather than by insertion order — a
|
||||||
|
/// plain <see cref="Dictionary{TKey,TValue}"/> does not guarantee enumeration order, and this
|
||||||
|
/// encoding has to be reproducible.
|
||||||
|
/// </remarks>
|
||||||
|
public SortedDictionary<string, string>? Options { get; set; }
|
||||||
|
|
||||||
|
public bool RelayEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
// An unknown member means a newer client wrote a field this build has no concept of. Skipping it
|
||||||
|
// is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(HostPayloadDocument))]
|
||||||
|
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A value the merge had to override, kept so the user can see it and put it back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only
|
||||||
|
/// acceptable because the other side is preserved verbatim and surfaced; without that, a
|
||||||
|
/// field-level merge is just last-writer-wins with extra steps.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Field">
|
||||||
|
/// Which field, as a path. A directive reads <c>Options[ServerAliveInterval]</c> so the user is told
|
||||||
|
/// which one rather than merely that "options" changed.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DiscardedSide">Whose intent was overridden.</param>
|
||||||
|
/// <param name="Kept">The value that survives, rendered for display.</param>
|
||||||
|
/// <param name="Discarded">The value that lost, rendered for display.</param>
|
||||||
|
/// <param name="DiscardedWasRemoval">
|
||||||
|
/// True when what lost was a deletion rather than a different value.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HostFieldConflict(
|
||||||
|
string Field,
|
||||||
|
MergeSide DiscardedSide,
|
||||||
|
string? Kept,
|
||||||
|
string? Discarded,
|
||||||
|
bool DiscardedWasRemoval);
|
||||||
|
|
||||||
|
/// <summary>The merged host, and everything that had to be overridden to produce it.</summary>
|
||||||
|
/// <param name="Merged">The host to store and push.</param>
|
||||||
|
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||||
|
public sealed record HostMergeResult(
|
||||||
|
HostSecret Merged,
|
||||||
|
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||||
|
{
|
||||||
|
/// <summary>Whether anything had to be overridden.</summary>
|
||||||
|
public bool HasConflicts => Conflicts.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges two divergent versions of a host against the version they both started from.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Called when a pull brings down a change to an item that also has a local edit pending, and again
|
||||||
|
/// when a push comes back <c>Conflict</c> carrying the server's current row. Both paths need the
|
||||||
|
/// same answer, so both go through here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value,
|
||||||
|
/// because its order is its meaning. Directives merge per name, which is what lets two people each
|
||||||
|
/// add one and both keep it. See <see cref="ThreeWayMerge"/> for why the remote side wins.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class HostSecretMerge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces the merged host.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ancestor">
|
||||||
|
/// The version both sides branched from — the ciphertext the client retained when it queued its
|
||||||
|
/// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a
|
||||||
|
/// revert and so cannot avoid resurrecting deleted values.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="local">The pending local version.</param>
|
||||||
|
/// <param name="remote">The server's current version.</param>
|
||||||
|
public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ancestor);
|
||||||
|
ArgumentNullException.ThrowIfNull(local);
|
||||||
|
ArgumentNullException.ThrowIfNull(remote);
|
||||||
|
|
||||||
|
var conflicts = new List<HostFieldConflict>();
|
||||||
|
|
||||||
|
var merged = new HostSecret
|
||||||
|
{
|
||||||
|
Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts),
|
||||||
|
Hostname = Text(
|
||||||
|
nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts),
|
||||||
|
Port = Field(
|
||||||
|
nameof(HostSecret.Port),
|
||||||
|
ancestor.Port,
|
||||||
|
local.Port,
|
||||||
|
remote.Port,
|
||||||
|
conflicts,
|
||||||
|
static port => port.ToString(CultureInfo.InvariantCulture)),
|
||||||
|
Username = Text(
|
||||||
|
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
|
||||||
|
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||||
|
JumpHostIds = Field(
|
||||||
|
nameof(HostSecret.JumpHostIds),
|
||||||
|
ancestor.JumpHostIds,
|
||||||
|
local.JumpHostIds,
|
||||||
|
remote.JumpHostIds,
|
||||||
|
conflicts,
|
||||||
|
FormatChain),
|
||||||
|
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
|
||||||
|
RelayEnabled = Field(
|
||||||
|
nameof(HostSecret.RelayEnabled),
|
||||||
|
ancestor.RelayEnabled,
|
||||||
|
local.RelayEnabled,
|
||||||
|
remote.RelayEnabled,
|
||||||
|
conflicts,
|
||||||
|
static enabled => enabled ? "enabled" : "disabled"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HostMergeResult(merged, conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(
|
||||||
|
string name,
|
||||||
|
string? ancestor,
|
||||||
|
string? local,
|
||||||
|
string? remote,
|
||||||
|
List<HostFieldConflict> conflicts) =>
|
||||||
|
Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A scalar clash always overrides the local side — see <see cref="ThreeWayMerge"/> — so the
|
||||||
|
/// discarded side is fixed here rather than derived.
|
||||||
|
/// </remarks>
|
||||||
|
private static T Field<T>(
|
||||||
|
string name,
|
||||||
|
T ancestor,
|
||||||
|
T local,
|
||||||
|
T remote,
|
||||||
|
List<HostFieldConflict> conflicts,
|
||||||
|
Func<T, string?> format,
|
||||||
|
IEqualityComparer<T>? comparer = null)
|
||||||
|
{
|
||||||
|
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
|
||||||
|
|
||||||
|
if (merge.IsConflicted)
|
||||||
|
{
|
||||||
|
conflicts.Add(new HostFieldConflict(
|
||||||
|
name,
|
||||||
|
MergeSide.Local,
|
||||||
|
format(merge.Value),
|
||||||
|
merge.Discarded is null ? null : format(merge.Discarded),
|
||||||
|
DiscardedWasRemoval: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merge.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostOptions MergeOptions(
|
||||||
|
HostOptions ancestor,
|
||||||
|
HostOptions local,
|
||||||
|
HostOptions remote,
|
||||||
|
List<HostFieldConflict> conflicts)
|
||||||
|
{
|
||||||
|
var merge = ThreeWayMerge.Map(
|
||||||
|
ancestor.ToNameMap(),
|
||||||
|
local.ToNameMap(),
|
||||||
|
remote.ToNameMap(),
|
||||||
|
HostOption.NameComparer,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var conflict in merge.Conflicts)
|
||||||
|
{
|
||||||
|
conflicts.Add(new HostFieldConflict(
|
||||||
|
$"{nameof(HostSecret.Options)}[{conflict.Key}]",
|
||||||
|
conflict.DiscardedSide,
|
||||||
|
conflict.Kept,
|
||||||
|
conflict.Discarded,
|
||||||
|
conflict.DiscardedWasRemoval));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The merged map is keyed by the same comparer, so uniqueness already holds and Create
|
||||||
|
// cannot throw here.
|
||||||
|
return HostOptions.Create(
|
||||||
|
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatChain(JumpChain chain) =>
|
||||||
|
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An ordered route to a host: the intermediate hosts to tunnel through, nearest hop first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A dedicated type rather than a list of ids, for two reasons. It compares by contents, which the
|
||||||
|
/// merge depends on — a plain <see cref="IReadOnlyList{T}"/> on a record gets reference equality from
|
||||||
|
/// the compiler-generated <c>Equals</c>, so every host would read as changed on every sync pass and
|
||||||
|
/// two identical edits would register as a conflict. And it names the thing: the order here is the
|
||||||
|
/// route, so this is not a set and must never be merged as one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Duplicate and empty hops are not rejected at construction. They arrive from a decrypted payload
|
||||||
|
/// written by another client, and a constructor that threw would turn one bad item into a failed sync
|
||||||
|
/// pass for every other item behind it. <see cref="HostSecret.TryValidate"/> is where that is caught.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class JumpChain : IReadOnlyList<Guid>, IEquatable<JumpChain>
|
||||||
|
{
|
||||||
|
private readonly Guid[] hops;
|
||||||
|
private readonly int hash;
|
||||||
|
|
||||||
|
private JumpChain(Guid[] hops)
|
||||||
|
{
|
||||||
|
this.hops = hops;
|
||||||
|
hash = ComputeHash(hops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A direct connection: no intermediate hosts.</summary>
|
||||||
|
public static JumpChain Empty { get; } = new([]);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Count => hops.Length;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid this[int index] => hops[index];
|
||||||
|
|
||||||
|
/// <summary>Copies a sequence of hops, preserving order.</summary>
|
||||||
|
public static JumpChain Create(IEnumerable<Guid> hops)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(hops);
|
||||||
|
|
||||||
|
var copy = hops.ToArray();
|
||||||
|
return copy.Length == 0 ? Empty : new JumpChain(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies a span of hops, preserving order.</summary>
|
||||||
|
public static JumpChain Create(ReadOnlySpan<Guid> hops) =>
|
||||||
|
hops.IsEmpty ? Empty : new JumpChain(hops.ToArray());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Equals(JumpChain? other)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(this, other))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return other is not null
|
||||||
|
&& other.hash == hash
|
||||||
|
&& hops.AsSpan().SequenceEqual(other.hops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool Equals(object? obj) => Equals(obj as JumpChain);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override int GetHashCode() => hash;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerator<Guid> GetEnumerator() => ((IEnumerable<Guid>)hops).GetEnumerator();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => hops.GetEnumerator();
|
||||||
|
|
||||||
|
/// <summary>The hops, without copying.</summary>
|
||||||
|
public ReadOnlySpan<Guid> AsSpan() => hops;
|
||||||
|
|
||||||
|
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||||
|
[SuppressMessage(
|
||||||
|
"Usage",
|
||||||
|
"CA2225:Operator overloads have named alternates",
|
||||||
|
Justification = "Equals(JumpChain) is the named alternate.")]
|
||||||
|
public static bool operator ==(JumpChain? left, JumpChain? right) =>
|
||||||
|
left is null ? right is null : left.Equals(right);
|
||||||
|
|
||||||
|
/// <summary>Contents inequality.</summary>
|
||||||
|
public static bool operator !=(JumpChain? left, JumpChain? right) => !(left == right);
|
||||||
|
|
||||||
|
private static int ComputeHash(Guid[] hops)
|
||||||
|
{
|
||||||
|
// Order-sensitive, because reordering a route changes which machine is reached through which.
|
||||||
|
var accumulator = new HashCode();
|
||||||
|
accumulator.Add(hops.Length);
|
||||||
|
|
||||||
|
foreach (var hop in hops)
|
||||||
|
{
|
||||||
|
accumulator.Add(hop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator.ToHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>Which of the two diverging replicas a value came from.</summary>
|
||||||
|
public enum MergeSide
|
||||||
|
{
|
||||||
|
/// <summary>The edit made on this machine.</summary>
|
||||||
|
Local = 0,
|
||||||
|
|
||||||
|
/// <summary>The edit that arrived from the server.</summary>
|
||||||
|
Remote = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a single field was resolved.</summary>
|
||||||
|
public enum MergeDecision
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Both sides hold the same value — either neither changed it, or both made the identical
|
||||||
|
/// change. Distinguishing those two is not useful: the outcome is the same and no one is
|
||||||
|
/// surprised.
|
||||||
|
/// </summary>
|
||||||
|
Agreed = 0,
|
||||||
|
|
||||||
|
/// <summary>Only this machine changed it.</summary>
|
||||||
|
TookLocal = 1,
|
||||||
|
|
||||||
|
/// <summary>Only the server side changed it.</summary>
|
||||||
|
TookRemote = 2,
|
||||||
|
|
||||||
|
/// <summary>Both changed it, differently. One value survives and the other is reported.</summary>
|
||||||
|
Conflicted = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The outcome of merging one field.</summary>
|
||||||
|
/// <typeparam name="T">The field's type.</typeparam>
|
||||||
|
/// <param name="Value">The value to keep.</param>
|
||||||
|
/// <param name="Decision">How it was resolved.</param>
|
||||||
|
/// <param name="Discarded">
|
||||||
|
/// The value that lost, meaningful only when <paramref name="Decision"/> is
|
||||||
|
/// <see cref="MergeDecision.Conflicted"/>. Never simply dropped: the caller is expected to record it.
|
||||||
|
/// </param>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
public readonly record struct FieldMerge<T>(T Value, MergeDecision Decision, T? Discarded)
|
||||||
|
{
|
||||||
|
/// <summary>Whether both sides changed this field to different values.</summary>
|
||||||
|
public bool IsConflicted => Decision == MergeDecision.Conflicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A key whose value both sides changed, or which one side removed while the other edited.</summary>
|
||||||
|
/// <typeparam name="TKey">Key type.</typeparam>
|
||||||
|
/// <typeparam name="TValue">Value type.</typeparam>
|
||||||
|
/// <param name="Key">The key in question.</param>
|
||||||
|
/// <param name="Kept">The value that survives, or <see langword="default"/> if the key is removed.</param>
|
||||||
|
/// <param name="DiscardedSide">Which replica's intent was overridden.</param>
|
||||||
|
/// <param name="Discarded">
|
||||||
|
/// The value that lost, or <see langword="default"/> when what lost was a removal.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DiscardedWasRemoval">
|
||||||
|
/// True when the overridden intent was to remove the key rather than to set it to a different value.
|
||||||
|
/// </param>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
public readonly record struct MapConflict<TKey, TValue>(
|
||||||
|
TKey Key,
|
||||||
|
TValue? Kept,
|
||||||
|
MergeSide DiscardedSide,
|
||||||
|
TValue? Discarded,
|
||||||
|
bool DiscardedWasRemoval);
|
||||||
|
|
||||||
|
/// <summary>The outcome of merging a keyed collection.</summary>
|
||||||
|
/// <typeparam name="TKey">Key type.</typeparam>
|
||||||
|
/// <typeparam name="TValue">Value type.</typeparam>
|
||||||
|
/// <param name="Merged">The resulting collection.</param>
|
||||||
|
/// <param name="Conflicts">Every key where the two sides disagreed.</param>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
public readonly record struct MapMerge<TKey, TValue>(
|
||||||
|
IReadOnlyDictionary<TKey, TValue> Merged,
|
||||||
|
IReadOnlyList<MapConflict<TKey, TValue>> Conflicts)
|
||||||
|
where TKey : notnull;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two
|
||||||
|
/// divergent versions.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why
|
||||||
|
/// a conflicting push comes back with the server's current row rather than being resolved for us,
|
||||||
|
/// and why this code is the last line of defence against losing a credential.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why the remote side wins a genuine clash.</b> It has to be one of them, and it has to be the
|
||||||
|
/// same one on every replica. If each client kept its own value, two clients would resolve the same
|
||||||
|
/// triple in opposite directions, each push would conflict with the other's, and they would ping-pong
|
||||||
|
/// forever without converging. Deferring to the value already on the server converges in one round.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The losing value is <em>never</em> discarded silently. Every primitive returns it, the item-level
|
||||||
|
/// merge collects them, and the sync engine writes them to a conflict log the user can act on. This
|
||||||
|
/// is the whole point: a merge that quietly drops the password someone just typed is worse than one
|
||||||
|
/// that refuses to merge at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ThreeWayMerge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves one field.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ancestor">The value both sides started from.</param>
|
||||||
|
/// <param name="local">This machine's value.</param>
|
||||||
|
/// <param name="remote">The server's value.</param>
|
||||||
|
/// <param name="comparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
||||||
|
public static FieldMerge<T> Scalar<T>(
|
||||||
|
T ancestor,
|
||||||
|
T local,
|
||||||
|
T remote,
|
||||||
|
IEqualityComparer<T>? comparer = null)
|
||||||
|
{
|
||||||
|
comparer ??= EqualityComparer<T>.Default;
|
||||||
|
|
||||||
|
// Checked first, so two people making the identical edit is agreement rather than a
|
||||||
|
// conflict they have to be bothered about.
|
||||||
|
if (comparer.Equals(local, remote))
|
||||||
|
{
|
||||||
|
return new FieldMerge<T>(local, MergeDecision.Agreed, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparer.Equals(local, ancestor))
|
||||||
|
{
|
||||||
|
return new FieldMerge<T>(remote, MergeDecision.TookRemote, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparer.Equals(remote, ancestor))
|
||||||
|
{
|
||||||
|
return new FieldMerge<T>(local, MergeDecision.TookLocal, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FieldMerge<T>(remote, MergeDecision.Conflicted, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a keyed collection key by key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Per-key rather than whole-collection, which is the difference between two people each adding
|
||||||
|
/// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the
|
||||||
|
/// single most visible benefit of a field-level merge over last-writer-wins.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An edit beats a removal.</b> Where one side deleted a key and the other changed its value,
|
||||||
|
/// the value survives and the removal is reported. The asymmetry is deliberate and it is not a
|
||||||
|
/// preference: re-applying a removal costs one click, while a discarded value may be the only
|
||||||
|
/// copy of something the user cannot reconstruct.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="ancestor">The state both sides started from.</param>
|
||||||
|
/// <param name="local">This machine's state.</param>
|
||||||
|
/// <param name="remote">The server's state.</param>
|
||||||
|
/// <param name="keyComparer">Defines key identity.</param>
|
||||||
|
/// <param name="valueComparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
||||||
|
public static MapMerge<TKey, TValue> Map<TKey, TValue>(
|
||||||
|
IReadOnlyDictionary<TKey, TValue> ancestor,
|
||||||
|
IReadOnlyDictionary<TKey, TValue> local,
|
||||||
|
IReadOnlyDictionary<TKey, TValue> remote,
|
||||||
|
IEqualityComparer<TKey> keyComparer,
|
||||||
|
IEqualityComparer<TValue>? valueComparer = null)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ancestor);
|
||||||
|
ArgumentNullException.ThrowIfNull(local);
|
||||||
|
ArgumentNullException.ThrowIfNull(remote);
|
||||||
|
ArgumentNullException.ThrowIfNull(keyComparer);
|
||||||
|
|
||||||
|
valueComparer ??= EqualityComparer<TValue>.Default;
|
||||||
|
|
||||||
|
var merged = new Dictionary<TKey, TValue>(keyComparer);
|
||||||
|
var conflicts = new List<MapConflict<TKey, TValue>>();
|
||||||
|
|
||||||
|
foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer))
|
||||||
|
{
|
||||||
|
var a = Slot.For(ancestor, key);
|
||||||
|
var l = Slot.For(local, key);
|
||||||
|
var r = Slot.For(remote, key);
|
||||||
|
|
||||||
|
var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts);
|
||||||
|
|
||||||
|
if (resolved.Present)
|
||||||
|
{
|
||||||
|
merged[key] = resolved.Value!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MapMerge<TKey, TValue>(merged, conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One key's state on one replica: present with a value, or absent.</summary>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
private readonly record struct Slot<TValue>(bool Present, TValue? Value)
|
||||||
|
{
|
||||||
|
internal bool Matches(in Slot<TValue> other, IEqualityComparer<TValue> comparer) =>
|
||||||
|
Present == other.Present
|
||||||
|
&& (!Present || comparer.Equals(Value!, other.Value!));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Slot
|
||||||
|
{
|
||||||
|
internal static Slot<TValue> For<TKey, TValue>(
|
||||||
|
IReadOnlyDictionary<TKey, TValue> source,
|
||||||
|
TKey key) =>
|
||||||
|
source.TryGetValue(key, out var value)
|
||||||
|
? new Slot<TValue>(true, value)
|
||||||
|
: new Slot<TValue>(false, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Slot<TValue> ResolveKey<TKey, TValue>(
|
||||||
|
TKey key,
|
||||||
|
in Slot<TValue> ancestor,
|
||||||
|
in Slot<TValue> local,
|
||||||
|
in Slot<TValue> remote,
|
||||||
|
IEqualityComparer<TValue> valueComparer,
|
||||||
|
List<MapConflict<TKey, TValue>> conflicts)
|
||||||
|
{
|
||||||
|
if (local.Matches(remote, valueComparer))
|
||||||
|
{
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.Matches(ancestor, valueComparer))
|
||||||
|
{
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remote.Matches(ancestor, valueComparer))
|
||||||
|
{
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal;
|
||||||
|
// where both hold one, defer to the server so every replica converges the same way.
|
||||||
|
var winner = remote.Present ? remote : local;
|
||||||
|
var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote;
|
||||||
|
var loser = remote.Present ? local : remote;
|
||||||
|
|
||||||
|
conflicts.Add(new MapConflict<TKey, TValue>(
|
||||||
|
key,
|
||||||
|
winner.Value,
|
||||||
|
loserSide,
|
||||||
|
loser.Present ? loser.Value : default,
|
||||||
|
DiscardedWasRemoval: !loser.Present));
|
||||||
|
|
||||||
|
return winner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<TKey> UnionOfKeys<TKey, TValue>(
|
||||||
|
IReadOnlyDictionary<TKey, TValue> ancestor,
|
||||||
|
IReadOnlyDictionary<TKey, TValue> local,
|
||||||
|
IReadOnlyDictionary<TKey, TValue> remote,
|
||||||
|
IEqualityComparer<TKey> keyComparer)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
var seen = new HashSet<TKey>(keyComparer);
|
||||||
|
|
||||||
|
foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys))
|
||||||
|
{
|
||||||
|
if (seen.Add(key))
|
||||||
|
{
|
||||||
|
yield return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates between the payload columns and <see cref="EncryptedPayload"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The columns are nullable because a tombstone has no payload, but the three of them are all-or-
|
||||||
|
/// nothing: an envelope without its wrapped data key is a row no client can ever open. Reconstructing
|
||||||
|
/// through here rather than at each call site means that pairing is checked in one place.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class CacheMapping
|
||||||
|
{
|
||||||
|
internal static EncryptedPayload? ToPayload(
|
||||||
|
byte[]? envelope,
|
||||||
|
byte[]? wrappedDataKey,
|
||||||
|
Guid? dataKeyId,
|
||||||
|
uint keyGeneration,
|
||||||
|
byte aadVersion) =>
|
||||||
|
envelope is null || wrappedDataKey is null || dataKeyId is null
|
||||||
|
? null
|
||||||
|
: new EncryptedPayload(envelope, wrappedDataKey, dataKeyId.Value, keyGeneration, aadVersion);
|
||||||
|
|
||||||
|
internal static EncryptedPayload? ToAncestorPayload(OutboxRow row) =>
|
||||||
|
ToPayload(
|
||||||
|
row.AncestorPayload,
|
||||||
|
row.AncestorWrappedDataKey,
|
||||||
|
row.AncestorDataKeyId,
|
||||||
|
row.AncestorKeyGeneration ?? 0,
|
||||||
|
row.AncestorAadVersion ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises the plaintext columns so they can be sealed as one unit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The whole record is sealed together rather than split into columns. Nothing queries these yet — the
|
||||||
|
/// M1 interface lists every host in a vault — and the moment one field needs an index it gets its own
|
||||||
|
/// column, at which point the duplication is deliberate and visible rather than pre-emptive.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Goes through the Contracts serialiser rather than a hand-rolled encoding, so the local
|
||||||
|
/// representation cannot drift from the wire one. That matters when re-pushing a change: what the
|
||||||
|
/// server receives must be what the server sent.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class PlaintextFieldsCodec
|
||||||
|
{
|
||||||
|
internal static byte[] Encode(SyncPlaintextFields fields) =>
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
fields, DodoSshJsonContext.Default.SyncPlaintextFields);
|
||||||
|
|
||||||
|
/// <returns>
|
||||||
|
/// The fields, or <see langword="null"/> if the bytes are not a record this build understands. A
|
||||||
|
/// null must degrade to "treat the row as stale and re-pull", never to an exception inside a sync
|
||||||
|
/// pass.
|
||||||
|
/// </returns>
|
||||||
|
internal static SyncPlaintextFields? TryDecode(ReadOnlySpan<byte> utf8)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize(
|
||||||
|
utf8, DodoSshJsonContext.Default.SyncPlaintextFields);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What unlock needs, and nothing else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A single row: <see cref="Id"/> is always <see cref="SingletonId"/>. One cache database holds one
|
||||||
|
/// server and one user. Multiple accounts are a real feature and they deserve their own design —
|
||||||
|
/// which server a vault came from, which identity signed a grant, which profile a window belongs to
|
||||||
|
/// — rather than a half-provision now that would have to be undone.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This is the row that makes an offline launch work. The KDF salt and the wrapped bundle are cached
|
||||||
|
/// here precisely so that unlock needs no network: fetching a salt at unlock time would mean the
|
||||||
|
/// vault cannot be opened on a plane, which is the most common moment a user needs it. Neither is a
|
||||||
|
/// secret — the salt is public by design and the bundle is ciphertext.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class UnlockMaterialRow
|
||||||
|
{
|
||||||
|
/// <summary>The only legal primary key.</summary>
|
||||||
|
internal const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int Id { get; set; } = SingletonId;
|
||||||
|
|
||||||
|
public string ServerUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
public string Issuer { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
public uint KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
|
||||||
|
public byte[] WrappedPrivateKey { get; set; } = [];
|
||||||
|
|
||||||
|
public string KdfAlgorithm { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public byte[] KdfSalt { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>Kibibytes, matching both libsodium and the storage column on the server.</summary>
|
||||||
|
public int KdfMemoryKibibytes { get; set; }
|
||||||
|
|
||||||
|
public int KdfPasses { get; set; }
|
||||||
|
|
||||||
|
public int KdfParallelism { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||||
|
/// plaintext here for the same reason it is plaintext on the server: a user has to pick a vault
|
||||||
|
/// before anything has been decrypted.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CachedVaultRow
|
||||||
|
{
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsPersonal { get; set; }
|
||||||
|
|
||||||
|
public Guid? TeamId { get; set; }
|
||||||
|
|
||||||
|
public uint KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
public int Permissions { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vault key sealed to this user's X25519 key. Null while a grant awaits re-wrap after a
|
||||||
|
/// rekey, in which case the vault is temporarily unreadable.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? WrappedVaultKey { get; set; }
|
||||||
|
|
||||||
|
public bool RekeyRequired { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last state of an item that the server confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Strictly a mirror: this row is what the server said, never what the user has typed but not yet
|
||||||
|
/// pushed. Local edits live in <see cref="OutboxRow"/>, which also retains the ancestor they branched
|
||||||
|
/// from. Keeping the two apart is what makes a three-way merge possible at all — a single row that
|
||||||
|
/// held "current local state" would have overwritten the common ancestor and left only a two-way
|
||||||
|
/// diff, which cannot tell an edit from a revert.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Payload"/> is the server's ciphertext byte for byte, so its AAD still verifies. Storing
|
||||||
|
/// a re-encrypted copy would work but would throw away the ability to detect that the server handed
|
||||||
|
/// back something it should not have.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CachedItemRow
|
||||||
|
{
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
public SyncEntityType EntityType { get; set; }
|
||||||
|
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The server-assigned item version, and the value a push must expect.</summary>
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
public long ChangeSequence { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Payload { get; set; }
|
||||||
|
|
||||||
|
public byte[]? WrappedDataKey { get; set; }
|
||||||
|
|
||||||
|
public Guid? DataKeyId { get; set; }
|
||||||
|
|
||||||
|
public uint KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
public byte AadVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plaintext columns the server needs, sealed under the LocalCacheKey.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sealed rather than stored as columns because the cache can do better than the server here for
|
||||||
|
/// free. The server must hold a relay-enabled host's address in the clear — it has to resolve it
|
||||||
|
/// — but this machine already holds the key that decrypts the payload, so nothing is gained by
|
||||||
|
/// leaving the address readable in a file that ends up in backups. No query needs these yet; when
|
||||||
|
/// one does, the field it needs gets its own column and this comment gets revisited.
|
||||||
|
/// </remarks>
|
||||||
|
public byte[]? ProtectedFields { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A tombstone. Deletes are never hard, or an offline client could not learn of them.</summary>
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local change that the server has not yet accepted.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// At most one row per item, and it carries the ancestor it branched from. That ancestor is the
|
||||||
|
/// entire reason a conflict can be merged rather than arbitrated: with it, the client can tell which
|
||||||
|
/// side changed which field.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Attempts"/> and <see cref="LastError"/> exist so a permanently rejected operation can be
|
||||||
|
/// parked and shown rather than retried forever. An operation the server calls
|
||||||
|
/// <c>Invalid</c> will never succeed on retry, and spinning on it would block every change queued
|
||||||
|
/// behind it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class OutboxRow
|
||||||
|
{
|
||||||
|
/// <summary>Local, monotonic. Defines the order changes are pushed in.</summary>
|
||||||
|
public long Sequence { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server's idempotency key for this operation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Re-minted whenever the payload changes — see <c>OutboxStore.QueueAsync</c>. Keeping the old id
|
||||||
|
/// across an edit would let the server answer <c>Duplicate</c> for an operation whose contents
|
||||||
|
/// have since changed, silently discarding the newer edit.
|
||||||
|
/// </remarks>
|
||||||
|
public Guid OperationId { get; set; }
|
||||||
|
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
public SyncEntityType EntityType { get; set; }
|
||||||
|
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
public SyncOperation Operation { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The version the client believes the server holds. Null means create.</summary>
|
||||||
|
public int? ExpectedVersion { get; set; }
|
||||||
|
|
||||||
|
public byte[]? Payload { get; set; }
|
||||||
|
|
||||||
|
public byte[]? WrappedDataKey { get; set; }
|
||||||
|
|
||||||
|
public Guid? DataKeyId { get; set; }
|
||||||
|
|
||||||
|
public uint KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
public byte AadVersion { get; set; }
|
||||||
|
|
||||||
|
public byte[]? ProtectedFields { get; set; }
|
||||||
|
|
||||||
|
// ---- The ancestor this edit branched from ----
|
||||||
|
// Kept verbatim, including the fields the AAD binds, because without the generation, the data
|
||||||
|
// key id and the version, the ancestor cannot be decrypted and the merge has no base.
|
||||||
|
|
||||||
|
public int? AncestorVersion { get; set; }
|
||||||
|
|
||||||
|
public byte[]? AncestorPayload { get; set; }
|
||||||
|
|
||||||
|
public byte[]? AncestorWrappedDataKey { get; set; }
|
||||||
|
|
||||||
|
public Guid? AncestorDataKeyId { get; set; }
|
||||||
|
|
||||||
|
public uint? AncestorKeyGeneration { get; set; }
|
||||||
|
|
||||||
|
public byte? AncestorAadVersion { get; set; }
|
||||||
|
|
||||||
|
public byte[]? AncestorProtectedFields { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset QueuedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public int Attempts { get; set; }
|
||||||
|
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when the server rejected this outright, so it stops being retried.</summary>
|
||||||
|
public bool IsParked { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where a vault's pull has reached.</summary>
|
||||||
|
internal sealed class SyncStateRow
|
||||||
|
{
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last cursor the server issued. Opaque and integrity-tagged: a client must never
|
||||||
|
/// construct or edit one, which is why this is stored verbatim and never parsed.
|
||||||
|
/// </summary>
|
||||||
|
public string? Cursor { get; set; }
|
||||||
|
|
||||||
|
public uint KeyGeneration { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastPulledAtUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastPushedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Observed difference between the server's clock and this machine's, from the last pull.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Recorded rather than corrected. Local timestamps are display metadata, never a merge input —
|
||||||
|
/// the merge uses versions and the retained ancestor — so a skewed clock must not be able to
|
||||||
|
/// decide which edit wins.
|
||||||
|
/// </remarks>
|
||||||
|
public long ServerTimeSkewMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
|
||||||
|
internal sealed class ConflictRow
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid VaultId { get; set; }
|
||||||
|
|
||||||
|
public SyncEntityType EntityType { get; set; }
|
||||||
|
|
||||||
|
public Guid EntityId { get; set; }
|
||||||
|
|
||||||
|
public ConflictKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The discarded values, sealed under the LocalCacheKey.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sealed because this is the one place the cache deliberately holds decrypted vault content: the
|
||||||
|
/// value a merge overrode. It has to be readable to be useful and it is exactly as sensitive as
|
||||||
|
/// the item it came from.
|
||||||
|
/// </remarks>
|
||||||
|
public byte[] Detail { get; set; } = [];
|
||||||
|
|
||||||
|
public DateTimeOffset DetectedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public bool Acknowledged { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores a timestamp as Unix milliseconds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Not a preference. SQLite has no date type, and EF's default mapping for
|
||||||
|
/// <see cref="DateTimeOffset"/> is a text form that it then <b>refuses to order or compare</b> — any
|
||||||
|
/// query with <c>ORDER BY</c> or a range filter on such a column throws
|
||||||
|
/// <see cref="NotSupportedException"/> at execution time, not at model build. Collecting tombstones
|
||||||
|
/// older than a cutoff and listing conflicts newest-first are both exactly that shape, so this was a
|
||||||
|
/// crash waiting for the first user with a deleted host. Found by the tests that do both.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An integer also sorts and compares correctly by construction, which the text form does not once
|
||||||
|
/// two rows carry different UTC offsets. The cost is losing sub-millisecond precision and normalising
|
||||||
|
/// to UTC — neither of which matters here, and both of which docs/crypto.md §7 already does to every
|
||||||
|
/// timestamp it signs over.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class UnixMillisecondsConverter : ValueConverter<DateTimeOffset, long>
|
||||||
|
{
|
||||||
|
/// <remarks>Public because EF instantiates this reflectively and needs a public constructor.</remarks>
|
||||||
|
public UnixMillisecondsConverter()
|
||||||
|
: base(
|
||||||
|
value => value.ToUnixTimeMilliseconds(),
|
||||||
|
value => DateTimeOffset.FromUnixTimeMilliseconds(value))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The local cache database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Public only because the migrations tooling needs to reach it. The row types stay internal and
|
||||||
|
/// there are no <see cref="DbSet{TEntity}"/> properties: callers go through the stores, which is what
|
||||||
|
/// keeps the sealing of protected columns from being something a call site can forget. Entities are
|
||||||
|
/// registered explicitly in <see cref="OnModelCreating"/> and reached with
|
||||||
|
/// <see cref="DbContext.Set{TEntity}()"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Migrations rather than <c>EnsureCreated</c>, even for a cache. The item rows are indeed disposable
|
||||||
|
/// — worst case they re-pull from a null cursor — but <see cref="UnlockMaterialRow"/> is not: dropping
|
||||||
|
/// it would mean a user who upgrades while offline cannot open their vault until they are back on the
|
||||||
|
/// network, which is exactly the situation the offline unlock exists for.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> options)
|
||||||
|
: DbContext(options)
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Applied as a convention rather than per property, so a timestamp added later cannot be the one
|
||||||
|
/// that is left un-converted — which would fail only when something eventually sorted by it.
|
||||||
|
/// </remarks>
|
||||||
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configurationBuilder);
|
||||||
|
|
||||||
|
configurationBuilder.Properties<DateTimeOffset>().HaveConversion<UnixMillisecondsConverter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||||
|
|
||||||
|
ConfigureUnlockMaterial(modelBuilder);
|
||||||
|
ConfigureVaults(modelBuilder);
|
||||||
|
ConfigureItems(modelBuilder);
|
||||||
|
ConfigureOutbox(modelBuilder);
|
||||||
|
ConfigureSyncState(modelBuilder);
|
||||||
|
ConfigureConflicts(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureUnlockMaterial(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<UnlockMaterialRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable(
|
||||||
|
"unlock_material",
|
||||||
|
// One server and one user per cache file. The constraint is here rather than only in
|
||||||
|
// code so that a second row cannot appear through any path at all — including a
|
||||||
|
// future migration written by someone who has not read this comment.
|
||||||
|
table => table.HasCheckConstraint(
|
||||||
|
"ck_unlock_material_singleton",
|
||||||
|
$"id = {UnlockMaterialRow.SingletonId}"));
|
||||||
|
|
||||||
|
entity.HasKey(row => row.Id);
|
||||||
|
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||||
|
entity.Property(row => row.ServerUrl).IsRequired();
|
||||||
|
entity.Property(row => row.Issuer).IsRequired();
|
||||||
|
entity.Property(row => row.Subject).IsRequired();
|
||||||
|
entity.Property(row => row.WrappedPrivateKey).IsRequired();
|
||||||
|
entity.Property(row => row.KdfAlgorithm).IsRequired();
|
||||||
|
entity.Property(row => row.KdfSalt).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("vault");
|
||||||
|
entity.HasKey(row => row.VaultId);
|
||||||
|
entity.Property(row => row.VaultId).ValueGeneratedNever();
|
||||||
|
entity.Property(row => row.Name).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void ConfigureItems(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<CachedItemRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("item");
|
||||||
|
|
||||||
|
// Composite rather than the entity id alone. Ids are UUIDv7 and globally unique in
|
||||||
|
// practice, but making the vault part of the identity means a row can never be read out
|
||||||
|
// of the wrong vault by a query that forgot to filter.
|
||||||
|
entity.HasKey(row => new { row.VaultId, row.EntityType, row.EntityId });
|
||||||
|
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.EntityType });
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.ChangeSequence });
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void ConfigureOutbox(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<OutboxRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("outbox");
|
||||||
|
entity.HasKey(row => row.Sequence);
|
||||||
|
entity.Property(row => row.Sequence).ValueGeneratedOnAdd();
|
||||||
|
|
||||||
|
// At most one pending operation per item, enforced by the database rather than by
|
||||||
|
// convention. Two queued edits to one item would have to be pushed in order, and the
|
||||||
|
// second would need the version the first produced — which is not known when it is
|
||||||
|
// queued. Coalescing into this single row avoids the problem instead of managing it.
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }).IsUnique();
|
||||||
|
|
||||||
|
// The drain order.
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.IsParked, row.Sequence });
|
||||||
|
|
||||||
|
entity.HasIndex(row => row.OperationId).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void ConfigureSyncState(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<SyncStateRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("sync_state");
|
||||||
|
entity.HasKey(row => row.VaultId);
|
||||||
|
entity.Property(row => row.VaultId).ValueGeneratedNever();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void ConfigureConflicts(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<ConflictRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("conflict");
|
||||||
|
entity.HasKey(row => row.Id);
|
||||||
|
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||||
|
entity.Property(row => row.Detail).IsRequired();
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.Acknowledged });
|
||||||
|
entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the local cache and hands out short-lived contexts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A factory rather than one long-lived context, because a sync pass runs on a background task while
|
||||||
|
/// the interface reads the same tables, and a <see cref="DbContext"/> is not thread-safe. Each store
|
||||||
|
/// operation takes a context, does one unit of work and disposes it; SQLite serialises the writes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The alternative — a single context guarded by a lock — would work and would also silently
|
||||||
|
/// accumulate a change tracker for the life of the process, which for a vault of thousands of items
|
||||||
|
/// is both a leak and a source of stale reads.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>, IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbContextOptions<ClientCacheContext> options;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An in-memory SQLite database exists only while at least one connection to it is open, so the
|
||||||
|
/// memory-backed factory holds one for its lifetime. Null for a file-backed one.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly SqliteConnection? keepAlive;
|
||||||
|
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
|
||||||
|
{
|
||||||
|
this.keepAlive = keepAlive;
|
||||||
|
|
||||||
|
options = new DbContextOptionsBuilder<ClientCacheContext>()
|
||||||
|
.UseSqlite(connectionString)
|
||||||
|
.UseSnakeCaseNamingConvention()
|
||||||
|
.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens, or creates, a cache file.</summary>
|
||||||
|
/// <param name="databasePath">Full path to the SQLite file.</param>
|
||||||
|
public static ClientCacheFactory ForFile(string databasePath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
|
||||||
|
|
||||||
|
var builder = new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = databasePath,
|
||||||
|
// The cache is written by one process. WAL would buy concurrent readers we do not have
|
||||||
|
// and would leave two extra files beside the database for a user to wonder about.
|
||||||
|
Pooling = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ClientCacheFactory(builder.ConnectionString, keepAlive: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a private in-memory cache, for tests and for a session that must leave no trace.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">
|
||||||
|
/// Distinguishes one in-memory database from another. Two factories given the same name share
|
||||||
|
/// storage, which is how a test can prove that data survives a context being disposed.
|
||||||
|
/// </param>
|
||||||
|
public static ClientCacheFactory ForMemory(string name)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
var builder = new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = name,
|
||||||
|
Mode = SqliteOpenMode.Memory,
|
||||||
|
Cache = SqliteCacheMode.Shared,
|
||||||
|
};
|
||||||
|
|
||||||
|
var connection = new SqliteConnection(builder.ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
return new ClientCacheFactory(builder.ConnectionString, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ClientCacheContext CreateDbContext()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
return new ClientCacheContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the schema up to date.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called by the client at startup, before unlock — it touches no encrypted content, only the
|
||||||
|
/// shape of the tables. It must therefore never need a key, which is also why the schema is
|
||||||
|
/// migrated rather than recreated.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
keepAlive?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies a context to <c>dotnet ef</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Exists only for the migrations tooling, which needs to build a model without running the
|
||||||
|
/// application. The path is a throwaway: the tool reads the model, not the data.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ClientCacheDesignTimeFactory : IDesignTimeDbContextFactory<ClientCacheContext>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ClientCacheContext CreateDbContext(string[] args) =>
|
||||||
|
ClientCacheFactory.ForFile("dodossh-design-time.db").CreateDbContext();
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the merge had to override, and what it could not process.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This table is what makes automatic merging defensible. The merge picks a winner field by field,
|
||||||
|
/// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a
|
||||||
|
/// field-level merge is last-writer-wins with a longer explanation.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately
|
||||||
|
/// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly
|
||||||
|
/// as sensitive as the item it came from and is treated that way.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ConflictStore(
|
||||||
|
IDbContextFactory<ClientCacheContext> contexts,
|
||||||
|
LocalCacheProtector protector,
|
||||||
|
TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Records a conflict.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The record's own id is generated here and the detail is bound to it, so one conflict's discarded
|
||||||
|
/// values can never be read back against another's row.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<Guid> RecordAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
ConflictKind kind,
|
||||||
|
ReadOnlyMemory<byte> detail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (kind == ConflictKind.Unspecified)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var id = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
context.Add(new ConflictRow
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
VaultId = vaultId,
|
||||||
|
EntityType = entityType,
|
||||||
|
EntityId = entityId,
|
||||||
|
Kind = kind,
|
||||||
|
Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span),
|
||||||
|
DetectedAtUtc = clock.GetUtcNow(),
|
||||||
|
Acknowledged = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads conflicts for a vault, newest first.</summary>
|
||||||
|
public async Task<IReadOnlyList<StoredConflict>> ListAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
bool includeAcknowledged,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = context.Set<ConflictRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(row => row.VaultId == vaultId);
|
||||||
|
|
||||||
|
if (!includeAcknowledged)
|
||||||
|
{
|
||||||
|
query = query.Where(row => !row.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(row => row.DetectedAtUtc)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToStored)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks a conflict as dealt with.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has
|
||||||
|
/// dismissed the notification. Someone who clicks past a warning and realises a minute later that
|
||||||
|
/// they wanted the other value should still be able to get it.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<bool> AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var updated = await context.Set<ConflictRow>()
|
||||||
|
.Where(row => row.Id == conflictId)
|
||||||
|
.ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return updated > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes an acknowledged conflict for good.</summary>
|
||||||
|
public async Task<bool> DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var removed = await context.Set<ConflictRow>()
|
||||||
|
.Where(row => row.Id == conflictId && row.Acknowledged)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return removed > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its
|
||||||
|
/// kind, its item, its timestamp — is still worth showing even when the discarded value has become
|
||||||
|
/// unreadable, for instance after a passphrase change re-derived the cache key.
|
||||||
|
/// </remarks>
|
||||||
|
private StoredConflict ToStored(ConflictRow row) =>
|
||||||
|
new(
|
||||||
|
row.Id,
|
||||||
|
row.VaultId,
|
||||||
|
row.EntityType,
|
||||||
|
row.EntityId,
|
||||||
|
row.Kind,
|
||||||
|
protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [],
|
||||||
|
row.DetectedAtUtc,
|
||||||
|
row.Acknowledged);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The client's local cache: ciphertext exactly as the server returned it, the outbox of local
|
||||||
|
changes not yet accepted, and the material an offline unlock needs.
|
||||||
|
|
||||||
|
SQLite through EF Core, and deliberately *not* SQLCipher. Item payloads arrive already
|
||||||
|
encrypted under keys the server has never seen, so an encrypted database file would protect
|
||||||
|
bytes that are protected already, at the cost of a native dependency and a licence obligation.
|
||||||
|
The one thing that would genuinely be plaintext — a search index — is kept in memory and
|
||||||
|
rebuilt on unlock. SQLitePCLRaw deprecated bundle_e_sqlcipher in 3.0 in any case.
|
||||||
|
|
||||||
|
Everything this project does store in the clear is either not a secret (a version number, a
|
||||||
|
change sequence) or is sealed under the LocalCacheKey first.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Storage.Tests" />
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Sync" />
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Sync.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mirror of what the server holds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
|
||||||
|
/// this table — that lives in <see cref="OutboxStore"/>, together with the ancestor it branched from.
|
||||||
|
/// Keeping the two apart is what makes a three-way merge possible: a single table holding "the current
|
||||||
|
/// local view" would have overwritten the ancestor and left only a two-way diff, which cannot tell an
|
||||||
|
/// edit from a revert.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Requires an unlocked <see cref="LocalCacheProtector"/>, which is deliberate. The protected columns
|
||||||
|
/// have to be sealed on every write and opened on every read, and a store that could be constructed
|
||||||
|
/// without a key would be a store that could write one of them in the clear.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ItemStore(
|
||||||
|
IDbContextFactory<ClientCacheContext> contexts,
|
||||||
|
LocalCacheProtector protector)
|
||||||
|
{
|
||||||
|
/// <summary>Reads one item, tombstones included.</summary>
|
||||||
|
public async Task<StoredItem?> FindAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<CachedItemRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return row is null ? null : ToStored(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads every item of one kind in a vault.</summary>
|
||||||
|
/// <param name="vaultId">The vault.</param>
|
||||||
|
/// <param name="entityType">Kind of item.</param>
|
||||||
|
/// <param name="includeDeleted">
|
||||||
|
/// Whether to return tombstones. The interface wants them excluded; the sync engine wants them,
|
||||||
|
/// because a tombstone is the only record that an item it once knew about has gone.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
public async Task<IReadOnlyList<StoredItem>> ListAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
bool includeDeleted,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var query = context.Set<CachedItemRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.VaultId == vaultId && r.EntityType == entityType);
|
||||||
|
|
||||||
|
if (!includeDeleted)
|
||||||
|
{
|
||||||
|
query = query.Where(r => !r.IsDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await query
|
||||||
|
.OrderBy(r => r.ChangeSequence)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToStored)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the server's version of an item, creating or replacing the row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately a blind overwrite. This is a mirror, and the server's answer is the truth about
|
||||||
|
/// what the server holds; a local edit that must survive is in the outbox, and it is the sync
|
||||||
|
/// engine's job to have merged it before calling this.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task SaveAsync(StoredItem item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<CachedItemRow>()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
r => r.VaultId == item.VaultId
|
||||||
|
&& r.EntityType == item.EntityType
|
||||||
|
&& r.EntityId == item.EntityId,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new CachedItemRow
|
||||||
|
{
|
||||||
|
VaultId = item.VaultId,
|
||||||
|
EntityType = item.EntityType,
|
||||||
|
EntityId = item.EntityId,
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(row, item);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
|
||||||
|
/// it indistinguishable from one this client has never seen, and it would silently reappear on the
|
||||||
|
/// next full pull.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<int> CollectTombstonesAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
DateTimeOffset olderThan,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await context.Set<CachedItemRow>()
|
||||||
|
.Where(r => r.VaultId == vaultId && r.IsDeleted && r.UpdatedAtUtc < olderThan)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Apply(CachedItemRow row, StoredItem item)
|
||||||
|
{
|
||||||
|
row.Version = item.Version;
|
||||||
|
row.ChangeSequence = item.ChangeSequence;
|
||||||
|
row.IsDeleted = item.IsDeleted;
|
||||||
|
row.UpdatedAtUtc = item.UpdatedAt;
|
||||||
|
|
||||||
|
row.Payload = item.Payload?.Envelope;
|
||||||
|
row.WrappedDataKey = item.Payload?.WrappedDataKey;
|
||||||
|
row.DataKeyId = item.Payload?.DataKeyId;
|
||||||
|
row.KeyGeneration = item.Payload?.KeyGeneration ?? 0;
|
||||||
|
row.AadVersion = item.Payload?.AadVersion ?? 0;
|
||||||
|
|
||||||
|
row.ProtectedFields = item.Fields is null
|
||||||
|
? null
|
||||||
|
: protector.Protect(
|
||||||
|
AadResourceTypes.For(item.EntityType),
|
||||||
|
item.EntityId,
|
||||||
|
PlaintextFieldsCodec.Encode(item.Fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoredItem ToStored(CachedItemRow row) =>
|
||||||
|
new(
|
||||||
|
row.VaultId,
|
||||||
|
row.EntityType,
|
||||||
|
row.EntityId,
|
||||||
|
row.Version,
|
||||||
|
row.ChangeSequence,
|
||||||
|
CacheMapping.ToPayload(
|
||||||
|
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
|
||||||
|
OpenFields(row.EntityType, row.EntityId, row.ProtectedFields),
|
||||||
|
row.IsDeleted,
|
||||||
|
row.UpdatedAtUtc);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A record that will not open is treated as absent rather than fatal. The cache is not the
|
||||||
|
/// authority — a re-pull restores it — and the alternative is one stale row aborting a sync pass
|
||||||
|
/// and stranding every change behind it.
|
||||||
|
/// </remarks>
|
||||||
|
private SyncPlaintextFields? OpenFields(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
|
||||||
|
{
|
||||||
|
if (sealedFields is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = protector.TryUnprotect(
|
||||||
|
AadResourceTypes.For(entityType), entityId, sealedFields);
|
||||||
|
|
||||||
|
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seals the few things the local cache holds that are not already ciphertext.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The cache stores item payloads exactly as the server sent them, so they need no further
|
||||||
|
/// protection. Two things do: the plaintext columns the server needs — a relay-enabled host's
|
||||||
|
/// address, chiefly — and the values a merge overrode, which are decrypted vault content by
|
||||||
|
/// definition. Both go through here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What this is and is not worth.</b> The key derives from the master key, so it exists only while
|
||||||
|
/// the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
|
||||||
|
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
|
||||||
|
/// <em>not</em> defend against a process running as the same user: that process can read this
|
||||||
|
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
|
||||||
|
/// compromised endpoint, and this layer does not pretend otherwise.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every record is bound to its own row, so a record cannot be moved to a different row of the same
|
||||||
|
/// cache. For a relay address that is not academic: two swapped rows would aim one host's connection
|
||||||
|
/// at another host's address.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LocalCacheProtector : IDisposable
|
||||||
|
{
|
||||||
|
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
private LocalCacheProtector(MasterKey master) => master.DeriveLocalCacheKey(key);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the cache key from an unlocked master key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The master key is not retained. Only the subkey is, and it is domain-separated by its HKDF
|
||||||
|
/// label from the key that wraps the secret bundle — the two live in very different threat models
|
||||||
|
/// and must not be the same bytes.
|
||||||
|
/// </remarks>
|
||||||
|
public static LocalCacheProtector From(MasterKey master)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(master);
|
||||||
|
|
||||||
|
return new LocalCacheProtector(master);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
|
||||||
|
public byte[] Protect(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid recordId,
|
||||||
|
ReadOnlySpan<byte> plaintext)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a sealed cache record.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The plaintext, or <see langword="null"/> if the record does not belong to this row or this
|
||||||
|
/// user. Null rather than an exception because a stale cache file is an ordinary situation — a
|
||||||
|
/// changed passphrase re-derives a different key — and the caller's answer is to discard the row
|
||||||
|
/// and re-pull, not to fail.
|
||||||
|
/// </returns>
|
||||||
|
public byte[]? TryUnprotect(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid recordId,
|
||||||
|
ReadOnlySpan<byte> envelope)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
CryptographicOperations.ZeroMemory(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a syncable entity type onto the resource type its AAD binds.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
|
||||||
|
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
|
||||||
|
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
|
||||||
|
/// without failing anywhere a test would notice.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class AadResourceTypes
|
||||||
|
{
|
||||||
|
internal static CryptoSpec.AadResourceType For(SyncEntityType entityType) => entityType switch
|
||||||
|
{
|
||||||
|
SyncEntityType.Host => CryptoSpec.AadResourceType.Host,
|
||||||
|
SyncEntityType.Credential => CryptoSpec.AadResourceType.Credential,
|
||||||
|
SyncEntityType.SshKey => CryptoSpec.AadResourceType.SshKey,
|
||||||
|
SyncEntityType.HostGroup => CryptoSpec.AadResourceType.HostGroup,
|
||||||
|
SyncEntityType.Tag => CryptoSpec.AadResourceType.Tag,
|
||||||
|
SyncEntityType.HostTag => CryptoSpec.AadResourceType.HostTag,
|
||||||
|
SyncEntityType.HostCredential => CryptoSpec.AadResourceType.HostCredential,
|
||||||
|
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
|
||||||
|
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
|
||||||
|
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||||||
|
};
|
||||||
|
}
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClientCacheContext))]
|
||||||
|
[Migration("20260729080003_InitialCache")]
|
||||||
|
partial class InitialCache
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasName("pk_item");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "ChangeSequence")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||||
|
|
||||||
|
b.ToTable("item", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPersonal")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_personal");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Permissions")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("permissions");
|
||||||
|
|
||||||
|
b.Property<bool>("RekeyRequired")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("rekey_required");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TeamId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("team_id");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedVaultKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_vault_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_vault");
|
||||||
|
|
||||||
|
b.ToTable("vault", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("acknowledged");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Detail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("detail");
|
||||||
|
|
||||||
|
b.Property<long>("DetectedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("detected_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_conflict");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "Acknowledged")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.ToTable("conflict", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Sequence")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sequence");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<byte?>("AncestorAadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_aad_version");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AncestorDataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ancestor_data_key_id");
|
||||||
|
|
||||||
|
b.Property<uint?>("AncestorKeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorPayload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_protected_fields");
|
||||||
|
|
||||||
|
b.Property<int?>("AncestorVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_wrapped_data_key");
|
||||||
|
|
||||||
|
b.Property<int>("Attempts")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("attempts");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ExpectedVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("expected_version");
|
||||||
|
|
||||||
|
b.Property<bool>("IsParked")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_parked");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_error");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<Guid>("OperationId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("operation_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("QueuedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("queued_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("Sequence")
|
||||||
|
.HasName("pk_outbox");
|
||||||
|
|
||||||
|
b.HasIndex("OperationId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_operation_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||||
|
|
||||||
|
b.ToTable("outbox", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<string>("Cursor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("cursor");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPulledAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pulled_at_utc");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPushedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
b.ToTable("sync_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("issuer");
|
||||||
|
|
||||||
|
b.Property<string>("KdfAlgorithm")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("kdf_algorithm");
|
||||||
|
|
||||||
|
b.Property<int>("KdfMemoryKibibytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_memory_kibibytes");
|
||||||
|
|
||||||
|
b.Property<int>("KdfParallelism")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_parallelism");
|
||||||
|
|
||||||
|
b.Property<int>("KdfPasses")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_passes");
|
||||||
|
|
||||||
|
b.Property<byte[]>("KdfSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("kdf_salt");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("ServerUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("server_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subject");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedPrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_private_key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_unlock_material");
|
||||||
|
|
||||||
|
b.ToTable("unlock_material", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCache : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "conflict",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
detail = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||||
|
detected_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
acknowledged = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_conflict", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "item",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
version = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
change_sequence = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
|
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||||
|
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
is_deleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_item", x => new { x.vault_id, x.entity_type, x.entity_id });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "outbox",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
sequence = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
operation_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
operation = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
expected_version = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
|
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||||
|
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
ancestor_version = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
ancestor_payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
ancestor_wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
ancestor_data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
ancestor_key_generation = table.Column<uint>(type: "INTEGER", nullable: true),
|
||||||
|
ancestor_aad_version = table.Column<byte>(type: "INTEGER", nullable: true),
|
||||||
|
ancestor_protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
queued_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
attempts = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
last_error = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
is_parked = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_outbox", x => x.sequence);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sync_state",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
cursor = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
|
last_pulled_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
last_pushed_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
|
||||||
|
server_time_skew_ms = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_sync_state", x => x.vault_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "unlock_material",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
server_url = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
issuer = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
email = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
display_name = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
|
wrapped_private_key = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||||
|
kdf_algorithm = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
kdf_salt = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||||
|
kdf_memory_kibibytes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
kdf_passes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
kdf_parallelism = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_unlock_material", x => x.id);
|
||||||
|
table.CheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "vault",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
is_personal = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
team_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||||
|
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||||
|
permissions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
wrapped_vault_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||||
|
rekey_required = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_vault", x => x.vault_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_conflict_vault_id_acknowledged",
|
||||||
|
table: "conflict",
|
||||||
|
columns: new[] { "vault_id", "acknowledged" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_conflict_vault_id_entity_type_entity_id",
|
||||||
|
table: "conflict",
|
||||||
|
columns: new[] { "vault_id", "entity_type", "entity_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_item_vault_id_change_sequence",
|
||||||
|
table: "item",
|
||||||
|
columns: new[] { "vault_id", "change_sequence" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_item_vault_id_entity_type",
|
||||||
|
table: "item",
|
||||||
|
columns: new[] { "vault_id", "entity_type" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_operation_id",
|
||||||
|
table: "outbox",
|
||||||
|
column: "operation_id",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_vault_id_entity_type_entity_id",
|
||||||
|
table: "outbox",
|
||||||
|
columns: new[] { "vault_id", "entity_type", "entity_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_outbox_vault_id_is_parked_sequence",
|
||||||
|
table: "outbox",
|
||||||
|
columns: new[] { "vault_id", "is_parked", "sequence" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "conflict");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "item");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "outbox");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sync_state");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "unlock_material");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "vault");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClientCacheContext))]
|
||||||
|
partial class ClientCacheContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasName("pk_item");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "ChangeSequence")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||||
|
|
||||||
|
b.ToTable("item", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPersonal")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_personal");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Permissions")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("permissions");
|
||||||
|
|
||||||
|
b.Property<bool>("RekeyRequired")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("rekey_required");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TeamId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("team_id");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedVaultKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_vault_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_vault");
|
||||||
|
|
||||||
|
b.ToTable("vault", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("acknowledged");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Detail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("detail");
|
||||||
|
|
||||||
|
b.Property<long>("DetectedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("detected_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_conflict");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "Acknowledged")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.ToTable("conflict", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Sequence")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sequence");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<byte?>("AncestorAadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_aad_version");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AncestorDataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ancestor_data_key_id");
|
||||||
|
|
||||||
|
b.Property<uint?>("AncestorKeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorPayload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_protected_fields");
|
||||||
|
|
||||||
|
b.Property<int?>("AncestorVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_wrapped_data_key");
|
||||||
|
|
||||||
|
b.Property<int>("Attempts")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("attempts");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ExpectedVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("expected_version");
|
||||||
|
|
||||||
|
b.Property<bool>("IsParked")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_parked");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_error");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<Guid>("OperationId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("operation_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("QueuedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("queued_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("Sequence")
|
||||||
|
.HasName("pk_outbox");
|
||||||
|
|
||||||
|
b.HasIndex("OperationId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_operation_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||||
|
|
||||||
|
b.ToTable("outbox", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<string>("Cursor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("cursor");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPulledAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pulled_at_utc");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPushedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
b.ToTable("sync_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("issuer");
|
||||||
|
|
||||||
|
b.Property<string>("KdfAlgorithm")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("kdf_algorithm");
|
||||||
|
|
||||||
|
b.Property<int>("KdfMemoryKibibytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_memory_kibibytes");
|
||||||
|
|
||||||
|
b.Property<int>("KdfParallelism")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_parallelism");
|
||||||
|
|
||||||
|
b.Property<int>("KdfPasses")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_passes");
|
||||||
|
|
||||||
|
b.Property<byte[]>("KdfSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("kdf_salt");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("ServerUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("server_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subject");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedPrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_private_key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_unlock_material");
|
||||||
|
|
||||||
|
b.ToTable("unlock_material", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>A local change to queue.</summary>
|
||||||
|
/// <param name="VaultId">Owning vault.</param>
|
||||||
|
/// <param name="EntityType">Kind of item.</param>
|
||||||
|
/// <param name="EntityId">The item. Client-generated UUIDv7, so items can be made offline.</param>
|
||||||
|
/// <param name="Operation">Upsert or delete.</param>
|
||||||
|
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
|
||||||
|
/// <param name="Payload">Ciphertext. Required for an upsert.</param>
|
||||||
|
/// <param name="Fields">Plaintext columns the server needs.</param>
|
||||||
|
/// <param name="Ancestor">
|
||||||
|
/// The version this edit branched from, so a conflict can be merged rather than arbitrated. Null when
|
||||||
|
/// creating, where there is nothing to have branched from.
|
||||||
|
/// </param>
|
||||||
|
public sealed record QueuedChange(
|
||||||
|
Guid VaultId,
|
||||||
|
SyncEntityType EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
SyncOperation Operation,
|
||||||
|
int? ExpectedVersion,
|
||||||
|
EncryptedPayload? Payload,
|
||||||
|
SyncPlaintextFields? Fields,
|
||||||
|
StoredAncestor? Ancestor);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes made here that the server has not yet accepted.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One row per item, and that is a database constraint rather than a convention. Two queued edits to
|
||||||
|
/// one item would have to be pushed in order, and the second's <c>expectedVersion</c> is the version
|
||||||
|
/// the first will produce — which is not known when it is queued. Coalescing sidesteps that instead of
|
||||||
|
/// managing it, and the row holds a desired end state rather than a delta, so coalescing loses nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why a coalesced row gets a new operation id.</b> The id is the server's exactly-once key. If a
|
||||||
|
/// push has already gone out and the user edits again, keeping the id would let the server answer
|
||||||
|
/// <c>Duplicate</c> for an operation whose contents have since changed — silently discarding the newer
|
||||||
|
/// edit. A fresh id means the newer state is offered on its own terms: if the earlier push did land,
|
||||||
|
/// the version has moved on, the push comes back <c>Conflict</c>, and the merge resolves it against an
|
||||||
|
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
|
||||||
|
/// on the newest state with nothing for the user to arbitrate.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OutboxStore(
|
||||||
|
IDbContextFactory<ClientCacheContext> contexts,
|
||||||
|
LocalCacheProtector protector,
|
||||||
|
TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Queues a change the user just made, coalescing into any row already pending for the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A coalesced row keeps the ancestor and <c>expectedVersion</c> of the row it replaces, because
|
||||||
|
/// the new state is still a descendant of that same base. Taking the caller's values instead would
|
||||||
|
/// throw away the common ancestor after the first edit, and with it the ability to merge.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<PendingOperation> QueueAsync(
|
||||||
|
QueuedChange change,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(change);
|
||||||
|
Validate(change);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await FindRowAsync(context, change.VaultId, change.EntityType, change.EntityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new OutboxRow
|
||||||
|
{
|
||||||
|
VaultId = change.VaultId,
|
||||||
|
EntityType = change.EntityType,
|
||||||
|
EntityId = change.EntityId,
|
||||||
|
ExpectedVersion = change.ExpectedVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
SetAncestor(row, change.EntityType, change.EntityId, change.Ancestor);
|
||||||
|
context.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.OperationId = Guid.CreateVersion7();
|
||||||
|
row.Operation = change.Operation;
|
||||||
|
row.QueuedAtUtc = clock.GetUtcNow();
|
||||||
|
row.Attempts = 0;
|
||||||
|
row.LastError = null;
|
||||||
|
row.IsParked = false;
|
||||||
|
|
||||||
|
SetPayload(row, change.EntityType, change.EntityId, change.Payload, change.Fields);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ToPending(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces a pending operation with the outcome of a merge.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Distinct from <see cref="QueueAsync"/> because the intent is opposite: this <em>does</em> move
|
||||||
|
/// the ancestor forward, to the server version the merge was performed against. Without that the
|
||||||
|
/// re-push would conflict against the same base for ever.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It also <b>keeps the attempt count</b>, where queueing resets it. That difference is what makes
|
||||||
|
/// the retry bound real: a row that has conflicted five times needs a person to look at it whether
|
||||||
|
/// or not each attempt carried a freshly merged payload, whereas a user making a new edit has
|
||||||
|
/// genuinely started over.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<PendingOperation?> ReviseAsync(
|
||||||
|
long sequence,
|
||||||
|
SyncOperation operation,
|
||||||
|
int? expectedVersion,
|
||||||
|
EncryptedPayload? payload,
|
||||||
|
SyncPlaintextFields? fields,
|
||||||
|
StoredAncestor? ancestor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<OutboxRow>()
|
||||||
|
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.OperationId = Guid.CreateVersion7();
|
||||||
|
row.Operation = operation;
|
||||||
|
row.ExpectedVersion = expectedVersion;
|
||||||
|
row.LastError = null;
|
||||||
|
row.IsParked = false;
|
||||||
|
|
||||||
|
SetPayload(row, row.EntityType, row.EntityId, payload, fields);
|
||||||
|
SetAncestor(row, row.EntityType, row.EntityId, ancestor);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ToPending(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the next operations to push, oldest first, skipping parked ones.</summary>
|
||||||
|
public async Task<IReadOnlyList<PendingOperation>> TakeAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<OutboxRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.VaultId == vaultId && !r.IsParked)
|
||||||
|
.OrderBy(r => r.Sequence)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToPending)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the operation pending for one item, if any.</summary>
|
||||||
|
public async Task<PendingOperation?> FindAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await FindRowAsync(context, vaultId, entityType, entityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return row is null ? null : ToPending(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads every pending operation for a vault, parked ones included.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What the interface needs, as opposed to what the pusher needs. A parked change is still the
|
||||||
|
/// user's current intent for that item and must be what they see; hiding it because the server
|
||||||
|
/// refused it would show them the old values and look like their edit was lost.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<IReadOnlyList<PendingOperation>> ListAllAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<OutboxRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.VaultId == vaultId)
|
||||||
|
.OrderBy(r => r.Sequence)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToPending)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads operations the server refused, which need a person.</summary>
|
||||||
|
public async Task<IReadOnlyList<PendingOperation>> ListParkedAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<OutboxRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(r => r.VaultId == vaultId && r.IsParked)
|
||||||
|
.OrderBy(r => r.Sequence)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToPending)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
|
||||||
|
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
|
||||||
|
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Removes an operation the server accepted.</summary>
|
||||||
|
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var removed = await context.Set<OutboxRow>()
|
||||||
|
.Where(r => r.Sequence == sequence)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return removed > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops retrying an operation and records why.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For the answers that will not change on retry — the server calling an operation structurally
|
||||||
|
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
|
||||||
|
/// worse, would block every change queued behind it in a vault the user can still write to.
|
||||||
|
/// </remarks>
|
||||||
|
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||||
|
UpdateAsync(
|
||||||
|
sequence,
|
||||||
|
row =>
|
||||||
|
{
|
||||||
|
row.IsParked = true;
|
||||||
|
row.LastError = reason;
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Records a transient failure without parking the operation.</summary>
|
||||||
|
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||||
|
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
|
||||||
|
|
||||||
|
private static Task<OutboxRow?> FindRowAsync(
|
||||||
|
ClientCacheContext context,
|
||||||
|
Guid vaultId,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
context.Set<OutboxRow>()
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private static void Validate(QueuedChange change)
|
||||||
|
{
|
||||||
|
if (change.Operation == SyncOperation.Upsert && change.Payload is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("An upsert requires a payload.", nameof(change));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.Operation == SyncOperation.Unspecified)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("An operation is required.", nameof(change));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.EntityId == Guid.Empty)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("An entity id is required.", nameof(change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateAsync(
|
||||||
|
long sequence,
|
||||||
|
Action<OutboxRow> mutate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<OutboxRow>()
|
||||||
|
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate(row);
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetPayload(
|
||||||
|
OutboxRow row,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
EncryptedPayload? payload,
|
||||||
|
SyncPlaintextFields? fields)
|
||||||
|
{
|
||||||
|
row.Payload = payload?.Envelope;
|
||||||
|
row.WrappedDataKey = payload?.WrappedDataKey;
|
||||||
|
row.DataKeyId = payload?.DataKeyId;
|
||||||
|
row.KeyGeneration = payload?.KeyGeneration ?? 0;
|
||||||
|
row.AadVersion = payload?.AadVersion ?? 0;
|
||||||
|
|
||||||
|
row.ProtectedFields = Seal(entityType, entityId, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetAncestor(
|
||||||
|
OutboxRow row,
|
||||||
|
SyncEntityType entityType,
|
||||||
|
Guid entityId,
|
||||||
|
StoredAncestor? ancestor)
|
||||||
|
{
|
||||||
|
row.AncestorVersion = ancestor?.Version;
|
||||||
|
row.AncestorPayload = ancestor?.Payload.Envelope;
|
||||||
|
row.AncestorWrappedDataKey = ancestor?.Payload.WrappedDataKey;
|
||||||
|
row.AncestorDataKeyId = ancestor?.Payload.DataKeyId;
|
||||||
|
row.AncestorKeyGeneration = ancestor?.Payload.KeyGeneration;
|
||||||
|
row.AncestorAadVersion = ancestor?.Payload.AadVersion;
|
||||||
|
|
||||||
|
row.AncestorProtectedFields = Seal(entityType, entityId, ancestor?.Fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? Seal(SyncEntityType entityType, Guid entityId, SyncPlaintextFields? fields) =>
|
||||||
|
fields is null
|
||||||
|
? null
|
||||||
|
: protector.Protect(
|
||||||
|
AadResourceTypes.For(entityType), entityId, PlaintextFieldsCodec.Encode(fields));
|
||||||
|
|
||||||
|
private SyncPlaintextFields? Open(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
|
||||||
|
{
|
||||||
|
if (sealedFields is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = protector.TryUnprotect(
|
||||||
|
AadResourceTypes.For(entityType), entityId, sealedFields);
|
||||||
|
|
||||||
|
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PendingOperation ToPending(OutboxRow row)
|
||||||
|
{
|
||||||
|
var ancestorPayload = CacheMapping.ToAncestorPayload(row);
|
||||||
|
|
||||||
|
var ancestor = ancestorPayload is null || row.AncestorVersion is null
|
||||||
|
? null
|
||||||
|
: new StoredAncestor(
|
||||||
|
row.AncestorVersion.Value,
|
||||||
|
ancestorPayload,
|
||||||
|
Open(row.EntityType, row.EntityId, row.AncestorProtectedFields));
|
||||||
|
|
||||||
|
return new PendingOperation(
|
||||||
|
row.Sequence,
|
||||||
|
row.OperationId,
|
||||||
|
row.VaultId,
|
||||||
|
row.EntityType,
|
||||||
|
row.EntityId,
|
||||||
|
row.Operation,
|
||||||
|
row.ExpectedVersion,
|
||||||
|
CacheMapping.ToPayload(
|
||||||
|
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
|
||||||
|
Open(row.EntityType, row.EntityId, row.ProtectedFields),
|
||||||
|
ancestor,
|
||||||
|
row.QueuedAtUtc,
|
||||||
|
row.Attempts,
|
||||||
|
row.LastError,
|
||||||
|
row.IsParked);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>Why a conflict record exists.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Persisted, so append only. These are the vocabulary the UI reasons about: each one implies a
|
||||||
|
/// different remedy, which is why they are distinguished rather than collapsed into "conflict".
|
||||||
|
/// </remarks>
|
||||||
|
public enum ConflictKind
|
||||||
|
{
|
||||||
|
/// <summary>Not a legal value.</summary>
|
||||||
|
Unspecified = 0,
|
||||||
|
|
||||||
|
/// <summary>Both sides changed a field. One value survived; the other is in the detail.</summary>
|
||||||
|
FieldOverridden = 1,
|
||||||
|
|
||||||
|
/// <summary>This machine deleted an item that someone else edited. The edit won.</summary>
|
||||||
|
LocalDeleteOverridden = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Someone else deleted an item this machine had edited. The local content was preserved under a
|
||||||
|
/// new id rather than being lost to the tombstone.
|
||||||
|
/// </summary>
|
||||||
|
RemoteDeleteResurrected = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A payload failed its authentication tag or its schema. Either a client bug or a server that
|
||||||
|
/// handed back the wrong bytes; both need a human.
|
||||||
|
/// </summary>
|
||||||
|
Undecryptable = 4,
|
||||||
|
|
||||||
|
/// <summary>Written by a newer client than this one, so it is readable but not editable.</summary>
|
||||||
|
TooNewToEdit = 5,
|
||||||
|
|
||||||
|
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
|
||||||
|
Rejected = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What an offline unlock needs.</summary>
|
||||||
|
/// <param name="ServerUrl">The server this cache belongs to.</param>
|
||||||
|
/// <param name="UserId">The user, which every AAD in the bundle wrap binds to.</param>
|
||||||
|
/// <param name="Issuer">OIDC issuer.</param>
|
||||||
|
/// <param name="Subject">OIDC subject.</param>
|
||||||
|
/// <param name="Email">Email, for display.</param>
|
||||||
|
/// <param name="DisplayName">Display name, for display.</param>
|
||||||
|
/// <param name="KeyGeneration">Identity key generation.</param>
|
||||||
|
/// <param name="WrappedPrivateKey">The secret bundle, wrapped under the passphrase-derived key.</param>
|
||||||
|
/// <param name="KdfParameters">Parameters needed to re-derive that key.</param>
|
||||||
|
/// <param name="UpdatedAt">When this was last refreshed from the server.</param>
|
||||||
|
public sealed record StoredUnlockMaterial(
|
||||||
|
string ServerUrl,
|
||||||
|
Guid UserId,
|
||||||
|
string Issuer,
|
||||||
|
string Subject,
|
||||||
|
string? Email,
|
||||||
|
string? DisplayName,
|
||||||
|
uint KeyGeneration,
|
||||||
|
byte[] WrappedPrivateKey,
|
||||||
|
KdfParameters KdfParameters,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>A cached vault and the grant that opens it.</summary>
|
||||||
|
/// <param name="VaultId">The vault.</param>
|
||||||
|
/// <param name="Name">Display name.</param>
|
||||||
|
/// <param name="IsPersonal">Whether this is the user's personal vault.</param>
|
||||||
|
/// <param name="TeamId">Owning team, for a team vault.</param>
|
||||||
|
/// <param name="KeyGeneration">Current key generation.</param>
|
||||||
|
/// <param name="Permissions">Effective permissions, as a flags value.</param>
|
||||||
|
/// <param name="WrappedVaultKey">The vault key sealed to this user. Null while awaiting re-wrap.</param>
|
||||||
|
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
|
||||||
|
public sealed record StoredVault(
|
||||||
|
Guid VaultId,
|
||||||
|
string Name,
|
||||||
|
bool IsPersonal,
|
||||||
|
Guid? TeamId,
|
||||||
|
uint KeyGeneration,
|
||||||
|
int Permissions,
|
||||||
|
byte[]? WrappedVaultKey,
|
||||||
|
bool RekeyRequired);
|
||||||
|
|
||||||
|
/// <summary>The last item state the server confirmed.</summary>
|
||||||
|
/// <param name="VaultId">Owning vault.</param>
|
||||||
|
/// <param name="EntityType">Kind of item.</param>
|
||||||
|
/// <param name="EntityId">The item.</param>
|
||||||
|
/// <param name="Version">Server-assigned version — what a push must expect.</param>
|
||||||
|
/// <param name="ChangeSequence">Position in the vault's change log.</param>
|
||||||
|
/// <param name="Payload">Ciphertext as the server returned it. Null for a tombstone.</param>
|
||||||
|
/// <param name="Fields">The plaintext columns. Null for a tombstone.</param>
|
||||||
|
/// <param name="IsDeleted">Whether this is a tombstone.</param>
|
||||||
|
/// <param name="UpdatedAt">When the change was recorded.</param>
|
||||||
|
public sealed record StoredItem(
|
||||||
|
Guid VaultId,
|
||||||
|
SyncEntityType EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
int Version,
|
||||||
|
long ChangeSequence,
|
||||||
|
EncryptedPayload? Payload,
|
||||||
|
SyncPlaintextFields? Fields,
|
||||||
|
bool IsDeleted,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>The item version a pending local edit branched from.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Without this a conflict can only be arbitrated, not merged. It is retained verbatim, including the
|
||||||
|
/// key generation and data key id, because those are part of the payload's AAD and the ancestor cannot
|
||||||
|
/// be decrypted without them.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Version">The version this edit was made against.</param>
|
||||||
|
/// <param name="Payload">That version's ciphertext.</param>
|
||||||
|
/// <param name="Fields">That version's plaintext columns.</param>
|
||||||
|
public sealed record StoredAncestor(
|
||||||
|
int Version,
|
||||||
|
EncryptedPayload Payload,
|
||||||
|
SyncPlaintextFields? Fields);
|
||||||
|
|
||||||
|
/// <summary>A local change waiting to be pushed.</summary>
|
||||||
|
/// <param name="Sequence">Local ordering. Assigned by the store; ignored on queue.</param>
|
||||||
|
/// <param name="OperationId">The server's idempotency key for this operation.</param>
|
||||||
|
/// <param name="VaultId">Owning vault.</param>
|
||||||
|
/// <param name="EntityType">Kind of item.</param>
|
||||||
|
/// <param name="EntityId">The item.</param>
|
||||||
|
/// <param name="Operation">Upsert or delete.</param>
|
||||||
|
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
|
||||||
|
/// <param name="Payload">Ciphertext to store. Null for a delete.</param>
|
||||||
|
/// <param name="Fields">Plaintext columns. Null for a delete.</param>
|
||||||
|
/// <param name="Ancestor">The version this branched from. Null when creating.</param>
|
||||||
|
/// <param name="QueuedAt">When the user made the change.</param>
|
||||||
|
/// <param name="Attempts">How many times this has been dispatched.</param>
|
||||||
|
/// <param name="LastError">Why it last failed.</param>
|
||||||
|
/// <param name="IsParked">Whether it has been abandoned pending user action.</param>
|
||||||
|
public sealed record PendingOperation(
|
||||||
|
long Sequence,
|
||||||
|
Guid OperationId,
|
||||||
|
Guid VaultId,
|
||||||
|
SyncEntityType EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
SyncOperation Operation,
|
||||||
|
int? ExpectedVersion,
|
||||||
|
EncryptedPayload? Payload,
|
||||||
|
SyncPlaintextFields? Fields,
|
||||||
|
StoredAncestor? Ancestor,
|
||||||
|
DateTimeOffset QueuedAt,
|
||||||
|
int Attempts = 0,
|
||||||
|
string? LastError = null,
|
||||||
|
bool IsParked = false);
|
||||||
|
|
||||||
|
/// <summary>Where a vault's pull has reached.</summary>
|
||||||
|
/// <param name="VaultId">The vault.</param>
|
||||||
|
/// <param name="Cursor">The last server-issued cursor. Never constructed by a client.</param>
|
||||||
|
/// <param name="KeyGeneration">The generation the server last reported.</param>
|
||||||
|
/// <param name="LastPulledAt">When the last pull completed.</param>
|
||||||
|
/// <param name="LastPushedAt">When the last push completed.</param>
|
||||||
|
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
|
||||||
|
public sealed record StoredSyncState(
|
||||||
|
Guid VaultId,
|
||||||
|
string? Cursor,
|
||||||
|
uint KeyGeneration,
|
||||||
|
DateTimeOffset? LastPulledAt = null,
|
||||||
|
DateTimeOffset? LastPushedAt = null,
|
||||||
|
long ServerTimeSkewMs = 0);
|
||||||
|
|
||||||
|
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
|
||||||
|
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
|
||||||
|
/// <param name="VaultId">Owning vault.</param>
|
||||||
|
/// <param name="EntityType">Kind of item.</param>
|
||||||
|
/// <param name="EntityId">The item.</param>
|
||||||
|
/// <param name="Kind">What happened.</param>
|
||||||
|
/// <param name="Detail">
|
||||||
|
/// The discarded values, in plaintext across this boundary and sealed at rest. Opaque to the store:
|
||||||
|
/// its shape belongs to the sync layer, which owns what a conflict means.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DetectedAt">When it was noticed.</param>
|
||||||
|
/// <param name="Acknowledged">Whether the user has dealt with it.</param>
|
||||||
|
public sealed record StoredConflict(
|
||||||
|
Guid Id,
|
||||||
|
Guid VaultId,
|
||||||
|
SyncEntityType EntityType,
|
||||||
|
Guid EntityId,
|
||||||
|
ConflictKind Kind,
|
||||||
|
byte[] Detail,
|
||||||
|
DateTimeOffset DetectedAt,
|
||||||
|
bool Acknowledged = false);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where each vault's pull has reached.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted.
|
||||||
|
/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to
|
||||||
|
/// resume from a position the server never granted, and a tampered cursor is rejected rather than
|
||||||
|
/// silently mis-serving a range.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been
|
||||||
|
/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is
|
||||||
|
/// not.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> contexts)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a vault's position, or a fresh one starting from the beginning.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Never returns null. An unknown vault is not an error — it is a vault this client has not synced
|
||||||
|
/// yet — and a caller forced to handle a null here would most likely handle it by starting from the
|
||||||
|
/// beginning anyway.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<StoredSyncState> ReadAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<SyncStateRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return row is null
|
||||||
|
? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0)
|
||||||
|
: new StoredSyncState(
|
||||||
|
row.VaultId,
|
||||||
|
row.Cursor,
|
||||||
|
row.KeyGeneration,
|
||||||
|
row.LastPulledAtUtc,
|
||||||
|
row.LastPushedAtUtc,
|
||||||
|
row.ServerTimeSkewMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a vault's position.</summary>
|
||||||
|
public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(state);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<SyncStateRow>()
|
||||||
|
.SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new SyncStateRow { VaultId = state.VaultId };
|
||||||
|
context.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Cursor = state.Cursor;
|
||||||
|
row.KeyGeneration = state.KeyGeneration;
|
||||||
|
row.LastPulledAtUtc = state.LastPulledAt;
|
||||||
|
row.LastPushedAtUtc = state.LastPushedAt;
|
||||||
|
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forgets a vault's position so the next pull starts over.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or
|
||||||
|
/// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about
|
||||||
|
/// which half of the cache is still valid.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The outbox is deliberately not cleared.</b> Those rows are the only copy of changes the user
|
||||||
|
/// made and the server has not accepted; discarding them here would turn a recoverable cache
|
||||||
|
/// problem into lost work. They re-push against the re-pulled state, conflicting and merging where
|
||||||
|
/// they must.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await context.Set<SyncStateRow>()
|
||||||
|
.Where(r => r.VaultId == vaultId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await context.Set<CachedItemRow>()
|
||||||
|
.Where(r => r.VaultId == vaultId)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when the cache belongs to a different account than the one signing in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list
|
||||||
|
/// and, worse, would offer an unlock prompt whose passphrase can never work.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CacheIdentityMismatchException : InvalidOperationException
|
||||||
|
{
|
||||||
|
/// <summary>Creates the exception.</summary>
|
||||||
|
public CacheIdentityMismatchException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the exception.</summary>
|
||||||
|
public CacheIdentityMismatchException()
|
||||||
|
: base("This cache belongs to a different account.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the exception.</summary>
|
||||||
|
public CacheIdentityMismatchException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The material an offline unlock needs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret
|
||||||
|
/// bundle are cached the moment the server hands them over, so deriving the master key and opening the
|
||||||
|
/// bundle need no network at all. Fetching either at unlock time would make an offline launch
|
||||||
|
/// impossible, which is the most common moment a user actually needs their vault.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key
|
||||||
|
/// exists only in the user's head. The master key itself is never written here or anywhere else.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>Reads the cached material, or null when this cache has never been enrolled.</summary>
|
||||||
|
public async Task<StoredUnlockMaterial?> ReadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<UnlockMaterialRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return row is null ? null : ToStored(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the material, replacing what is there.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="CacheIdentityMismatchException">
|
||||||
|
/// The cache already holds a different user. One cache file is one account; see
|
||||||
|
/// <see cref="UnlockMaterialRow"/> for why multiple accounts are not half-supported here.
|
||||||
|
/// </exception>
|
||||||
|
public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(material);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<UnlockMaterialRow>()
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new UnlockMaterialRow();
|
||||||
|
context.Add(row);
|
||||||
|
}
|
||||||
|
else if (row.UserId != material.UserId)
|
||||||
|
{
|
||||||
|
throw new CacheIdentityMismatchException(
|
||||||
|
$"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(row, material, clock.GetUtcNow());
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(
|
||||||
|
UnlockMaterialRow row,
|
||||||
|
StoredUnlockMaterial material,
|
||||||
|
DateTimeOffset now)
|
||||||
|
{
|
||||||
|
row.ServerUrl = material.ServerUrl;
|
||||||
|
row.UserId = material.UserId;
|
||||||
|
row.Issuer = material.Issuer;
|
||||||
|
row.Subject = material.Subject;
|
||||||
|
row.Email = material.Email;
|
||||||
|
row.DisplayName = material.DisplayName;
|
||||||
|
row.KeyGeneration = material.KeyGeneration;
|
||||||
|
row.WrappedPrivateKey = material.WrappedPrivateKey;
|
||||||
|
row.KdfAlgorithm = material.KdfParameters.Algorithm;
|
||||||
|
row.KdfSalt = material.KdfParameters.Salt;
|
||||||
|
row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes;
|
||||||
|
row.KdfPasses = material.KdfParameters.Passes;
|
||||||
|
row.KdfParallelism = material.KdfParameters.Parallelism;
|
||||||
|
row.UpdatedAtUtc = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) =>
|
||||||
|
new(
|
||||||
|
row.ServerUrl,
|
||||||
|
row.UserId,
|
||||||
|
row.Issuer,
|
||||||
|
row.Subject,
|
||||||
|
row.Email,
|
||||||
|
row.DisplayName,
|
||||||
|
row.KeyGeneration,
|
||||||
|
row.WrappedPrivateKey,
|
||||||
|
new KdfParameters(
|
||||||
|
row.KdfAlgorithm,
|
||||||
|
row.KdfSalt,
|
||||||
|
row.KdfMemoryKibibytes,
|
||||||
|
row.KdfPasses,
|
||||||
|
row.KdfParallelism),
|
||||||
|
row.UpdatedAtUtc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vaults this user can reach, and the grants that open them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
|
||||||
|
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
|
||||||
|
/// either public metadata or ciphertext.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>Reads every known vault.</summary>
|
||||||
|
public async Task<IReadOnlyList<StoredVault>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<CachedVaultRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(row => row.IsPersonal)
|
||||||
|
.ThenBy(row => row.Name)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return [.. rows.Select(ToStored)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads one vault.</summary>
|
||||||
|
public async Task<StoredVault?> FindAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var row = await context.Set<CachedVaultRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return row is null ? null : ToStored(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the cached vault list with what the server reported.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Vaults absent from the list are removed, because losing access to a vault is exactly what that
|
||||||
|
/// absence means and a stale row would offer the user a vault they can no longer sync.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Their <em>items</em> are a separate matter and are not touched here. Removing a member does not
|
||||||
|
/// retroactively erase what they already hold — that is not achievable, which is why offboarding
|
||||||
|
/// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
|
||||||
|
/// would only make the client pretend otherwise.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task ReplaceAllAsync(
|
||||||
|
IReadOnlyList<StoredVault> vaults,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(vaults);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var existing = await context.Set<CachedVaultRow>()
|
||||||
|
.ToDictionaryAsync(row => row.VaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
|
||||||
|
foreach (var vault in vaults)
|
||||||
|
{
|
||||||
|
if (!existing.Remove(vault.VaultId, out var row))
|
||||||
|
{
|
||||||
|
row = new CachedVaultRow { VaultId = vault.VaultId };
|
||||||
|
context.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(row, vault, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.RemoveRange(existing.Values);
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
row.Name = vault.Name;
|
||||||
|
row.IsPersonal = vault.IsPersonal;
|
||||||
|
row.TeamId = vault.TeamId;
|
||||||
|
row.KeyGeneration = vault.KeyGeneration;
|
||||||
|
row.Permissions = vault.Permissions;
|
||||||
|
row.WrappedVaultKey = vault.WrappedVaultKey;
|
||||||
|
row.RekeyRequired = vault.RekeyRequired;
|
||||||
|
row.UpdatedAtUtc = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoredVault ToStored(CachedVaultRow row) =>
|
||||||
|
new(
|
||||||
|
row.VaultId,
|
||||||
|
row.Name,
|
||||||
|
row.IsPersonal,
|
||||||
|
row.TeamId,
|
||||||
|
row.KeyGeneration,
|
||||||
|
row.Permissions,
|
||||||
|
row.WrappedVaultKey,
|
||||||
|
row.RekeyRequired);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"EFCore.NamingConventions": {
|
||||||
|
"type": "Direct",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore.Design": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[10.0.10, )",
|
||||||
|
"resolved": "10.0.10",
|
||||||
|
"contentHash": "BsvxiKcy8k4/ijAPitmwKG1mlVsdC2lQtFLP28K2N8PlsGYbqPFOyfJ7p2kWil3gM6xXgQGf8Hz/pJB8ej+Dug==",
|
||||||
|
"dependencies": {
|
||||||
|
"Humanizer.Core": "2.14.1",
|
||||||
|
"Microsoft.Build.Framework": "18.0.2",
|
||||||
|
"Microsoft.CodeAnalysis.CSharp": "5.0.0",
|
||||||
|
"Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
|
||||||
|
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
|
||||||
|
"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",
|
||||||
|
"Mono.TextTemplating": "3.0.0",
|
||||||
|
"Newtonsoft.Json": "13.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||||
|
"type": "Direct",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Humanizer.Core": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.14.1",
|
||||||
|
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||||
|
},
|
||||||
|
"Microsoft.Build.Framework": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "18.0.2",
|
||||||
|
"contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.11.0",
|
||||||
|
"contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.Common": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.0.0",
|
||||||
|
"contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": "3.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.CSharp": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.0.0",
|
||||||
|
"contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||||
|
"Microsoft.CodeAnalysis.Common": "[5.0.0]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.0.0",
|
||||||
|
"contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Humanizer.Core": "2.14.1",
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||||
|
"Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
|
||||||
|
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
|
||||||
|
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
|
||||||
|
"System.Composition": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.Workspaces.Common": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.0.0",
|
||||||
|
"contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Humanizer.Core": "2.14.1",
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||||
|
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
|
||||||
|
"System.Composition": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.Workspaces.MSBuild": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "5.0.0",
|
||||||
|
"contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"Humanizer.Core": "2.14.1",
|
||||||
|
"Microsoft.Build.Framework": "17.11.31",
|
||||||
|
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||||
|
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
|
||||||
|
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Options": "9.0.0",
|
||||||
|
"Microsoft.Extensions.Primitives": "9.0.0",
|
||||||
|
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
|
||||||
|
"Newtonsoft.Json": "13.0.3",
|
||||||
|
"System.Composition": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.VisualStudio.SolutionPersistence": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "1.0.52",
|
||||||
|
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
|
||||||
|
},
|
||||||
|
"Mono.TextTemplating": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.0.0",
|
||||||
|
"contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.CodeDom": "6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Newtonsoft.Json": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "13.0.3",
|
||||||
|
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||||
|
},
|
||||||
|
"System.CodeDom": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "6.0.0",
|
||||||
|
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||||
|
},
|
||||||
|
"System.Composition": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.Composition.AttributedModel": "9.0.0",
|
||||||
|
"System.Composition.Convention": "9.0.0",
|
||||||
|
"System.Composition.Hosting": "9.0.0",
|
||||||
|
"System.Composition.Runtime": "9.0.0",
|
||||||
|
"System.Composition.TypedParts": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System.Composition.AttributedModel": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
|
||||||
|
},
|
||||||
|
"System.Composition.Convention": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.Composition.AttributedModel": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System.Composition.Hosting": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.Composition.Runtime": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"System.Composition.Runtime": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
|
||||||
|
},
|
||||||
|
"System.Composition.TypedParts": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "9.0.0",
|
||||||
|
"contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
|
||||||
|
"dependencies": {
|
||||||
|
"System.Composition.AttributedModel": "9.0.0",
|
||||||
|
"System.Composition.Hosting": "9.0.0",
|
||||||
|
"System.Composition.Runtime": "9.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.contracts": {
|
||||||
|
"type": "Project"
|
||||||
|
},
|
||||||
|
"dodossh.crypto": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"NSec.Cryptography": "[26.4.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The pull / apply / push loop, and the conflict policy it enforces.
|
||||||
|
|
||||||
|
This is the layer where data gets lost if it is wrong, so the shape is deliberate: the merge rules
|
||||||
|
live in Client.Domain with no I/O, persistence lives in Client.Storage with no policy, and this
|
||||||
|
project is the only place that decides what to do when two people edited the same host. Everything
|
||||||
|
it talks to is an interface or a store, so the conflict matrix runs against an in-memory server that
|
||||||
|
enforces real version checks rather than against HTTP.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="DodoSSH.Client.Sync.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a host into an item payload and back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every operation binds to the item's identity, its key generation <em>and</em> its version, because
|
||||||
|
/// that is what <c>DshAad.ItemPayload</c> requires. The version part has a consequence worth stating
|
||||||
|
/// plainly: a payload must be sealed at the version the server will assign, not the version it is
|
||||||
|
/// replacing. See <see cref="SyncVersions"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither
|
||||||
|
/// is ever handed to a caller: a data key that escaped this class would be a data key some other layer
|
||||||
|
/// could forget to clear.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class HostCipher
|
||||||
|
{
|
||||||
|
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encrypts a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">The host. Must be valid for storage.</param>
|
||||||
|
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
||||||
|
/// <param name="entityId">The item id, which the AAD binds.</param>
|
||||||
|
/// <param name="keyGeneration">The vault's current key generation.</param>
|
||||||
|
/// <param name="itemVersion">
|
||||||
|
/// The version this payload will hold once the server accepts it — one more than the version being
|
||||||
|
/// replaced. Sealing at the version being replaced would produce a payload that authenticates
|
||||||
|
/// against a row that no longer exists, and the item would read as corrupt from then on.
|
||||||
|
/// </param>
|
||||||
|
public static EncryptedPayload Seal(
|
||||||
|
HostSecret host,
|
||||||
|
ReadOnlySpan<byte> vaultKey,
|
||||||
|
Guid entityId,
|
||||||
|
uint keyGeneration,
|
||||||
|
int itemVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
|
||||||
|
|
||||||
|
var plaintext = HostSecretCodec.Encode(host);
|
||||||
|
var dataKey = ItemKeys.CreateDataKey();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dataKeyId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
var wrappedDataKey = ItemKeys.WrapDataKey(
|
||||||
|
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
|
||||||
|
|
||||||
|
var envelope = ItemKeys.SealPayload(
|
||||||
|
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
|
||||||
|
|
||||||
|
return new EncryptedPayload(
|
||||||
|
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(dataKey);
|
||||||
|
CryptographicOperations.ZeroMemory(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The host and the schema version it was written at, or <see langword="null"/> if the payload does
|
||||||
|
/// not belong to this item, version or generation, or does not parse.
|
||||||
|
/// <para>
|
||||||
|
/// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating
|
||||||
|
/// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like
|
||||||
|
/// before new grants arrive. The caller distinguishes them by comparing generations; either way one
|
||||||
|
/// unreadable item must not abort a sync pass and strand every change behind it.
|
||||||
|
/// </para>
|
||||||
|
/// </returns>
|
||||||
|
public static HostSecretDocument? TryOpen(
|
||||||
|
EncryptedPayload payload,
|
||||||
|
ReadOnlySpan<byte> vaultKey,
|
||||||
|
Guid entityId,
|
||||||
|
int itemVersion)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(payload);
|
||||||
|
|
||||||
|
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataKey = ItemKeys.TryUnwrapDataKey(
|
||||||
|
vaultKey,
|
||||||
|
payload.WrappedDataKey,
|
||||||
|
Resource,
|
||||||
|
entityId,
|
||||||
|
payload.KeyGeneration,
|
||||||
|
(uint)itemVersion);
|
||||||
|
|
||||||
|
if (dataKey is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var plaintext = ItemKeys.TryOpenPayload(
|
||||||
|
dataKey,
|
||||||
|
payload.Envelope,
|
||||||
|
Resource,
|
||||||
|
entityId,
|
||||||
|
payload.DataKeyId,
|
||||||
|
payload.KeyGeneration,
|
||||||
|
(uint)itemVersion);
|
||||||
|
|
||||||
|
if (plaintext is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return HostSecretCodec.TryDecode(plaintext, out var document) ? document : null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(dataKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one place that decides which item version a payload is sealed at.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The payload's AAD binds the item version, so the sealing side has to predict what the server will
|
||||||
|
/// assign. That prediction is safe because it is checked: the server applies an upsert only when
|
||||||
|
/// <c>expectedVersion</c> matches, and then increments by exactly one. A mismatch is a conflict, not a
|
||||||
|
/// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot
|
||||||
|
/// drift — the failure if they did would be an item that encrypts fine and never decrypts again.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SyncVersions
|
||||||
|
{
|
||||||
|
/// <summary>The version an accepted upsert will produce.</summary>
|
||||||
|
/// <param name="expectedVersion">The version being replaced, or null for a create.</param>
|
||||||
|
internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the plaintext columns the server needs from a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it
|
||||||
|
/// is a function rather than something each call site assembles. The address is emitted only when the
|
||||||
|
/// user has turned the relay on for that host; with relay off, the server learns nothing but that an
|
||||||
|
/// item exists. See ADR 0004 for why the relay cannot work any other way.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class HostFields
|
||||||
|
{
|
||||||
|
internal static SyncPlaintextFields From(HostSecret host) =>
|
||||||
|
host.RelayEnabled
|
||||||
|
? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port)
|
||||||
|
: new SyncPlaintextFields();
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>A host as the interface should show it.</summary>
|
||||||
|
/// <param name="EntityId">The item id.</param>
|
||||||
|
/// <param name="Host">The decrypted host.</param>
|
||||||
|
/// <param name="Version">
|
||||||
|
/// The server version this is based on. Zero for an item that has never been accepted.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HasUnsyncedChanges">
|
||||||
|
/// Whether this reflects a local edit the server has not accepted yet. Worth showing: it is the
|
||||||
|
/// difference between "saved" and "saved here".
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsBlocked">
|
||||||
|
/// Whether the pending change was refused and is waiting on a person, so it will not retry on its own.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsReadOnly">
|
||||||
|
/// Whether this host was written by a newer client and so must not be edited here, because re-encoding
|
||||||
|
/// it would drop fields this build cannot represent.
|
||||||
|
/// </param>
|
||||||
|
public sealed record VaultHost(
|
||||||
|
Guid EntityId,
|
||||||
|
HostSecret Host,
|
||||||
|
int Version,
|
||||||
|
bool HasUnsyncedChanges,
|
||||||
|
bool IsBlocked,
|
||||||
|
bool IsReadOnly);
|
||||||
|
|
||||||
|
/// <summary>The hosts in a vault, and what could not be read.</summary>
|
||||||
|
/// <param name="Hosts">The readable hosts, newest change last.</param>
|
||||||
|
/// <param name="Unreadable">
|
||||||
|
/// How many items would not decrypt. Surfaced rather than swallowed: a non-zero count here after a
|
||||||
|
/// rekey is the signal that new grants are needed.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HostListing(IReadOnlyList<VaultHost> Hosts, int Unreadable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reading and writing hosts, as the interface sees them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The view is the mirror of the server's state with the outbox laid over it, which is what makes the
|
||||||
|
/// application feel local: an edit appears immediately and a delete disappears immediately, whether or
|
||||||
|
/// not the network is there. Nothing here talks to the server; the sync engine reconciles later.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Writes never touch the mirror. That separation is load-bearing — the mirror is the common ancestor a
|
||||||
|
/// three-way merge needs, and a repository that updated it on save would destroy the very state that
|
||||||
|
/// lets a conflict be merged instead of arbitrated.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
|
||||||
|
{
|
||||||
|
/// <summary>Reads every host the user should see in a vault.</summary>
|
||||||
|
public async Task<HostListing> ListAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
|
||||||
|
{
|
||||||
|
throw new VaultUnreadableException(vaultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrored = await items
|
||||||
|
.ListAsync(vaultId, SyncEntityType.Host, includeDeleted: true, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pendingByEntity = pending
|
||||||
|
.Where(operation => operation.EntityType == SyncEntityType.Host)
|
||||||
|
.ToDictionary(operation => operation.EntityId);
|
||||||
|
|
||||||
|
var hosts = new List<VaultHost>();
|
||||||
|
var unreadable = 0;
|
||||||
|
|
||||||
|
foreach (var item in mirrored)
|
||||||
|
{
|
||||||
|
if (pendingByEntity.Remove(item.EntityId, out var local))
|
||||||
|
{
|
||||||
|
AddPending(hosts, ref unreadable, vaultKey, local);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.IsDeleted || item.Payload is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = HostCipher.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
|
||||||
|
|
||||||
|
if (opened is null)
|
||||||
|
{
|
||||||
|
unreadable++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.Add(new VaultHost(
|
||||||
|
item.EntityId, opened.Host, item.Version, false, false, opened.IsReadOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever is left has no mirror row yet: items created here and not yet accepted.
|
||||||
|
foreach (var local in pendingByEntity.Values)
|
||||||
|
{
|
||||||
|
AddPending(hosts, ref unreadable, vaultKey, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HostListing(hosts, unreadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a host, returning the id it was given.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The id is generated here, not by the server, which is what lets a host be created with no network
|
||||||
|
/// at all — the point of the whole outbox. UUIDv7 so that ids sort by creation time, which keeps
|
||||||
|
/// index locality reasonable on the server side.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<Guid> CreateAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
HostSecret host,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
Validate(host);
|
||||||
|
|
||||||
|
var (vaultKey, generation) = Key(vaultId);
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await outbox.QueueAsync(
|
||||||
|
new QueuedChange(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
ExpectedVersion: null,
|
||||||
|
HostCipher.Seal(host, vaultKey.Span, entityId, generation, itemVersion: 1),
|
||||||
|
HostFields.From(host),
|
||||||
|
Ancestor: null),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces a host's contents.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The base is taken from the pending operation when there is one, and from the mirror otherwise.
|
||||||
|
/// Reading it the other way round would seal the payload at a version that does not match the
|
||||||
|
/// <c>expectedVersion</c> the coalesced row keeps — and because the AAD binds the item version, the
|
||||||
|
/// result would encrypt cleanly and never decrypt again.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task UpdateAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
Guid entityId,
|
||||||
|
HostSecret host,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
|
Validate(host);
|
||||||
|
|
||||||
|
var (vaultKey, generation) = Key(vaultId);
|
||||||
|
|
||||||
|
var pending = await outbox
|
||||||
|
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var expectedVersion = pending is not null
|
||||||
|
? pending.ExpectedVersion
|
||||||
|
: await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var ancestor = pending?.Ancestor
|
||||||
|
?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await outbox.QueueAsync(
|
||||||
|
new QueuedChange(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
expectedVersion,
|
||||||
|
HostCipher.Seal(
|
||||||
|
host, vaultKey.Span, entityId, generation, SyncVersions.NextVersion(expectedVersion)),
|
||||||
|
HostFields.From(host),
|
||||||
|
ancestor),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Queued as a tombstone, never a local removal. An offline client that simply forgot the row would
|
||||||
|
/// be unable to tell the server anything, and the item would come back on the next pull.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pending = await outbox
|
||||||
|
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var expectedVersion = pending is not null
|
||||||
|
? pending.ExpectedVersion
|
||||||
|
: await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var ancestor = pending?.Ancestor
|
||||||
|
?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await outbox.QueueAsync(
|
||||||
|
new QueuedChange(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
SyncOperation.Delete,
|
||||||
|
expectedVersion,
|
||||||
|
Payload: null,
|
||||||
|
Fields: null,
|
||||||
|
ancestor),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Validate(HostSecret host)
|
||||||
|
{
|
||||||
|
if (!host.TryValidate(out var error))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(error, nameof(host));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPending(
|
||||||
|
List<VaultHost> hosts,
|
||||||
|
ref int unreadable,
|
||||||
|
ReadOnlyMemory<byte> vaultKey,
|
||||||
|
PendingOperation local)
|
||||||
|
{
|
||||||
|
if (local.Operation == SyncOperation.Delete)
|
||||||
|
{
|
||||||
|
// Gone as far as this machine is concerned, even before the server agrees.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.Payload is null)
|
||||||
|
{
|
||||||
|
unreadable++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = SyncVersions.NextVersion(local.ExpectedVersion);
|
||||||
|
var opened = HostCipher.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
|
||||||
|
|
||||||
|
if (opened is null)
|
||||||
|
{
|
||||||
|
unreadable++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.Add(new VaultHost(
|
||||||
|
local.EntityId,
|
||||||
|
opened.Host,
|
||||||
|
local.ExpectedVersion ?? 0,
|
||||||
|
HasUnsyncedChanges: true,
|
||||||
|
local.IsParked,
|
||||||
|
opened.IsReadOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
private (ReadOnlyMemory<byte> VaultKey, uint Generation) Key(Guid vaultId) =>
|
||||||
|
keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||||
|
? (vaultKey, generation)
|
||||||
|
: throw new VaultUnreadableException(vaultId);
|
||||||
|
|
||||||
|
private async Task<int?> MirrorVersionAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
Guid entityId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var item = await items
|
||||||
|
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// A null means the server has never seen this item, which is exactly what "create" is.
|
||||||
|
return item?.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<StoredAncestor?> MirrorAncestorAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
Guid entityId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var item = await items
|
||||||
|
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return item?.Payload is null
|
||||||
|
? null
|
||||||
|
: new StoredAncestor(item.Version, item.Payload, item.Fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the id a resurrected item takes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deterministic, from the original id and the version of the tombstone that displaced it. That matters
|
||||||
|
/// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied,
|
||||||
|
/// so a process that dies in between re-applies them on the next start. A random id would resurrect the
|
||||||
|
/// same host twice and leave the user with duplicates to sort out; this way the second attempt produces
|
||||||
|
/// the same id and coalesces into the same outbox row.
|
||||||
|
/// <para>
|
||||||
|
/// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id
|
||||||
|
/// carries is meaningless for a copy created to rescue content from a deletion.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ResurrectionId
|
||||||
|
{
|
||||||
|
internal static Guid For(Guid entityId, int tombstoneVersion)
|
||||||
|
{
|
||||||
|
Span<byte> input = stackalloc byte[19 + 16 + sizeof(int)];
|
||||||
|
|
||||||
|
"dsh1/resurrect/v1"u8.CopyTo(input);
|
||||||
|
var offset = 17;
|
||||||
|
|
||||||
|
input[offset++] = 0;
|
||||||
|
input[offset++] = 0;
|
||||||
|
|
||||||
|
if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to write the entity id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += 16;
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion);
|
||||||
|
|
||||||
|
Span<byte> digest = stackalloc byte[32];
|
||||||
|
SHA256.HashData(input, digest);
|
||||||
|
|
||||||
|
return new Guid(digest[..16], bigEndian: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides what happens when a remote change collides with an unpushed local one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Shared by the pull and the push paths, because both meet the same six situations and must answer them
|
||||||
|
/// identically — a pull that merged one way and a push that merged the other would make the outcome
|
||||||
|
/// depend on which side happened to notice first.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The governing rule is that nothing is discarded silently.</b> Where the two sides can be
|
||||||
|
/// reconciled field by field, they are. Where they cannot, one value survives, the other is written to
|
||||||
|
/// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives:
|
||||||
|
/// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot
|
||||||
|
/// reconstruct.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ItemReconciler(
|
||||||
|
ItemStore items,
|
||||||
|
OutboxStore outbox,
|
||||||
|
ConflictStore conflicts,
|
||||||
|
VaultKeyring keyring)
|
||||||
|
{
|
||||||
|
/// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
|
||||||
|
internal Task ReconcileAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (remote.Operation == SyncOperation.Delete)
|
||||||
|
{
|
||||||
|
return pending.Operation == SyncOperation.Delete
|
||||||
|
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
|
||||||
|
? outbox.CompleteAsync(pending.Sequence, cancellationToken)
|
||||||
|
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending.Operation == SyncOperation.Delete
|
||||||
|
? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken)
|
||||||
|
: MergeAsync(vaultId, remote, pending, report, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles a pending create that the server says already exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// In practice this means an earlier push of the same create did land and its acknowledgement was
|
||||||
|
/// lost — a timeout, a dropped connection — after which the local row may also have been edited. The
|
||||||
|
/// resolution adopts the server's row as the base and re-offers the local content as an update, so
|
||||||
|
/// the newer local state wins and no duplicate host appears. A genuine id collision between two
|
||||||
|
/// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's
|
||||||
|
/// values would be in the conflict log rather than gone.
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task AdoptRemoteAsBaseAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (opened is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (local, remoteHost, vaultKey, generation) = opened.Value;
|
||||||
|
|
||||||
|
if (local == remoteHost)
|
||||||
|
{
|
||||||
|
// Byte-for-byte the same host: this is our own create coming back. Nothing to do but stop
|
||||||
|
// trying to send it again.
|
||||||
|
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ReviseAsUpdateAsync(
|
||||||
|
vaultId, remote, pending, local, vaultKey, generation, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
remote.EntityId,
|
||||||
|
ConflictKind.FieldOverridden,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
$"An item with this id already existed on the server at version {remote.Version}. "
|
||||||
|
+ "The version from this machine was kept; the server's values are recorded here."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Merged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Merges two divergent edits of the same item.</summary>
|
||||||
|
private async Task MergeAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (pending.Ancestor is null)
|
||||||
|
{
|
||||||
|
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (opened is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (local, remoteHost, vaultKey, generation) = opened.Value;
|
||||||
|
|
||||||
|
var ancestor = HostCipher.TryOpen(
|
||||||
|
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
|
||||||
|
|
||||||
|
if (ancestor is null)
|
||||||
|
{
|
||||||
|
// The base is unreadable, so a three-way merge is not possible. Falling back to a two-way
|
||||||
|
// one would have to guess which side changed what, so the honest move is to keep the local
|
||||||
|
// state as an update over the server's and record what was overridden.
|
||||||
|
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = HostSecretMerge.Merge(ancestor.Host, local, remoteHost);
|
||||||
|
|
||||||
|
await ReviseAsUpdateAsync(
|
||||||
|
vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (merged.HasConflicts)
|
||||||
|
{
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
remote.EntityId,
|
||||||
|
ConflictKind.FieldOverridden,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
$"'{merged.Merged.Label}' was edited in two places at once. "
|
||||||
|
+ $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
|
||||||
|
merged.Conflicts),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Merged++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps local content that a remote deletion would otherwise take with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late
|
||||||
|
/// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user
|
||||||
|
/// can see what happened. That is the whole of "never silently drop a host": the original goes, the
|
||||||
|
/// work does not.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ResurrectAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation))
|
||||||
|
{
|
||||||
|
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var local = pending.Payload is null
|
||||||
|
? null
|
||||||
|
: HostCipher.TryOpen(
|
||||||
|
pending.Payload,
|
||||||
|
vaultKey.Span,
|
||||||
|
remote.EntityId,
|
||||||
|
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||||
|
|
||||||
|
if (local is null || local.IsReadOnly)
|
||||||
|
{
|
||||||
|
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var restoredId = ResurrectionId.For(remote.EntityId, remote.Version);
|
||||||
|
var restored = local.Host with { Label = $"{local.Host.Label} (restored)" };
|
||||||
|
|
||||||
|
// Queued before the original is cleared, and that order matters. These are two separate
|
||||||
|
// transactions, so a process that dies between them has to fail in the direction that keeps the
|
||||||
|
// work: this way the original stays pending and the next pass resurrects again — landing on the
|
||||||
|
// same deterministic id, which coalesces into the row already queued. The other order would
|
||||||
|
// leave the tombstone accepted and the local content gone.
|
||||||
|
await outbox.QueueAsync(
|
||||||
|
new QueuedChange(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
restoredId,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
ExpectedVersion: null,
|
||||||
|
HostCipher.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1),
|
||||||
|
HostFields.From(restored),
|
||||||
|
Ancestor: null),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Now the tombstone can stand.
|
||||||
|
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
remote.EntityId,
|
||||||
|
ConflictKind.RemoteDeleteResurrected,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
$"'{local.Host.Label}' was deleted elsewhere while this machine had unsaved changes. "
|
||||||
|
+ $"The deletion stands and the local version was kept as '{restored.Label}'."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Resurrected++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops a local deletion because the other side edited the item instead.</summary>
|
||||||
|
private async Task AbandonLocalDeleteAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
remote.EntityId,
|
||||||
|
ConflictKind.LocalDeleteOverridden,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
"This host was edited elsewhere after it was deleted here, so the deletion was not "
|
||||||
|
+ "applied. Delete it again if that is still what you want."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.DeletesAbandoned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-offers a host as an update against the server's current version.</summary>
|
||||||
|
private async Task ReviseAsUpdateAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
HostSecret host,
|
||||||
|
ReadOnlyMemory<byte> vaultKey,
|
||||||
|
uint generation,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var nextVersion = SyncVersions.NextVersion(remote.Version);
|
||||||
|
|
||||||
|
await outbox.ReviseAsync(
|
||||||
|
pending.Sequence,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
expectedVersion: remote.Version,
|
||||||
|
HostCipher.Seal(host, vaultKey.Span, remote.EntityId, generation, nextVersion),
|
||||||
|
HostFields.From(host),
|
||||||
|
new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens both sides of a collision, parking the operation if either will not open.</summary>
|
||||||
|
private async Task<(HostSecret Local, HostSecret Remote, ReadOnlyMemory<byte> VaultKey, uint Generation)?>
|
||||||
|
OpenPairAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange remote,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||||
|
|| pending.Payload is null
|
||||||
|
|| remote.Payload is null)
|
||||||
|
{
|
||||||
|
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var local = HostCipher.TryOpen(
|
||||||
|
pending.Payload,
|
||||||
|
vaultKey.Span,
|
||||||
|
remote.EntityId,
|
||||||
|
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||||
|
|
||||||
|
var remoteHost = HostCipher.TryOpen(
|
||||||
|
remote.Payload, vaultKey.Span, remote.EntityId, remote.Version);
|
||||||
|
|
||||||
|
if (local is null || remoteHost is null)
|
||||||
|
{
|
||||||
|
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.IsReadOnly || remoteHost.IsReadOnly)
|
||||||
|
{
|
||||||
|
// A newer client wrote fields this build cannot represent. Re-encoding would drop them, so
|
||||||
|
// the item is left alone until this client is updated.
|
||||||
|
await outbox.ParkAsync(
|
||||||
|
pending.Sequence,
|
||||||
|
"Written by a newer version of DodoSSH; update before editing this host.",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
remote.EntityId,
|
||||||
|
ConflictKind.TooNewToEdit,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
"This host was written by a newer version of DodoSSH. It can be read but not "
|
||||||
|
+ "merged here, because saving it would discard fields this version does not know "
|
||||||
|
+ "about."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Parked++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (local.Host, remoteHost.Host, vaultKey, generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ParkAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
Guid entityId,
|
||||||
|
PendingOperation pending,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await outbox.ParkAsync(
|
||||||
|
pending.Sequence,
|
||||||
|
"The local or the server copy of this host could not be decrypted.",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
ConflictKind.Undecryptable,
|
||||||
|
ConflictDetailCodec.Encode(
|
||||||
|
"This host could not be decrypted, so the change made here could not be merged. "
|
||||||
|
+ "The vault key may have been rotated, or the stored payload may not belong to this "
|
||||||
|
+ "item."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Unreadable++;
|
||||||
|
report.Parked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the server's version of an item into the local mirror.</summary>
|
||||||
|
internal Task MirrorAsync(Guid vaultId, SyncChange change, CancellationToken cancellationToken) =>
|
||||||
|
items.SaveAsync(
|
||||||
|
new StoredItem(
|
||||||
|
vaultId,
|
||||||
|
change.EntityType,
|
||||||
|
change.EntityId,
|
||||||
|
change.Version,
|
||||||
|
change.ChangeSequence,
|
||||||
|
change.Payload,
|
||||||
|
change.PlaintextFields,
|
||||||
|
change.Operation == SyncOperation.Delete,
|
||||||
|
change.UpdatedAt),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using DodoSSH.Client.Api;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One vault's synchronisation pass: pull, reconcile, push, pull again.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Pull first, so a local change is merged against the newest server state before it is offered — which
|
||||||
|
/// turns most would-be conflicts into ordinary merges and keeps the push round count down. Push second.
|
||||||
|
/// Pull once more at the end only if something was pushed, so the mirror reflects the versions the
|
||||||
|
/// server actually assigned.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Pulling does not decrypt.</b> A change with no local work pending is copied into the mirror as
|
||||||
|
/// ciphertext and nothing more. Decryption happens when a merge needs it, or when the interface reads an
|
||||||
|
/// item. For a five-thousand-item first sync that is the difference between plumbing bytes and running
|
||||||
|
/// ten thousand AEAD operations for nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SyncEngine
|
||||||
|
{
|
||||||
|
private readonly ISyncApi api;
|
||||||
|
private readonly ItemStore items;
|
||||||
|
private readonly OutboxStore outbox;
|
||||||
|
private readonly SyncStateStore syncState;
|
||||||
|
private readonly ConflictStore conflicts;
|
||||||
|
private readonly VaultKeyring keyring;
|
||||||
|
private readonly TimeProvider clock;
|
||||||
|
private readonly SyncOptions options;
|
||||||
|
private readonly ItemReconciler reconciler;
|
||||||
|
|
||||||
|
/// <summary>Creates the engine.</summary>
|
||||||
|
public SyncEngine(
|
||||||
|
ISyncApi api,
|
||||||
|
ItemStore items,
|
||||||
|
OutboxStore outbox,
|
||||||
|
SyncStateStore syncState,
|
||||||
|
ConflictStore conflicts,
|
||||||
|
VaultKeyring keyring,
|
||||||
|
TimeProvider clock,
|
||||||
|
SyncOptions? options = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(api);
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
ArgumentNullException.ThrowIfNull(outbox);
|
||||||
|
ArgumentNullException.ThrowIfNull(syncState);
|
||||||
|
ArgumentNullException.ThrowIfNull(conflicts);
|
||||||
|
ArgumentNullException.ThrowIfNull(keyring);
|
||||||
|
ArgumentNullException.ThrowIfNull(clock);
|
||||||
|
|
||||||
|
this.api = api;
|
||||||
|
this.items = items;
|
||||||
|
this.outbox = outbox;
|
||||||
|
this.syncState = syncState;
|
||||||
|
this.conflicts = conflicts;
|
||||||
|
this.keyring = keyring;
|
||||||
|
this.clock = clock;
|
||||||
|
this.options = options ?? SyncOptions.Default;
|
||||||
|
|
||||||
|
reconciler = new ItemReconciler(items, outbox, conflicts, keyring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs a full pass over one vault.</summary>
|
||||||
|
public async Task<SyncReport> SyncAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var report = new SyncReportBuilder(vaultId);
|
||||||
|
|
||||||
|
await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pushedAnything = false;
|
||||||
|
|
||||||
|
for (var round = 1; ; round++)
|
||||||
|
{
|
||||||
|
var outcome = await DrainAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (outcome.Sent == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushedAnything = true;
|
||||||
|
|
||||||
|
if (!outcome.NeedsAnotherRound)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (round >= options.MaxPushRounds)
|
||||||
|
{
|
||||||
|
report.RoundsExhausted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pushedAnything)
|
||||||
|
{
|
||||||
|
await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads every change available and applies it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The cursor is saved after each page's changes are applied, which makes applying at-least-once
|
||||||
|
/// rather than exactly-once: a process that dies between the two re-reads that page next time. That
|
||||||
|
/// is deliberate and safe, because applying a change is a blind overwrite of a mirror row and a
|
||||||
|
/// resurrection takes a deterministic id. The other ordering — save the cursor first — would lose
|
||||||
|
/// changes outright, which no amount of idempotence can repair.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task PullAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
for (var page = 0; page < options.MaxPullPages; page++)
|
||||||
|
{
|
||||||
|
var response = await api.SyncPullAsync(
|
||||||
|
vaultId,
|
||||||
|
new SyncPullRequest(state.Cursor, options.PullPageSize, [SyncEntityType.Host]),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var change in response.Changes)
|
||||||
|
{
|
||||||
|
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
report.Pulled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
state = Record(vaultId, state, response, report);
|
||||||
|
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.HasMore)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server that claims more but neither returns a change nor moves the cursor would spin
|
||||||
|
// this loop for ever. Stopping is the only safe reading of that answer.
|
||||||
|
if (!advanced && response.Changes.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoredSyncState Record(
|
||||||
|
Guid vaultId,
|
||||||
|
StoredSyncState state,
|
||||||
|
SyncPullResponse response,
|
||||||
|
SyncReportBuilder report)
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
var skew = (long)(response.ServerTime - now).TotalMilliseconds;
|
||||||
|
|
||||||
|
report.ServerKeyGeneration = response.CurrentKeyGeneration;
|
||||||
|
report.ServerTimeSkewMs = skew;
|
||||||
|
|
||||||
|
// A generation ahead of the key this client holds means the vault was rekeyed and this client's
|
||||||
|
// grant has not been re-wrapped. Items pulled meanwhile are stored but cannot be read.
|
||||||
|
keyring.TryGet(vaultId, out _, out var held);
|
||||||
|
report.RekeyRequired = response.CurrentKeyGeneration > held;
|
||||||
|
|
||||||
|
return state with
|
||||||
|
{
|
||||||
|
Cursor = response.NextCursor,
|
||||||
|
KeyGeneration = response.CurrentKeyGeneration,
|
||||||
|
LastPulledAt = now,
|
||||||
|
ServerTimeSkewMs = skew,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncChange change,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (change.EntityType != SyncEntityType.Host)
|
||||||
|
{
|
||||||
|
// Reserved in the contract but not yet syncable. Ignoring it keeps a newer server's extra
|
||||||
|
// entity types from breaking an older client's pull.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconciler.MirrorAsync(vaultId, change, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var pending = await outbox
|
||||||
|
.FindAsync(vaultId, change.EntityType, change.EntityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (pending is null || pending.IsParked)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconciler.ReconcileAsync(vaultId, change, pending, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What one push round achieved.</summary>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
|
private readonly record struct DrainOutcome(int Sent, int Conflicts, bool BatchWasFull)
|
||||||
|
{
|
||||||
|
internal bool NeedsAnotherRound => Conflicts > 0 || BatchWasFull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends one batch and acts on each per-operation answer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>The cursor in the push response is deliberately ignored.</b> It sits after this push's own
|
||||||
|
/// changes, so adopting it would skip any change another client committed at a lower sequence
|
||||||
|
/// between this client's last pull and this push — permanently. Continuing from the cursor this
|
||||||
|
/// client already holds re-reads its own writes, which costs one redundant page and is idempotent.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<DrainOutcome> DrainAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pending = await outbox
|
||||||
|
.TakeAsync(vaultId, options.MaxOperationsPerPush, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var operations = new List<SyncPushOperation>(pending.Count);
|
||||||
|
var byOperationId = new Dictionary<Guid, PendingOperation>(pending.Count);
|
||||||
|
|
||||||
|
foreach (var operation in pending)
|
||||||
|
{
|
||||||
|
operations.Add(new SyncPushOperation(
|
||||||
|
operation.OperationId,
|
||||||
|
operation.EntityType,
|
||||||
|
operation.EntityId,
|
||||||
|
operation.Operation,
|
||||||
|
operation.ExpectedVersion,
|
||||||
|
operation.Payload,
|
||||||
|
operation.Fields));
|
||||||
|
|
||||||
|
byOperationId[operation.OperationId] = operation;
|
||||||
|
|
||||||
|
await outbox.MarkDispatchedAsync(operation.Sequence, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await api
|
||||||
|
.SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var conflicted = 0;
|
||||||
|
|
||||||
|
foreach (var result in response.Results)
|
||||||
|
{
|
||||||
|
if (!byOperationId.TryGetValue(result.OperationId, out var operation))
|
||||||
|
{
|
||||||
|
// An id this client did not send. Nothing sane to do with it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The attempt count was incremented above, so the bound is read from the fresh value.
|
||||||
|
var attempts = operation.Attempts + 1;
|
||||||
|
|
||||||
|
if (await HandleAsync(vaultId, operation with { Attempts = attempts }, result, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
conflicted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DrainOutcome(
|
||||||
|
pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <returns>Whether this answer warrants another push round.</returns>
|
||||||
|
private async Task<bool> HandleAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation operation,
|
||||||
|
SyncPushResult result,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case SyncOperationStatus.Applied:
|
||||||
|
case SyncOperationStatus.Duplicate:
|
||||||
|
// Duplicate means an earlier push of this exact operation id already landed, so the
|
||||||
|
// stored state is what this operation intended. Treated as success on purpose: that is
|
||||||
|
// what makes a retry after a timeout exactly-once rather than merely at-least-once.
|
||||||
|
await AcceptAsync(vaultId, operation, result, cancellationToken).ConfigureAwait(false);
|
||||||
|
report.Pushed++;
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case SyncOperationStatus.Conflict:
|
||||||
|
return await ResolveAsync(vaultId, operation, result, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
case SyncOperationStatus.Forbidden:
|
||||||
|
await RejectAsync(
|
||||||
|
vaultId,
|
||||||
|
operation,
|
||||||
|
"You no longer have permission to change this item.",
|
||||||
|
report,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case SyncOperationStatus.Invalid:
|
||||||
|
await RejectAsync(
|
||||||
|
vaultId,
|
||||||
|
operation,
|
||||||
|
result.Detail ?? "The server rejected this change as invalid.",
|
||||||
|
report,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await outbox.RecordFailureAsync(
|
||||||
|
operation.Sequence,
|
||||||
|
$"Unexpected push status {result.Status}.",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records an accepted operation and clears it from the outbox.</summary>
|
||||||
|
private async Task AcceptAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation operation,
|
||||||
|
SyncPushResult result,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var expected = SyncVersions.NextVersion(operation.ExpectedVersion);
|
||||||
|
var version = result.Version ?? expected;
|
||||||
|
var isDelete = operation.Operation == SyncOperation.Delete;
|
||||||
|
|
||||||
|
// The payload was sealed at the version this client predicted, and the AAD binds that version.
|
||||||
|
// If the server assigned a different one — which its own version check should make impossible —
|
||||||
|
// storing the payload would leave a mirror row that never decrypts. Skip the write; the pull at
|
||||||
|
// the end of the pass brings the authoritative row.
|
||||||
|
var canMirror = isDelete || version == expected;
|
||||||
|
|
||||||
|
if (canMirror)
|
||||||
|
{
|
||||||
|
await items.SaveAsync(
|
||||||
|
new StoredItem(
|
||||||
|
vaultId,
|
||||||
|
operation.EntityType,
|
||||||
|
operation.EntityId,
|
||||||
|
version,
|
||||||
|
result.ChangeSequence ?? 0,
|
||||||
|
isDelete ? null : operation.Payload,
|
||||||
|
isDelete ? null : operation.Fields,
|
||||||
|
isDelete,
|
||||||
|
clock.GetUtcNow()),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ResolveAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation operation,
|
||||||
|
SyncPushResult result,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (operation.Attempts >= options.MaxAttemptsBeforeParking)
|
||||||
|
{
|
||||||
|
await RejectAsync(
|
||||||
|
vaultId,
|
||||||
|
operation,
|
||||||
|
string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"Could not be reconciled after {operation.Attempts} attempts."),
|
||||||
|
report,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ServerEntity is null)
|
||||||
|
{
|
||||||
|
// The version check failed but the server has no such row. Re-offer it as a create.
|
||||||
|
return await RetryAsCreateAsync(vaultId, operation, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await reconciler.MirrorAsync(vaultId, result.ServerEntity, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await reconciler
|
||||||
|
.ReconcileAsync(vaultId, result.ServerEntity, operation, report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> RetryAsCreateAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation operation,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (operation.Operation == SyncOperation.Delete)
|
||||||
|
{
|
||||||
|
// Nothing there to delete, so the intent is already satisfied.
|
||||||
|
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||||
|
|| operation.Payload is null)
|
||||||
|
{
|
||||||
|
await RejectAsync(
|
||||||
|
vaultId, operation, "This item has no usable vault key.", report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var local = HostCipher.TryOpen(
|
||||||
|
operation.Payload,
|
||||||
|
vaultKey.Span,
|
||||||
|
operation.EntityId,
|
||||||
|
SyncVersions.NextVersion(operation.ExpectedVersion));
|
||||||
|
|
||||||
|
if (local is null)
|
||||||
|
{
|
||||||
|
await RejectAsync(
|
||||||
|
vaultId,
|
||||||
|
operation,
|
||||||
|
"The queued change could not be decrypted, so it could not be re-offered.",
|
||||||
|
report,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sealed at version 1, because that is what the server assigns to a create and the AAD binds
|
||||||
|
// the version.
|
||||||
|
await outbox.ReviseAsync(
|
||||||
|
operation.Sequence,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
expectedVersion: null,
|
||||||
|
HostCipher.Seal(local.Host, vaultKey.Span, operation.EntityId, generation, itemVersion: 1),
|
||||||
|
HostFields.From(local.Host),
|
||||||
|
ancestor: null,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parks an operation the server will never accept, and says why.</summary>
|
||||||
|
private async Task RejectAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation operation,
|
||||||
|
string reason,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
operation.EntityType,
|
||||||
|
operation.EntityId,
|
||||||
|
ConflictKind.Rejected,
|
||||||
|
ConflictDetailCodec.Encode(reason),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Parked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>Tuning for a sync pass.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The push batch default is well below the server's own cap. A smaller batch means a conflict is
|
||||||
|
/// discovered and merged sooner, and it bounds how much work one rejected batch delays.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SyncOptions
|
||||||
|
{
|
||||||
|
/// <summary>Changes requested per pull. The server clamps this.</summary>
|
||||||
|
public int PullPageSize { get; init; } = 500;
|
||||||
|
|
||||||
|
/// <summary>Operations sent per push. Must not exceed the server's advertised maximum.</summary>
|
||||||
|
public int MaxOperationsPerPush { get; init; } = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many push rounds one pass may take.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A bound rather than a loop until clear. Each round either applies, parks, or merges and advances
|
||||||
|
/// a version, so it does terminate — but against a vault someone else is writing to continuously it
|
||||||
|
/// could keep finding new conflicts, and a sync pass that never returns is worse than one that stops
|
||||||
|
/// and says so.
|
||||||
|
/// </remarks>
|
||||||
|
public int MaxPushRounds { get; init; } = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many times an operation may be dispatched before it is parked for a person to look at.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxAttemptsBeforeParking { get; init; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many pages one pull may read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A backstop against a server that keeps saying there is more. At the default page size this is
|
||||||
|
/// half a million changes, well past any real vault, so reaching it means something is wrong rather
|
||||||
|
/// than merely large.
|
||||||
|
/// </remarks>
|
||||||
|
public int MaxPullPages { get; init; } = 1000;
|
||||||
|
|
||||||
|
/// <summary>The defaults.</summary>
|
||||||
|
public static SyncOptions Default { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What one sync pass did.</summary>
|
||||||
|
/// <param name="VaultId">The vault.</param>
|
||||||
|
/// <param name="Pulled">Changes received.</param>
|
||||||
|
/// <param name="Pushed">Operations the server accepted.</param>
|
||||||
|
/// <param name="Merged">Items where local and remote edits were reconciled.</param>
|
||||||
|
/// <param name="Resurrected">
|
||||||
|
/// Items someone else deleted while this machine had unpushed edits. The tombstone stands and the local
|
||||||
|
/// content survives under a new id; nothing is discarded.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DeletesAbandoned">
|
||||||
|
/// Local deletions dropped because the other side edited the item instead. An edit outlives a removal:
|
||||||
|
/// re-deleting costs a click, and a discarded edit may be irrecoverable.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Parked">Operations the server refused, now waiting on a person.</param>
|
||||||
|
/// <param name="Unreadable">Items whose payload would not decrypt.</param>
|
||||||
|
/// <param name="ServerKeyGeneration">The generation the server reports for this vault.</param>
|
||||||
|
/// <param name="RekeyRequired">
|
||||||
|
/// True when the server's generation is ahead of the key this client holds, so items cannot be read
|
||||||
|
/// until new grants arrive.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ServerTimeSkewMs">
|
||||||
|
/// Difference between the server's clock and this machine's. Recorded, never acted on — the merge uses
|
||||||
|
/// versions and a retained ancestor, so a skewed clock must not be able to decide which edit wins.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RoundsExhausted">
|
||||||
|
/// True when the push loop hit <see cref="SyncOptions.MaxPushRounds"/> with work still outstanding. Not
|
||||||
|
/// a failure: the next pass continues from here.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SyncReport(
|
||||||
|
Guid VaultId,
|
||||||
|
int Pulled,
|
||||||
|
int Pushed,
|
||||||
|
int Merged,
|
||||||
|
int Resurrected,
|
||||||
|
int DeletesAbandoned,
|
||||||
|
int Parked,
|
||||||
|
int Unreadable,
|
||||||
|
uint ServerKeyGeneration,
|
||||||
|
bool RekeyRequired,
|
||||||
|
long ServerTimeSkewMs,
|
||||||
|
bool RoundsExhausted)
|
||||||
|
{
|
||||||
|
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||||
|
public bool NeedsAttention =>
|
||||||
|
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accumulates a <see cref="SyncReport"/> while a pass runs.</summary>
|
||||||
|
internal sealed class SyncReportBuilder(Guid vaultId)
|
||||||
|
{
|
||||||
|
internal int Pulled { get; set; }
|
||||||
|
|
||||||
|
internal int Pushed { get; set; }
|
||||||
|
|
||||||
|
internal int Merged { get; set; }
|
||||||
|
|
||||||
|
internal int Resurrected { get; set; }
|
||||||
|
|
||||||
|
internal int DeletesAbandoned { get; set; }
|
||||||
|
|
||||||
|
internal int Parked { get; set; }
|
||||||
|
|
||||||
|
internal int Unreadable { get; set; }
|
||||||
|
|
||||||
|
internal uint ServerKeyGeneration { get; set; }
|
||||||
|
|
||||||
|
internal bool RekeyRequired { get; set; }
|
||||||
|
|
||||||
|
internal long ServerTimeSkewMs { get; set; }
|
||||||
|
|
||||||
|
internal bool RoundsExhausted { get; set; }
|
||||||
|
|
||||||
|
internal SyncReport Build() =>
|
||||||
|
new(
|
||||||
|
vaultId,
|
||||||
|
Pulled,
|
||||||
|
Pushed,
|
||||||
|
Merged,
|
||||||
|
Resurrected,
|
||||||
|
DeletesAbandoned,
|
||||||
|
Parked,
|
||||||
|
Unreadable,
|
||||||
|
ServerKeyGeneration,
|
||||||
|
RekeyRequired,
|
||||||
|
ServerTimeSkewMs,
|
||||||
|
RoundsExhausted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||||
|
/// <param name="Field">Which field, as a path.</param>
|
||||||
|
/// <param name="DiscardedSide">Whose intent was overridden: <c>Local</c> or <c>Remote</c>.</param>
|
||||||
|
/// <param name="Kept">The value that survives.</param>
|
||||||
|
/// <param name="Discarded">The value that lost.</param>
|
||||||
|
/// <param name="DiscardedWasRemoval">Whether what lost was a deletion rather than a value.</param>
|
||||||
|
public sealed record ConflictDetailEntry(
|
||||||
|
string Field,
|
||||||
|
string DiscardedSide,
|
||||||
|
string? Kept,
|
||||||
|
string? Discarded,
|
||||||
|
bool DiscardedWasRemoval);
|
||||||
|
|
||||||
|
/// <summary>A conflict log entry.</summary>
|
||||||
|
/// <param name="Summary">One line for a person to read.</param>
|
||||||
|
/// <param name="Fields">Everything the merge overrode.</param>
|
||||||
|
public sealed record ConflictDetail(string Summary, IReadOnlyList<ConflictDetailEntry> Fields);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises what a merge discarded, for the conflict log.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The bytes crossing into <c>ConflictStore</c> are plaintext vault content and are sealed there under
|
||||||
|
/// the LocalCacheKey. Deliberately its own format rather than the item payload's: this is local
|
||||||
|
/// bookkeeping and is never pushed, so it has no compatibility obligation to any other client.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ConflictDetailCodec
|
||||||
|
{
|
||||||
|
internal static byte[] Encode(string summary, IReadOnlyList<HostFieldConflict> conflicts) =>
|
||||||
|
JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
new ConflictDetail(
|
||||||
|
summary,
|
||||||
|
[.. conflicts.Select(c => new ConflictDetailEntry(
|
||||||
|
c.Field,
|
||||||
|
c.DiscardedSide.ToString(),
|
||||||
|
c.Kept,
|
||||||
|
c.Discarded,
|
||||||
|
c.DiscardedWasRemoval))]),
|
||||||
|
ConflictJsonContext.Default.ConflictDetail);
|
||||||
|
|
||||||
|
internal static byte[] Encode(string summary) => Encode(summary, []);
|
||||||
|
|
||||||
|
/// <summary>Reads a detail back, for display.</summary>
|
||||||
|
internal static ConflictDetail? TryDecode(ReadOnlySpan<byte> utf8)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize(utf8, ConflictJsonContext.Default.ConflictDetail);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
|
[JsonSerializable(typeof(ConflictDetail))]
|
||||||
|
internal sealed partial class ConflictJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The vault keys held for the duration of an unlocked session.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One place that holds plaintext vault keys, so there is one place that clears them. Every store and
|
||||||
|
/// every cipher call borrows a key from here rather than keeping a copy, which is what makes
|
||||||
|
/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A grant that will not open is not an error: it means the vault has been rekeyed and this client's
|
||||||
|
/// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily
|
||||||
|
/// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults
|
||||||
|
/// down with it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VaultKeyring : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Guid, byte[]> keys = [];
|
||||||
|
private readonly Dictionary<Guid, uint> generations = [];
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
private VaultKeyring()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vaults whose grant could not be opened, and which are therefore unreadable.</summary>
|
||||||
|
public IReadOnlyList<Guid> Unopened { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens every grant the bundle can.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bundle">The unlocked identity keys.</param>
|
||||||
|
/// <param name="vaults">The cached vault list, each with its wrapped key.</param>
|
||||||
|
public static VaultKeyring Open(UserSecretBundle bundle, IReadOnlyList<StoredVault> vaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bundle);
|
||||||
|
ArgumentNullException.ThrowIfNull(vaults);
|
||||||
|
|
||||||
|
var keyring = new VaultKeyring();
|
||||||
|
var unopened = new List<Guid>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var vault in vaults)
|
||||||
|
{
|
||||||
|
if (vault.WrappedVaultKey is null)
|
||||||
|
{
|
||||||
|
// The server said so itself: a grant awaiting re-wrap after a rekey.
|
||||||
|
unopened.Add(vault.VaultId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = VaultKeys.TryUnwrap(
|
||||||
|
bundle.EncryptionKey,
|
||||||
|
vault.WrappedVaultKey,
|
||||||
|
vault.VaultId,
|
||||||
|
vault.KeyGeneration);
|
||||||
|
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
unopened.Add(vault.VaultId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyring.keys[vault.VaultId] = key;
|
||||||
|
keyring.generations[vault.VaultId] = vault.KeyGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
keyring.Unopened = unopened;
|
||||||
|
return keyring;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
keyring.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Borrows a vault's key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is
|
||||||
|
/// disposed. Callers must not retain it past the operation they borrowed it for.
|
||||||
|
/// </remarks>
|
||||||
|
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
if (keys.TryGetValue(vaultId, out var key))
|
||||||
|
{
|
||||||
|
vaultKey = key;
|
||||||
|
keyGeneration = generations[vaultId];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultKey = default;
|
||||||
|
keyGeneration = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether this vault can be read at all.</summary>
|
||||||
|
public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
|
||||||
|
foreach (var key in keys.Values)
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.Clear();
|
||||||
|
generations.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thrown when an operation needs a vault key the keyring does not hold.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An exception rather than a silent no-op, because every caller that reaches this point has already
|
||||||
|
/// been given the chance to check <see cref="VaultKeyring.CanRead"/>. Continuing without the key would
|
||||||
|
/// mean writing an item nobody can open.
|
||||||
|
/// </remarks>
|
||||||
|
[SuppressMessage(
|
||||||
|
"Design",
|
||||||
|
"CA1032:Implement standard exception constructors",
|
||||||
|
Justification = "The vault id is required context; a message-only constructor would lose it.")]
|
||||||
|
public sealed class VaultUnreadableException(Guid vaultId)
|
||||||
|
: InvalidOperationException(
|
||||||
|
$"Vault {vaultId} has no usable key. Its grant is missing or awaiting re-wrap after a rekey.")
|
||||||
|
{
|
||||||
|
/// <summary>The vault that cannot be read.</summary>
|
||||||
|
public Guid VaultId { get; } = vaultId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
|
"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.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ namespace DodoSSH.Contracts;
|
|||||||
[JsonSerializable(typeof(SyncPushRequest))]
|
[JsonSerializable(typeof(SyncPushRequest))]
|
||||||
[JsonSerializable(typeof(SyncPushResponse))]
|
[JsonSerializable(typeof(SyncPushResponse))]
|
||||||
[JsonSerializable(typeof(SyncChange))]
|
[JsonSerializable(typeof(SyncChange))]
|
||||||
|
// Registered in its own right, not only as a member of the sync DTOs: the client's local
|
||||||
|
// cache seals this record under the LocalCacheKey and needs its type info directly.
|
||||||
|
[JsonSerializable(typeof(SyncPlaintextFields))]
|
||||||
[JsonSerializable(typeof(RelayTicketRequest))]
|
[JsonSerializable(typeof(RelayTicketRequest))]
|
||||||
[JsonSerializable(typeof(RelayTicketResponse))]
|
[JsonSerializable(typeof(RelayTicketResponse))]
|
||||||
[JsonSerializable(typeof(RelaySessionSummary))]
|
[JsonSerializable(typeof(RelaySessionSummary))]
|
||||||
|
|||||||
@@ -15,11 +15,32 @@ namespace DodoSSH.Contracts;
|
|||||||
/// rather than transmitted, and because they are what makes a lazy re-encrypt-on-write
|
/// rather than transmitted, and because they are what makes a lazy re-encrypt-on-write
|
||||||
/// migration possible later.
|
/// migration possible later.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Added 2026-07-29:</b> <see cref="WrappedDataKey"/> and <see cref="DataKeyId"/>. The
|
||||||
|
/// specification has required a per-item data key since §3, the database has carried
|
||||||
|
/// <c>data_key_wrap</c> and <c>content_key_id</c> since the first migration, and
|
||||||
|
/// <c>DshAad.ItemPayload</c> binds the data key id — but this record had nowhere to put either,
|
||||||
|
/// so a spec-compliant item could not actually be transmitted. Found by writing the client that
|
||||||
|
/// has to produce one. Safe to add now and not later: no payload has ever been stored, and only
|
||||||
|
/// clients can re-encrypt.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="Envelope">The complete DSH1 envelope, base64 on the wire.</param>
|
/// <param name="Envelope">The complete DSH1 envelope, base64 on the wire.</param>
|
||||||
|
/// <param name="WrappedDataKey">
|
||||||
|
/// The item's data key, wrapped under the vault key. Also a DSH1 envelope, and also opaque. It
|
||||||
|
/// travels with the payload because a data key belongs to one item <em>version</em>: rotating a
|
||||||
|
/// vault key re-wraps 32 bytes per item and never rewrites a content blob.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DataKeyId">
|
||||||
|
/// Identifies the data key, stored as <c>content_key_id</c>. Part of the payload's AAD, so a
|
||||||
|
/// server cannot pair one item's envelope with another's key wrap. Reserved as the seam for
|
||||||
|
/// per-item grants in M5.
|
||||||
|
/// </param>
|
||||||
/// <param name="KeyGeneration">Vault key generation this payload was encrypted under.</param>
|
/// <param name="KeyGeneration">Vault key generation this payload was encrypted under.</param>
|
||||||
/// <param name="AadVersion">Version of the AAD derivation rule used.</param>
|
/// <param name="AadVersion">Version of the AAD derivation rule used.</param>
|
||||||
public sealed record EncryptedPayload(
|
public sealed record EncryptedPayload(
|
||||||
byte[] Envelope,
|
byte[] Envelope,
|
||||||
|
byte[] WrappedDataKey,
|
||||||
|
Guid DataKeyId,
|
||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
byte AadVersion);
|
byte AadVersion);
|
||||||
|
|||||||
@@ -36,13 +36,17 @@ DodoSSH.Contracts.EncryptedPayload
|
|||||||
DodoSSH.Contracts.EncryptedPayload.<Clone>$() -> DodoSSH.Contracts.EncryptedPayload!
|
DodoSSH.Contracts.EncryptedPayload.<Clone>$() -> DodoSSH.Contracts.EncryptedPayload!
|
||||||
DodoSSH.Contracts.EncryptedPayload.AadVersion.get -> byte
|
DodoSSH.Contracts.EncryptedPayload.AadVersion.get -> byte
|
||||||
DodoSSH.Contracts.EncryptedPayload.AadVersion.init -> void
|
DodoSSH.Contracts.EncryptedPayload.AadVersion.init -> void
|
||||||
DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out uint KeyGeneration, out byte AadVersion) -> void
|
DodoSSH.Contracts.EncryptedPayload.DataKeyId.get -> System.Guid
|
||||||
DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, uint KeyGeneration, byte AadVersion) -> void
|
DodoSSH.Contracts.EncryptedPayload.DataKeyId.init -> void
|
||||||
|
DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out byte[]! WrappedDataKey, out System.Guid DataKeyId, out uint KeyGeneration, out byte AadVersion) -> void
|
||||||
|
DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, byte[]! WrappedDataKey, System.Guid DataKeyId, uint KeyGeneration, byte AadVersion) -> void
|
||||||
DodoSSH.Contracts.EncryptedPayload.Envelope.get -> byte[]!
|
DodoSSH.Contracts.EncryptedPayload.Envelope.get -> byte[]!
|
||||||
DodoSSH.Contracts.EncryptedPayload.Envelope.init -> void
|
DodoSSH.Contracts.EncryptedPayload.Envelope.init -> void
|
||||||
DodoSSH.Contracts.EncryptedPayload.Equals(DodoSSH.Contracts.EncryptedPayload? other) -> bool
|
DodoSSH.Contracts.EncryptedPayload.Equals(DodoSSH.Contracts.EncryptedPayload? other) -> bool
|
||||||
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint
|
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint
|
||||||
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void
|
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void
|
||||||
|
DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.get -> byte[]!
|
||||||
|
DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.init -> void
|
||||||
DodoSSH.Contracts.EnrollmentRequest
|
DodoSSH.Contracts.EnrollmentRequest
|
||||||
DodoSSH.Contracts.EnrollmentRequest.<Clone>$() -> DodoSSH.Contracts.EnrollmentRequest!
|
DodoSSH.Contracts.EnrollmentRequest.<Clone>$() -> DodoSSH.Contracts.EnrollmentRequest!
|
||||||
DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void
|
DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void
|
||||||
|
|||||||
@@ -91,8 +91,16 @@ public sealed record SyncPushResult(
|
|||||||
/// <summary>Per-operation outcomes for a push.</summary>
|
/// <summary>Per-operation outcomes for a push.</summary>
|
||||||
/// <param name="Results">One entry per submitted operation, in request order.</param>
|
/// <param name="Results">One entry per submitted operation, in request order.</param>
|
||||||
/// <param name="Cursor">
|
/// <param name="Cursor">
|
||||||
/// A cursor positioned after every change this push produced, so the client can continue
|
/// A cursor positioned after every change this push produced.
|
||||||
/// pulling without re-reading its own writes.
|
/// <para>
|
||||||
|
/// <b>Adopting this is only safe if the client had already pulled to the log head.</b> The cursor is
|
||||||
|
/// a sequence position, so if another client committed at sequence 10 while this push took 11,
|
||||||
|
/// jumping to 11 skips 10 permanently. The per-vault advisory lock guarantees that sequence order
|
||||||
|
/// matches commit order; it cannot tell this client about a write it never read. A client that
|
||||||
|
/// keeps its own cursor and re-reads its own writes — which is idempotent, since applying a change
|
||||||
|
/// is a blind overwrite of a local mirror — is strictly safer, and that is what
|
||||||
|
/// <c>DodoSSH.Client.Sync</c> does.
|
||||||
|
/// </para>
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record SyncPushResponse(
|
public sealed record SyncPushResponse(
|
||||||
IReadOnlyList<SyncPushResult> Results,
|
IReadOnlyList<SyncPushResult> Results,
|
||||||
|
|||||||
@@ -147,6 +147,17 @@ public static class CryptoSpec
|
|||||||
|
|
||||||
/// <summary>A known SSH host key.</summary>
|
/// <summary>A known SSH host key.</summary>
|
||||||
KnownHostKey = 11,
|
KnownHostKey = 11,
|
||||||
|
|
||||||
|
// 12 and 13 close a hole rather than adding a feature. Contracts.SyncEntityType has carried
|
||||||
|
// HostTag and HostCredential since it was frozen, so those items are syncable — but with no
|
||||||
|
// resource type here, their payloads had nothing to bind an AAD to. Added 2026-07-29, while
|
||||||
|
// the enum is still append-only and no such item has been stored.
|
||||||
|
|
||||||
|
/// <summary>A host-to-tag association.</summary>
|
||||||
|
HostTag = 12,
|
||||||
|
|
||||||
|
/// <summary>A host-to-credential association.</summary>
|
||||||
|
HostCredential = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||||
|
|||||||
@@ -124,16 +124,30 @@ public static class DshAad
|
|||||||
itemVersion);
|
itemVersion);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds a record in the client's own on-disk cache.
|
/// Binds a record in the client's own on-disk cache to the row that holds it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Separate from every server-side purpose so a cache record can never be accepted as vault
|
/// Separate from every server-side purpose so a cache record can never be accepted as vault
|
||||||
/// content, nor the reverse. The cache is local, so the adversary here is another process on
|
/// content, nor the reverse. The threat model differs too: the cache is local, so the adversary
|
||||||
/// the same machine rather than the server.
|
/// is a process or a backup with access to the file rather than the server.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Changed 2026-07-29</b> from taking the user id to taking the record's identity. The user
|
||||||
|
/// was already bound by the key — <c>LocalCacheKey</c> is derived from that user's master key, so
|
||||||
|
/// another user's record cannot decrypt at all — which left the AAD binding nothing, and a cache
|
||||||
|
/// record could be moved to a different row of the same user's cache. For plaintext columns like
|
||||||
|
/// a relay address that is not academic: swapping two rows would point one host's connection at
|
||||||
|
/// another host's address. No cache has ever been written, so there is nothing to migrate.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static AadDescriptor LocalCache(Guid userId) =>
|
/// <param name="resourceType">What kind of item the record belongs to.</param>
|
||||||
|
/// <param name="recordId">The row it belongs to — an item id, or a conflict entry's own id.</param>
|
||||||
|
public static AadDescriptor LocalCache(
|
||||||
|
CryptoSpec.AadResourceType resourceType,
|
||||||
|
Guid recordId) =>
|
||||||
AadDescriptor.Create(
|
AadDescriptor.Create(
|
||||||
CryptoSpec.AadPurpose.LocalCache,
|
CryptoSpec.AadPurpose.LocalCache,
|
||||||
CryptoSpec.AadResourceType.User,
|
resourceType,
|
||||||
userId);
|
recordId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1, 2, 3, 4], 1, 1),
|
new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1),
|
||||||
new SyncPlaintextFields()),
|
new SyncPlaintextFields()),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
Payload([1, 2, 3]),
|
||||||
new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)),
|
new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
Payload([1, 2, 3]),
|
||||||
new SyncPlaintextFields(RelayEnabled: true)),
|
new SyncPlaintextFields(RelayEnabled: true)),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
@@ -505,7 +505,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
entityId,
|
entityId,
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
Payload([1, 2, 3]),
|
||||||
new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)),
|
new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1], 1, 1),
|
Payload([1]),
|
||||||
null),
|
null),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
@@ -616,6 +616,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
|
|
||||||
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
|
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A structurally valid payload. The bytes are meaningless on purpose: the server cannot read
|
||||||
|
/// any of them, and a test that pretended otherwise would be testing the wrong thing.
|
||||||
|
/// </summary>
|
||||||
|
private static EncryptedPayload Payload(byte[] envelope) =>
|
||||||
|
new(envelope, WrappedDataKey: [0xD, 0xE], DataKeyId: Guid.CreateVersion7(), 1, 1);
|
||||||
|
|
||||||
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
|
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
|
||||||
new(
|
new(
|
||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
@@ -623,7 +630,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
entityId,
|
entityId,
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
expectedVersion,
|
expectedVersion,
|
||||||
new EncryptedPayload(envelope, 1, 1),
|
Payload(envelope),
|
||||||
new SyncPlaintextFields());
|
new SyncPlaintextFields());
|
||||||
|
|
||||||
private static SyncPushRequest NewCreateBatch() =>
|
private static SyncPushRequest NewCreateBatch() =>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public sealed class DodoSshApiClientTests : IDisposable
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
null,
|
null,
|
||||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
new EncryptedPayload([1, 2, 3], [7, 7], Guid.CreateVersion7(), 1, 1),
|
||||||
new SyncPlaintextFields()),
|
new SyncPlaintextFields()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ public sealed class DodoSshApiClientTests : IDisposable
|
|||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
Version: 1,
|
Version: 1,
|
||||||
ChangeSequence: 5,
|
ChangeSequence: 5,
|
||||||
Payload: new EncryptedPayload([4, 5, 6], 1, 1),
|
Payload: new EncryptedPayload([4, 5, 6], [8, 8], Guid.CreateVersion7(), 1, 1),
|
||||||
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
|
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
|
||||||
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
|
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The merge rules, in isolation. No ciphertext, no database, no HTTP — which is the point of
|
||||||
|
keeping the item model in its own dependency-free project: the suite that decides whether a
|
||||||
|
credential can be lost runs in milliseconds and has nothing to mock.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace DodoSSH.Client.Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>Builds hosts for the suites, so each test varies only what it is about.</summary>
|
||||||
|
internal static class HostFactory
|
||||||
|
{
|
||||||
|
internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01");
|
||||||
|
|
||||||
|
internal static Guid Relay { get; } = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e02");
|
||||||
|
|
||||||
|
internal static HostSecret Host(
|
||||||
|
string label = "prod-db",
|
||||||
|
string hostname = "db.internal",
|
||||||
|
int port = 22,
|
||||||
|
string? username = "deploy",
|
||||||
|
string? notes = null,
|
||||||
|
Guid[]? jumps = null,
|
||||||
|
(string Name, string Value)[]? options = null,
|
||||||
|
bool relayEnabled = false) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
Hostname = hostname,
|
||||||
|
Port = port,
|
||||||
|
Username = username,
|
||||||
|
Notes = notes,
|
||||||
|
JumpHostIds = jumps is null ? JumpChain.Empty : JumpChain.Create(jumps),
|
||||||
|
Options = options is null
|
||||||
|
? HostOptions.Empty
|
||||||
|
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
|
||||||
|
RelayEnabled = relayEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The payload encoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether
|
||||||
|
/// to push, and a codec that produced different bytes for the same host would make every pass look
|
||||||
|
/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside
|
||||||
|
/// a sync pass where an exception would strand every item queued behind the bad one.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HostSecretCodecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AFullHost_RoundTrips()
|
||||||
|
{
|
||||||
|
var host = Host(
|
||||||
|
label: "prod-db",
|
||||||
|
hostname: "db.internal",
|
||||||
|
port: 2222,
|
||||||
|
username: "deploy",
|
||||||
|
notes: "primary replica",
|
||||||
|
jumps: [Bastion, Relay],
|
||||||
|
options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||||
|
|
||||||
|
document.ShouldNotBeNull();
|
||||||
|
document.Host.ShouldBe(host);
|
||||||
|
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||||
|
document.IsReadOnly.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AMinimalHost_RoundTrips()
|
||||||
|
{
|
||||||
|
var host = Host(username: null, notes: null);
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||||
|
|
||||||
|
document!.Host.ShouldBe(host);
|
||||||
|
document.Host.Username.ShouldBeNull();
|
||||||
|
document.Host.Notes.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encoding_IsDeterministic()
|
||||||
|
{
|
||||||
|
var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
||||||
|
|
||||||
|
HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectiveOrder_DoesNotAffectTheEncoding()
|
||||||
|
{
|
||||||
|
// Two clients that agree on the content must produce the same bytes regardless of the order
|
||||||
|
// the user happened to type the directives in.
|
||||||
|
var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
||||||
|
var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
|
||||||
|
|
||||||
|
HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void APayloadFromANewerSchema_IsReadableButReadOnly()
|
||||||
|
{
|
||||||
|
// The forward-compatibility rule. An old client can show the host but must not re-encode it,
|
||||||
|
// because it has no representation for the newer client's extra fields and would drop them.
|
||||||
|
var payload = Json("""
|
||||||
|
{
|
||||||
|
"schemaVersion": 99,
|
||||||
|
"label": "prod-db",
|
||||||
|
"hostname": "db.internal",
|
||||||
|
"port": 22,
|
||||||
|
"unknownFutureField": { "nested": true }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||||
|
|
||||||
|
document!.Host.Label.ShouldBe("prod-db");
|
||||||
|
document.Host.Hostname.ShouldBe("db.internal");
|
||||||
|
document.IsReadOnly.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal()
|
||||||
|
{
|
||||||
|
var payload = Json("""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "prod-db",
|
||||||
|
"hostname": "db.internal",
|
||||||
|
"port": 22,
|
||||||
|
"somethingElse": 5
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||||
|
document!.IsReadOnly.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("not json at all")]
|
||||||
|
[InlineData("{")]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public void MalformedBytes_ReturnFalseRatherThanThrow(string text)
|
||||||
|
{
|
||||||
|
HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse();
|
||||||
|
document.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")]
|
||||||
|
[InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")]
|
||||||
|
[InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")]
|
||||||
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")]
|
||||||
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")]
|
||||||
|
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")]
|
||||||
|
public void AStructurallyInvalidPayload_IsRejected(string json)
|
||||||
|
{
|
||||||
|
HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected()
|
||||||
|
{
|
||||||
|
// Fails closed. SSH treats keywords case-insensitively, so this payload has no single
|
||||||
|
// meaning; guessing which one wins would make two clients disagree about the same bytes.
|
||||||
|
var payload = Json("""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "prod-db",
|
||||||
|
"hostname": "db.internal",
|
||||||
|
"port": 22,
|
||||||
|
"options": { "Compression": "yes", "compression": "no" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnEmptyJumpHostId_IsRejected()
|
||||||
|
{
|
||||||
|
var payload = Json($$"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "prod-db",
|
||||||
|
"hostname": "db.internal",
|
||||||
|
"port": 22,
|
||||||
|
"jumpHostIds": ["{{Guid.Empty}}"]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encode_RefusesAnInvalidHost()
|
||||||
|
{
|
||||||
|
// Throwing rather than returning false, because unlike decoding, this is a caller bug: the
|
||||||
|
// host came from this process and should have been validated before it got here.
|
||||||
|
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(label: " ")));
|
||||||
|
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(port: 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope()
|
||||||
|
{
|
||||||
|
// A reminder of what this codec is for: every one of these values is inside the ciphertext.
|
||||||
|
// There is no plaintext host label anywhere in the system.
|
||||||
|
var host = Host(label: "prod-db", notes: "root password in 1Password");
|
||||||
|
|
||||||
|
var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host));
|
||||||
|
|
||||||
|
text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue();
|
||||||
|
text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merging a host field by field.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The primitives are covered by <see cref="ThreeWayMergeTests"/>; this is about the wiring — that
|
||||||
|
/// every field is actually routed through a merge, that the collections use the right strategy, and
|
||||||
|
/// that a conflict names the field precisely enough for a user to act on it.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HostSecretMergeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NeitherSideChanged_ProducesTheSameHostAndNoConflicts()
|
||||||
|
{
|
||||||
|
var host = Host();
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(host, host, host);
|
||||||
|
|
||||||
|
result.Merged.ShouldBe(host);
|
||||||
|
result.HasConflicts.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EachSideChangedADifferentField_BothSurvive()
|
||||||
|
{
|
||||||
|
// The reason a field-level merge is worth writing at all.
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { Notes = "rotate quarterly" };
|
||||||
|
var remote = ancestor with { Username = "postgres" };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.Notes.ShouldBe("rotate quarterly");
|
||||||
|
result.Merged.Username.ShouldBe("postgres");
|
||||||
|
result.HasConflicts.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryScalarField_IsRoutedThroughAMerge()
|
||||||
|
{
|
||||||
|
// A field added to HostSecret but forgotten in the merge would silently revert to the remote
|
||||||
|
// value forever. Changing each one only locally proves each is actually consulted.
|
||||||
|
var ancestor = Host();
|
||||||
|
|
||||||
|
var local = ancestor with
|
||||||
|
{
|
||||||
|
Label = "prod-db-1",
|
||||||
|
Hostname = "db1.internal",
|
||||||
|
Port = 2222,
|
||||||
|
Username = "admin",
|
||||||
|
Notes = "primary",
|
||||||
|
JumpHostIds = JumpChain.Create([Bastion]),
|
||||||
|
Options = HostOptions.Create([new HostOption("Compression", "yes")]),
|
||||||
|
RelayEnabled = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||||
|
|
||||||
|
result.Merged.ShouldBe(local);
|
||||||
|
result.HasConflicts.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AClashingScalar_TakesRemoteAndNamesTheFieldItDiscarded()
|
||||||
|
{
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { Hostname = "db-mine.internal" };
|
||||||
|
var remote = ancestor with { Hostname = "db-theirs.internal" };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.Hostname.ShouldBe("db-theirs.internal");
|
||||||
|
|
||||||
|
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.Field.ShouldBe(nameof(HostSecret.Hostname));
|
||||||
|
conflict.Kept.ShouldBe("db-theirs.internal");
|
||||||
|
conflict.Discarded.ShouldBe("db-mine.internal");
|
||||||
|
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AClashingPort_IsReportedAsANumberNotAsBlank()
|
||||||
|
{
|
||||||
|
// Rendering the losing value is the entire point of the conflict record; a non-string field
|
||||||
|
// that formatted to nothing would leave the user unable to restore it.
|
||||||
|
var ancestor = Host(port: 22);
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, ancestor with { Port = 2222 }, ancestor with { Port = 2200 });
|
||||||
|
|
||||||
|
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.Field.ShouldBe(nameof(HostSecret.Port));
|
||||||
|
conflict.Kept.ShouldBe("2200");
|
||||||
|
conflict.Discarded.ShouldBe("2222");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AJumpChain_MergesAsAWholeRouteRatherThanAsASet()
|
||||||
|
{
|
||||||
|
// Deliberate, and the opposite of how the directives merge. Unioning two chains would
|
||||||
|
// produce a route neither user configured and would silently change which machine is
|
||||||
|
// reached through which — so this conflicts instead, and reports the discarded route.
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { JumpHostIds = JumpChain.Create([Bastion]) };
|
||||||
|
var remote = ancestor with { JumpHostIds = JumpChain.Create([Relay]) };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay])).ShouldBeTrue();
|
||||||
|
result.Merged.JumpHostIds.Count.ShouldBe(1);
|
||||||
|
|
||||||
|
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.Field.ShouldBe(nameof(HostSecret.JumpHostIds));
|
||||||
|
conflict.Discarded.ShouldNotBeNull();
|
||||||
|
conflict.Discarded.ShouldContain(Bastion.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AReorderedJumpChain_IsAChange()
|
||||||
|
{
|
||||||
|
var ancestor = Host(jumps: [Bastion, Relay]);
|
||||||
|
var local = ancestor with { JumpHostIds = JumpChain.Create([Relay, Bastion]) };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||||
|
|
||||||
|
result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay, Bastion])).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_MergePerNameSoBothAdditionsSurvive()
|
||||||
|
{
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "yes")]) };
|
||||||
|
var remote = ancestor with
|
||||||
|
{
|
||||||
|
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.Options.Count.ShouldBe(2);
|
||||||
|
result.Merged.Options.TryGetValue("Compression", out var compression).ShouldBeTrue();
|
||||||
|
compression.ShouldBe("yes");
|
||||||
|
result.Merged.Options.TryGetValue("ServerAliveInterval", out var keepAlive).ShouldBeTrue();
|
||||||
|
keepAlive.ShouldBe("30");
|
||||||
|
result.HasConflicts.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AClashingDirective_NamesTheDirectiveNotJustTheField()
|
||||||
|
{
|
||||||
|
// "Options changed" would be useless. The user needs to know which one.
|
||||||
|
var ancestor = Host(options: [("Compression", "yes")]);
|
||||||
|
var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };
|
||||||
|
var remote = ancestor with
|
||||||
|
{
|
||||||
|
Options = HostOptions.Create([new HostOption("Compression", "delayed")]),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.Field.ShouldBe("Options[Compression]");
|
||||||
|
conflict.Kept.ShouldBe("delayed");
|
||||||
|
conflict.Discarded.ShouldBe("no");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ARemovedDirectiveTheOtherSideEdited_KeepsTheValue()
|
||||||
|
{
|
||||||
|
var ancestor = Host(options: [("Compression", "yes")]);
|
||||||
|
var local = ancestor with { Options = HostOptions.Empty };
|
||||||
|
var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.Options.TryGetValue("Compression", out var value).ShouldBeTrue();
|
||||||
|
value.ShouldBe("no");
|
||||||
|
result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheMergedHost_IsAlwaysValidWhenBothInputsWere()
|
||||||
|
{
|
||||||
|
// A merge that produced an unstorable host would strand the item: it could never be pushed
|
||||||
|
// and the conflict could never clear.
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { Label = "mine", Port = 2222 };
|
||||||
|
var remote = ancestor with { Label = "theirs", Hostname = "other.internal" };
|
||||||
|
|
||||||
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
|
||||||
|
result.Merged.TryValidate(out var error).ShouldBeTrue(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvingAConflictConverges()
|
||||||
|
{
|
||||||
|
// Two clients, both merging, must reach the same host and then stop. Re-merging the result
|
||||||
|
// against the remote produces no further conflict — which is what stops an endless
|
||||||
|
// push-conflict-merge-push loop between two machines.
|
||||||
|
var ancestor = Host();
|
||||||
|
var local = ancestor with { Notes = "mine", Username = "a" };
|
||||||
|
var remote = ancestor with { Notes = "theirs", Hostname = "other.internal" };
|
||||||
|
|
||||||
|
var first = HostSecretMerge.Merge(ancestor, local, remote);
|
||||||
|
first.HasConflicts.ShouldBeTrue();
|
||||||
|
|
||||||
|
var second = HostSecretMerge.Merge(remote, first.Merged, remote);
|
||||||
|
|
||||||
|
second.HasConflicts.ShouldBeFalse();
|
||||||
|
second.Merged.ShouldBe(first.Merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
namespace DodoSSH.Client.Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The merge primitives.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// These are the rules the whole sync story rests on, so they are tested as rules rather than
|
||||||
|
/// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each
|
||||||
|
/// asserts both the surviving value and — where a side lost — that the losing value came back.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ThreeWayMergeTests
|
||||||
|
{
|
||||||
|
// ---- Scalar ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeitherSideChanged_IsAgreement()
|
||||||
|
{
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "base", "base");
|
||||||
|
|
||||||
|
merge.Value.ShouldBe("base");
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||||
|
merge.IsConflicted.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnlyLocalChanged_KeepsTheLocalValue()
|
||||||
|
{
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "mine", "base");
|
||||||
|
|
||||||
|
merge.Value.ShouldBe("mine");
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.TookLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnlyRemoteChanged_KeepsTheRemoteValue()
|
||||||
|
{
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "base", "theirs");
|
||||||
|
|
||||||
|
merge.Value.ShouldBe("theirs");
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.TookRemote);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict()
|
||||||
|
{
|
||||||
|
// Two people fixing the same typo must not be asked to arbitrate.
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed");
|
||||||
|
|
||||||
|
merge.Value.ShouldBe("fixed");
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||||
|
merge.IsConflicted.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal()
|
||||||
|
{
|
||||||
|
// Remote wins so that every replica resolves the same triple identically; without a fixed
|
||||||
|
// winner two clients each keep their own value and push over each other forever.
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "mine", "theirs");
|
||||||
|
|
||||||
|
merge.Value.ShouldBe("theirs");
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.Conflicted);
|
||||||
|
merge.IsConflicted.ShouldBeTrue();
|
||||||
|
|
||||||
|
// The whole justification for picking a side: the other one is handed back, never dropped.
|
||||||
|
merge.Discarded.ShouldBe("mine");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AConflictedMerge_IsIdempotentOnceResolved()
|
||||||
|
{
|
||||||
|
// Convergence, spelled out. Having taken the remote value, re-merging against the same
|
||||||
|
// remote must be agreement rather than a fresh conflict — otherwise the two clients
|
||||||
|
// ping-pong.
|
||||||
|
var first = ThreeWayMerge.Scalar("base", "mine", "theirs");
|
||||||
|
var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs");
|
||||||
|
|
||||||
|
second.Decision.ShouldBe(MergeDecision.Agreed);
|
||||||
|
second.Value.ShouldBe("theirs");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scalar_UsesTheSuppliedComparer()
|
||||||
|
{
|
||||||
|
// Ordinal by default would call these a conflict; the comparer is how a field opts out.
|
||||||
|
var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scalar_HandlesNullOnAnySide()
|
||||||
|
{
|
||||||
|
// Nullable fields are the common case — Username and Notes are both optional — so a null
|
||||||
|
// must be an ordinary value here rather than a special case that throws.
|
||||||
|
ThreeWayMerge.Scalar<string?>(null, "set", null).Value.ShouldBe("set");
|
||||||
|
ThreeWayMerge.Scalar<string?>("was", null, "was").Value.ShouldBeNull();
|
||||||
|
ThreeWayMerge.Scalar<string?>(null, null, null).Decision.ShouldBe(MergeDecision.Agreed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Map ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EachSideAddedADifferentKey_KeepsBoth()
|
||||||
|
{
|
||||||
|
// The single most visible benefit of a per-key merge over comparing whole collections: two
|
||||||
|
// people adding different directives to one host both keep theirs.
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [],
|
||||||
|
local: [("Compression", "yes")],
|
||||||
|
remote: [("ServerAliveInterval", "30")]);
|
||||||
|
|
||||||
|
merge.Merged.Count.ShouldBe(2);
|
||||||
|
merge.Merged["Compression"].ShouldBe("yes");
|
||||||
|
merge.Merged["ServerAliveInterval"].ShouldBe("30");
|
||||||
|
merge.Conflicts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal()
|
||||||
|
{
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [],
|
||||||
|
local: [("Port", "2222")],
|
||||||
|
remote: [("Port", "2200")]);
|
||||||
|
|
||||||
|
merge.Merged["Port"].ShouldBe("2200");
|
||||||
|
|
||||||
|
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.Key.ShouldBe("Port");
|
||||||
|
conflict.Kept.ShouldBe("2200");
|
||||||
|
conflict.Discarded.ShouldBe("2222");
|
||||||
|
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||||
|
conflict.DiscardedWasRemoval.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt()
|
||||||
|
{
|
||||||
|
Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [],
|
||||||
|
remote: [("Compression", "yes")])
|
||||||
|
.Merged.ShouldBeEmpty();
|
||||||
|
|
||||||
|
Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [("Compression", "yes")],
|
||||||
|
remote: [])
|
||||||
|
.Merged.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval()
|
||||||
|
{
|
||||||
|
// An edit outlives a removal in both directions. Re-applying a removal costs one click;
|
||||||
|
// a discarded value may be the only copy of something the user cannot reconstruct.
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [],
|
||||||
|
remote: [("Compression", "no")]);
|
||||||
|
|
||||||
|
merge.Merged["Compression"].ShouldBe("no");
|
||||||
|
|
||||||
|
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||||
|
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||||
|
conflict.DiscardedWasRemoval.ShouldBeTrue();
|
||||||
|
conflict.Kept.ShouldBe("no");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval()
|
||||||
|
{
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [("Compression", "no")],
|
||||||
|
remote: []);
|
||||||
|
|
||||||
|
merge.Merged["Compression"].ShouldBe("no");
|
||||||
|
|
||||||
|
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// The overridden side is the remote one here, which is what makes this asymmetric from the
|
||||||
|
// scalar rule: the tie-break is "a value beats an absence" before it is "remote wins".
|
||||||
|
conflict.DiscardedSide.ShouldBe(MergeSide.Remote);
|
||||||
|
conflict.DiscardedWasRemoval.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BothSidesRemovedTheSameKey_IsAgreement()
|
||||||
|
{
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [],
|
||||||
|
remote: []);
|
||||||
|
|
||||||
|
merge.Merged.ShouldBeEmpty();
|
||||||
|
merge.Conflicts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnchangedKeys_SurviveAlongsideConflictingOnes()
|
||||||
|
{
|
||||||
|
// A conflict on one key must not disturb its neighbours, which is the difference between
|
||||||
|
// field-level merge and replacing the collection.
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [("Keep", "same"), ("Fight", "base")],
|
||||||
|
local: [("Keep", "same"), ("Fight", "mine")],
|
||||||
|
remote: [("Keep", "same"), ("Fight", "theirs")]);
|
||||||
|
|
||||||
|
merge.Merged["Keep"].ShouldBe("same");
|
||||||
|
merge.Merged["Fight"].ShouldBe("theirs");
|
||||||
|
merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_TreatsKeysUnderTheSuppliedComparer()
|
||||||
|
{
|
||||||
|
// SSH keywords are case-insensitive. Treating these as two keys would let a host carry
|
||||||
|
// both Compression and compression, which no client could then reconcile.
|
||||||
|
var merge = Map(
|
||||||
|
ancestor: [("Compression", "yes")],
|
||||||
|
local: [("compression", "yes")],
|
||||||
|
remote: [("COMPRESSION", "yes")]);
|
||||||
|
|
||||||
|
merge.Merged.Count.ShouldBe(1);
|
||||||
|
merge.Conflicts.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Map_NeverDropsAValueWithoutReportingIt()
|
||||||
|
{
|
||||||
|
// The invariant, asserted directly rather than inferred from the cases above: every value
|
||||||
|
// present on either side either survives into the merge or appears in the conflict list.
|
||||||
|
var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") };
|
||||||
|
var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") };
|
||||||
|
|
||||||
|
var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote);
|
||||||
|
|
||||||
|
foreach (var (key, value) in local.Concat(remote))
|
||||||
|
{
|
||||||
|
var survived = merge.Merged.TryGetValue(key, out var kept)
|
||||||
|
&& string.Equals(kept, value, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
var reported = merge.Conflicts.Any(c =>
|
||||||
|
HostOption.NameComparer.Equals(c.Key, key)
|
||||||
|
&& string.Equals(c.Discarded, value, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
(survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MapMerge<string, string> Map(
|
||||||
|
(string Key, string Value)[] ancestor,
|
||||||
|
(string Key, string Value)[] local,
|
||||||
|
(string Key, string Value)[] remote) =>
|
||||||
|
ThreeWayMerge.Map(
|
||||||
|
ToMap(ancestor),
|
||||||
|
ToMap(local),
|
||||||
|
ToMap(remote),
|
||||||
|
HostOption.NameComparer,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ToMap((string Key, string Value)[] entries)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(HostOption.NameComparer);
|
||||||
|
|
||||||
|
foreach (var (key, value) in entries)
|
||||||
|
{
|
||||||
|
map[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Equality of the collection types, and of the host that holds them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This suite guards a failure that would be invisible rather than loud. If any of these compared by
|
||||||
|
/// reference, the merge would report every host as changed on every sync pass, two identical edits
|
||||||
|
/// would register as a conflict, and the engine would push spurious updates forever. Nothing would
|
||||||
|
/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ValueSemanticsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TwoHostsWithEqualContents_AreEqual()
|
||||||
|
{
|
||||||
|
var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
|
||||||
|
var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
|
||||||
|
|
||||||
|
one.ShouldBe(other);
|
||||||
|
one.GetHashCode().ShouldBe(other.GetHashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AHostDifferingOnlyInACollection_IsNotEqual()
|
||||||
|
{
|
||||||
|
Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay]));
|
||||||
|
Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note on the assertion style below: these call Equals and the operators directly rather than
|
||||||
|
// going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares
|
||||||
|
// enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one
|
||||||
|
// thing this suite exists to check.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AJumpChain_ComparesByContentsAndOrder()
|
||||||
|
{
|
||||||
|
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
|
||||||
|
(JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
|
||||||
|
|
||||||
|
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse();
|
||||||
|
JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse();
|
||||||
|
|
||||||
|
JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue();
|
||||||
|
JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AJumpChain_HashesByContents()
|
||||||
|
{
|
||||||
|
JumpChain.Create([Bastion, Relay]).GetHashCode()
|
||||||
|
.ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories()
|
||||||
|
{
|
||||||
|
Guid[] hops = [Bastion, Relay];
|
||||||
|
|
||||||
|
JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_CompareIgnoringNameCaseAndInputOrder()
|
||||||
|
{
|
||||||
|
// Both halves matter. Case, because a merge picks whichever spelling it saw first and two
|
||||||
|
// clients must still agree. Order, because the collection canonicalises and a user typing
|
||||||
|
// the same two directives in the other order has not changed anything.
|
||||||
|
var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
|
||||||
|
var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]);
|
||||||
|
|
||||||
|
one.Equals(other).ShouldBeTrue();
|
||||||
|
(one == other).ShouldBeTrue();
|
||||||
|
one.GetHashCode().ShouldBe(other.GetHashCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_CompareValuesCaseSensitively()
|
||||||
|
{
|
||||||
|
// Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the
|
||||||
|
// same to sshd, but this layer must not decide that for every directive that exists.
|
||||||
|
HostOptions.Create([new HostOption("Compression", "yes")])
|
||||||
|
.Equals(HostOptions.Create([new HostOption("Compression", "YES")]))
|
||||||
|
.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_CompareUnequalWhenOneSideHasMore()
|
||||||
|
{
|
||||||
|
var one = HostOptions.Create([new HostOption("Compression", "yes")]);
|
||||||
|
var other = HostOptions.Create(
|
||||||
|
[new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
|
||||||
|
|
||||||
|
one.Equals(other).ShouldBeFalse();
|
||||||
|
HostOptions.Empty.Equals(one).ShouldBeFalse();
|
||||||
|
one.Equals(null).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Directives_AreHeldInNameOrder()
|
||||||
|
{
|
||||||
|
var options = HostOptions.Create(
|
||||||
|
[
|
||||||
|
new HostOption("ServerAliveInterval", "30"),
|
||||||
|
new HostOption("Compression", "yes"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
options[0].Name.ShouldBe("Compression");
|
||||||
|
options[1].Name.ShouldBe("ServerAliveInterval");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ARepeatedDirectiveName_IsRefused()
|
||||||
|
{
|
||||||
|
// A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest
|
||||||
|
// answer; silently keeping one of the two would lose data without saying so.
|
||||||
|
var duplicate = new[]
|
||||||
|
{
|
||||||
|
new HostOption("Compression", "yes"),
|
||||||
|
new HostOption("compression", "no"),
|
||||||
|
};
|
||||||
|
|
||||||
|
HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse();
|
||||||
|
error.ShouldNotBeNull();
|
||||||
|
error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue();
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => HostOptions.Create(duplicate));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABlankDirectiveName_IsRefused()
|
||||||
|
{
|
||||||
|
HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AHostBuiltWithWith_KeepsCollectionEquality()
|
||||||
|
{
|
||||||
|
// `with` copies the collection references, so this would pass even under reference equality.
|
||||||
|
// It is here because the merge builds its result with an object initialiser rather than
|
||||||
|
// `with`, and both paths have to agree.
|
||||||
|
var host = Host(options: [("Compression", "yes")]);
|
||||||
|
var copy = host with { Notes = "changed" };
|
||||||
|
|
||||||
|
copy.Options.Equals(host.Options).ShouldBeTrue();
|
||||||
|
(copy with { Notes = host.Notes }).ShouldBe(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryValidate_RejectsWhatCannotBeStored()
|
||||||
|
{
|
||||||
|
Host(label: "").TryValidate(out _).ShouldBeFalse();
|
||||||
|
Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
|
||||||
|
Host(port: 65536).TryValidate(out _).ShouldBeFalse();
|
||||||
|
Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
|
||||||
|
Host().TryValidate(out _).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
{
|
||||||
|
"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.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.domain": {
|
||||||
|
"type": "Project"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A migrated, unlocked cache for one test.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each harness gets its own in-memory database, so tests cannot interfere and can run in parallel.
|
||||||
|
/// The Argon2id cost is deliberately far below the shipped profile — 8 MiB and one pass rather than
|
||||||
|
/// 256 MiB and four. The stretching is what makes a stolen wrap expensive to attack, and none of these
|
||||||
|
/// tests attack one; paying 320 ms per test to prove nothing would only encourage sharing state
|
||||||
|
/// between them.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CacheHarness : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Argon2Profile CheapProfile =
|
||||||
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||||
|
|
||||||
|
private readonly MasterKey master;
|
||||||
|
|
||||||
|
private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector)
|
||||||
|
{
|
||||||
|
Factory = factory;
|
||||||
|
this.master = master;
|
||||||
|
Protector = protector;
|
||||||
|
|
||||||
|
Items = new ItemStore(factory, protector);
|
||||||
|
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
|
||||||
|
Vaults = new VaultStore(factory, TimeProvider.System);
|
||||||
|
Unlock = new UnlockStore(factory, TimeProvider.System);
|
||||||
|
SyncState = new SyncStateStore(factory);
|
||||||
|
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
|
||||||
|
internal static Guid UserId { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0f");
|
||||||
|
|
||||||
|
internal ClientCacheFactory Factory { get; }
|
||||||
|
|
||||||
|
internal LocalCacheProtector Protector { get; }
|
||||||
|
|
||||||
|
internal ItemStore Items { get; }
|
||||||
|
|
||||||
|
internal OutboxStore Outbox { get; }
|
||||||
|
|
||||||
|
internal VaultStore Vaults { get; }
|
||||||
|
|
||||||
|
internal UnlockStore Unlock { get; }
|
||||||
|
|
||||||
|
internal SyncStateStore SyncState { get; }
|
||||||
|
|
||||||
|
internal ConflictStore Conflicts { get; }
|
||||||
|
|
||||||
|
internal static async Task<CacheHarness> CreateAsync(
|
||||||
|
string passphrase = "correct horse battery staple")
|
||||||
|
{
|
||||||
|
var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await factory.MigrateAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var salt = new byte[CryptoSpec.SaltSize];
|
||||||
|
var derived = MasterKey.Derive(passphrase, salt, CheapProfile);
|
||||||
|
|
||||||
|
return new CacheHarness(factory, derived, LocalCacheProtector.From(derived));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
factory.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Protector.Dispose();
|
||||||
|
master.Dispose();
|
||||||
|
Factory.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Builders ----
|
||||||
|
|
||||||
|
internal static EncryptedPayload Payload(byte seed = 1, uint keyGeneration = 1) =>
|
||||||
|
new(
|
||||||
|
Envelope: [seed, (byte)(seed + 1), (byte)(seed + 2)],
|
||||||
|
WrappedDataKey: [(byte)(seed + 10), (byte)(seed + 11)],
|
||||||
|
DataKeyId: Guid.Parse($"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e{seed:x2}"),
|
||||||
|
KeyGeneration: keyGeneration,
|
||||||
|
AadVersion: CryptoSpec.CurrentAadVersion);
|
||||||
|
|
||||||
|
internal static StoredItem Item(
|
||||||
|
Guid entityId,
|
||||||
|
int version = 1,
|
||||||
|
long changeSequence = 1,
|
||||||
|
byte seed = 1,
|
||||||
|
bool deleted = false,
|
||||||
|
SyncPlaintextFields? fields = null) =>
|
||||||
|
new(
|
||||||
|
VaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
version,
|
||||||
|
changeSequence,
|
||||||
|
deleted ? null : Payload(seed),
|
||||||
|
deleted ? null : fields ?? new SyncPlaintextFields(),
|
||||||
|
deleted,
|
||||||
|
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + changeSequence));
|
||||||
|
|
||||||
|
internal static QueuedChange Change(
|
||||||
|
Guid entityId,
|
||||||
|
SyncOperation operation = SyncOperation.Upsert,
|
||||||
|
int? expectedVersion = null,
|
||||||
|
byte seed = 1,
|
||||||
|
StoredAncestor? ancestor = null,
|
||||||
|
SyncPlaintextFields? fields = null) =>
|
||||||
|
new(
|
||||||
|
VaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
entityId,
|
||||||
|
operation,
|
||||||
|
expectedVersion,
|
||||||
|
operation == SyncOperation.Delete ? null : Payload(seed),
|
||||||
|
operation == SyncOperation.Delete ? null : fields ?? new SyncPlaintextFields(),
|
||||||
|
ancestor);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using static DodoSSH.Client.Storage.Tests.CacheHarness;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The item mirror, the vault list, the sync cursor, and the conflict log.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CacheStoreTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private CacheHarness harness = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync() => harness = await CreateAsync();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
harness.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Items ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnItem_RoundTripsItsCiphertextByteForByte()
|
||||||
|
{
|
||||||
|
// Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but
|
||||||
|
// would throw away the ability to notice the server handing back bytes it should not have.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var item = Item(entityId, version: 4, changeSequence: 17, seed: 3);
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(item, Token);
|
||||||
|
|
||||||
|
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||||
|
|
||||||
|
read.ShouldNotBeNull();
|
||||||
|
read.Version.ShouldBe(4);
|
||||||
|
read.ChangeSequence.ShouldBe(17);
|
||||||
|
read.Payload.ShouldNotBeNull();
|
||||||
|
read.Payload.Envelope.ShouldBe(item.Payload!.Envelope);
|
||||||
|
read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey);
|
||||||
|
read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId);
|
||||||
|
read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration);
|
||||||
|
read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase()
|
||||||
|
{
|
||||||
|
// The server has to hold a relay-enabled host's address in the clear because it resolves it.
|
||||||
|
// This machine already holds the key that opens the payload, so leaving the address readable in
|
||||||
|
// a file that ends up in a backup buys nothing.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var fields = new SyncPlaintextFields(
|
||||||
|
RelayEnabled: true, Hostname: "bastion.internal", Port: 2222);
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(Item(entityId, fields: fields), Token);
|
||||||
|
|
||||||
|
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||||
|
read!.Fields.ShouldBe(fields);
|
||||||
|
|
||||||
|
var stored = await ReadRawFieldsAsync(entityId);
|
||||||
|
stored.ShouldNotBeNull();
|
||||||
|
|
||||||
|
System.Text.Encoding.UTF8.GetString(stored)
|
||||||
|
.Contains("bastion.internal", StringComparison.Ordinal)
|
||||||
|
.ShouldBeFalse("the hostname is stored in the clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ACacheRecord_CannotBeMovedToAnotherRow()
|
||||||
|
{
|
||||||
|
// Why the record is bound to its own row rather than only to the user. Swapping two rows would
|
||||||
|
// otherwise point one host's connection at another host's address.
|
||||||
|
var mine = Guid.CreateVersion7();
|
||||||
|
var other = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
var sealedFields = harness.Protector.Protect(
|
||||||
|
CryptoSpec.AadResourceType.Host,
|
||||||
|
mine,
|
||||||
|
PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22)));
|
||||||
|
|
||||||
|
harness.Protector
|
||||||
|
.TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields)
|
||||||
|
.ShouldBeNull();
|
||||||
|
|
||||||
|
harness.Protector
|
||||||
|
.TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields)
|
||||||
|
.ShouldBeNull();
|
||||||
|
|
||||||
|
harness.Protector
|
||||||
|
.TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields)
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ARecordSealedUnderAnotherPassphrase_DoesNotOpen()
|
||||||
|
{
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
using var stranger = await CreateAsync(passphrase: "a completely different passphrase");
|
||||||
|
|
||||||
|
var sealedFields = stranger.Protector.Protect(
|
||||||
|
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
|
||||||
|
|
||||||
|
harness.Protector
|
||||||
|
.TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields)
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SavingTwice_ReplacesRatherThanDuplicates()
|
||||||
|
{
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token);
|
||||||
|
await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token);
|
||||||
|
|
||||||
|
var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token);
|
||||||
|
|
||||||
|
items.ShouldHaveSingleItem().Version.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
|
||||||
|
{
|
||||||
|
// The interface must not show a deleted host. The sync engine must still be able to tell a
|
||||||
|
// deleted item from one it has never seen — a row that simply vanished is indistinguishable
|
||||||
|
// from the latter, and would silently reappear.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(Item(entityId, version: 1), Token);
|
||||||
|
await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token);
|
||||||
|
|
||||||
|
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty();
|
||||||
|
|
||||||
|
var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
|
||||||
|
withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue();
|
||||||
|
|
||||||
|
var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||||
|
found.ShouldNotBeNull();
|
||||||
|
found.IsDeleted.ShouldBeTrue();
|
||||||
|
found.Payload.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectingTombstones_LeavesLiveItemsAlone()
|
||||||
|
{
|
||||||
|
var live = Guid.CreateVersion7();
|
||||||
|
var dead = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token);
|
||||||
|
await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token);
|
||||||
|
|
||||||
|
var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100);
|
||||||
|
var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token);
|
||||||
|
|
||||||
|
collected.ShouldBe(1);
|
||||||
|
|
||||||
|
var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
|
||||||
|
remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Vaults ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves()
|
||||||
|
{
|
||||||
|
var keep = Guid.CreateVersion7();
|
||||||
|
var drop = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Vaults.ReplaceAllAsync(
|
||||||
|
[Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token);
|
||||||
|
|
||||||
|
await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token);
|
||||||
|
|
||||||
|
var vaults = await harness.Vaults.ListAsync(Token);
|
||||||
|
var only = vaults.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
only.VaultId.ShouldBe(keep);
|
||||||
|
only.Name.ShouldBe("Renamed");
|
||||||
|
only.KeyGeneration.ShouldBe(2u);
|
||||||
|
(await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt()
|
||||||
|
{
|
||||||
|
// Without this, an offline start could unlock the identity bundle and still not open a single
|
||||||
|
// item.
|
||||||
|
var vaultId = Guid.CreateVersion7();
|
||||||
|
byte[] wrapped = [9, 9, 9, 9];
|
||||||
|
|
||||||
|
await harness.Vaults.ReplaceAllAsync(
|
||||||
|
[Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token);
|
||||||
|
|
||||||
|
var read = await harness.Vaults.FindAsync(vaultId, Token);
|
||||||
|
read!.WrappedVaultKey.ShouldBe(wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unlock material ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt()
|
||||||
|
{
|
||||||
|
// The offline unlock story, asserted rather than assumed: a fresh store over the same database
|
||||||
|
// reads back the salt and the wrapped bundle with no network involved.
|
||||||
|
var material = Material();
|
||||||
|
|
||||||
|
await harness.Unlock.SaveAsync(material, Token);
|
||||||
|
|
||||||
|
var reader = new UnlockStore(harness.Factory, TimeProvider.System);
|
||||||
|
var read = await reader.ReadAsync(Token);
|
||||||
|
|
||||||
|
read.ShouldNotBeNull();
|
||||||
|
read.UserId.ShouldBe(material.UserId);
|
||||||
|
read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
|
||||||
|
read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
|
||||||
|
read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes);
|
||||||
|
read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow()
|
||||||
|
{
|
||||||
|
await harness.Unlock.SaveAsync(Material(), Token);
|
||||||
|
await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token);
|
||||||
|
|
||||||
|
var read = await harness.Unlock.ReadAsync(Token);
|
||||||
|
read!.Email.ShouldBe("changed@example.com");
|
||||||
|
|
||||||
|
(await CountUnlockRowsAsync()).ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn()
|
||||||
|
{
|
||||||
|
// Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one
|
||||||
|
// user's items into another's vault list.
|
||||||
|
await harness.Unlock.SaveAsync(Material(), Token);
|
||||||
|
|
||||||
|
var other = Material() with { UserId = Guid.CreateVersion7() };
|
||||||
|
|
||||||
|
await Should.ThrowAsync<CacheIdentityMismatchException>(
|
||||||
|
async () => await harness.Unlock.SaveAsync(other, Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sync state ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning()
|
||||||
|
{
|
||||||
|
// Not an error. A null cursor is exactly right for a vault this client has not synced, and is
|
||||||
|
// also the recovery path for a cache that had to be discarded.
|
||||||
|
var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token);
|
||||||
|
|
||||||
|
state.Cursor.ShouldBeNull();
|
||||||
|
state.KeyGeneration.ShouldBe(0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheCursor_IsStoredVerbatim()
|
||||||
|
{
|
||||||
|
// Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position
|
||||||
|
// the server never granted; storing it untouched is the only correct handling.
|
||||||
|
const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl";
|
||||||
|
|
||||||
|
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token);
|
||||||
|
|
||||||
|
var read = await harness.SyncState.ReadAsync(VaultId, Token);
|
||||||
|
read.Cursor.ShouldBe(Cursor);
|
||||||
|
read.KeyGeneration.ShouldBe(3u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox()
|
||||||
|
{
|
||||||
|
// Local unpushed changes are the only copy of the user's work. Clearing them along with the
|
||||||
|
// cache would turn a recoverable cache problem into lost work.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Items.SaveAsync(Item(entityId), Token);
|
||||||
|
await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||||
|
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token);
|
||||||
|
|
||||||
|
await harness.SyncState.ResetAsync(VaultId, Token);
|
||||||
|
|
||||||
|
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty();
|
||||||
|
(await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull();
|
||||||
|
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Conflicts ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AConflictDetail_RoundTripsAndIsSealedAtRest()
|
||||||
|
{
|
||||||
|
// This is the one place the cache holds decrypted vault content on purpose: the value a merge
|
||||||
|
// displaced. It has to be readable to be useful, and it is as sensitive as the item it came
|
||||||
|
// from.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}""");
|
||||||
|
|
||||||
|
var id = await harness.Conflicts.RecordAsync(
|
||||||
|
VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token);
|
||||||
|
|
||||||
|
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
|
||||||
|
listed.Id.ShouldBe(id);
|
||||||
|
listed.Kind.ShouldBe(ConflictKind.FieldOverridden);
|
||||||
|
listed.Detail.ShouldBe(detail);
|
||||||
|
|
||||||
|
var raw = await ReadRawConflictDetailAsync(id);
|
||||||
|
System.Text.Encoding.UTF8.GetString(raw!)
|
||||||
|
.Contains("my secret", StringComparison.Ordinal)
|
||||||
|
.ShouldBeFalse("the discarded value is stored in the clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue()
|
||||||
|
{
|
||||||
|
// Someone who dismisses a warning and realises a minute later that they wanted the other value
|
||||||
|
// should still be able to get it.
|
||||||
|
var id = await harness.Conflicts.RecordAsync(
|
||||||
|
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden,
|
||||||
|
new byte[] { 1, 2, 3 }, Token);
|
||||||
|
|
||||||
|
(await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue();
|
||||||
|
|
||||||
|
(await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty();
|
||||||
|
|
||||||
|
var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem();
|
||||||
|
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
|
||||||
|
{
|
||||||
|
var id = await harness.Conflicts.RecordAsync(
|
||||||
|
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable,
|
||||||
|
new byte[] { 1 }, Token);
|
||||||
|
|
||||||
|
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse();
|
||||||
|
|
||||||
|
await harness.Conflicts.AcknowledgeAsync(id, Token);
|
||||||
|
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) =>
|
||||||
|
new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31,
|
||||||
|
WrappedVaultKey: [1, 2, 3], RekeyRequired: false);
|
||||||
|
|
||||||
|
private static StoredUnlockMaterial Material() =>
|
||||||
|
new(
|
||||||
|
"https://dodossh.example",
|
||||||
|
UserId,
|
||||||
|
"https://idp.example",
|
||||||
|
"alice",
|
||||||
|
"alice@example.com",
|
||||||
|
"Alice",
|
||||||
|
KeyGeneration: 1,
|
||||||
|
WrappedPrivateKey: [4, 5, 6, 7],
|
||||||
|
new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1),
|
||||||
|
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
|
||||||
|
|
||||||
|
private async Task<byte[]?> ReadRawFieldsAsync(Guid entityId)
|
||||||
|
{
|
||||||
|
var context = harness.Factory.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await context.Set<CachedItemRow>()
|
||||||
|
.Where(row => row.EntityId == entityId)
|
||||||
|
.Select(row => row.ProtectedFields)
|
||||||
|
.SingleAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<byte[]?> ReadRawConflictDetailAsync(Guid id)
|
||||||
|
{
|
||||||
|
var context = harness.Factory.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await context.Set<ConflictRow>()
|
||||||
|
.Where(row => row.Id == id)
|
||||||
|
.Select(row => row.Detail)
|
||||||
|
.SingleAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CountUnlockRowsAsync()
|
||||||
|
{
|
||||||
|
var context = harness.Factory.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await context.Set<UnlockMaterialRow>().CountAsync(Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Against real SQLite, in memory. Not an in-memory *provider*: the point of these tests is that the
|
||||||
|
schema, the composite keys and the unique index behave as configured, none of which the in-memory
|
||||||
|
provider enforces. An in-memory SQLite file exercises the actual query pipeline and still runs in
|
||||||
|
milliseconds.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
|
using static DodoSSH.Client.Storage.Tests.CacheHarness;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The outbox, whose coalescing rules are where offline work is kept or lost.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Two properties carry the weight. The ancestor must survive every coalesce, or a conflict can only
|
||||||
|
/// be arbitrated rather than merged. And a coalesced row must get a fresh operation id, or the server
|
||||||
|
/// can answer <c>Duplicate</c> for an operation whose contents have since changed and silently discard
|
||||||
|
/// the newer edit.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OutboxStoreTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private CacheHarness harness = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync() => harness = await CreateAsync();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
harness.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AQueuedChange_ComesBackWithEverythingItNeedsToBePushed()
|
||||||
|
{
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var ancestor = new StoredAncestor(3, Payload(seed: 40), new SyncPlaintextFields());
|
||||||
|
|
||||||
|
var queued = await harness.Outbox.QueueAsync(
|
||||||
|
Change(entityId, expectedVersion: 3, seed: 7, ancestor: ancestor), Token);
|
||||||
|
|
||||||
|
queued.OperationId.ShouldNotBe(Guid.Empty);
|
||||||
|
queued.ExpectedVersion.ShouldBe(3);
|
||||||
|
queued.Operation.ShouldBe(SyncOperation.Upsert);
|
||||||
|
queued.Payload.ShouldNotBeNull();
|
||||||
|
queued.Payload.Envelope.ShouldBe(Payload(seed: 7).Envelope);
|
||||||
|
queued.Payload.WrappedDataKey.ShouldBe(Payload(seed: 7).WrappedDataKey);
|
||||||
|
queued.Payload.DataKeyId.ShouldBe(Payload(seed: 7).DataKeyId);
|
||||||
|
queued.Ancestor.ShouldNotBeNull();
|
||||||
|
queued.Ancestor.Version.ShouldBe(3);
|
||||||
|
queued.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 40).Envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ASecondEditToTheSameItem_CoalescesIntoOneRow()
|
||||||
|
{
|
||||||
|
// Two rows would have to be pushed in order, and the second's expectedVersion is the version
|
||||||
|
// the first will produce — which is not known when it is queued.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
|
||||||
|
await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
|
||||||
|
|
||||||
|
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||||
|
|
||||||
|
pending.ShouldHaveSingleItem().Payload!.Envelope.ShouldBe(Payload(seed: 2).Envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ACoalescedEdit_KeepsTheOriginalAncestorAndExpectedVersion()
|
||||||
|
{
|
||||||
|
// The load-bearing rule. The newest state is still a descendant of the base the first edit
|
||||||
|
// branched from; adopting the caller's values here would discard the common ancestor after the
|
||||||
|
// first edit and leave nothing to merge against.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var ancestor = new StoredAncestor(5, Payload(seed: 90), new SyncPlaintextFields());
|
||||||
|
|
||||||
|
await harness.Outbox.QueueAsync(
|
||||||
|
Change(entityId, expectedVersion: 5, seed: 1, ancestor: ancestor), Token);
|
||||||
|
|
||||||
|
// A second edit arrives knowing nothing about the base.
|
||||||
|
await harness.Outbox.QueueAsync(
|
||||||
|
Change(entityId, expectedVersion: null, seed: 2, ancestor: null), Token);
|
||||||
|
|
||||||
|
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
pending.ExpectedVersion.ShouldBe(5);
|
||||||
|
pending.Ancestor.ShouldNotBeNull();
|
||||||
|
pending.Ancestor.Version.ShouldBe(5);
|
||||||
|
pending.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 90).Envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ACoalescedEdit_GetsAFreshOperationId()
|
||||||
|
{
|
||||||
|
// Reusing the id would let the server report Duplicate — meaning "already applied" — for an
|
||||||
|
// operation whose payload has since changed, and the newer edit would vanish with the push
|
||||||
|
// reported as a success.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
var first = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
|
||||||
|
await harness.Outbox.MarkDispatchedAsync(first.Sequence, Token);
|
||||||
|
|
||||||
|
var second = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
|
||||||
|
|
||||||
|
second.OperationId.ShouldNotBe(first.OperationId);
|
||||||
|
second.Sequence.ShouldBe(first.Sequence);
|
||||||
|
|
||||||
|
// And the retry counter resets, because this is a new operation rather than a further attempt
|
||||||
|
// at the old one.
|
||||||
|
second.Attempts.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpsertFollowedByADelete_BecomesADelete()
|
||||||
|
{
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Outbox.QueueAsync(Change(entityId, expectedVersion: 2, seed: 1), Token);
|
||||||
|
await harness.Outbox.QueueAsync(
|
||||||
|
Change(entityId, SyncOperation.Delete, expectedVersion: 2), Token);
|
||||||
|
|
||||||
|
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
pending.Operation.ShouldBe(SyncOperation.Delete);
|
||||||
|
pending.Payload.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangesToDifferentItems_DrainInTheOrderTheyWereMade()
|
||||||
|
{
|
||||||
|
// Order matters for creates that reference each other — a host naming a jump host — so the
|
||||||
|
// outbox is a queue, not a set.
|
||||||
|
var first = Guid.CreateVersion7();
|
||||||
|
var second = Guid.CreateVersion7();
|
||||||
|
var third = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
foreach (var id in new[] { first, second, third })
|
||||||
|
{
|
||||||
|
await harness.Outbox.QueueAsync(Change(id), Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||||
|
|
||||||
|
pending.Select(p => p.EntityId).ShouldBe([first, second, third]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescingDoesNotJumpTheQueue()
|
||||||
|
{
|
||||||
|
// The row keeps its original position. Re-editing the first item should not push it behind
|
||||||
|
// items queued after it, because the later ones may depend on it existing.
|
||||||
|
var first = Guid.CreateVersion7();
|
||||||
|
var second = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
await harness.Outbox.QueueAsync(Change(first), Token);
|
||||||
|
await harness.Outbox.QueueAsync(Change(second), Token);
|
||||||
|
await harness.Outbox.QueueAsync(Change(first, seed: 9), Token);
|
||||||
|
|
||||||
|
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||||
|
|
||||||
|
pending.Select(p => p.EntityId).ShouldBe([first, second]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Revise_MovesTheAncestorForwardUnlikeQueue()
|
||||||
|
{
|
||||||
|
// The opposite intent from a coalesce: a merge has just been performed against a newer server
|
||||||
|
// version, so that version becomes the base. Leaving the old ancestor would make the re-push
|
||||||
|
// conflict against the same point for ever.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
var original = new StoredAncestor(1, Payload(seed: 10), new SyncPlaintextFields());
|
||||||
|
|
||||||
|
var queued = await harness.Outbox.QueueAsync(
|
||||||
|
Change(entityId, expectedVersion: 1, ancestor: original), Token);
|
||||||
|
|
||||||
|
var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields());
|
||||||
|
|
||||||
|
var revised = await harness.Outbox.ReviseAsync(
|
||||||
|
queued.Sequence,
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
expectedVersion: 4,
|
||||||
|
Payload(seed: 30),
|
||||||
|
new SyncPlaintextFields(),
|
||||||
|
merged,
|
||||||
|
Token);
|
||||||
|
|
||||||
|
revised.ShouldNotBeNull();
|
||||||
|
revised.ExpectedVersion.ShouldBe(4);
|
||||||
|
revised.Ancestor!.Version.ShouldBe(4);
|
||||||
|
revised.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 20).Envelope);
|
||||||
|
revised.OperationId.ShouldNotBe(queued.OperationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AParkedOperation_IsNotHandedOutForPushing()
|
||||||
|
{
|
||||||
|
// An operation the server called Invalid will never succeed. Retrying it would spin and, worse,
|
||||||
|
// would block every change queued behind it in a vault the user can still write to.
|
||||||
|
var parked = Guid.CreateVersion7();
|
||||||
|
var healthy = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
var queued = await harness.Outbox.QueueAsync(Change(parked), Token);
|
||||||
|
await harness.Outbox.QueueAsync(Change(healthy), Token);
|
||||||
|
|
||||||
|
await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token);
|
||||||
|
|
||||||
|
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||||
|
pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy);
|
||||||
|
|
||||||
|
var listed = (await harness.Outbox.ListParkedAsync(VaultId, Token)).ShouldHaveSingleItem();
|
||||||
|
listed.EntityId.ShouldBe(parked);
|
||||||
|
listed.LastError.ShouldBe("Entity type not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReEditingAParkedOperation_Unparks()
|
||||||
|
{
|
||||||
|
// The user's remedy for a rejected change is to change it. That has to actually re-arm it.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
|
||||||
|
var queued = await harness.Outbox.QueueAsync(Change(entityId), Token);
|
||||||
|
await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token);
|
||||||
|
|
||||||
|
var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token);
|
||||||
|
|
||||||
|
requeued.IsParked.ShouldBeFalse();
|
||||||
|
requeued.LastError.ShouldBeNull();
|
||||||
|
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_RemovesTheOperation()
|
||||||
|
{
|
||||||
|
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||||
|
|
||||||
|
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue();
|
||||||
|
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty();
|
||||||
|
|
||||||
|
// Idempotent: a drain that retries after a crash must not fail on an already-cleared row.
|
||||||
|
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkDispatched_CountsAttempts()
|
||||||
|
{
|
||||||
|
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||||
|
|
||||||
|
await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||||
|
await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||||
|
|
||||||
|
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||||
|
pending.Attempts.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpsertWithoutAPayload_IsRefused()
|
||||||
|
{
|
||||||
|
// Caught here rather than at the server, where it would come back as one opaque Invalid among
|
||||||
|
// a batch of otherwise good operations.
|
||||||
|
var change = new QueuedChange(
|
||||||
|
VaultId,
|
||||||
|
SyncEntityType.Host,
|
||||||
|
Guid.CreateVersion7(),
|
||||||
|
SyncOperation.Upsert,
|
||||||
|
ExpectedVersion: null,
|
||||||
|
Payload: null,
|
||||||
|
Fields: null,
|
||||||
|
Ancestor: null);
|
||||||
|
|
||||||
|
await Should.ThrowAsync<ArgumentException>(
|
||||||
|
async () => await harness.Outbox.QueueAsync(change, Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePendingOperationForAnItem_CanBeLookedUpDirectly()
|
||||||
|
{
|
||||||
|
// How a pull discovers that an incoming change collides with local work.
|
||||||
|
var entityId = Guid.CreateVersion7();
|
||||||
|
await harness.Outbox.QueueAsync(Change(entityId), Token);
|
||||||
|
|
||||||
|
(await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, entityId, Token))
|
||||||
|
.ShouldNotBeNull();
|
||||||
|
|
||||||
|
(await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, Guid.CreateVersion7(), Token))
|
||||||
|
.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
{
|
||||||
|
"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.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.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,18 @@ public sealed class SerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Properties_AreCamelCase()
|
public void Properties_AreCamelCase()
|
||||||
{
|
{
|
||||||
var payload = new EncryptedPayload([1, 2, 3], KeyGeneration: 4, AadVersion: 1);
|
var payload = new EncryptedPayload(
|
||||||
|
[1, 2, 3],
|
||||||
|
WrappedDataKey: [4, 5],
|
||||||
|
DataKeyId: Guid.CreateVersion7(),
|
||||||
|
KeyGeneration: 4,
|
||||||
|
AadVersion: 1);
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(payload, Options);
|
var json = JsonSerializer.Serialize(payload, Options);
|
||||||
|
|
||||||
json.ShouldContain("\"envelope\"");
|
json.ShouldContain("\"envelope\"");
|
||||||
|
json.ShouldContain("\"wrappedDataKey\"");
|
||||||
|
json.ShouldContain("\"dataKeyId\"");
|
||||||
json.ShouldContain("\"keyGeneration\"");
|
json.ShouldContain("\"keyGeneration\"");
|
||||||
json.ShouldContain("\"aadVersion\"");
|
json.ShouldContain("\"aadVersion\"");
|
||||||
}
|
}
|
||||||
@@ -32,11 +39,16 @@ public sealed class SerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ByteArrays_AreBase64()
|
public void ByteArrays_AreBase64()
|
||||||
{
|
{
|
||||||
var payload = new EncryptedPayload([0xDE, 0xAD, 0xBE, 0xEF], 1, 1);
|
var payload = new EncryptedPayload(
|
||||||
|
[0xDE, 0xAD, 0xBE, 0xEF], [0xC0, 0xFF, 0xEE], Guid.CreateVersion7(), 1, 1);
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(payload, Options);
|
var json = JsonSerializer.Serialize(payload, Options);
|
||||||
|
|
||||||
json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));
|
json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));
|
||||||
|
|
||||||
|
// The data key wrap is a second envelope and must travel the same way. A string here
|
||||||
|
// instead would mean a client silently storing an item nobody can ever open.
|
||||||
|
json.ShouldContain(Convert.ToBase64String([0xC0, 0xFF, 0xEE]));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -64,13 +76,16 @@ public sealed class SerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void EncryptedPayload_RoundTrips()
|
public void EncryptedPayload_RoundTrips()
|
||||||
{
|
{
|
||||||
var original = new EncryptedPayload([9, 8, 7, 6, 5], 12, 1);
|
var original = new EncryptedPayload(
|
||||||
|
[9, 8, 7, 6, 5], [1, 2, 3, 4], Guid.CreateVersion7(), 12, 1);
|
||||||
|
|
||||||
var restored = JsonSerializer.Deserialize<EncryptedPayload>(
|
var restored = JsonSerializer.Deserialize<EncryptedPayload>(
|
||||||
JsonSerializer.Serialize(original, Options), Options);
|
JsonSerializer.Serialize(original, Options), Options);
|
||||||
|
|
||||||
restored.ShouldNotBeNull();
|
restored.ShouldNotBeNull();
|
||||||
restored.Envelope.ShouldBe(original.Envelope);
|
restored.Envelope.ShouldBe(original.Envelope);
|
||||||
|
restored.WrappedDataKey.ShouldBe(original.WrappedDataKey);
|
||||||
|
restored.DataKeyId.ShouldBe(original.DataKeyId);
|
||||||
restored.KeyGeneration.ShouldBe(original.KeyGeneration);
|
restored.KeyGeneration.ShouldBe(original.KeyGeneration);
|
||||||
restored.AadVersion.ShouldBe(original.AadVersion);
|
restored.AadVersion.ShouldBe(original.AadVersion);
|
||||||
}
|
}
|
||||||
@@ -86,7 +101,7 @@ public sealed class SerializationTests
|
|||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
ExpectedVersion: 3,
|
ExpectedVersion: 3,
|
||||||
Payload: new EncryptedPayload([1, 2, 3], 2, 1),
|
Payload: new EncryptedPayload([1, 2, 3], [4, 5], Guid.CreateVersion7(), 2, 1),
|
||||||
PlaintextFields: new SyncPlaintextFields(
|
PlaintextFields: new SyncPlaintextFields(
|
||||||
RelayEnabled: true,
|
RelayEnabled: true,
|
||||||
Hostname: "bastion.internal",
|
Hostname: "bastion.internal",
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ public sealed class CryptoSpecTests
|
|||||||
[InlineData(CryptoSpec.AadResourceType.Snippet, 9)]
|
[InlineData(CryptoSpec.AadResourceType.Snippet, 9)]
|
||||||
[InlineData(CryptoSpec.AadResourceType.PortForward, 10)]
|
[InlineData(CryptoSpec.AadResourceType.PortForward, 10)]
|
||||||
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
|
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
|
||||||
|
[InlineData(CryptoSpec.AadResourceType.HostTag, 12)]
|
||||||
|
[InlineData(CryptoSpec.AadResourceType.HostCredential, 13)]
|
||||||
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
||||||
{
|
{
|
||||||
((int)type).ShouldBe(expected);
|
((int)type).ShouldBe(expected);
|
||||||
|
|||||||
Reference in New Issue
Block a user