Public Access
Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,24 +3,36 @@ using System.Diagnostics.CodeAnalysis;
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A folder hosts can be filed under, decrypted.
|
||||
/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
|
||||
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
|
||||
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
|
||||
/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
|
||||
/// <see cref="ParentId"/> so headings nest, and four <c>Default</c> fields a host under this group falls
|
||||
/// back to when it leaves the matching field unset.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
|
||||
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
|
||||
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
|
||||
/// side winning outright and the other host silently leaving the group it was just put in.
|
||||
/// writes to one item, and while <see cref="ThreeWayMerge.Map"/> could now resolve that key by key, the
|
||||
/// pointer on the host is still the better place: it is one write to the item the user just edited, and it
|
||||
/// cannot disagree with itself about which group a host is in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
|
||||
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
|
||||
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
|
||||
/// <b>Groups nest, and the cycle is contained rather than prevented.</b> This is a reversal, and the
|
||||
/// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a
|
||||
/// scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot even see,
|
||||
/// because it is inside the payload. What changed is that a merge is not the only place a cycle has to
|
||||
/// survive. Inheritance means the chain is walked at connect time, so the answer had to be a walk that
|
||||
/// terminates regardless — the resolver carries a visited set and stops at a repeat. A cycle therefore
|
||||
/// degrades to a group that reads as a root: flat headings, defaults unresolved past that point, and
|
||||
/// clearing the parent in the editor is the repair. Given a walk that already had to be cycle-safe,
|
||||
/// refusing to nest bought nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The editor additionally refuses a parent that is already a descendant, which stops a cycle being made
|
||||
/// on this machine. That is a convenience, not the guarantee — the guarantee is the visited set, because a
|
||||
/// cycle assembled from two offline edits was never offered to an editor at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HostGroupSecret : IVaultSecret
|
||||
@@ -28,11 +40,85 @@ public sealed record HostGroupSecret : IVaultSecret
|
||||
/// <summary>What the group is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The group this one sits under, or null for a root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Points upwards for the same reason <see cref="HostSecret.GroupId"/> does: re-parenting two groups
|
||||
/// under one parent on two machines is then two writes to two items rather than two writes to one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The reference may dangle</b>, and a dangling one is a root — the group whose parent was deleted on
|
||||
/// another machine appears at the top level rather than disappearing. Same handling as a host whose group
|
||||
/// is gone, and for the same reason: preventing it would mean one delete rewriting every item that named
|
||||
/// the deleted thing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Inside the payload. <c>SyncPlaintextFields</c> has a <c>ParentId</c> and the server refuses to accept
|
||||
/// one, deliberately: what a plaintext parent hands over is the shape of the user's estate, which is the
|
||||
/// same disclosure a plaintext <c>GroupId</c> would have been. See ADR 0004.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? ParentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The TCP port hosts in this group use when they do not pin one, or null for no default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inherited, not copied. A host created here is not stamped with this value; it is left unset and reads
|
||||
/// through at connect time, so changing this changes every host that never overrode it. Copying at
|
||||
/// creation was the alternative and it makes a group a one-shot template — every host ever created under
|
||||
/// it stays pinned to whatever the default happened to be that day.
|
||||
/// </remarks>
|
||||
public int? DefaultPort { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The login user hosts in this group use when they do not pin one, or null for no default.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="DefaultPort" path="/remarks" />
|
||||
public string? DefaultUsername { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no
|
||||
/// default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Same dangling-reference story as <see cref="HostSecret.SshKeyId"/>, and the same refusal to fall back
|
||||
/// to a password when it cannot be resolved. One extra consequence: a key named only here is named by no
|
||||
/// host at all, so anything counting the hosts bound to a key has to walk groups or it will report a key
|
||||
/// as unused and then refuse every host under this group at connect time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Mutually exclusive with <see cref="DefaultCredentialId"/></b> on this record, exactly as the two
|
||||
/// are on a host. That is necessary and not sufficient: a host naming a credential under a group naming a
|
||||
/// key is two individually valid records, so the resolver enforces the same exclusion again across the
|
||||
/// chain, host over group, and never returns both.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? DefaultSshKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault credential hosts in this group authenticate with when they bind nothing, or null for no
|
||||
/// default.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="DefaultSshKeyId" path="/remarks" />
|
||||
public Guid? DefaultCredentialId { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
|
||||
/// <para>
|
||||
/// A blank name is refused rather than defaulted. A group is a heading, so a nameless one is
|
||||
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
|
||||
/// cannot tell apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A group's own id is not knowable here</b>, so "a group is not its own parent" cannot be checked at
|
||||
/// this layer: the id belongs to the item, and this record is only the payload inside it. A self-parent
|
||||
/// is therefore caught twice further out — refused by the editor, and survived by the resolver's visited
|
||||
/// set, which is the same machinery that has to contain a longer cycle anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -42,6 +128,40 @@ public sealed record HostGroupSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ParentId == Guid.Empty)
|
||||
{
|
||||
// An empty id is not "no parent" — that is null. It is a reference that can never resolve, and a
|
||||
// group holding one would read as a root while claiming to be nested.
|
||||
reason = "A parent reference cannot be an empty id; use no parent instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Null is "no default port" and passes this comparison, which is the wanted behaviour and is
|
||||
// silent about it: `is < 1 or > 65535` is false for a null int?.
|
||||
if (DefaultPort is < 1 or > 65535)
|
||||
{
|
||||
reason = $"A default port must be between 1 and 65535, not {DefaultPort}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultSshKeyId == Guid.Empty)
|
||||
{
|
||||
reason = "An SSH key reference cannot be an empty id; use no default key instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultCredentialId == Guid.Empty)
|
||||
{
|
||||
reason = "A credential reference cannot be an empty id; use no default credential instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultSshKeyId is not null && DefaultCredentialId is not null)
|
||||
{
|
||||
reason = "A group defaults to a key or to a credential, not both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -17,15 +17,29 @@ public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVe
|
||||
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
|
||||
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
|
||||
/// version, which is what lets a later build add a field without every older client silently dropping it on
|
||||
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>.
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. What the
|
||||
/// JSON envelope buys is a schema version, which is what lets a later build add a field without every older
|
||||
/// client silently dropping it on the next edit — and this is the build that spent it, five fields at once.
|
||||
/// See <see cref="HostSecretDocument.IsReadOnly"/>.
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
/// <summary>The first version, and the one a group with no newer field is still written at.</summary>
|
||||
public const int BaseSchemaVersion = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The version that introduced <see cref="HostGroupSecret.ParentId"/> and the four defaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One constant for five fields, because they arrive in the same build: no client exists that can read
|
||||
/// some of them and not the others, so a version per field would draw a distinction nothing can observe.
|
||||
/// <see cref="SchemaVersionFor"/> is still written as a maximum, which is what has to stay true when the
|
||||
/// sixth field arrives on its own.
|
||||
/// </remarks>
|
||||
public const int ParentAndDefaultsSchemaVersion = 2;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = ParentAndDefaultsSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a group to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The group is not valid for storage.</exception>
|
||||
@@ -40,14 +54,55 @@ public static class HostGroupSecretCodec
|
||||
|
||||
var document = new HostGroupPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SchemaVersion = SchemaVersionFor(group),
|
||||
Label = group.Label,
|
||||
ParentId = group.ParentId,
|
||||
DefaultPort = group.DefaultPort,
|
||||
DefaultUsername = group.DefaultUsername,
|
||||
DefaultSshKeyId = group.DefaultSshKeyId,
|
||||
DefaultCredentialId = group.DefaultCredentialId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lowest schema version that can represent this group without losing anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not simply <see cref="CurrentSchemaVersion"/>, which is what this codec used to stamp
|
||||
/// unconditionally — harmless while there was one field and one version, and not harmless from here on.
|
||||
/// The version is what makes an older client treat an
|
||||
/// item as read-only, so stamping the newest one regardless would mean that upgrading a single machine
|
||||
/// and then renaming <em>any</em> group made that group uneditable on every machine still on the old
|
||||
/// build. Emitting the lowest version that loses nothing confines that cost to the groups which actually
|
||||
/// nest or actually carry a default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It also means a group carrying none of the new fields encodes byte for byte as it did before they
|
||||
/// existed, so adding them did not make every group in every vault look like a change to the sync
|
||||
/// engine. <see cref="HostSecretCodec"/> has the same rule and it is the same argument; see
|
||||
/// <c>SchemaVersionFor</c> there for why it is a maximum over the fields present rather than a ladder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int SchemaVersionFor(HostGroupSecret group)
|
||||
{
|
||||
var version = BaseSchemaVersion;
|
||||
|
||||
if (group.ParentId is not null
|
||||
|| group.DefaultPort is not null
|
||||
|| group.DefaultUsername is not null
|
||||
|| group.DefaultSshKeyId is not null
|
||||
|| group.DefaultCredentialId is not null)
|
||||
{
|
||||
version = Math.Max(version, ParentAndDefaultsSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
@@ -72,7 +127,15 @@ public static class HostGroupSecretCodec
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
|
||||
var candidate = new HostGroupSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
ParentId = parsed.ParentId,
|
||||
DefaultPort = parsed.DefaultPort,
|
||||
DefaultUsername = parsed.DefaultUsername,
|
||||
DefaultSshKeyId = parsed.DefaultSshKeyId,
|
||||
DefaultCredentialId = parsed.DefaultCredentialId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
@@ -91,6 +154,26 @@ internal sealed class HostGroupPayloadDocument
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// After <see cref="Label"/>, deliberately. Property order is the serialisation order, so appending
|
||||
/// keeps the bytes for the one field that existed before these identical — and a null is omitted
|
||||
/// entirely, which is what makes a flat group with no defaults encode exactly as it did before any of
|
||||
/// this existed.
|
||||
/// </remarks>
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="ParentId" />
|
||||
public int? DefaultPort { get; set; }
|
||||
|
||||
/// <inheritdoc cref="ParentId" />
|
||||
public string? DefaultUsername { get; set; }
|
||||
|
||||
/// <inheritdoc cref="ParentId" />
|
||||
public Guid? DefaultSshKeyId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="ParentId" />
|
||||
public Guid? DefaultCredentialId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
|
||||
@@ -16,14 +18,22 @@ public sealed record HostGroupMergeResult(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
|
||||
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people
|
||||
/// organising the same vault at the same time never collide here — the only way to reach this code is for two
|
||||
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
|
||||
/// Six scalars, no collections, which keeps this the simplest item merge in the client — and the
|
||||
/// interesting thing about it is still what it does <em>not</em> have to consider. Filing a host into a
|
||||
/// group does not write to the group, so two people organising the same vault at the same time never
|
||||
/// collide here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
|
||||
/// differed" would leave the user unable to tell which of their two names survived.
|
||||
/// <b>The parent merges as a scalar, and that is what admits a cycle.</b> Two clients re-parenting A under
|
||||
/// B and B under A while offline each produce a locally sensible group, and there is nothing here that can
|
||||
/// see the pair. Resolving it would mean a merge that reads the whole group list, which the merge does not
|
||||
/// have and should not grow — this layer resolves one item against one item. The cycle is contained where
|
||||
/// it is walked instead: see <see cref="HostGroupSecret.ParentId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. The name is the one thing a group has, and a notice saying only that "the name
|
||||
/// differed" would leave the user unable to tell which of their two names survived. The ids are shown for
|
||||
/// the same reason they are on a host — an id names a vault item, it is not the secret inside it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretMerge
|
||||
@@ -43,21 +53,123 @@ public static class HostGroupSecretMerge
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merge = ThreeWayMerge.Scalar(
|
||||
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
var merged = new HostGroupSecret
|
||||
{
|
||||
Label = Field(
|
||||
nameof(HostGroupSecret.Label),
|
||||
ancestor.Label,
|
||||
local.Label,
|
||||
remote.Label,
|
||||
conflicts,
|
||||
static label => label,
|
||||
StringComparer.Ordinal),
|
||||
|
||||
ParentId = Field(
|
||||
nameof(HostGroupSecret.ParentId),
|
||||
ancestor.ParentId,
|
||||
local.ParentId,
|
||||
remote.ParentId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "no parent"),
|
||||
};
|
||||
|
||||
return new HostGroupMergeResult(
|
||||
WithDefaults(merged, ancestor, local, remote, conflicts), conflicts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the four values hosts under this group fall back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out for length, and they do belong together: each is a value a host reads through to when it
|
||||
/// leaves its own field unset, and each merges as a plain scalar. The two ids can additionally end up
|
||||
/// dangling, exactly as a host's can — handled where the reference is used, not here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The exclusion between the key and the credential is not re-checked here</b>, and it does not need
|
||||
/// to be: neither side can hold both, and a scalar merge takes one value per field from one side or the
|
||||
/// other, so it cannot manufacture a pair that was not offered. What it can manufacture is a group
|
||||
/// defaulting to a key beneath which a host names a credential, and no item merge can see that — it is
|
||||
/// the resolver's to enforce.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static HostGroupSecret WithDefaults(
|
||||
HostGroupSecret merged,
|
||||
HostGroupSecret ancestor,
|
||||
HostGroupSecret local,
|
||||
HostGroupSecret remote,
|
||||
List<HostFieldConflict> conflicts) =>
|
||||
merged with
|
||||
{
|
||||
DefaultPort = Field(
|
||||
nameof(HostGroupSecret.DefaultPort),
|
||||
ancestor.DefaultPort,
|
||||
local.DefaultPort,
|
||||
remote.DefaultPort,
|
||||
conflicts,
|
||||
static port => port?.ToString(CultureInfo.InvariantCulture) ?? "no default port"),
|
||||
|
||||
DefaultUsername = Field(
|
||||
nameof(HostGroupSecret.DefaultUsername),
|
||||
ancestor.DefaultUsername,
|
||||
local.DefaultUsername,
|
||||
remote.DefaultUsername,
|
||||
conflicts,
|
||||
static username => username ?? "no default user",
|
||||
StringComparer.Ordinal),
|
||||
|
||||
DefaultSshKeyId = Field(
|
||||
nameof(HostGroupSecret.DefaultSshKeyId),
|
||||
ancestor.DefaultSshKeyId,
|
||||
local.DefaultSshKeyId,
|
||||
remote.DefaultSshKeyId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "no default key"),
|
||||
|
||||
DefaultCredentialId = Field(
|
||||
nameof(HostGroupSecret.DefaultCredentialId),
|
||||
ancestor.DefaultCredentialId,
|
||||
local.DefaultCredentialId,
|
||||
remote.DefaultCredentialId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "no default credential"),
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The local side always loses a scalar clash — see <see cref="ThreeWayMerge"/> — so the discarded side
|
||||
/// is fixed here rather than derived from the outcome.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The formatter is handed the discarded value even when that value is null, and the null-forgiving
|
||||
/// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a
|
||||
/// nullable field whose discarded value was "unset" rather than a missing one. Every field on this
|
||||
/// record but the name has a name for its absence, and short-circuiting on null would mean none of them
|
||||
/// could report it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static T Field<T>(
|
||||
string name,
|
||||
T ancestor,
|
||||
T local,
|
||||
T remote,
|
||||
List<HostFieldConflict> conflicts,
|
||||
Func<T, string?> format,
|
||||
IEqualityComparer<T>? comparer = null)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
|
||||
// fixed here rather than derived from the outcome.
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(HostGroupSecret.Label),
|
||||
name,
|
||||
MergeSide.Local,
|
||||
merge.Value,
|
||||
merge.Discarded,
|
||||
format(merge.Value),
|
||||
format(merge.Discarded!),
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
|
||||
return merge.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>Which of the three ways a resolved host authenticates.</summary>
|
||||
public enum ResolvedBindingKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A password typed at connect time. The terminal answer, reached either because nothing in the chain
|
||||
/// bound anything or because the host said so outright.
|
||||
/// </summary>
|
||||
TypedPassword = 0,
|
||||
|
||||
/// <summary>A vault SSH key.</summary>
|
||||
SshKey = 1,
|
||||
|
||||
/// <summary>A vault credential.</summary>
|
||||
Credential = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One resolved value, and the group it came from.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The value's type.</typeparam>
|
||||
/// <param name="Value">What the connect path should use.</param>
|
||||
/// <param name="FromGroupId">
|
||||
/// The group that supplied it, or null when the host supplied it itself — which is also what the editor
|
||||
/// needs, because a value the host owns is text in a box and a value it inherits is a placeholder behind an
|
||||
/// empty one.
|
||||
/// </param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct Inherited<T>(T Value, Guid? FromGroupId)
|
||||
{
|
||||
/// <summary>Whether this came from a group rather than from the host.</summary>
|
||||
public bool IsInherited => FromGroupId is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a host authenticates once its group chain has been consulted.
|
||||
/// </summary>
|
||||
/// <param name="Kind">Which of the three ways.</param>
|
||||
/// <param name="EntityId">
|
||||
/// The key or credential item id, or null for a typed password.
|
||||
/// </param>
|
||||
/// <param name="FromGroupId">The group that supplied the binding, or null when the host did.</param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct ResolvedBinding(
|
||||
ResolvedBindingKind Kind,
|
||||
Guid? EntityId,
|
||||
Guid? FromGroupId)
|
||||
{
|
||||
/// <summary>A password typed at connect time, decided by the host itself.</summary>
|
||||
public static ResolvedBinding TypedByTheHost { get; } =
|
||||
new(ResolvedBindingKind.TypedPassword, null, null);
|
||||
|
||||
/// <summary>Whether this came from a group rather than from the host.</summary>
|
||||
public bool IsInherited => FromGroupId is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything about a host that only makes sense once its groups have been read.
|
||||
/// </summary>
|
||||
/// <param name="Port">The port to dial.</param>
|
||||
/// <param name="Username">The user to log in as, or null when nothing supplied one.</param>
|
||||
/// <param name="Binding">How to authenticate.</param>
|
||||
public sealed record ResolvedHost(
|
||||
Inherited<int> Port,
|
||||
Inherited<string?> Username,
|
||||
ResolvedBinding Binding);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a host against the groups above it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A host may leave its port, its username and its binding unset, in which case each is taken from the
|
||||
/// nearest group above it that states one — its own group, then that group's parent, and so on. Nothing is
|
||||
/// copied at creation, so changing a group changes every host beneath it that never overrode the field. See
|
||||
/// <see cref="HostGroupSecret.DefaultPort"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every walk carries a visited set, and that is load-bearing rather than defensive.</b> Two clients can
|
||||
/// each re-parent A under B and B under A while offline; the merge resolves one item against one item and
|
||||
/// cannot see the pair, and the server cannot see it either because the pointer is inside the payload. With
|
||||
/// inheritance the chain is walked on the connect path, so an unguarded cycle is not an undrawable sidebar
|
||||
/// — it is a shell that never opens. Stopping at the first repeat degrades a cycle to a group that reads as
|
||||
/// a root: flat headings, defaults unresolved past that point, and clearing the parent in the editor is the
|
||||
/// repair.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key-or-credential exclusion is enforced here as well as on each record.</b> A host naming a
|
||||
/// credential under a group naming a key is two individually valid items, so per-record validation cannot
|
||||
/// catch it. The nearest statement wins outright — host over group, nearer group over further — and this
|
||||
/// never returns both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Groups are handed in as a lookup rather than fetched, because this runs on the connect path and on every
|
||||
/// row of a list. The caller already holds the decrypted group list; a resolver that went back to a
|
||||
/// repository would decrypt the vault once per host drawn.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostInheritance
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves one host.
|
||||
/// </summary>
|
||||
/// <param name="host">The host, as stored.</param>
|
||||
/// <param name="groups">
|
||||
/// Every group in the vaults being read, by item id. A missing id is a group deleted on another machine,
|
||||
/// and the walk stops there — the host keeps whatever it had already resolved and falls back for the
|
||||
/// rest, rather than failing.
|
||||
/// </param>
|
||||
public static ResolvedHost Resolve(HostSecret host, IReadOnlyDictionary<Guid, HostGroupSecret> groups)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
ArgumentNullException.ThrowIfNull(groups);
|
||||
|
||||
Inherited<int>? port = host.Port is { } pinned ? new Inherited<int>(pinned, null) : null;
|
||||
|
||||
// Note which null is which. `host.Username is null` means the host stated nothing and the walk
|
||||
// continues; an empty string is a statement — "no username" — and stops it, so a host under a group
|
||||
// can opt out of the group's user. See HostSecret.Username.
|
||||
Inherited<string?>? username = host.Username is not null
|
||||
? new Inherited<string?>(host.Username, null)
|
||||
: null;
|
||||
|
||||
var binding = BindingOf(host);
|
||||
|
||||
foreach (var (groupId, group) in Chain(host.GroupId, groups))
|
||||
{
|
||||
port ??= group.DefaultPort is { } inheritedPort
|
||||
? new Inherited<int>(inheritedPort, groupId)
|
||||
: null;
|
||||
|
||||
username ??= group.DefaultUsername is not null
|
||||
? new Inherited<string?>(group.DefaultUsername, groupId)
|
||||
: null;
|
||||
|
||||
binding ??= BindingOf(group, groupId);
|
||||
|
||||
if (port is not null && username is not null && binding is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ResolvedHost(
|
||||
port ?? new Inherited<int>(HostSecret.DefaultPort, null),
|
||||
username ?? new Inherited<string?>(null, null),
|
||||
binding ?? ResolvedBinding.TypedByTheHost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The groups above a host, nearest first, stopping at a repeat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Public because the editor walks the same chain to draw its placeholders and the sidebar walks it to
|
||||
/// draw headings, and three separate walks would be three places to forget the visited set. Yields the
|
||||
/// id beside the group, because a resolved value has to be able to say which group it came from.
|
||||
/// </remarks>
|
||||
/// <param name="groupId">Where to start: a host's group, or a group's parent.</param>
|
||||
/// <param name="groups">Every group in the vaults being read, by item id.</param>
|
||||
public static IEnumerable<(Guid Id, HostGroupSecret Group)> Chain(
|
||||
Guid? groupId,
|
||||
IReadOnlyDictionary<Guid, HostGroupSecret> groups)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(groups);
|
||||
|
||||
// Allocated per walk rather than shared, because these run concurrently on the connect path and on
|
||||
// whatever thread a list is drawn from.
|
||||
var visited = new HashSet<Guid>();
|
||||
var current = groupId;
|
||||
|
||||
while (current is { } id && visited.Add(id))
|
||||
{
|
||||
if (!groups.TryGetValue(id, out var group))
|
||||
{
|
||||
// A dangling id: the group was deleted on another machine. The host falls under the
|
||||
// ungrouped heading and inherits nothing further, which is the same answer the interface
|
||||
// gives for a group that is simply not there.
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return (id, group);
|
||||
|
||||
current = group.ParentId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Null means "this record did not answer", which is what lets the caller keep walking. It is not the
|
||||
/// same as <see cref="ResolvedBindingKind.TypedPassword"/>, which is an answer — and conflating the two
|
||||
/// is how a host deliberately put back on a typed password would silently pick up its group's key.
|
||||
/// </remarks>
|
||||
private static ResolvedBinding? BindingOf(HostSecret host) => host switch
|
||||
{
|
||||
{ SshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, null),
|
||||
{ CredentialId: { } credential } =>
|
||||
new ResolvedBinding(ResolvedBindingKind.Credential, credential, null),
|
||||
{ AsksForPassword: true } => ResolvedBinding.TypedByTheHost,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// A group has no equivalent of <see cref="HostSecret.AsksForPassword"/>, so it either lends a binding
|
||||
/// or says nothing. "Everything under here types its password" is not a thing a group can assert: it
|
||||
/// would be indistinguishable from a group that simply has no default, and the two would resolve the
|
||||
/// same way anyway once the chain ran out.
|
||||
/// </remarks>
|
||||
private static ResolvedBinding? BindingOf(HostGroupSecret group, Guid groupId) => group switch
|
||||
{
|
||||
{ DefaultSshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, groupId),
|
||||
{ DefaultCredentialId: { } credential } =>
|
||||
new ResolvedBinding(ResolvedBindingKind.Credential, credential, groupId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -35,10 +35,37 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// <summary>Hostname or address to connect to.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>TCP port.</summary>
|
||||
public int Port { get; init; } = DefaultPort;
|
||||
/// <summary>
|
||||
/// TCP port, or null to take the group's.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Nullable, and it had to become so: an <see cref="int"/> defaulting to 22 has no way to say "I have no
|
||||
/// port of my own". <see cref="DefaultPort"/> stays, as the last fallback after the group chain runs out
|
||||
/// — so a host that inherits nothing still dials 22, exactly as it always did.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is the field with the widest blast radius in the vault, and the sharp edge is on the way
|
||||
/// out, not in.</b> A host with a null port is written at a schema version older clients do not know,
|
||||
/// and they will not merely find it read-only — they cannot decode it at all, because the property is
|
||||
/// omitted, their <c>int Port</c> reads 0 and <see cref="TryValidate"/> refuses it. That cost is
|
||||
/// confined to hosts which actually inherit, because the codec stamps the lowest version that loses
|
||||
/// nothing; the alternative, writing 22 into every host, would make inheritance a lie the moment a
|
||||
/// group's default changed. See <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? Port { get; init; }
|
||||
|
||||
/// <summary>Login user, when the host pins one.</summary>
|
||||
/// <summary>
|
||||
/// Login user: pinned when set, no username when empty, the group's when null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three states in one nullable string, and the empty one is not an oversight. Null used to mean "no
|
||||
/// username", which the connect path refuses; it now means "whatever the group says", so the refusal
|
||||
/// still has to be reachable or a host under a group could not opt out of the group's user. An empty
|
||||
/// string is that opt-out: stored, resolved to nothing, and refused at connect with the same message it
|
||||
/// always gave.
|
||||
/// </remarks>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
@@ -58,10 +85,17 @@ public sealed record HostSecret : IVaultSecret
|
||||
public HostOptions Options { get; init; } = HostOptions.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The vault SSH key to authenticate with, or null to use a password.
|
||||
/// The vault SSH key to authenticate with, or null to take the group's binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Null used to mean "use a typed password" and now means "ask the group".</b> Nothing already stored
|
||||
/// changed meaning when it did: no group could lend a binding until this build, so every host in every
|
||||
/// vault that named neither a key nor a credential resolved then, and resolves now, to a password typed
|
||||
/// at connect time. What is new is that a host under a group which <em>does</em> lend one can no longer
|
||||
/// say "not that, ask me" by naming nothing — which is what <see cref="AsksForPassword"/> is for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An item id rather than the key itself, because the key is a vault item in its own right and a copy
|
||||
/// embedded here would be a second copy of a private key to keep in step — rotated in one place and
|
||||
/// stale in the other. The cost is that the reference can dangle: the key may be deleted on another
|
||||
@@ -83,14 +117,15 @@ public sealed record HostSecret : IVaultSecret
|
||||
public Guid? SshKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault credential to authenticate with, or null to be asked for a password.
|
||||
/// The vault credential to authenticate with, or null to take the group's binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The password counterpart of <see cref="SshKeyId"/>, with the same reasoning about ids rather than
|
||||
/// copies, the same dangling-reference handling, and the same refusal to fall back when the reference
|
||||
/// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly
|
||||
/// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget.
|
||||
/// copies, the same dangling-reference handling, the same meaning for null, and the same refusal to fall
|
||||
/// back when the reference cannot be resolved. One credential is very often the same account on twenty
|
||||
/// hosts, which is exactly why it is referenced and not embedded — a copy per host is twenty places to
|
||||
/// rotate and one to forget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Mutually exclusive with <see cref="SshKeyId"/>.</b> SSH itself would happily try a key and fall
|
||||
@@ -98,9 +133,61 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// answer — and the interface, the connect path and the user would each be free to guess differently.
|
||||
/// One host, one method; <see cref="TryValidate"/> enforces it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Enforcing it per record is necessary and not sufficient.</b> A host naming a credential under a
|
||||
/// group naming a key is two individually valid records that resolve to two bindings, so the exclusion
|
||||
/// is applied again across the chain — host over group — where the chain is walked. Nothing that
|
||||
/// resolves a host may return both.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? CredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this host is pinned to a password typed at connect time, or null to leave it unstated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fourth state, and the reason it needs a field of its own. A host can authenticate with a key,
|
||||
/// with a stored credential, with a password typed each time, or with whatever its group lends it — and
|
||||
/// two nullable ids can only express three of those. Naming neither id used to be the third; it is now
|
||||
/// the fourth, so the third needs saying out loud.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only <see langword="true"/> means anything.</b> Null is "not stated", which resolves through the
|
||||
/// group chain and lands on a typed password if the chain lends nothing — so null and false would say
|
||||
/// the same thing, and the codec writes null rather than false so that a host which never touched this
|
||||
/// encodes exactly as it did before the field existed. A decoded <see langword="false"/> is folded back
|
||||
/// to null for the same reason: two spellings of one state is a difference the merge would report as a
|
||||
/// change nobody made.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Refused beside a binding.</b> A host that names a key and also says "ask me for a password" has
|
||||
/// given two answers to one question, which is the same failure <see cref="SshKeyId"/> and
|
||||
/// <see cref="CredentialId"/> are kept apart to avoid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? AsksForPassword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tags this host wears, as tag item ids.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Ids rather than names, because a tag is a vault item in its own right — see <see cref="TagSecret"/>
|
||||
/// for why renaming one has to be a single write. The set lives here rather than on the tag, and rather
|
||||
/// than in the <c>HostTag</c> join the contract reserves, because tagging two hosts must be two writes
|
||||
/// to two items; the one thing the join would add on top of that is bought instead by merging this per
|
||||
/// id. See <see cref="TagSet.ToIdMap"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The references may dangle</b>, exactly as <see cref="GroupId"/> may. A tag deleted on another
|
||||
/// machine leaves an id here that resolves to nothing, and the host simply stops drawing that chip —
|
||||
/// handled where it is noticed rather than prevented here, because preventing it would mean one tag
|
||||
/// delete rewriting every host that wore it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public TagSet TagIds { get; init; } = TagSet.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The group this host is filed under, or null for none.
|
||||
/// </summary>
|
||||
@@ -168,12 +255,31 @@ public sealed record HostSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
// Null is "take the group's port" and passes this comparison, which is the wanted behaviour and is
|
||||
// silent about it: `is < 1 or > 65535` is false for a null int?. The message is interpolated only
|
||||
// on the branch where the value exists, because a null renders as an empty string and would produce
|
||||
// "not ." on screen.
|
||||
if (Port is < 1 or > 65535)
|
||||
{
|
||||
reason = $"Port must be between 1 and 65535, not {Port}.";
|
||||
reason = $"Port must be between 1 and 65535, not {Port.Value}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return ReferencesAreStorable(out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the five places a host points at something else, and the one answer it must not give twice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out for length and they do belong together: every check here is about an id, and an id has
|
||||
/// exactly two ways to be wrong. It can be <see cref="Guid.Empty"/>, which is not "nothing" — nothing is
|
||||
/// null — but a reference that can never resolve, so storing one produces a host that refuses to connect
|
||||
/// with nothing on screen to say why. Or two ids can be present that answer the same question, which
|
||||
/// leaves the interface, the connect path and the user each free to guess differently.
|
||||
/// </remarks>
|
||||
private bool ReferencesAreStorable([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
|
||||
{
|
||||
reason = "A jump chain cannot contain an empty host id.";
|
||||
@@ -182,8 +288,6 @@ public sealed record HostSecret : IVaultSecret
|
||||
|
||||
if (SshKeyId == Guid.Empty)
|
||||
{
|
||||
// An empty id is not "no key" — that is null. It is a reference that can never resolve, and
|
||||
// storing one would produce a host that refuses to connect with no way to see why.
|
||||
reason = "An SSH key reference cannot be an empty id; use no key instead.";
|
||||
return false;
|
||||
}
|
||||
@@ -200,12 +304,24 @@ public sealed record HostSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
if (AsksForPassword is true && (SshKeyId is not null || CredentialId is not null))
|
||||
{
|
||||
reason = "A host asks for a password or binds a key or credential, not both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GroupId == Guid.Empty)
|
||||
{
|
||||
reason = "A group reference cannot be an empty id; use no group instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TagIds.Contains(Guid.Empty))
|
||||
{
|
||||
reason = "A tag reference cannot be an empty id.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,8 +65,49 @@ public static class HostSecretCodec
|
||||
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
|
||||
public const int GroupIdSchemaVersion = 4;
|
||||
|
||||
/// <summary>
|
||||
/// The version that introduced inheritance: a null <see cref="HostSecret.Port"/> and
|
||||
/// <see cref="HostSecret.AsksForPassword"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The one version where "read-only on an older client" understates the cost.</b> Every field before
|
||||
/// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port
|
||||
/// is subtractive — the property is omitted, an older build's <c>int Port</c> reads 0, and
|
||||
/// <see cref="HostSecret.TryValidate"/> refuses the host outright. The item does not appear locked on
|
||||
/// that machine; it does not appear at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host
|
||||
/// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to
|
||||
/// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a
|
||||
/// rule about which wins, on every screen and on the wire. What confines the cost is
|
||||
/// <see cref="SchemaVersionFor"/>: only a host that actually inherits its port is written here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="HostSecret.AsksForPassword"/> shares this version because it arrives in the same build and
|
||||
/// answers the same question — it is what a host says instead of naming a binding, once naming nothing
|
||||
/// has come to mean "ask the group".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int PortInheritSchemaVersion = 5;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.TagIds"/>.</summary>
|
||||
/// <remarks>
|
||||
/// One past inheritance rather than sharing with it, because the two are independent: a host can wear
|
||||
/// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That
|
||||
/// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule
|
||||
/// this file follows is one version per field and the exception would have to be re-justified by whoever
|
||||
/// adds the seventh.
|
||||
/// </remarks>
|
||||
public const int TagIdsSchemaVersion = 6;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = GroupIdSchemaVersion;
|
||||
/// <remarks>
|
||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||
/// maximum. A new field added below this line has to be named here too.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = TagIdsSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -99,6 +140,15 @@ public static class HostSecretCodec
|
||||
SshKeyId = host.SshKeyId,
|
||||
CredentialId = host.CredentialId,
|
||||
GroupId = host.GroupId,
|
||||
|
||||
// Null rather than false, so a host that never said anything about this encodes exactly as it
|
||||
// did before the field existed. See HostSecret.AsksForPassword.
|
||||
AsksForPassword = host.AsksForPassword is true ? true : null,
|
||||
|
||||
// Null rather than an empty array, for the same reason and with a wider blast radius: an empty
|
||||
// [] here would land in every host in every vault and make the first sync after the upgrade
|
||||
// read as though every one of them had changed.
|
||||
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -156,6 +206,19 @@ public static class HostSecretCodec
|
||||
version = Math.Max(version, GroupIdSchemaVersion);
|
||||
}
|
||||
|
||||
// Both halves of inheritance, and this is the branch that loses an item rather than locking one if
|
||||
// it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a
|
||||
// host an older client deletes from its own view.
|
||||
if (host.Port is null || host.AsksForPassword is true)
|
||||
{
|
||||
version = Math.Max(version, PortInheritSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.TagIds.Count > 0)
|
||||
{
|
||||
version = Math.Max(version, TagIdsSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -226,6 +289,12 @@ public static class HostSecretCodec
|
||||
SshKeyId = parsed.SshKeyId,
|
||||
CredentialId = parsed.CredentialId,
|
||||
GroupId = parsed.GroupId,
|
||||
|
||||
// False folds back to null: they say the same thing, and letting both onto the record would
|
||||
// give the merge two spellings of one state to report as a change nobody made.
|
||||
AsksForPassword = parsed.AsksForPassword is true ? true : null,
|
||||
|
||||
TagIds = TagSet.Create(parsed.TagIds ?? []),
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -255,7 +324,13 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
public string? Hostname { get; set; }
|
||||
|
||||
public int Port { get; set; }
|
||||
/// <remarks>
|
||||
/// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also
|
||||
/// the one property here whose absence an older build cannot survive: it deserialises as
|
||||
/// <see langword="int"/> 0 there, which <see cref="HostSecret.TryValidate"/> refuses. See
|
||||
/// <see cref="HostSecretCodec.PortInheritSchemaVersion"/>.
|
||||
/// </remarks>
|
||||
public int? Port { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
@@ -284,6 +359,16 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? GroupId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public bool? AsksForPassword { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and it must stay last for the reason <see cref="SshKeyId"/> gives. Null when the host wears no
|
||||
/// tags, never <c>[]</c> — an empty array would be a new key in the JSON of every host in every vault,
|
||||
/// which the sync engine would read as every host having changed.
|
||||
/// </remarks>
|
||||
public Guid[]? TagIds { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
@@ -84,7 +84,7 @@ public static class HostSecretMerge
|
||||
local.Port,
|
||||
remote.Port,
|
||||
conflicts,
|
||||
static port => port.ToString(CultureInfo.InvariantCulture)),
|
||||
static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"),
|
||||
Username = Text(
|
||||
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
|
||||
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
@@ -96,6 +96,7 @@ public static class HostSecretMerge
|
||||
conflicts,
|
||||
FormatChain),
|
||||
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
|
||||
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
|
||||
RelayEnabled = Field(
|
||||
nameof(HostSecret.RelayEnabled),
|
||||
ancestor.RelayEnabled,
|
||||
@@ -155,6 +156,14 @@ public static class HostSecretMerge
|
||||
remote.GroupId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "ungrouped"),
|
||||
|
||||
AsksForPassword = Field(
|
||||
nameof(HostSecret.AsksForPassword),
|
||||
ancestor.AsksForPassword,
|
||||
local.AsksForPassword,
|
||||
remote.AsksForPassword,
|
||||
conflicts,
|
||||
static asked => asked is true ? "a typed password" : "the group's binding"),
|
||||
};
|
||||
|
||||
private static string Text(
|
||||
@@ -231,6 +240,56 @@ public static class HostSecretMerge
|
||||
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the tag set per tag, so two people each adding a different one both keep theirs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The reason <see cref="TagSet"/> exposes a map at all. A whole-value merge here would take one side's
|
||||
/// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west"
|
||||
/// would silently lose one of the two, which is the single most visible difference between a field-level
|
||||
/// merge and last-writer-wins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No conflict is reachable, and the loop below is kept anyway.</b> The value in the map is the key,
|
||||
/// so a tag can only be present or absent — and running that through
|
||||
/// <see cref="ThreeWayMerge.Map"/> leaves nothing to disagree about. Where both sides hold the tag or
|
||||
/// neither does they agree; where exactly one side moved, the other still matches the ancestor and the
|
||||
/// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this
|
||||
/// map cannot express. A set is the one collection shape that merges without ever asking the user.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The loop stays because that proof depends on <see cref="TagSet.ToIdMap"/> keying by the value, which
|
||||
/// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that
|
||||
/// nothing records. Unreachable code that costs a <c>foreach</c> over an empty list is a cheaper way to
|
||||
/// hold that invariant than a comment alone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static TagSet MergeTags(
|
||||
TagSet ancestor,
|
||||
TagSet local,
|
||||
TagSet remote,
|
||||
List<HostFieldConflict> conflicts)
|
||||
{
|
||||
var merge = ThreeWayMerge.Map(
|
||||
ancestor.ToIdMap(),
|
||||
local.ToIdMap(),
|
||||
remote.ToIdMap(),
|
||||
EqualityComparer<Guid>.Default);
|
||||
|
||||
foreach (var conflict in merge.Conflicts)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
$"{nameof(HostSecret.TagIds)}[{conflict.Key}]",
|
||||
conflict.DiscardedSide,
|
||||
conflict.Kept == Guid.Empty ? "not tagged" : "tagged",
|
||||
conflict.DiscardedWasRemoval ? "not tagged" : "tagged",
|
||||
conflict.DiscardedWasRemoval));
|
||||
}
|
||||
|
||||
return TagSet.Create(merge.Merged.Keys);
|
||||
}
|
||||
|
||||
private static string FormatChain(JumpChain chain) =>
|
||||
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A label that can be put on many hosts, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One field, and the one field is the whole argument for the type existing. A tag is only worth having
|
||||
/// because the same tag goes on twenty machines and filters them back out later, so it needs an identity of
|
||||
/// its own: renaming "staging" to "stage" is then one write to one item, rather than twenty host payloads
|
||||
/// that have to be found, decrypted, edited and pushed — nineteen of which can fail halfway and leave a
|
||||
/// vault holding two spellings of one tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Membership is a <see cref="HostSecret.TagIds"/> set on the host</b>, not a member list here and not the
|
||||
/// <c>HostTag</c> join the contract reserves. The pointer-on-the-host argument that decided
|
||||
/// <see cref="HostSecret.GroupId"/> applies unchanged — tagging two hosts is two writes to two items — and
|
||||
/// the extra thing a join would have bought, two machines tagging the <em>same</em> host without one losing,
|
||||
/// is already bought by <see cref="ThreeWayMerge.Map"/>, which resolves a keyed collection key by key and so
|
||||
/// gives set semantics with removals. <c>SyncEntityType.HostTag</c> therefore stays reserved and unused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No colour, no description, no ordering.</b> Each was considered and each would be a field two clients
|
||||
/// can disagree about in exchange for nothing the filter needs. If one arrives later it arrives behind a
|
||||
/// schema version, which is what the envelope in <see cref="TagSecretCodec"/> is for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record TagSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the tag is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A blank name is refused rather than defaulted, for a sharper reason than a group's: a tag is drawn as
|
||||
/// a chip beside a host and selected from a list of chips, so a nameless one is an empty chip that
|
||||
/// filters a set nobody can name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A duplicate name is not refused here</b>, and cannot be — this record is one tag and knows nothing
|
||||
/// about the others. Two tags called "staging" are two tags, which is a mess the editor should warn about
|
||||
/// and the vault must still be able to hold: two people creating the same tag offline is exactly how it
|
||||
/// happens, and refusing to store the second one would mean dropping the tags a colleague had already
|
||||
/// put on their hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A tag needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded tag payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Tag">The tag.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record TagSecretDocument(TagSecret Tag, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > TagSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a tag item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
|
||||
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
|
||||
/// version, which is what lets a later build add a field without every older client silently dropping it on
|
||||
/// the next edit. <see cref="HostGroupSecretCodec"/> is the type that had to spend that budget, and the shape
|
||||
/// here is the shape it had beforehand. See <see cref="HostSecretDocument.IsReadOnly"/>.
|
||||
/// </remarks>
|
||||
public static class TagSecretCodec
|
||||
{
|
||||
/// <summary>The first version, and the one a tag with no newer field is still written at.</summary>
|
||||
public const int BaseSchemaVersion = 1;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = BaseSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a tag to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The tag is not valid for storage.</exception>
|
||||
public static byte[] Encode(TagSecret tag)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
|
||||
if (!tag.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(tag));
|
||||
}
|
||||
|
||||
var document = new TagPayloadDocument
|
||||
{
|
||||
SchemaVersion = SchemaVersionFor(tag),
|
||||
Label = tag.Label,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, TagPayloadJsonContext.Default.TagPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The lowest schema version that can represent this tag without losing anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// With one field there is nothing to take a maximum over, and this returns a constant. It is a method
|
||||
/// anyway, because the alternative — stamping <see cref="CurrentSchemaVersion"/> at the call site — is
|
||||
/// indistinguishable from this today and becomes wrong the moment a second field exists: the version is
|
||||
/// what makes an older client treat an item as read-only, so a build that stamps the newest one
|
||||
/// unconditionally makes every tag in the vault uneditable everywhere as soon as one machine upgrades
|
||||
/// and renames one. <see cref="HostGroupSecretCodec"/> shipped without this and had to grow it; the
|
||||
/// method is here so the second field has an obvious place to go rather than a call site to notice.
|
||||
/// </remarks>
|
||||
private static int SchemaVersionFor(TagSecret tag) => BaseSchemaVersion;
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out TagSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
TagPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, TagPayloadJsonContext.Default.TagPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new TagSecret { Label = parsed.Label ?? string.Empty };
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new TagSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class TagPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(TagPayloadDocument))]
|
||||
internal sealed partial class TagPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged tag, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The tag to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record TagMergeResult(
|
||||
TagSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a tag against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One scalar, so this is now the simplest merge in the client, and the interesting thing about it is what
|
||||
/// it never has to consider. Putting a tag on a host does not write to the tag — membership is a set on the
|
||||
/// host — so two people tagging two different machines at the same time cannot reach this code. The only way
|
||||
/// to get here is for two people to rename the same tag differently, which is a real disagreement and gets a
|
||||
/// conflict notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two tags that end up with the same name are not merged into one, here or anywhere.</b> They are two
|
||||
/// items with two ids and two sets of hosts, and collapsing them would mean rewriting every host that named
|
||||
/// the loser — the exact N-payload write that keeping membership on the host exists to avoid. A duplicate is
|
||||
/// something the editor should warn about before it is created, not something the merge silently repairs
|
||||
/// afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. A tag name is the one thing a tag has, and a notice saying only that "the name
|
||||
/// differed" would leave the user unable to tell which of their two names survived.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class TagSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged tag.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static TagMergeResult Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merge = ThreeWayMerge.Scalar(
|
||||
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
|
||||
// fixed here rather than derived from the outcome.
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(TagSecret.Label),
|
||||
MergeSide.Local,
|
||||
merge.Value,
|
||||
merge.Discarded,
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return new TagMergeResult(new TagSecret { Label = merge.Value }, conflicts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// The tags a host wears, as item ids, in no meaningful order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A dedicated type rather than a list of ids, for the reason <see cref="JumpChain"/> gives: a plain
|
||||
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
|
||||
/// <c>Equals</c>, so every host would read as changed on every sync pass and two identical edits would
|
||||
/// register as a conflict.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Unlike a jump chain, this is a set, and the difference is the whole point.</b> A route's order is its
|
||||
/// meaning; a tag list's order is an artefact of which chip the user tapped first. So this sorts and
|
||||
/// deduplicates at construction, which makes two users who added the same two tags in opposite orders
|
||||
/// produce the same value — and therefore no change to push and nothing to merge. It also means the merge
|
||||
/// may treat it as a set: see <see cref="ToIdMap"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty ids are not rejected at construction. They arrive from a decrypted payload written by another
|
||||
/// client, and a constructor that threw would turn one bad item into a failed sync pass for every other item
|
||||
/// behind it. <see cref="HostSecret.TryValidate"/> is where that is caught.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TagSet : IReadOnlyList<Guid>, IEquatable<TagSet>
|
||||
{
|
||||
private readonly Guid[] ids;
|
||||
private readonly int hash;
|
||||
|
||||
private TagSet(Guid[] ids)
|
||||
{
|
||||
this.ids = ids;
|
||||
hash = ComputeHash(ids);
|
||||
}
|
||||
|
||||
/// <summary>No tags.</summary>
|
||||
public static TagSet Empty { get; } = new([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => ids.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid this[int index] => ids[index];
|
||||
|
||||
/// <summary>Copies a sequence of tag ids, sorting and removing repeats.</summary>
|
||||
public static TagSet Create(IEnumerable<Guid> ids)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ids);
|
||||
|
||||
return Canonicalise([.. ids]);
|
||||
}
|
||||
|
||||
/// <summary>Copies a span of tag ids, sorting and removing repeats.</summary>
|
||||
public static TagSet Create(ReadOnlySpan<Guid> ids) => Canonicalise(ids.ToArray());
|
||||
|
||||
/// <summary>Whether this host wears the given tag.</summary>
|
||||
public bool Contains(Guid id) => Array.BinarySearch(ids, id) >= 0;
|
||||
|
||||
/// <summary>
|
||||
/// The set as a map from tag id to tag id, which is the shape <see cref="ThreeWayMerge.Map"/> takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The value repeats the key deliberately. A per-key merge resolves presence and absence independently,
|
||||
/// which is set semantics with removals — so two people each adding a different tag to one host both
|
||||
/// keep theirs, and a removal on one side is reported rather than silently undone. A whole-value merge
|
||||
/// would drop one side outright.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is the reason there is no <c>HostTag</c> join item.</b> The one thing a join buys over a set
|
||||
/// on the host — two machines tagging the same host without either losing — is exactly what a keyed
|
||||
/// merge already gives. See <see cref="TagSecret"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Because the value is the key, no key can ever have two different values, so a conflict here can only
|
||||
/// ever be one side adding what the other removed. That is the only disagreement a set can have.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<Guid, Guid> ToIdMap() => ids.ToDictionary(id => id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(TagSet? other)
|
||||
{
|
||||
if (ReferenceEquals(this, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is not null
|
||||
&& other.hash == hash
|
||||
&& ids.AsSpan().SequenceEqual(other.ids);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => Equals(obj as TagSet);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => hash;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<Guid> GetEnumerator() => ((IEnumerable<Guid>)ids).GetEnumerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => ids.GetEnumerator();
|
||||
|
||||
/// <summary>The ids, without copying, in sorted order.</summary>
|
||||
public ReadOnlySpan<Guid> AsSpan() => ids;
|
||||
|
||||
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||
[SuppressMessage(
|
||||
"Usage",
|
||||
"CA2225:Operator overloads have named alternates",
|
||||
Justification = "Equals(TagSet) is the named alternate.")]
|
||||
public static bool operator ==(TagSet? left, TagSet? right) =>
|
||||
left is null ? right is null : left.Equals(right);
|
||||
|
||||
/// <summary>Contents inequality.</summary>
|
||||
public static bool operator !=(TagSet? left, TagSet? right) => !(left == right);
|
||||
|
||||
/// <remarks>
|
||||
/// Sorting is what makes the sequence comparison below a set comparison, and deduplicating is what stops
|
||||
/// a payload that repeats an id from comparing unequal to the same set written once. Both happen here,
|
||||
/// once, rather than at every comparison.
|
||||
/// </remarks>
|
||||
private static TagSet Canonicalise(Guid[] ids)
|
||||
{
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
Array.Sort(ids);
|
||||
|
||||
var written = 1;
|
||||
for (var read = 1; read < ids.Length; read++)
|
||||
{
|
||||
if (ids[read] != ids[written - 1])
|
||||
{
|
||||
ids[written++] = ids[read];
|
||||
}
|
||||
}
|
||||
|
||||
return new TagSet(written == ids.Length ? ids : ids[..written]);
|
||||
}
|
||||
|
||||
private static int ComputeHash(Guid[] ids)
|
||||
{
|
||||
// Order-sensitive over an already-sorted array, which is order-insensitive over the set. Written
|
||||
// this way rather than by summing or xoring hashes because those collide on swapped pairs.
|
||||
var accumulator = new HashCode();
|
||||
accumulator.Add(ids.Length);
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
accumulator.Add(id);
|
||||
}
|
||||
|
||||
return accumulator.ToHashCode();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user