using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; namespace DodoSSH.Api.Features.Sync; /// /// Everything about one item type that the shared write path cannot know. /// /// /// /// The push path is the most dangerous code in the server: it holds the version check, the change-log /// append, the receipt that makes a retry exactly-once, and the advisory lock that keeps the sequence in /// commit order. Duplicating that per item type would mean ten copies of it by M5, and a fix applied to nine /// of them. So it is written once over , and everything genuinely type-specific — /// which table, which plaintext columns, what those columns must satisfy — arrives through here. /// /// /// Deliberately not generic. A generic IItemKind<TItem> reads better in isolation and would /// force every method on the write path to be generic too, including the async ones, for no benefit: the /// path never needs the concrete type, only the shared shape. The downcast each implementation performs is /// contained to one class per type and is guaranteed by construction, because the same class both creates /// and queries the rows. /// /// internal interface IItemKind { /// The type as the wire contract names it. SyncEntityType WireType { get; } /// The type as the change log names it. /// /// Stated rather than cast. The two enums do agree numerically — and they have different member names /// for the same value, Host against SshHost — but that alignment is hand-kept and nothing /// in the type system defends it. Writing it out per kind means adding a type cannot silently file its /// changes under another type's name. /// ChangeEntityType ChangeType { get; } /// Finds one item by id, across every vault, so a cross-vault id can be refused. Task FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken); /// Loads the rows behind a set of change-log entries, scoped to one vault. Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken); /// Creates an empty row of this type and tracks it. IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId); /// Rejects plaintext fields this type may not carry. bool ValidateFields(SyncPlaintextFields fields, out string error); /// Copies this type's plaintext columns out of the request. void ApplyFields(IVaultItem item, SyncPlaintextFields fields); /// Clears plaintext columns that must not outlive the item. void ClearFieldsOnDelete(IVaultItem item); /// Reads this type's plaintext columns back for a pull, or null when it has none. SyncPlaintextFields? Hydrate(IVaultItem item); } /// The item types this server can synchronise, by wire type. internal static class ItemKinds { private static readonly Dictionary Supported = new[] { (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), new HostGroupKind(), new SnippetKind(), new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(), }.ToDictionary(kind => kind.WireType); /// The kind for a wire type, or null when this server does not synchronise it yet. /// /// Null rather than a throw. Every entity type in the contract is reachable by a newer client, and the /// push endpoint answers per operation — so an unsupported type has to become one Invalid result /// with a reason, not a failed batch that also rejects the operations this server did understand. /// internal static IItemKind? For(SyncEntityType type) => Supported.TryGetValue(type, out var kind) ? kind : null; } /// Hosts: the one item type with a deliberate plaintext concession. internal sealed class HostKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.Host; /// public ChangeEntityType ChangeType => ChangeEntityType.SshHost; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.Hosts .Where(h => h.VaultId == vaultId && ids.Contains(h.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var host = new SshHost { Id = id, VaultId = vaultId }; database.Hosts.Add(host); return host; } /// /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint /// violation surfacing as a 500. /// /// /// /// The group check comes first, ahead of the relay branch, and that placement is the point: the /// relay branch returns early on its happy path, so a check placed after it would apply to non-relay /// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely /// to carry it. /// /// /// SyncPlaintextFields.GroupId is part of a frozen wire contract and cannot be removed from it, so /// refusing it here is what actually keeps the value out of the database. The column it used to be copied /// into was dropped when groups landed; see for why membership travels inside /// the payload instead. /// /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.GroupId is not null) { error = "A host's group is inside its encrypted payload; the server does not store one."; return false; } if (fields.RelayEnabled) { if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null) { error = "Relay-enabled hosts require both a hostname and a port."; return false; } if (fields.Port is < 1 or > 65535) { error = "Port must be between 1 and 65535."; return false; } return true; } if (fields.Hostname is not null || fields.Port is not null) { error = "A hostname or port may only be supplied when relay is enabled for the host."; return false; } return true; } /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { ArgumentNullException.ThrowIfNull(fields); var host = (SshHost)item; host.RelayEnabled = fields.RelayEnabled; host.Hostname = fields.RelayEnabled ? fields.Hostname : null; host.Port = fields.RelayEnabled ? fields.Port : null; } /// /// The address goes with the item. Leaving it would keep the server able to resolve a host the user /// believes they deleted. /// /// public void ClearFieldsOnDelete(IVaultItem item) { var host = (SshHost)item; host.RelayEnabled = false; host.Hostname = null; host.Port = null; } /// public SyncPlaintextFields? Hydrate(IVaultItem item) { var host = (SshHost)item; return new SyncPlaintextFields( RelayEnabled: host.RelayEnabled, Hostname: host.Hostname, Port: host.Port); } } /// SSH keys: ciphertext and nothing else. internal sealed class SshKeyKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.SshKey; /// public ChangeEntityType ChangeType => ChangeEntityType.SshKey; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.SshKeys .Where(k => k.VaultId == vaultId && ids.Contains(k.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var key = new VaultSshKey { Id = id, VaultId = vaultId }; database.SshKeys.Add(key); return key; } /// /// Refuses the relay columns outright. /// /// /// A key is not something the server dials, so an address on one is either a client bug or an attempt to /// get the server to store something it has no reason to hold. Refused with a reason rather than /// silently dropped, because a client that thinks it is storing a field and is not will eventually be /// surprised by its absence. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "An SSH key has no relay target; relay fields may only be set on a host."; return false; } return true; } /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { ArgumentNullException.ThrowIfNull(fields); // Only the fingerprint, and only because the contract reserved it. Everything that identifies the // key to a person — its label, its comment — is inside the ciphertext. ((VaultSshKey)item).PublicKeyFingerprint = fields.PublicKeyFingerprint; } /// public void ClearFieldsOnDelete(IVaultItem item) => ((VaultSshKey)item).PublicKeyFingerprint = null; /// /// Null rather than an all-defaults instance, and the difference is visible on the wire: a /// SyncPlaintextFields with nothing set still serialises relayEnabled: false, which /// invites a reader to believe this type has a relay setting that happens to be off. It has none. /// /// public SyncPlaintextFields? Hydrate(IVaultItem item) => ((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint ? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint) : null; } /// Credentials: an envelope and nothing else. /// /// As strict as a kind gets about plaintext — is the other one — and the /// reason is not symmetry. A key at least has a fingerprint that is public by nature; a password has no part /// that is safe to expose, so this kind accepts no plaintext fields at all and hydrates none. /// internal sealed class CredentialKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.Credential; /// public ChangeEntityType ChangeType => ChangeEntityType.Credential; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.Credentials.SingleOrDefaultAsync(c => c.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.Credentials .Where(c => c.VaultId == vaultId && ids.Contains(c.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var credential = new VaultCredential { Id = id, VaultId = vaultId }; database.Credentials.Add(credential); return credential; } /// /// Refuses every plaintext field there is. /// /// /// Refused with a reason rather than silently dropped, so a client that believes it is storing something /// finds out now rather than when the field turns out to be missing. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A credential has no relay target; relay fields may only be set on a host."; return false; } if (fields.PublicKeyFingerprint is not null) { error = "A credential has no public key."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// /// Always null, which is a stronger statement than an empty record: this type has no plaintext columns, /// so there is nothing a pull could hydrate even in principle. /// /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Known host keys: an envelope and nothing else. /// /// As strict as about plaintext, for a reason that is about aggregation rather /// than secrecy. A fingerprint is published so it can be compared and an address may already sit in a /// relay-enabled host's columns — but the set of endpoints one user has approved is a map of their estate, /// and this server has nothing to do with it. /// internal sealed class KnownHostKeyKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.KnownHostKey; /// public ChangeEntityType ChangeType => ChangeEntityType.KnownHostKey; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.KnownHostKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.KnownHostKeys .Where(k => k.VaultId == vaultId && ids.Contains(k.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var knownHost = new VaultKnownHostKey { Id = id, VaultId = vaultId }; database.KnownHostKeys.Add(knownHost); return knownHost; } /// /// Refuses every plaintext field there is. /// /// /// The relay fields are refused although this type is the one that does hold an address, and /// that is the point: the address belongs in the ciphertext. A client sending it here is either confused /// or trying to get the server to keep a list it has no business keeping, and either way it should be /// told rather than have the value quietly dropped. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A known host key is not something the server dials; its address stays encrypted."; return false; } if (fields.PublicKeyFingerprint is not null) { error = "A known host key's fingerprint stays inside its payload."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// /// Always null, as for a credential: this type has no plaintext columns, so there is nothing a pull could /// hydrate even in principle. /// /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Host groups: an envelope and nothing else. /// /// The kind that closes a hole rather than opening one. SyncPlaintextFields has carried a /// GroupId since the contract was frozen and used to copy it into a column; /// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the /// server can read, which is the same answer gives for the same reason. /// internal sealed class HostGroupKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.HostGroup; /// public ChangeEntityType ChangeType => ChangeEntityType.HostGroup; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.HostGroups .Where(g => g.VaultId == vaultId && ids.Contains(g.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var group = new VaultHostGroup { Id = id, VaultId = vaultId }; database.HostGroups.Add(group); return group; } /// /// Refuses every plaintext field there is, including the one named after this type. /// /// /// A GroupId on a group would be a parent pointer, and groups are flat — see /// for why nesting merged by a scalar three-way merge can produce a cycle /// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by /// accident. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A host group is not something the server dials."; return false; } if (fields.GroupId is not null || fields.ParentId is not null) { error = "Host groups are flat, and a group's name is inside its payload."; return false; } if (fields.PublicKeyFingerprint is not null) { error = "A host group has no public key."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Snippets: an envelope and nothing else. /// /// A label column here would sort a list this server never draws, and the commands beside that label describe /// the estate as precisely as a list of hostnames would. So this kind is as strict as /// , and for the aggregation reason rather than the secrecy one. /// internal sealed class SnippetKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.Snippet; /// public ChangeEntityType ChangeType => ChangeEntityType.Snippet; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.Snippets .Where(s => s.VaultId == vaultId && ids.Contains(s.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var snippet = new VaultSnippet { Id = id, VaultId = vaultId }; database.Snippets.Add(snippet); return snippet; } /// Refuses every plaintext field there is. /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A snippet is not something the server dials."; return false; } if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) { error = "A snippet's contents, including anything it is scoped to, stay inside its payload."; return false; } if (fields.PublicKeyFingerprint is not null) { error = "A snippet has no public key."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Connection log entries: an envelope and nothing else. /// /// /// The strictest kind here, and the one where a plaintext column would have been most tempting: a /// started_at would let this server order and prune a log without any client's help. It gets none, /// because a timestamp column on this table is a record of when each user works, and the times are the /// interesting part of a connection log even when the hostnames are sealed. /// /// /// The server cannot enforce write-once, and does not pretend to. That an entry is created and never /// updated is a client rule — see — and the shared write path would /// accept an upsert with a correct expectedVersion like any other. Adding a refusal here would be a /// guarantee about payload semantics this server cannot read. /// /// internal sealed class ConnectionLogEntryKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry; /// public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.ConnectionLog .Where(e => e.VaultId == vaultId && ids.Contains(e.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId }; database.ConnectionLog.Add(entry); return entry; } /// Refuses every plaintext field there is. /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A log entry is not something the server dials; what was connected to stays encrypted."; return false; } if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) { error = "A log entry names what it is about inside its payload."; return false; } if (fields.Kind is not null || fields.PublicKeyFingerprint is not null) { error = "A log entry carries no plaintext fields at all."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Activity log entries: an envelope and nothing else. /// /// As strict as . SyncPlaintextFields.Kind exists and would fit /// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording /// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts /// that each look harmless. /// internal sealed class ActivityLogEntryKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.ActivityLogEntry; /// public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.ActivityLog .Where(e => e.VaultId == vaultId && ids.Contains(e.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId }; database.ActivityLog.Add(entry); return entry; } /// Refuses every plaintext field there is. /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A log entry is not something the server dials."; return false; } if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) { error = "Which item a log entry is about stays inside its payload."; return false; } if (fields.Kind is not null || fields.PublicKeyFingerprint is not null) { error = "A log entry carries no plaintext fields at all."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } /// Object stores: an envelope and nothing else. /// /// As strict as , because it holds the same class of thing. A secret access key /// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The /// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to /// weigh. /// internal sealed class ObjectStoreKind : IItemKind { /// public SyncEntityType WireType => SyncEntityType.ObjectStore; /// public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore; /// public async Task FindAsync( DodoDbContext database, Guid id, CancellationToken cancellationToken) => await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken) .ConfigureAwait(false); /// public async Task> LoadAsync( DodoDbContext database, Guid vaultId, Guid[] ids, CancellationToken cancellationToken) { var rows = await database.ObjectStores .Where(o => o.VaultId == vaultId && ids.Contains(o.Id)) .ToListAsync(cancellationToken) .ConfigureAwait(false); return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); } /// public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) { var store = new VaultObjectStore { Id = id, VaultId = vaultId }; database.ObjectStores.Add(store); return store; } /// Refuses every plaintext field there is. /// /// The relay fields are refused although this type does hold an address, exactly as they are for /// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either /// confused or trying to get the server to keep a list of where its users store data. /// /// public bool ValidateFields(SyncPlaintextFields fields, out string error) { ArgumentNullException.ThrowIfNull(fields); error = string.Empty; if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) { error = "A bucket is not something the server dials; its endpoint stays encrypted."; return false; } if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) { error = "A bucket's contents are inside its payload."; return false; } if (fields.Kind is not null || fields.PublicKeyFingerprint is not null) { error = "A bucket carries no plaintext fields at all."; return false; } return true; } /// Nothing to copy: this type has no plaintext columns to copy anything into. /// public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) { } /// public void ClearFieldsOnDelete(IVaultItem item) { } /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; }