using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync;
/// One vault item as the interface should show it.
/// The item id.
/// The decrypted item.
///
/// The server version this is based on. Zero for an item that has never been accepted.
///
///
/// Whether this reflects a local edit the server has not accepted yet. Worth showing: it is the
/// difference between "saved" and "saved here".
///
///
/// Whether the pending change was refused and is waiting on a person, so it will not retry on its own.
///
///
/// Whether this item was written by a newer client and so must not be edited here, because re-encoding
/// it would drop fields this build cannot represent.
///
public sealed record VaultItem(
Guid EntityId,
TSecret Secret,
int Version,
bool HasUnsyncedChanges,
bool IsBlocked,
bool IsReadOnly)
where TSecret : class, IVaultSecret;
/// The items of one kind in a vault, and what could not be read.
/// The readable items.
///
/// 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.
///
public sealed record ItemListing(IReadOnlyList> Items, int Unreadable)
where TSecret : class, IVaultSecret;
///
/// Reading and writing one kind of vault item, as the interface sees them.
///
///
///
/// 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.
///
///
/// 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.
///
///
/// Every read and write is scoped to , which is also what
/// keeps two kinds apart in storage: the item table is keyed on the type as well as the id, so a host and
/// a key could share an id and never see each other's rows.
///
///
internal sealed class VaultItemRepository(
IItemKind kind,
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
where TSecret : class, IVaultSecret
{
///
/// Whether writes through this repository are worth recording.
///
///
/// Asked once rather than at each call site, and false for the log kinds themselves — which is the guard
/// that stops the activity log producing an entry for every entry it writes, without end. See
/// .
///
private bool IsAudited => activity is not null && kind.IsAudited;
/// Reads every item of this kind the user should see in a vault.
internal async Task> ListAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
// TryGet rather than CanRead, which answers false for a disposed keyring where this has to
// throw: a locked session being read from is a caller holding something it should have let go
// of, and the exception is what says so.
if (!keyring.TryGet(vaultId, out _, out _))
{
throw new VaultUnreadableException(vaultId);
}
var mirrored = await items
.ListAsync(vaultId, kind.EntityType, includeDeleted: true, cancellationToken)
.ConfigureAwait(false);
var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);
var pendingByEntity = pending
.Where(operation => operation.EntityType == kind.EntityType)
.ToDictionary(operation => operation.EntityId);
var listed = new List>();
var unreadable = 0;
foreach (var item in mirrored)
{
if (pendingByEntity.Remove(item.EntityId, out var local))
{
AddPending(listed, ref unreadable, vaultId, local);
continue;
}
AddMirrored(listed, ref unreadable, vaultId, item);
}
// Whatever is left has no mirror row yet: items created here and not yet accepted.
foreach (var local in pendingByEntity.Values)
{
AddPending(listed, ref unreadable, vaultId, local);
}
return new ItemListing(listed, unreadable);
}
///
/// Adds an item, returning the id it was given.
///
///
/// The id is generated here, not by the server, which is what lets an item 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.
///
internal async Task CreateAsync(
Guid vaultId,
TSecret secret,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(secret);
Validate(secret);
var (vaultKey, generation) = Key(vaultId);
var entityId = Guid.CreateVersion7();
await outbox.QueueAsync(
new QueuedChange(
vaultId,
kind.EntityType,
entityId,
SyncOperation.Upsert,
ExpectedVersion: null,
kind.Seal(secret, vaultKey.Span, entityId, generation, itemVersion: 1),
kind.Fields(secret),
Ancestor: null),
cancellationToken).ConfigureAwait(false);
// After the queue, deliberately. A crash between the two loses one advisory line; the reverse order
// records an item that was never created.
if (IsAudited)
{
activity!.Record(
vaultId, kind.EntityType, entityId, secret.Label, ActivityOperation.Created, []);
}
return entityId;
}
///
/// Replaces an item's contents.
///
///
/// 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
/// expectedVersion the coalesced row keeps — and because the AAD binds the item version, the
/// result would encrypt cleanly and never decrypt again.
///
internal async Task UpdateAsync(
Guid vaultId,
Guid entityId,
TSecret secret,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(secret);
Validate(secret);
var (vaultKey, generation) = Key(vaultId);
var pending = await outbox
.FindAsync(vaultId, kind.EntityType, 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);
// Read before the queue overwrites it, and compared after. The version this decrypts at is the one
// the payload was sealed at, which is why the pending and mirror cases differ: a pending payload
// holds the version the server will assign, and a mirror row holds the one it has.
var before = IsAudited
? Open(vaultId, entityId, pending, ancestor)
: null;
await outbox.QueueAsync(
new QueuedChange(
vaultId,
kind.EntityType,
entityId,
SyncOperation.Upsert,
expectedVersion,
kind.Seal(
secret,
vaultKey.Span,
entityId,
generation,
SyncVersions.NextVersion(expectedVersion)),
kind.Fields(secret),
ancestor),
cancellationToken).ConfigureAwait(false);
if (IsAudited)
{
// An empty list when the previous version could not be read, which is why nothing may take empty
// to mean "nothing changed" — it also means "we could not tell".
activity!.Record(
vaultId,
kind.EntityType,
entityId,
secret.Label,
ActivityOperation.Updated,
before is null ? [] : kind.Changes(before, secret));
}
}
///
/// Moves an item into another vault.
///
/// The vault it is in.
/// The vault it should be in.
/// The item.
///
/// What to write into the destination. The caller's, rather than read from here, because moving is the
/// one operation where the item does not arrive unchanged: references to things that live in the vault
/// it is leaving are the mover's to resolve, and this layer has no way to know which those are.
///
/// Cancellation token.
/// The id the item has in its new vault.
///
///
/// A copy and a tombstone, and it cannot be anything else. An item's payload is sealed under
/// its vault's key and its AAD binds the vault, the entity id and the item version — so there is no
/// edit that moves one, and no server call that could: the server holds ciphertext it cannot read.
/// What crosses is the plaintext, in this process, between an unwrap under one key and a seal under
/// another.
///
///
/// A new id, deliberately. Keeping it would put one entity id in two vaults, and the item table
/// is keyed on the type and the id rather than on the vault — so the destination's row and the
/// source's tombstone would be the same row, and the move would delete what it had just written.
/// Callers holding the old id have to take the new one back.
///
///
/// The write comes first and the tombstone second, which decides what an interruption leaves
/// behind: a copy in both vaults, which is visible and can be deleted, rather than a tombstone with
/// nothing on the other side, which is the host gone. Both are queued rather than sent, so the window
/// is a crash between two local writes — narrow, and worth choosing the survivable side of anyway.
///
///
/// Two activity lines, not one: a create in the destination and a delete in the source, which is what
/// the vaults actually record. A single "moved" line would have to be written to one of them and would
/// be missing from the other's history.
///
///
internal async Task MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
TSecret secret,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(secret);
if (fromVaultId == toVaultId)
{
throw new ArgumentException(
"That item is already in that vault.", nameof(toVaultId));
}
// Both keys before either write, so a destination this session cannot write to is refused with
// nothing having happened rather than after the source item has gone.
_ = Key(fromVaultId);
_ = Key(toVaultId);
var moved = await CreateAsync(toVaultId, secret, cancellationToken).ConfigureAwait(false);
await DeleteAsync(fromVaultId, entityId, cancellationToken).ConfigureAwait(false);
return moved;
}
///
/// Deletes an item.
///
///
///
/// 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.
///
///
/// Unless the server has never heard of the item, which is the one case where a tombstone is not only
/// unnecessary but wrong — see .
///
///
internal async Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken)
{
var pending = await outbox
.FindAsync(vaultId, kind.EntityType, entityId, cancellationToken)
.ConfigureAwait(false);
// Read before either branch, because both of them destroy it — and a delete's line is the one that
// most needs a name, since the item it refers to is about to stop existing.
var label = IsAudited
? await LabelAsync(vaultId, entityId, pending, cancellationToken).ConfigureAwait(false)
: null;
if (pending is not null && NeverReachedTheServer(pending))
{
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
// Recorded even though nothing goes to the server. Somebody created an item and then removed it,
// which is two things they did — and a log that showed only the create would describe a keychain
// that does not exist.
Audit(vaultId, entityId, label);
return;
}
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,
kind.EntityType,
entityId,
SyncOperation.Delete,
expectedVersion,
Payload: null,
Fields: null,
ancestor),
cancellationToken).ConfigureAwait(false);
Audit(vaultId, entityId, label);
}
/// Records a delete, if this kind is audited at all.
private void Audit(Guid vaultId, Guid entityId, string? label)
{
if (IsAudited)
{
// The label may be null when the item could not be decrypted, which is a state worth recording
// rather than skipping: an item nobody can read is still one somebody deleted.
activity!.Record(
vaultId,
kind.EntityType,
entityId,
label ?? "(an item that could not be read)",
ActivityOperation.Deleted,
[]);
}
}
/// What an item is currently called, for a log line written as it goes away.
private async Task LabelAsync(
Guid vaultId,
Guid entityId,
PendingOperation? pending,
CancellationToken cancellationToken)
{
var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
.ConfigureAwait(false);
return Open(vaultId, entityId, pending, ancestor)?.Label;
}
///
/// Decrypts whichever version of an item this machine currently shows.
///
///
/// The pending payload first, because that is what the user is looking at — an item edited offline twice
/// should report the second edit against the first, not against what the server last accepted. The
/// version each is opened at differs for the reason the sealing side differs: a queued payload is sealed
/// at the version the server will assign, and a mirror row holds the one it has.
///
private TSecret? Open(
Guid vaultId,
Guid entityId,
PendingOperation? pending,
StoredAncestor? ancestor)
{
if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
{
return keyring.TryGetAt(vaultId, queued.KeyGeneration, out var queuedKey)
? kind.TryOpen(
queued,
queuedKey.Span,
entityId,
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret
: null;
}
if (ancestor is null
|| !keyring.TryGetAt(vaultId, ancestor.Payload.KeyGeneration, out var vaultKey))
{
return null;
}
return kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
}
///
/// Whether a queued change describes an item the server cannot be holding.
///
///
///
/// A null ExpectedVersion means the row is a create — including a create that has since been
/// edited, because coalescing keeps the original expected version. So there is no server row and no
/// mirror row, and dropping the queued change makes the item genuinely gone. Queueing a tombstone
/// instead asks the server to delete something it has never seen, which it answers Invalid; the
/// change is parked, and the user is left with a rejected item they already deleted and a pending count
/// that never reaches zero. Add a host on a laptop with no network, change your mind, and that is the
/// state — it applies to all four item types.
///
///
/// The attempt count is what makes this safe rather than merely convenient. Nothing sent cannot have
/// landed. A parked row cannot have landed either — parking is what the pusher does when the server has
/// refused, so the refusal is the evidence. What is left is a create that went out and whose answer was
/// never seen: in flight, or failed in a way that might yet have been applied. That one still gets a
/// tombstone, because the server may be holding the item and a local drop would strand it there for
/// ever. A refused tombstone is recoverable; an orphan on the server is not.
///
///
private static bool NeverReachedTheServer(PendingOperation pending) =>
pending is { Operation: SyncOperation.Upsert, ExpectedVersion: null }
&& (pending.Attempts == 0 || pending.IsParked);
private static void Validate(TSecret secret)
{
if (!secret.TryValidate(out var error))
{
throw new ArgumentException(error, nameof(secret));
}
}
/// Adds one row of the server's mirror to a listing, or counts it as unreadable.
private void AddMirrored(
List> listed,
ref int unreadable,
Guid vaultId,
StoredItem item)
{
if (item.IsDeleted || item.Payload is null)
{
return;
}
// The generation the item names, not the vault's current one. A rotated vault holds items
// written under two or three keys at once, and a list that assumed the newest would report
// everything older as unreadable.
if (!keyring.TryGetAt(vaultId, item.Payload.KeyGeneration, out var vaultKey))
{
unreadable++;
return;
}
var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
if (opened is null)
{
unreadable++;
return;
}
listed.Add(new VaultItem(
item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly));
}
private void AddPending(
List> listed,
ref int unreadable,
Guid vaultId,
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;
}
// A queued change is sealed under whatever generation was current when it was queued, which is
// not necessarily the current one: a rotation can land between an offline edit and its push.
if (!keyring.TryGetAt(vaultId, local.Payload.KeyGeneration, out var vaultKey))
{
unreadable++;
return;
}
var version = SyncVersions.NextVersion(local.ExpectedVersion);
var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
if (opened is null)
{
unreadable++;
return;
}
listed.Add(new VaultItem(
local.EntityId,
opened.Secret,
local.ExpectedVersion ?? 0,
HasUnsyncedChanges: true,
local.IsParked,
opened.IsReadOnly));
}
private (ReadOnlyMemory VaultKey, uint Generation) Key(Guid vaultId) =>
keyring.TryGet(vaultId, out var vaultKey, out var generation)
? (vaultKey, generation)
: throw new VaultUnreadableException(vaultId);
private async Task MirrorVersionAsync(
Guid vaultId,
Guid entityId,
CancellationToken cancellationToken)
{
var item = await items
.FindAsync(vaultId, kind.EntityType, 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 MirrorAncestorAsync(
Guid vaultId,
Guid entityId,
CancellationToken cancellationToken)
{
var item = await items
.FindAsync(vaultId, kind.EntityType, entityId, cancellationToken)
.ConfigureAwait(false);
return item?.Payload is null
? null
: new StoredAncestor(item.Version, item.Payload, item.Fields);
}
}