using DodoSSH.Client.Api; using DodoSSH.Client.Storage; using DodoSSH.Client.Sync; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Session; /// A conflict, decoded and ready to show. /// The conflict record, so it can be acknowledged. /// The item it happened to. /// What happened. /// One line for a person. /// /// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field /// detail — a rejected push, or an item that would not decrypt. /// /// When it was noticed. public sealed record ConflictNotice( Guid Id, Guid EntityId, ConflictKind Kind, string Summary, IReadOnlyList Fields, DateTimeOffset DetectedAt); /// /// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable. /// /// /// /// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That /// is the whole reason this is a disposable object rather than a set of long-lived services: locking is /// disposing, and there is exactly one place that has to be right. /// /// /// The sync engine is not held here. It carries no state, so it is constructed per pass around /// whichever transport the caller currently has — which models the actual situation, where a session is /// perfectly usable with no network at all and syncing is the occasional thing that needs one. /// /// public sealed class VaultSession : IAsyncDisposable { private readonly UserSecretBundle bundle; private readonly LocalCacheProtector protector; private readonly VaultKeyring keyring; private readonly TimeProvider clock; private readonly SyncOptions options; private bool disposed; internal VaultSession( StoredUnlockMaterial profile, IReadOnlyList vaults, Guid activeVaultId, UserSecretBundle bundle, LocalCacheProtector protector, VaultKeyring keyring, ClientCacheFactory caches, TimeProvider clock, SyncOptions options) { Profile = profile; Vaults = vaults; ActiveVaultId = activeVaultId; this.bundle = bundle; this.protector = protector; this.keyring = keyring; this.clock = clock; this.options = options; Items = new ItemStore(caches, protector); Outbox = new OutboxStore(caches, protector, clock); SyncState = new SyncStateStore(caches); Conflicts = new ConflictStore(caches, protector, clock); Vault = new VaultStore(caches, clock); Hosts = new HostRepository(Items, Outbox, keyring); SshKeys = new SshKeyRepository(Items, Outbox, keyring); Credentials = new CredentialRepository(Items, Outbox, keyring); KnownHosts = new KnownHostRepository(Items, Outbox, keyring); } /// Who this session belongs to, and the material that unlocked it. public StoredUnlockMaterial Profile { get; } /// Every vault this user can reach, readable or not. public IReadOnlyList Vaults { get; } /// The vault the interface is showing. The personal one, for now. public Guid ActiveVaultId { get; } /// Hosts, decrypted, with unpushed local changes laid over them. public HostRepository Hosts { get; } /// SSH keys, decrypted, with unpushed local changes laid over them. /// /// Shares the item store and outbox with , so one synchronisation pass carries /// both and a key edit made offline queues behind a host edit in the order the user made them. /// public SshKeyRepository SshKeys { get; } /// Usernames and passwords, decrypted, with unpushed local changes laid over them. public CredentialRepository Credentials { get; } /// The host keys this vault trusts, decrypted, with unpushed local changes laid over them. /// /// Read through rather than directly by anything that connects. The /// handshake asks about host key trust from inside a synchronous SSH.NET event, and listing decrypts every /// pin in the vault — see that type for why the two must not meet. /// public KnownHostRepository KnownHosts { get; } /// Vaults whose grant could not be opened, so their items cannot be read. public IReadOnlyList UnreadableVaults => keyring.Unopened; internal ItemStore Items { get; } internal OutboxStore Outbox { get; } internal SyncStateStore SyncState { get; } internal ConflictStore Conflicts { get; } internal VaultStore Vault { get; } /// Runs one synchronisation pass over the active vault. /// The transport. Supplied per call because a session outlives any one connection. /// Cancellation token. public Task SyncAsync(ISyncApi api, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); ArgumentNullException.ThrowIfNull(api); var engine = new SyncEngine( api, Items, Outbox, SyncState, Conflicts, keyring, clock, options); return engine.SyncAsync(ActiveVaultId, cancellationToken); } /// /// Reads the conflicts a person still needs to see. /// /// /// A conflict whose detail will not decode is still reported, with the reason in place of the /// summary. The record itself — which item, when, what kind — remains useful even when the /// discarded value has become unreadable, and dropping the row would be the one outcome the whole /// conflict log exists to avoid. /// public async Task> ReadConflictsAsync( CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); var stored = await Conflicts .ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken) .ConfigureAwait(false); return [.. stored.Select(Describe)]; } /// Marks a conflict as seen, keeping the discarded value retrievable. public Task AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); return Conflicts.AcknowledgeAsync(conflictId, cancellationToken); } /// How many local changes are waiting to be pushed. public async Task PendingChangeCountAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false); return pending.Count; } /// public ValueTask DisposeAsync() { if (disposed) { return ValueTask.CompletedTask; } disposed = true; // Order is not important — none of these depend on another — but completeness is. Missing one // leaves key material in memory for the life of the process, which is the opposite of what // locking is supposed to mean. keyring.Dispose(); protector.Dispose(); bundle.Dispose(); return ValueTask.CompletedTask; } private static ConflictNotice Describe(StoredConflict conflict) { var detail = ConflictDetails.TryRead(conflict.Detail); return new ConflictNotice( conflict.Id, conflict.EntityId, conflict.Kind, detail?.Summary ?? "The details of this conflict could not be read.", detail?.Fields ?? [], conflict.DetectedAt); } }