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