using Microsoft.EntityFrameworkCore; namespace DodoSSH.Client.Storage; /// /// The vaults this user can reach, and the grants that open them. /// /// /// 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. /// public sealed class VaultStore(IDbContextFactory contexts, TimeProvider clock) { /// Reads every known vault. public async Task> ListAsync(CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var rows = await context.Set() .AsNoTracking() .OrderByDescending(row => row.IsPersonal) .ThenBy(row => row.Name) .ToListAsync(cancellationToken) .ConfigureAwait(false); return [.. rows.Select(ToStored)]; } /// Reads one vault. public async Task FindAsync(Guid vaultId, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var row = await context.Set() .AsNoTracking() .SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken) .ConfigureAwait(false); return row is null ? null : ToStored(row); } /// /// Replaces the cached vault list with what the server reported. /// /// /// /// 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. /// /// /// Their items 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. /// /// public async Task ReplaceAllAsync( IReadOnlyList vaults, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(vaults); var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var existing = await context.Set() .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); } /// /// Records one vault, leaving the rest alone. /// /// /// For the vault this machine has just created, which exists here before the server's next /// /me confirms it. would be wrong for that: it treats absence /// as loss of access, and the one list that does not yet mention this vault is the one this client /// last fetched. /// public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(vault); var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var row = await context.Set() .SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken) .ConfigureAwait(false); if (row is null) { row = new CachedVaultRow { VaultId = vault.VaultId }; context.Add(row); } Apply(row, vault, clock.GetUtcNow()); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// Reads the vaults this machine has been asked to leave off the screens. /// /// The ids alone: a caller wanting the names already has , and a list that /// carried them would invite somebody to build a vault list out of this one, which is the list that /// must never decide what syncs. /// public async Task> ListHiddenAsync(CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); return await context.Set() .AsNoTracking() .Where(row => row.Hidden) .Select(row => row.VaultId) .ToListAsync(cancellationToken) .ConfigureAwait(false); } /// Records whether one vault's items are shown. /// /// A vault with no row here is not an error and not worth reporting: a grant withdrawn between the /// click and this write leaves nothing to record a preference about, and the vault is already gone /// from every list the preference would have applied to. /// public async Task SetHiddenAsync(Guid vaultId, bool hidden, CancellationToken cancellationToken) { var context = contexts.CreateDbContext(); await using var scope = context.ConfigureAwait(false); var row = await context.Set() .SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken) .ConfigureAwait(false); if (row is null || row.Hidden == hidden) { return; } row.Hidden = hidden; row.UpdatedAtUtc = clock.GetUtcNow(); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } /// /// is deliberately not among these. This method exists to write /// what the server said, and the server has never been told which vaults this machine is currently /// showing — so a refresh that touched the flag would be a refresh that silently un-hid every vault, /// once a minute. Leaving it out is what makes the preference survive . /// 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); }