using DodoSSH.Client.Domain; using DodoSSH.Client.Storage; using DodoSSH.Contracts; using static DodoSSH.Client.Sync.FieldChange; namespace DodoSSH.Client.Sync; /// A decrypted item, and whether this build may write it back. /// The item. /// /// Whether a newer client wrote it, in which case re-encoding it here would drop fields this build has /// no concept of. /// internal sealed record OpenedItem(TSecret Secret, bool IsReadOnly) where TSecret : class, IVaultSecret; /// A merged item, and everything that had to be overridden to produce it. /// The item to store and push. /// Empty when the two sides were reconcilable field by field. internal sealed record MergedItem( TSecret Merged, IReadOnlyList Conflicts) where TSecret : class, IVaultSecret; /// /// Everything about one item type that the shared sync path cannot know. /// /// /// /// The reconciler holds the six answers a collision can have — merge, adopt, resurrect, abandon, park, /// refuse — and every one of them is identical for a host, an SSH key, a credential and a pinned host key. /// Only the encoding, /// the merge and the plaintext columns differ, and those arrive through here. A second copy of the /// reconciler per item type is the alternative, and it is not a real one: the file's whole premise is /// that the pull and push paths must answer the same situation the same way, and two copies would drift /// the moment one of them was fixed. /// /// /// Generic, unlike the server's IItemKind, and for a reason that reverses there: the client /// does need the concrete type. It merges two versions of an item field by field and hands the /// result to a codec, so erasing the type would only move the downcasts inside the reconciler, where /// they would be a cast per branch instead of none. /// /// internal interface IItemKind where TSecret : class, IVaultSecret { /// The type as the wire contract names it. SyncEntityType EntityType { get; } /// /// What to call one of these when telling a person what happened to it. /// /// /// Lower case and singular, because every use is mid-sentence. This exists because the conflict log /// is read by people: "this host could not be decrypted" is actively misleading when the item was a /// private key, and a user who is told the wrong noun looks in the wrong place. /// string Noun { get; } /// OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion); /// EncryptedPayload Seal( TSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion); /// /// The plaintext columns the server gets, or null when this type gives it nothing. /// /// /// Nullable rather than an all-defaults record, because the difference is visible on the wire and to /// a reader: SyncPlaintextFields with nothing set still serialises relayEnabled: false, /// which invites the belief that the type has a relay setting which happens to be off. /// SyncPlaintextFields? Fields(TSecret secret); /// Merges two divergent versions against the version they both started from. MergedItem Merge(TSecret ancestor, TSecret local, TSecret remote); /// /// The names of the fields that differ between two versions of an item. /// /// /// /// For the activity log, which records what changed and never what it changed to. Written per kind /// rather than derived by reflection, for two reasons: this project's serialisation is source-generated /// precisely to keep reflection out of it, and the names here are read by a person — so each kind gets /// to say "Passphrase" rather than whatever a property happens to be called. /// /// /// Never a value. A log that recorded an old password would be a plaintext credential store with /// a vault drawn around it; see ADR 0006, which imposes the same rule on the server's own detail column. /// /// IReadOnlyList Changes(TSecret before, TSecret after); /// /// Whether writing one of these is worth a line in the activity log. /// /// /// False for the log kinds themselves, and that is not a preference: the activity hook sits in the one /// generic repository every kind goes through, so a log entry that logged itself would produce an entry /// per entry, for ever. It is stated per kind rather than special-cased at the call site so that a /// future kind with the same shape — anything written by the machine rather than by a person — cannot /// re-enter the loop by being forgotten. /// bool IsAudited => true; /// The same item under a new name, for a resurrection. TSecret Relabel(TSecret secret, string label); } /// /// The item types this client synchronises. /// /// /// /// One list, and the pull filter is derived from it. The engine asks the server for exactly the /// types in and refuses to apply a change of any other type, so adding a kind /// cannot leave the filter behind — which is the specific way this would otherwise break: an item type /// that reads and writes perfectly in every unit test and is never once requested from the server. /// /// /// A reconciler per type rather than one shared instance, because each closes the generic over its own /// secret type. They are cheap — four fields and no state — and building them once per engine keeps the /// per-change path a dictionary lookup. /// /// internal static class ItemKinds { private static readonly (SyncEntityType Type, ReconcilerFactory Create)[] Registry = [ (SyncEntityType.Host, static (outbox, conflicts, keyring) => new ItemReconciler(HostKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.SshKey, static (outbox, conflicts, keyring) => new ItemReconciler(SshKeyKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.Credential, static (outbox, conflicts, keyring) => new ItemReconciler(CredentialKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) => new ItemReconciler(KnownHostKeyKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.HostGroup, static (outbox, conflicts, keyring) => new ItemReconciler(HostGroupKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.Tag, static (outbox, conflicts, keyring) => new ItemReconciler(TagKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.Snippet, static (outbox, conflicts, keyring) => new ItemReconciler(SnippetKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) => new ItemReconciler( ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) => new ItemReconciler( ActivityLogEntryKind.Instance, outbox, conflicts, keyring)), (SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) => new ItemReconciler(ObjectStoreKind.Instance, outbox, conflicts, keyring)), ]; /// The types to ask the server for, in a fixed order. internal static IReadOnlyList SyncedTypes { get; } = [.. Registry.Select(entry => entry.Type)]; private delegate IItemReconciler ReconcilerFactory( OutboxStore outbox, ConflictStore conflicts, VaultKeyring keyring); /// Builds one reconciler per synchronised type. internal static Dictionary Reconcilers( OutboxStore outbox, ConflictStore conflicts, VaultKeyring keyring) => Registry.ToDictionary( entry => entry.Type, entry => entry.Create(outbox, conflicts, keyring)); } /// What the per-kind field comparisons share. /// /// One line per field at each call site, which is the point: the alternative was reflection, and this /// project keeps reflection out of its serialisation on purpose. Comparison is /// on the values, which is why the collection-shaped fields on a host are types with structural equality — /// JumpChain and HostOptions — rather than plain lists. /// internal static class FieldChange { internal static void Note(List changed, string name, T before, T after) { if (!EqualityComparer.Default.Equals(before, after)) { changed.Add(name); } } } /// Hosts. internal sealed class HostKind : IItemKind { internal static HostKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.Host; /// public string Noun => "host"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = HostCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Host, document.IsReadOnly); } /// public EncryptedPayload Seal( HostSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => HostCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// public SyncPlaintextFields? Fields(HostSecret secret) => HostFields.From(secret); /// public MergedItem Merge(HostSecret ancestor, HostSecret local, HostSecret remote) { var merged = HostSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(HostSecret before, HostSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Hostname", before.Hostname, after.Hostname); Note(changed, "Port", before.Port, after.Port); Note(changed, "Username", before.Username, after.Username); Note(changed, "Notes", before.Notes, after.Notes); Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds); Note(changed, "Options", before.Options, after.Options); Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled); Note(changed, "Spelunk", before.Spelunk, after.Spelunk); Note(changed, "SSH key", before.SshKeyId, after.SshKeyId); Note(changed, "Credential", before.CredentialId, after.CredentialId); Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword); Note(changed, "Group", before.GroupId, after.GroupId); Note(changed, "Tags", before.TagIds, after.TagIds); Note(changed, "Port forwards", before.PortForwards, after.PortForwards); Note(changed, "Pinned paths", before.PinnedPaths, after.PinnedPaths); return changed; } /// public HostSecret Relabel(HostSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// SSH keys. internal sealed class SshKeyKind : IItemKind { internal static SshKeyKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.SshKey; /// public string Noun => "SSH key"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Key, document.IsReadOnly); } /// public EncryptedPayload Seal( SshKeySecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => SshKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing at all. /// /// /// The server has a public_key_fingerprint column and would accept one here, and this client /// deliberately declines to fill it. A fingerprint is not a secret, but it is a stable identifier for /// a key pair, and handing it over would let the operator tell which of their users hold the same key /// and correlate one key across vaults — for a column nothing in the product reads. The design allows /// itself exactly one plaintext concession, the relay address, and it is a concession because the /// relay cannot work without it. This is not that. See ADR 0004. /// /// public SyncPlaintextFields? Fields(SshKeySecret secret) => null; /// public MergedItem Merge(SshKeySecret ancestor, SshKeySecret local, SshKeySecret remote) { var merged = SshKeySecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// /// The private key is compared and never reported by value, which is the whole rule. "Private key" /// appearing in a log is the fact somebody needs; the key itself is what nobody does. /// /// public IReadOnlyList Changes(SshKeySecret before, SshKeySecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Private key", before.PrivateKeyPem, after.PrivateKeyPem); Note(changed, "Passphrase", before.Passphrase, after.Passphrase); Note(changed, "Public key", before.PublicKey, after.PublicKey); Note(changed, "Notes", before.Notes, after.Notes); return changed; } /// public SshKeySecret Relabel(SshKeySecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// Credentials. internal sealed class CredentialKind : IItemKind { internal static CredentialKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.Credential; /// public string Noun => "credential"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Credential, document.IsReadOnly); } /// public EncryptedPayload Seal( CredentialSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => CredentialCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and for this type there was never a candidate. /// /// /// A key at least has a fingerprint, which is public by nature and which this client still declines to /// send. A password has no part that is safe to expose — not its length, not a hash, not a hint — so /// there is no decision to make here. The server refuses plaintext fields on this type outright. /// /// public SyncPlaintextFields? Fields(CredentialSecret secret) => null; /// public MergedItem Merge( CredentialSecret ancestor, CredentialSecret local, CredentialSecret remote) { var merged = CredentialSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(CredentialSecret before, CredentialSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Password", before.Password, after.Password); Note(changed, "Username", before.Username, after.Username); Note(changed, "Notes", before.Notes, after.Notes); return changed; } /// public CredentialSecret Relabel(CredentialSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// Known host keys. internal sealed class KnownHostKeyKind : IItemKind { internal static KnownHostKeyKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.KnownHostKey; /// /// What to call one of these to a person. /// /// /// "Known host key" rather than "host key", because a user who reads "this host key could not be /// decrypted" would go looking at the host they are connecting to. The item is the record of a decision /// they made about that host, and the noun has to say so. /// /// public string Noun => "known host key"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.KnownHost, document.IsReadOnly); } /// public EncryptedPayload Seal( KnownHostSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => KnownHostKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and here the temptation is the strongest of the four. /// /// /// This item holds an address and a fingerprint, and neither is confidential in itself: the operator /// published the fingerprint and the address is one the server may already hold for a relay-enabled host. /// Handing them over anyway would tell the operator which machines each user actually connects to and /// when they first did — a map of the estate assembled out of individually harmless facts, for columns /// that do not exist and nothing that would read them. See ADR 0004. /// /// public SyncPlaintextFields? Fields(KnownHostSecret secret) => null; /// public MergedItem Merge( KnownHostSecret ancestor, KnownHostSecret local, KnownHostSecret remote) { var merged = KnownHostSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// /// The pin unchanged, because a pin has no name of its own to change. /// /// /// The only caller is the reconciler's resurrection path, which renames rescued content so a user can see /// what happened to it. is derived from the host, port and algorithm /// the pin is about, so there is nothing here that a rename could move: renaming it would mean claiming /// the pin is about a different host. The resurrected item still gets its own id and still produces a /// conflict notice, so the event is visible — the notice simply names the pin the same way twice. /// /// /// In practice only the fingerprint can change: the store rewrites one or creates a new item, and never /// re-addresses an existing pin. The other three are compared anyway, because a payload from elsewhere /// is untrusted input and a silently unreported change to what a pin is about is the one thing /// an audit trail must not miss. /// /// public IReadOnlyList Changes(KnownHostSecret before, KnownHostSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Host", before.Host, after.Host); Note(changed, "Port", before.Port, after.Port); Note(changed, "Algorithm", before.Algorithm, after.Algorithm); Note(changed, "Fingerprint", before.Fingerprint, after.Fingerprint); return changed; } /// public KnownHostSecret Relabel(KnownHostSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret; } } /// Host groups. internal sealed class HostGroupKind : IItemKind { internal static HostGroupKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.HostGroup; /// public string Noun => "group"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Group, document.IsReadOnly); } /// public EncryptedPayload Seal( HostGroupSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and the two fields it declines to send are the two this type could have filled. /// /// /// SyncPlaintextFields has a GroupId and a ParentId, the server had a column for the /// first, and no client ever wrote either. What they would hand over is the shape of the estate — which /// machines this user files together, and which of those groupings sit under which — for columns nothing /// in the product reads. Groups nest now and the parent is still not sent: it lives in the payload, and /// the server refuses the plaintext field outright, on hosts as well as here. See ADR 0004. /// /// public SyncPlaintextFields? Fields(HostGroupSecret secret) => null; /// public MergedItem Merge( HostGroupSecret ancestor, HostGroupSecret local, HostGroupSecret remote) { var merged = HostGroupSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(HostGroupSecret before, HostGroupSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Parent", before.ParentId, after.ParentId); Note(changed, "Default port", before.DefaultPort, after.DefaultPort); Note(changed, "Default username", before.DefaultUsername, after.DefaultUsername); Note(changed, "Default SSH key", before.DefaultSshKeyId, after.DefaultSshKeyId); Note(changed, "Default credential", before.DefaultCredentialId, after.DefaultCredentialId); return changed; } /// public HostGroupSecret Relabel(HostGroupSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// Tags. internal sealed class TagKind : IItemKind { internal static TagKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.Tag; /// public string Noun => "tag"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Tag, document.IsReadOnly); } /// public EncryptedPayload Seal( TagSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and null rather than an empty . /// /// /// /// The distinction is not pedantry. An empty record still serialises relayEnabled: false, which /// invites every reader of the wire — including a future implementation of this client — to conclude /// that a tag has a relay setting and that it is switched off. Null says the type has no plaintext at /// all, which is the true statement. /// /// /// A tag name in the clear would be the most aggregable column in the schema. Groups file machines; /// tags describe them — "pci", "customer-a", "eu-west" — and one tag spans twenty hosts by design, so a /// column here would hand the operator a labelled map of every user's estate for a sort no server in /// this product performs. See ADR 0004. /// /// /// public SyncPlaintextFields? Fields(TagSecret secret) => null; /// public MergedItem Merge(TagSecret ancestor, TagSecret local, TagSecret remote) { var merged = TagSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(TagSecret before, TagSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); return changed; } /// public TagSecret Relabel(TagSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// Snippets. internal sealed class SnippetKind : IItemKind { internal static SnippetKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.Snippet; /// public string Noun => "snippet"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Snippet, document.IsReadOnly); } /// public EncryptedPayload Seal( SnippetSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing. A command is a description of the estate as precise as a hostname is. /// /// public SyncPlaintextFields? Fields(SnippetSecret secret) => null; /// public MergedItem Merge( SnippetSecret ancestor, SnippetSecret local, SnippetSecret remote) { var merged = SnippetSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(SnippetSecret before, SnippetSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Command", before.Command, after.Command); Note(changed, "Notes", before.Notes, after.Notes); // Named for what it means rather than after the property, because this is the line somebody // reviewing a log actually needs to see: a snippet that has been turned into one that runs. Note(changed, "Runs on insert", before.RunsOnInsert, after.RunsOnInsert); return changed; } /// public SnippetSecret Relabel(SnippetSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } } /// Connection log entries. internal sealed class ConnectionLogEntryKind : IItemKind { internal static ConnectionLogEntryKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry; /// What to call one of these to a person. /// /// "Log entry" and not "connection". A user told that "this connection could not be decrypted" would go /// looking at a machine they cannot reach; the item is the record of one, and the noun has to /// say so. /// public string Noun => "log entry"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Entry, document.IsReadOnly); } /// public EncryptedPayload Seal( ConnectionLogSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and here the temptation is a timestamp rather than an address. /// /// /// A plaintext startedAt would let the server order and prune a log without a client's help, which /// is genuinely useful and is exactly the wrong trade: a timestamp column on this table is a record of /// when each user works, assembled for free. The client sorts and prunes its own log. See ADR 0004. /// /// public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null; /// public MergedItem Merge( ConnectionLogSecret ancestor, ConnectionLogSecret local, ConnectionLogSecret remote) { var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// Never asked, because a log entry is never audited or updated. /// public IReadOnlyList Changes(ConnectionLogSecret before, ConnectionLogSecret after) => []; /// /// False, and this is the guard that stops the log logging itself. /// /// /// The activity hook lives in the one generic repository every kind writes through, so without this a /// connection entry would produce an activity entry, which would produce another, without end. It is a /// property of the kind rather than a check at the call site so that the next machine-written kind /// cannot re-enter the loop by being overlooked. /// public bool IsAudited => false; /// /// The entry unchanged, because an entry has no name of its own to change. /// /// /// As for a pinned host key: the label is derived from what the entry records, so renaming it would mean /// claiming the connection was to a different machine. A resurrected entry still gets its own id and /// still produces a conflict notice, so the event stays visible. /// /// public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret; } } /// Activity log entries. internal sealed class ActivityLogEntryKind : IItemKind { internal static ActivityLogEntryKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry; /// public string Noun => "log entry"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Entry, document.IsReadOnly); } /// public EncryptedPayload Seal( ActivityLogSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing. SyncPlaintextFields.Kind would fit and is refused for it. /// /// public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null; /// public MergedItem Merge( ActivityLogSecret ancestor, ActivityLogSecret local, ActivityLogSecret remote) { var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// public IReadOnlyList Changes(ActivityLogSecret before, ActivityLogSecret after) => []; /// public bool IsAudited => false; /// public ActivityLogSecret Relabel(ActivityLogSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret; } } /// Buckets. internal sealed class ObjectStoreKind : IItemKind { internal static ObjectStoreKind Instance { get; } = new(); /// public SyncEntityType EntityType => SyncEntityType.ObjectStore; /// What to call one of these to a person. /// /// "Bucket" rather than "object store", because that is the word on the screen and in every service's own /// documentation. The type is named for the protocol; the noun is named for what people say. /// public string Noun => "bucket"; /// public OpenedItem? TryOpen( EncryptedPayload payload, ReadOnlySpan vaultKey, Guid entityId, int itemVersion) { var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion); return document is null ? null : new OpenedItem(document.Store, document.IsReadOnly); } /// public EncryptedPayload Seal( ObjectStoreSecret secret, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) => ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); /// /// Nothing, and for this type there was never a candidate. /// /// /// The endpoint is the field somebody might reach for, and it is the one that must not go: for everybody /// self-hosting it is an address on their own network, which is exactly what the host table only holds in /// the clear when the relay cannot work without it. Nothing on the server dials a bucket. /// /// public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null; /// public MergedItem Merge( ObjectStoreSecret ancestor, ObjectStoreSecret local, ObjectStoreSecret remote) { var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote); return new MergedItem(merged.Merged, merged.Conflicts); } /// /// The secret access key is compared and never reported by value, which is the rule every credential-like /// field in this file follows. The access key id is shown: it is an identifier, not a secret. /// /// public IReadOnlyList Changes(ObjectStoreSecret before, ObjectStoreSecret after) { ArgumentNullException.ThrowIfNull(before); ArgumentNullException.ThrowIfNull(after); var changed = new List(); Note(changed, "Name", before.Label, after.Label); Note(changed, "Bucket", before.Bucket, after.Bucket); Note(changed, "Access key id", before.AccessKeyId, after.AccessKeyId); Note(changed, "Secret access key", before.SecretAccessKey, after.SecretAccessKey); Note(changed, "Region", before.Region, after.Region); Note(changed, "Endpoint", before.Endpoint, after.Endpoint); Note(changed, "Path-style addressing", before.UsePathStyle, after.UsePathStyle); Note(changed, "Notes", before.Notes, after.Notes); return changed; } /// public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label) { ArgumentNullException.ThrowIfNull(secret); return secret with { Label = label }; } }