Files
DodoSSH/src/DodoSSH.Client.Sync/ItemKinds.cs
T
jaap-jan 689ba6c1bf Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
2026-09-28 21:38:09 +02:00

1061 lines
40 KiB
C#

using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.FieldChange;
namespace DodoSSH.Client.Sync;
/// <summary>A decrypted item, and whether this build may write it back.</summary>
/// <param name="Secret">The item.</param>
/// <param name="IsReadOnly">
/// Whether a newer client wrote it, in which case re-encoding it here would drop fields this build has
/// no concept of.
/// </param>
internal sealed record OpenedItem<TSecret>(TSecret Secret, bool IsReadOnly)
where TSecret : class, IVaultSecret;
/// <summary>A merged item, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The item to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
internal sealed record MergedItem<TSecret>(
TSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
where TSecret : class, IVaultSecret;
/// <summary>
/// Everything about one item type that the shared sync path cannot know.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Generic, unlike the server's <c>IItemKind</c>, and for a reason that reverses there: the client
/// <em>does</em> 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.
/// </para>
/// </remarks>
internal interface IItemKind<TSecret>
where TSecret : class, IVaultSecret
{
/// <summary>The type as the wire contract names it.</summary>
SyncEntityType EntityType { get; }
/// <summary>
/// What to call one of these when telling a person what happened to it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
string Noun { get; }
/// <inheritdoc cref="HostCipher.TryOpen" />
OpenedItem<TSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion);
/// <inheritdoc cref="HostCipher.Seal" />
EncryptedPayload Seal(
TSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion);
/// <summary>
/// The plaintext columns the server gets, or null when this type gives it nothing.
/// </summary>
/// <remarks>
/// Nullable rather than an all-defaults record, because the difference is visible on the wire and to
/// a reader: <c>SyncPlaintextFields</c> with nothing set still serialises <c>relayEnabled: false</c>,
/// which invites the belief that the type has a relay setting which happens to be off.
/// </remarks>
SyncPlaintextFields? Fields(TSecret secret);
/// <summary>Merges two divergent versions against the version they both started from.</summary>
MergedItem<TSecret> Merge(TSecret ancestor, TSecret local, TSecret remote);
/// <summary>
/// The names of the fields that differ between two versions of an item.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Never a value.</b> 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.
/// </para>
/// </remarks>
IReadOnlyList<string> Changes(TSecret before, TSecret after);
/// <summary>
/// Whether writing one of these is worth a line in the activity log.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
bool IsAudited => true;
/// <summary>The same item under a new name, for a resurrection.</summary>
TSecret Relabel(TSecret secret, string label);
}
/// <summary>
/// The item types this client synchronises.
/// </summary>
/// <remarks>
/// <para>
/// <b>One list, and the pull filter is derived from it.</b> The engine asks the server for exactly the
/// types in <see cref="Registry"/> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class ItemKinds
{
private static readonly (SyncEntityType Type, ReconcilerFactory Create)[] Registry =
[
(SyncEntityType.Host, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostSecret>(HostKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.SshKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<SshKeySecret>(SshKeyKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Credential, static (outbox, conflicts, keyring) =>
new ItemReconciler<CredentialSecret>(CredentialKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<KnownHostSecret>(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostGroupSecret>(HostGroupKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Tag, static (outbox, conflicts, keyring) =>
new ItemReconciler<TagSecret>(TagKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ConnectionLogSecret>(
ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) =>
new ItemReconciler<ActivityLogSecret>(
ActivityLogEntryKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
];
/// <summary>The types to ask the server for, in a fixed order.</summary>
internal static IReadOnlyList<SyncEntityType> SyncedTypes { get; } =
[.. Registry.Select(entry => entry.Type)];
private delegate IItemReconciler ReconcilerFactory(
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring);
/// <summary>Builds one reconciler per synchronised type.</summary>
internal static Dictionary<SyncEntityType, IItemReconciler> Reconcilers(
OutboxStore outbox,
ConflictStore conflicts,
VaultKeyring keyring) =>
Registry.ToDictionary(
entry => entry.Type,
entry => entry.Create(outbox, conflicts, keyring));
}
/// <summary>What the per-kind field comparisons share.</summary>
/// <remarks>
/// 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 <see cref="object.Equals(object)"/>
/// on the values, which is why the collection-shaped fields on a host are types with structural equality —
/// <c>JumpChain</c> and <c>HostOptions</c> — rather than plain lists.
/// </remarks>
internal static class FieldChange
{
internal static void Note<T>(List<string> changed, string name, T before, T after)
{
if (!EqualityComparer<T>.Default.Equals(before, after))
{
changed.Add(name);
}
}
}
/// <summary>Hosts.</summary>
internal sealed class HostKind : IItemKind<HostSecret>
{
internal static HostKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Host;
/// <inheritdoc />
public string Noun => "host";
/// <inheritdoc />
public OpenedItem<HostSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = HostCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<HostSecret>(document.Host, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
HostSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
HostCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <inheritdoc />
public SyncPlaintextFields? Fields(HostSecret secret) => HostFields.From(secret);
/// <inheritdoc />
public MergedItem<HostSecret> Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
{
var merged = HostSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<HostSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostSecret before, HostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public HostSecret Relabel(HostSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>SSH keys.</summary>
internal sealed class SshKeyKind : IItemKind<SshKeySecret>
{
internal static SshKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.SshKey;
/// <inheritdoc />
public string Noun => "SSH key";
/// <inheritdoc />
public OpenedItem<SshKeySecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<SshKeySecret>(document.Key, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SshKeySecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SshKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing at all.
/// </summary>
/// <remarks>
/// The server has a <c>public_key_fingerprint</c> 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SshKeySecret secret) => null;
/// <inheritdoc />
public MergedItem<SshKeySecret> Merge(SshKeySecret ancestor, SshKeySecret local, SshKeySecret remote)
{
var merged = SshKeySecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SshKeySecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(SshKeySecret before, SshKeySecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public SshKeySecret Relabel(SshKeySecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Credentials.</summary>
internal sealed class CredentialKind : IItemKind<CredentialSecret>
{
internal static CredentialKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Credential;
/// <inheritdoc />
public string Noun => "credential";
/// <inheritdoc />
public OpenedItem<CredentialSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<CredentialSecret>(document.Credential, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
CredentialSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
CredentialCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and for this type there was never a candidate.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(CredentialSecret secret) => null;
/// <inheritdoc />
public MergedItem<CredentialSecret> Merge(
CredentialSecret ancestor,
CredentialSecret local,
CredentialSecret remote)
{
var merged = CredentialSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<CredentialSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(CredentialSecret before, CredentialSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public CredentialSecret Relabel(CredentialSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Known host keys.</summary>
internal sealed class KnownHostKeyKind : IItemKind<KnownHostSecret>
{
internal static KnownHostKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.KnownHostKey;
/// <summary>
/// What to call one of these to a person.
/// </summary>
/// <remarks>
/// "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.
/// </remarks>
/// <inheritdoc />
public string Noun => "known host key";
/// <inheritdoc />
public OpenedItem<KnownHostSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<KnownHostSecret>(document.KnownHost, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
KnownHostSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
KnownHostKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is the strongest of the four.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(KnownHostSecret secret) => null;
/// <inheritdoc />
public MergedItem<KnownHostSecret> Merge(
KnownHostSecret ancestor,
KnownHostSecret local,
KnownHostSecret remote)
{
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<KnownHostSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>
/// The pin unchanged, because a pin has no name of its own to change.
/// </summary>
/// <remarks>
/// The only caller is the reconciler's resurrection path, which renames rescued content so a user can see
/// what happened to it. <see cref="KnownHostSecret.Label"/> 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.
/// </remarks>
/// <remarks>
/// 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 <em>about</em> is the one thing
/// an audit trail must not miss.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(KnownHostSecret before, KnownHostSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public KnownHostSecret Relabel(KnownHostSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Host groups.</summary>
internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
{
internal static HostGroupKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.HostGroup;
/// <inheritdoc />
public string Noun => "group";
/// <inheritdoc />
public OpenedItem<HostGroupSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<HostGroupSecret>(document.Group, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
HostGroupSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and the two fields it declines to send are the two this type could have filled.
/// </summary>
/// <remarks>
/// <c>SyncPlaintextFields</c> has a <c>GroupId</c> and a <c>ParentId</c>, 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
/// <inheritdoc />
public MergedItem<HostGroupSecret> Merge(
HostGroupSecret ancestor,
HostGroupSecret local,
HostGroupSecret remote)
{
var merged = HostGroupSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<HostGroupSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(HostGroupSecret before, HostGroupSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public HostGroupSecret Relabel(HostGroupSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Tags.</summary>
internal sealed class TagKind : IItemKind<TagSecret>
{
internal static TagKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Tag;
/// <inheritdoc />
public string Noun => "tag";
/// <inheritdoc />
public OpenedItem<TagSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null ? null : new OpenedItem<TagSecret>(document.Tag, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
TagSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and null rather than an empty <see cref="SyncPlaintextFields"/>.
/// </summary>
/// <remarks>
/// <para>
/// The distinction is not pedantry. An empty record still serialises <c>relayEnabled: false</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(TagSecret secret) => null;
/// <inheritdoc />
public MergedItem<TagSecret> Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
{
var merged = TagSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<TagSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(TagSecret before, TagSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
return changed;
}
/// <inheritdoc />
public TagSecret Relabel(TagSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Snippets.</summary>
internal sealed class SnippetKind : IItemKind<SnippetSecret>
{
internal static SnippetKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.Snippet;
/// <inheritdoc />
public string Noun => "snippet";
/// <inheritdoc />
public OpenedItem<SnippetSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<SnippetSecret>(document.Snippet, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SnippetSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. A command is a description of the estate as precise as a hostname is.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SnippetSecret secret) => null;
/// <inheritdoc />
public MergedItem<SnippetSecret> Merge(
SnippetSecret ancestor,
SnippetSecret local,
SnippetSecret remote)
{
var merged = SnippetSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SnippetSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc />
public IReadOnlyList<string> Changes(SnippetSecret before, SnippetSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public SnippetSecret Relabel(SnippetSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
/// <summary>Connection log entries.</summary>
internal sealed class ConnectionLogEntryKind : IItemKind<ConnectionLogSecret>
{
internal static ConnectionLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "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 <em>record</em> of one, and the noun has to
/// say so.
/// </remarks>
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ConnectionLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ConnectionLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ConnectionLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and here the temptation is a timestamp rather than an address.
/// </summary>
/// <remarks>
/// A plaintext <c>startedAt</c> 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ConnectionLogSecret> Merge(
ConnectionLogSecret ancestor,
ConnectionLogSecret local,
ConnectionLogSecret remote)
{
var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ConnectionLogSecret>(merged.Merged, merged.Conflicts);
}
/// <summary>Never asked, because a log entry is never audited or updated.</summary>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ConnectionLogSecret before, ConnectionLogSecret after) => [];
/// <summary>
/// False, and this is the guard that stops the log logging itself.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool IsAudited => false;
/// <summary>
/// The entry unchanged, because an entry has no name of its own to change.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Activity log entries.</summary>
internal sealed class ActivityLogEntryKind : IItemKind<ActivityLogSecret>
{
internal static ActivityLogEntryKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry;
/// <inheritdoc />
public string Noun => "log entry";
/// <inheritdoc />
public OpenedItem<ActivityLogSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ActivityLogSecret>(document.Entry, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ActivityLogSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. <c>SyncPlaintextFields.Kind</c> would fit and is refused for it.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null;
/// <inheritdoc />
public MergedItem<ActivityLogSecret> Merge(
ActivityLogSecret ancestor,
ActivityLogSecret local,
ActivityLogSecret remote)
{
var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ActivityLogSecret>(merged.Merged, merged.Conflicts);
}
/// <inheritdoc cref="ConnectionLogEntryKind.Changes" />
public IReadOnlyList<string> Changes(ActivityLogSecret before, ActivityLogSecret after) => [];
/// <inheritdoc cref="ConnectionLogEntryKind.IsAudited" />
public bool IsAudited => false;
/// <inheritdoc cref="ConnectionLogEntryKind.Relabel" />
public ActivityLogSecret Relabel(ActivityLogSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret;
}
}
/// <summary>Buckets.</summary>
internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
{
internal static ObjectStoreKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.ObjectStore;
/// <summary>What to call one of these to a person.</summary>
/// <remarks>
/// "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.
/// </remarks>
public string Noun => "bucket";
/// <inheritdoc />
public OpenedItem<ObjectStoreSecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<ObjectStoreSecret>(document.Store, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
ObjectStoreSecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing, and for this type there was never a candidate.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null;
/// <inheritdoc />
public MergedItem<ObjectStoreSecret> Merge(
ObjectStoreSecret ancestor,
ObjectStoreSecret local,
ObjectStoreSecret remote)
{
var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote);
return new MergedItem<ObjectStoreSecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// 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.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(ObjectStoreSecret before, ObjectStoreSecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
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;
}
/// <inheritdoc />
public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}