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);
}
}