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:
2026-08-03 10:21:02 +02:00
co-authored by Claude Opus 5
parent c9eca96ce7
commit 8c04ba60b0
35 changed files with 4921 additions and 120 deletions
+32 -6
View File
@@ -4,7 +4,24 @@ The phone can read a keychain and connect through it. It cannot put anything in
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
ordered work, and the traps that are already known. ordered work, and the traps that are already known.
> **Status: planned, not started.** Nothing below is built. The only thing in the tree is this file. > **Status: steps 13 built, 46 not started.** The domain is done and the phone has not been touched. Each
> built step compiles with the whole suite green, which is the rule the ordering below sets.
>
> | Step | State | Notes |
> | --- | --- | --- |
> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
> | 4. The shared view model | Not started | The five port call sites already route through the resolver; the rest of the list below does not. |
> | 5. The phone | Not started | |
> | 6. Tests and false prose | Partly done as it went | The guards steps 13 tripped are fixed. `docs/design-import-gaps.md` and the three phone files are untouched. |
>
> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
> and two nullable ids express three of those. Naming neither id now means *inherit*, so
> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out
> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing
> host resolves exactly as it did.
## What was asked for ## What was asked for
@@ -73,12 +90,13 @@ HostSecret HostGroupSecret
Label Label Label Label
Hostname ParentId ← new, optional Hostname ParentId ← new, optional
Port int? — null inherits DefaultPort ← new Port int? — null inherits DefaultPort ← new
Username null inherits DefaultUsername ← new Username null inherits, "" none DefaultUsername ← new
Notes DefaultSshKeyId ← new Notes DefaultSshKeyId ← new
JumpHostIds DefaultCredentialId ← new JumpHostIds DefaultCredentialId ← new
Options Options
SshKeyId null inherits TagSecret SshKeyId null inherits TagSecret
CredentialId null inherits Label CredentialId null inherits Label
AsksForPassword ← new, true only
GroupId null = ungrouped GroupId null = ungrouped
TagIds ← new TagIds ← new
RelayEnabled RelayEnabled
@@ -92,10 +110,18 @@ after the chain runs out.
`SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an `SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an
absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated. absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated.
Inheritance adds a third state, so the picker needs an explicit **"Inherit from group"** entry beside Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password
**"Password (ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: (ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means
null means "no username" today and is refused at connect; it has to come to mean "inherit", with "no username" "no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still
still reachable and still refused. reachable and still refused.
**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed
password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword`
carries the difference: null is "not stated", which walks the chain and lands on a typed password if the
chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is
ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes
exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null
is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain.
Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per
record; a host naming a credential under a group naming a key is two individually valid records that resolve to record; a host naming a credential under a group naming a key is two individually valid records that resolve to
+113 -7
View File
@@ -71,7 +71,7 @@ internal static class ItemKinds
new[] new[]
{ {
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new SnippetKind(), new HostGroupKind(), new TagKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(), new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
}.ToDictionary(kind => kind.WireType); }.ToDictionary(kind => kind.WireType);
@@ -557,13 +557,17 @@ internal sealed class HostGroupKind : IItemKind
} }
/// <summary> /// <summary>
/// Refuses every plaintext field there is, including the one named after this type. /// Refuses every plaintext field there is, including the two that would describe this type's own place
/// in a tree.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A <c>GroupId</c> on a group would be a parent pointer, and groups are flat — see /// <b>Groups nest, and the pointer that nests them is still refused here.</b> The refusal used to mean
/// <see cref="VaultHostGroup"/> for why nesting merged by a scalar three-way merge can produce a cycle /// "there is no such thing as a parent"; it now means "the parent is not the server's to hold". A
/// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by /// <c>ParentId</c> or <c>GroupId</c> column on this table would let the operator reconstruct the shape of
/// accident. /// every user's estate — how many groupings, how deep, which under which — which is precisely the
/// disclosure ADR 0004 refuses everywhere except the one address the relay cannot dial without. The
/// parent travels inside the envelope like the name beside it, and the client is the only thing that can
/// read either.
/// </remarks> /// </remarks>
/// <inheritdoc /> /// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error) public bool ValidateFields(SyncPlaintextFields fields, out string error)
@@ -580,7 +584,7 @@ internal sealed class HostGroupKind : IItemKind
if (fields.GroupId is not null || fields.ParentId is not null) if (fields.GroupId is not null || fields.ParentId is not null)
{ {
error = "Host groups are flat, and a group's name is inside its payload."; error = "A host group's name and its place in the tree are inside its payload.";
return false; return false;
} }
@@ -608,6 +612,108 @@ internal sealed class HostGroupKind : IItemKind
public SyncPlaintextFields? Hydrate(IVaultItem item) => null; public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
} }
/// <summary>Tags: an envelope and nothing else.</summary>
/// <remarks>
/// <see cref="HostGroupKind"/>'s answer to the same question, at higher stakes. A group name says how one
/// user files their machines; a tag name says what the machines are, and one tag is meant to span twenty of
/// them — so a plaintext label here would hand the operator a labelled map of every estate on the server, to
/// sort a list nothing server-side draws.
/// </remarks>
internal sealed class TagKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.Tag;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.Tag;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Tags.SingleOrDefaultAsync(t => t.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Tags
.Where(t => t.VaultId == vaultId && ids.Contains(t.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var tag = new VaultTag { Id = id, VaultId = vaultId };
database.Tags.Add(tag);
return tag;
}
/// <summary>
/// Refuses every plaintext field there is, including the one a join table would have reached for.
/// </summary>
/// <remarks>
/// <b><c>RelatedId</c> matters more here than for any other kind.</b> It is the obvious place to put "the
/// host this tag is on", which is exactly what <c>SyncEntityType.HostTag</c> reserves a slot for and what
/// this design decided not to build — membership is a set inside each host's payload. A future client
/// reaching for the field would be storing the host-to-tag graph in the clear, one row at a time, which
/// is the aggregation this kind exists to refuse. <c>SyncPlaintextFields</c> is frozen so the field
/// cannot be removed; refusing it is the only place the decision can be enforced.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A tag is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A tag's name is inside its payload, and which hosts wear it is inside theirs.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A tag has no public key.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Snippets: an envelope and nothing else.</summary> /// <summary>Snippets: an envelope and nothing else.</summary>
/// <remarks> /// <remarks>
/// A label column here would sort a list this server never draws, and the commands beside that label describe /// A label column here would sort a list this server never draws, and the commands beside that label describe
+130 -10
View File
@@ -3,24 +3,36 @@ using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain; namespace DodoSSH.Client.Domain;
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group /// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it /// <see cref="ParentId"/> so headings nest, and four <c>Default</c> fields a host under this group falls
/// sits in a tree, what colour it is — was considered and left out, each for its own reason. /// back to when it leaves the matching field unset.
/// </para> /// </para>
/// <para> /// <para>
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two /// <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 /// 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 /// writes to one item, and while <see cref="ThreeWayMerge.Map"/> could now resolve that key by key, the
/// side winning outright and the other host silently leaving the group it was just put in. /// 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>
/// <para> /// <para>
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline, /// <b>Groups nest, and the cycle is contained rather than prevented.</b> This is a reversal, and the
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot /// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path. /// 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> /// </para>
/// </remarks> /// </remarks>
public sealed record HostGroupSecret : IVaultSecret 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> /// <summary>What the group is called. The only name it has anywhere.</summary>
public required string Label { get; init; } 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> /// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks> /// <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 /// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
/// cannot tell apart. /// 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> /// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason) public bool TryValidate([NotNullWhen(false)] out string? reason)
{ {
@@ -42,6 +128,40 @@ public sealed record HostGroupSecret : IVaultSecret
return false; 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; reason = null;
return true; 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. /// Encodes and decodes the plaintext inside a group item's encrypted payload.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field /// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. What the
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema /// JSON envelope buys is a schema version, which is what lets a later build add a field without every older
/// version, which is what lets a later build add a field without every older client silently dropping it on /// client silently dropping it on the next edit — and this is the build that spent it, five fields at once.
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>. /// See <see cref="HostSecretDocument.IsReadOnly"/>.
/// </remarks> /// </remarks>
public static class HostGroupSecretCodec public static class HostGroupSecretCodec
{ {
/// <summary>The schema version this build writes.</summary> /// <summary>The first version, and the one a group with no newer field is still written at.</summary>
public const int CurrentSchemaVersion = 1; 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> /// <summary>Serialises a group to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The group is not valid for storage.</exception> /// <exception cref="ArgumentException">The group is not valid for storage.</exception>
@@ -40,14 +54,55 @@ public static class HostGroupSecretCodec
var document = new HostGroupPayloadDocument var document = new HostGroupPayloadDocument
{ {
SchemaVersion = CurrentSchemaVersion, SchemaVersion = SchemaVersionFor(group),
Label = group.Label, Label = group.Label,
ParentId = group.ParentId,
DefaultPort = group.DefaultPort,
DefaultUsername = group.DefaultUsername,
DefaultSshKeyId = group.DefaultSshKeyId,
DefaultCredentialId = group.DefaultCredentialId,
}; };
return JsonSerializer.SerializeToUtf8Bytes( return JsonSerializer.SerializeToUtf8Bytes(
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument); 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> /// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" /> /// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode( public static bool TryDecode(
@@ -72,7 +127,15 @@ public static class HostGroupSecretCodec
return false; 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 _)) if (!candidate.TryValidate(out _))
{ {
@@ -91,6 +154,26 @@ internal sealed class HostGroupPayloadDocument
public int SchemaVersion { get; set; } public int SchemaVersion { get; set; }
public string? Label { 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( [JsonSourceGenerationOptions(
+126 -14
View File
@@ -1,3 +1,5 @@
using System.Globalization;
namespace DodoSSH.Client.Domain; namespace DodoSSH.Client.Domain;
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary> /// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
@@ -16,14 +18,22 @@ public sealed record HostGroupMergeResult(
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it /// Six scalars, no collections, which keeps this the simplest item merge in the client and the
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people /// interesting thing about it is still what it does <em>not</em> have to consider. Filing a host into a
/// organising the same vault at the same time never collide here — the only way to reach this code is for two /// group does not write to the group, so two people organising the same vault at the same time never
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice. /// collide here.
/// </para> /// </para>
/// <para> /// <para>
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name /// <b>The parent merges as a scalar, and that is what admits a cycle.</b> Two clients re-parenting A under
/// differed" would leave the user unable to tell which of their two names survived. /// 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> /// </para>
/// </remarks> /// </remarks>
public static class HostGroupSecretMerge public static class HostGroupSecretMerge
@@ -43,21 +53,123 @@ public static class HostGroupSecretMerge
var conflicts = new List<HostFieldConflict>(); var conflicts = new List<HostFieldConflict>();
var merge = ThreeWayMerge.Scalar( var merged = new HostGroupSecret
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal); {
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) 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( conflicts.Add(new HostFieldConflict(
nameof(HostGroupSecret.Label), name,
MergeSide.Local, MergeSide.Local,
merge.Value, format(merge.Value),
merge.Discarded, format(merge.Discarded!),
DiscardedWasRemoval: false)); 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,
};
}
+127 -11
View File
@@ -35,10 +35,37 @@ public sealed record HostSecret : IVaultSecret
/// <summary>Hostname or address to connect to.</summary> /// <summary>Hostname or address to connect to.</summary>
public required string Hostname { get; init; } public required string Hostname { get; init; }
/// <summary>TCP port.</summary> /// <summary>
public int Port { get; init; } = DefaultPort; /// 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; } public string? Username { get; init; }
/// <summary>Free-text notes.</summary> /// <summary>Free-text notes.</summary>
@@ -58,10 +85,17 @@ public sealed record HostSecret : IVaultSecret
public HostOptions Options { get; init; } = HostOptions.Empty; public HostOptions Options { get; init; } = HostOptions.Empty;
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <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 /// 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 /// 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 /// 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; } public Guid? SshKeyId { get; init; }
/// <summary> /// <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> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The password counterpart of <see cref="SshKeyId"/>, with the same reasoning about ids rather than /// 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 /// copies, the same dangling-reference handling, the same meaning for null, and the same refusal to fall
/// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly /// back when the reference cannot be resolved. One credential is very often the same account on twenty
/// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget. /// 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>
/// <para> /// <para>
/// <b>Mutually exclusive with <see cref="SshKeyId"/>.</b> SSH itself would happily try a key and fall /// <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. /// 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. /// One host, one method; <see cref="TryValidate"/> enforces it.
/// </para> /// </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> /// </remarks>
public Guid? CredentialId { get; init; } 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> /// <summary>
/// The group this host is filed under, or null for none. /// The group this host is filed under, or null for none.
/// </summary> /// </summary>
@@ -168,12 +255,31 @@ public sealed record HostSecret : IVaultSecret
return false; 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) 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 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)) if (JumpHostIds.AsSpan().Contains(Guid.Empty))
{ {
reason = "A jump chain cannot contain an empty host id."; reason = "A jump chain cannot contain an empty host id.";
@@ -182,8 +288,6 @@ public sealed record HostSecret : IVaultSecret
if (SshKeyId == Guid.Empty) 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."; reason = "An SSH key reference cannot be an empty id; use no key instead.";
return false; return false;
} }
@@ -200,12 +304,24 @@ public sealed record HostSecret : IVaultSecret
return false; 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) if (GroupId == Guid.Empty)
{ {
reason = "A group reference cannot be an empty id; use no group instead."; reason = "A group reference cannot be an empty id; use no group instead.";
return false; return false;
} }
if (TagIds.Contains(Guid.Empty))
{
reason = "A tag reference cannot be an empty id.";
return false;
}
reason = null; reason = null;
return true; return true;
} }
+87 -2
View File
@@ -65,8 +65,49 @@ public static class HostSecretCodec
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary> /// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
public const int GroupIdSchemaVersion = 4; 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> /// <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> /// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception> /// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -99,6 +140,15 @@ public static class HostSecretCodec
SshKeyId = host.SshKeyId, SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId, CredentialId = host.CredentialId,
GroupId = host.GroupId, 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( return JsonSerializer.SerializeToUtf8Bytes(
@@ -156,6 +206,19 @@ public static class HostSecretCodec
version = Math.Max(version, GroupIdSchemaVersion); 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; return version;
} }
@@ -226,6 +289,12 @@ public static class HostSecretCodec
SshKeyId = parsed.SshKeyId, SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId, CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId, 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 _)) if (!candidate.TryValidate(out _))
@@ -255,7 +324,13 @@ internal sealed class HostPayloadDocument
public string? Hostname { get; set; } 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; } public string? Username { get; set; }
@@ -284,6 +359,16 @@ internal sealed class HostPayloadDocument
/// <inheritdoc cref="SshKeyId" /> /// <inheritdoc cref="SshKeyId" />
public Guid? GroupId { get; set; } 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( [JsonSourceGenerationOptions(
+60 -1
View File
@@ -84,7 +84,7 @@ public static class HostSecretMerge
local.Port, local.Port,
remote.Port, remote.Port,
conflicts, conflicts,
static port => port.ToString(CultureInfo.InvariantCulture)), static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"),
Username = Text( Username = Text(
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts), nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts), Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
@@ -96,6 +96,7 @@ public static class HostSecretMerge
conflicts, conflicts,
FormatChain), FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts), Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
RelayEnabled = Field( RelayEnabled = Field(
nameof(HostSecret.RelayEnabled), nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled, ancestor.RelayEnabled,
@@ -155,6 +156,14 @@ public static class HostSecretMerge
remote.GroupId, remote.GroupId,
conflicts, conflicts,
static id => id?.ToString() ?? "ungrouped"), 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( private static string Text(
@@ -231,6 +240,56 @@ public static class HostSecretMerge
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value))); 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) => private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain); chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
} }
+61
View File
@@ -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;
}
}
+119
View File
@@ -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);
}
}
+164
View File
@@ -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();
}
}
+12 -3
View File
@@ -122,6 +122,7 @@ public sealed partial class VaultSession : IAsyncDisposable
Credentials = new CredentialRepository(Items, Outbox, keyring, activity); Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity); KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity); HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
Tags = new TagRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity); Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity); ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
} }
@@ -175,12 +176,20 @@ public sealed partial class VaultSession : IAsyncDisposable
/// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary> /// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
/// <remarks> /// <remarks>
/// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name /// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name,
/// which is what makes filing two hosts at once on two machines two independent writes rather than one /// a parent and the defaults hosts under it fall back to — which is what makes filing two hosts at once
/// contested one. /// on two machines two independent writes rather than one contested one.
/// </remarks> /// </remarks>
public HostGroupRepository HostGroups { get; } public HostGroupRepository HostGroups { get; }
/// <summary>The tags hosts wear, decrypted, with unpushed local changes laid over them.</summary>
/// <remarks>
/// Membership is not in here either, and for the same reason once removed: each host carries its own set
/// of tag ids. Read beside <see cref="HostGroups"/> whenever a host list is drawn, because a tag id on a
/// host resolves to a name only through this.
/// </remarks>
public TagRepository Tags { get; }
/// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary> /// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
public SnippetRepository Snippets { get; } public SnippetRepository Snippets { get; }
@@ -804,6 +804,17 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = []; private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];
/// <summary>
/// The same groups by id, which is the shape the inheritance walk takes.
/// </summary>
/// <remarks>
/// Cached beside <see cref="groupItems"/> rather than built per call, because resolving is on the path
/// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
/// would be one allocation per row per redraw.
/// </remarks>
private IReadOnlyDictionary<Guid, HostGroupSecret> groupsById =
new Dictionary<Guid, HostGroupSecret>();
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary> /// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
private readonly HashSet<Guid> collapsedGroups = []; private readonly HashSet<Guid> collapsedGroups = [];
@@ -1813,10 +1824,30 @@ internal sealed partial class VaultViewModel(
.ConfigureAwait(true); .ConfigureAwait(true);
groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)]; groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
groupsById = groupItems.ToDictionary(group => group.EntityId, group => group.Secret);
return listing.Unreadable; return listing.Unreadable;
} }
/// <summary>
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
/// authenticate.
/// </summary>
/// <remarks>
/// <para>
/// The one place this view model turns a stored host into a dialled one. A host may leave any of the
/// three unset and take its group's, so reading <c>host.Port</c> or <c>host.SshKeyId</c> directly
/// answers "what did the user type into this host" and not "what happens when this is connected" — and
/// almost everything on screen wants the second question. See <see cref="HostInheritance"/>.
/// </para>
/// <para>
/// Reads <see cref="groupsById"/>, which is refilled by <see cref="ReloadGroupsAsync"/> before the hosts
/// are read. That ordering is not incidental: a host resolved against a stale group list would show one
/// port and dial another.
/// </para>
/// </remarks>
internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary> /// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
private void RebuildGroups() private void RebuildGroups()
{ {
@@ -2177,7 +2208,7 @@ internal sealed partial class VaultViewModel(
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
// hundred scans of the host list on every background sync. // hundred scans of the host list on every background sync.
var dialled = Hosts var dialled = Hosts
.Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) .Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value))
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
@@ -2521,7 +2552,10 @@ internal sealed partial class VaultViewModel(
editingHostVaultId = row.VaultId; editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label; EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname; EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port; // The resolved port rather than the stored one, so a host that inherits opens showing what it
// actually dials rather than an empty box. The editor cannot yet express "leave this to the group",
// so saving pins whatever is shown — which is what it did before any of this existed.
EditorPort = Resolve(row.Host).Port.Value;
EditorUsername = row.Host.Username ?? string.Empty; EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled; EditorRelayEnabled = row.Host.RelayEnabled;
@@ -3807,7 +3841,11 @@ internal sealed partial class VaultViewModel(
} }
var address = row.Host.Hostname; var address = row.Host.Hostname;
var port = row.Host.Port;
// The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its
// group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch
// exactly where it was.
var port = Resolve(row.Host).Port.Value;
await RunAsync( await RunAsync(
$"Forgetting the pinned host key for {address}…", $"Forgetting the pinned host key for {address}…",
@@ -4004,7 +4042,7 @@ internal sealed partial class VaultViewModel(
var request = new SshConnectionRequest( var request = new SshConnectionRequest(
row.Host.Hostname, row.Host.Hostname,
row.Host.Port, Resolve(row.Host).Port.Value,
authentication.Username, authentication.Username,
authentication.Credential); authentication.Credential);
@@ -4170,7 +4208,10 @@ internal sealed partial class VaultViewModel(
} }
request = new SshConnectionRequest( request = new SshConnectionRequest(
host.Hostname, host.Port, authentication.Username, authentication.Credential); host.Hostname,
Resolve(host).Port.Value,
authentication.Username,
authentication.Credential);
return true; return true;
} }
+98 -5
View File
@@ -154,6 +154,9 @@ internal static class ItemKinds
(SyncEntityType.HostGroup, static (outbox, conflicts, keyring) => (SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
new ItemReconciler<HostGroupSecret>(HostGroupKind.Instance, 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) => (SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)), new ItemReconciler<SnippetSecret>(SnippetKind.Instance, outbox, conflicts, keyring)),
@@ -267,7 +270,9 @@ internal sealed class HostKind : IItemKind<HostSecret>
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled); Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId); Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
Note(changed, "Credential", before.CredentialId, after.CredentialId); Note(changed, "Credential", before.CredentialId, after.CredentialId);
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
Note(changed, "Group", before.GroupId, after.GroupId); Note(changed, "Group", before.GroupId, after.GroupId);
Note(changed, "Tags", before.TagIds, after.TagIds);
return changed; return changed;
} }
@@ -588,13 +593,14 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary> /// <summary>
/// Nothing, and the field it declines to send is the one named after this type. /// Nothing, and the two fields it declines to send are the two this type could have filled.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <c>SyncPlaintextFields.GroupId</c> exists, the server had a column for it, and no client ever wrote /// <c>SyncPlaintextFields</c> has a <c>GroupId</c> and a <c>ParentId</c>, the server had a column for the
/// one. What it would have handed over is a clustering of the estate — which machines this user files /// first, and no client ever wrote either. What they would hand over is the shape of the estate — which
/// together — for a column nothing in the product reads. The server now refuses the field outright, on /// machines this user files together, and which of those groupings sit under which — for columns nothing
/// hosts as well as here. See ADR 0004. /// 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> /// </remarks>
/// <inheritdoc /> /// <inheritdoc />
public SyncPlaintextFields? Fields(HostGroupSecret secret) => null; public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
@@ -619,6 +625,11 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
var changed = new List<string>(); var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label); 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; return changed;
} }
@@ -632,6 +643,88 @@ internal sealed class HostGroupKind : IItemKind<HostGroupSecret>
} }
} }
/// <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> /// <summary>Snippets.</summary>
internal sealed class SnippetKind : IItemKind<SnippetSecret> internal sealed class SnippetKind : IItemKind<SnippetSecret>
{ {
+132
View File
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a tag into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="HostGroupCipher"/> exactly, including the rule that a payload is sealed at the version
/// the server <em>will</em> assign rather than the one it replaces — see <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is the one thing not to copy, and this type is where copying it is most tempting.</b>
/// <c>SyncEntityType.Tag</c> is 5 and <c>CryptoSpec.AadResourceType.Tag</c> is 8, because the crypto enum
/// carries None, User, Device and Vault ahead of the item types. A cast from one to the other compiles, and
/// 5 in the crypto enum is <c>Credential</c> — so every tag in the vault would be sealed under the resource
/// type for a password. It would encrypt perfectly and decrypt perfectly on the machine that wrote it, and
/// the first thing to notice would be another implementation refusing the item. The AAD is frozen into
/// stored ciphertext, so by then it is not a bug that can be fixed by a release.
/// </para>
/// </remarks>
public static class TagCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Tag;
/// <summary>Encrypts a tag.</summary>
/// <param name="tag">The tag. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
TagSecret tag,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(tag);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = TagSecretCodec.Encode(tag);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// Wiped like every other payload in this folder, and here for the smallest reason of all: the
// buffer holds one word somebody chose for a filter. It is wiped anyway, because the rule this
// folder follows is that plaintext does not outlive the call that made it, and an exception for
// the case that seems harmless is how the rule stops being one.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a tag.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static TagSecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return TagSecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The tags in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// <para>
/// Another facade over the same generic repository, and like the ones before it, it needed no new sync logic
/// at all.
/// </para>
/// <para>
/// <b>Deleting a tag does not touch the hosts wearing it</b>, for the reason
/// <see cref="HostGroupRepository"/> gives about groups and one more besides. The shared reason: one user
/// action would become N host writes, N outbox rows and N chances to merge against an edit nobody made, and
/// the tag's own tombstone can still lose a merge — by which time the membership it was clearing is gone.
/// The extra one: a host can wear several tags, so unpicking one from N hosts is N read-modify-writes of a
/// set rather than N clears of a pointer, and any of them that loses a merge leaves the vault holding a tag
/// that was deleted and is still worn. Hosts left holding a dangling id simply do not show that chip. See
/// <see cref="HostSecret.TagIds"/>.
/// </para>
/// </remarks>
public sealed class TagRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<TagSecret> tags =
new(TagKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<TagSecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
tags.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
TagSecret tag,
CancellationToken cancellationToken) =>
tags.CreateAsync(vaultId, tag, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
TagSecret tag,
CancellationToken cancellationToken) =>
tags.UpdateAsync(vaultId, entityId, tag, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
tags.DeleteAsync(vaultId, entityId, cancellationToken);
}
+92 -7
View File
@@ -327,15 +327,25 @@ public sealed class VaultKnownHostKey : IVaultItem
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// A group is a name and nothing else, so this row is the narrowest one in the schema: an envelope and its /// Everything a group is — its name, the group it sits under, and the port, username and binding hosts
/// bookkeeping. There is no <c>parent_id</c> and no <c>name</c> column, and both absences are deliberate. /// inside it fall back to — is inside the envelope, so this row is one of the narrowest in the schema: an
/// envelope and its bookkeeping. There is no <c>parent_id</c> and no <c>name</c> column, and both absences
/// are deliberate.
/// </para> /// </para>
/// <para> /// <para>
/// <b>No parent, because groups are flat.</b> A nesting pointer merged by a scalar three-way merge lets two /// <b>No parent column, although groups do nest.</b> The pointer exists; it lives in the payload, and the
/// offline clients each re-parent A under B and B under A, and the result is a cycle the server cannot see — /// push path refuses a plaintext <c>ParentId</c> outright. What a column here would hand the operator is the
/// the pointer would be inside the payload, which the server cannot read — and which every client would then /// shape of every user's estate — how many groupings, how deep, which under which — which is the same
/// have to detect on every read, forever. Flat costs one level of organisation and removes a whole class of /// disclosure a plaintext <c>group_id</c> on a host would have been, and ADR 0004 spends the one plaintext
/// unrepairable state. /// concession this design allows on the relay address instead.
/// </para>
/// <para>
/// The consequence is that the server cannot see a cycle, and it was once the argument for having no parent
/// at all: two offline clients can each re-parent A under B and B under A, a scalar merge accepts both, and
/// nothing server-side can refuse the pair. That is still true and is now handled rather than avoided —
/// every walk of the chain carries a visited set and stops at a repeat, so a cycle degrades to a group that
/// reads as a root. See <c>HostGroupSecret</c> on the client, which is the only thing that can read any of
/// this.
/// </para> /// </para>
/// <para> /// <para>
/// <b>No name, for the reason <see cref="VaultKnownHostKey"/> has no host column.</b> A group name is not /// <b>No name, for the reason <see cref="VaultKnownHostKey"/> has no host column.</b> A group name is not
@@ -407,6 +417,81 @@ public sealed class VaultHostGroup : IVaultItem
public Guid UpdatedByUserId { get; set; } public Guid UpdatedByUserId { get; set; }
} }
/// <summary>
/// A label that can be put on many hosts, as ciphertext.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="VaultHostGroup"/>'s shape exactly — an envelope and its bookkeeping — and the reasoning for the
/// missing <c>name</c> column is the same argument at higher stakes. A group name describes how one user
/// files their machines; a tag name describes what the machines <em>are</em>, and one tag spans twenty hosts
/// by design. A column here would give the operator a labelled map of every estate on the server, to sort a
/// list nothing server-side draws.
/// </para>
/// <para>
/// <b>No membership, and no join table either.</b> Which hosts wear this tag is a set of ids inside each
/// host's payload. <c>SyncEntityType.HostTag</c> reserves a slot for the join and it stays reserved: the one
/// thing a join buys over a set on the host — two people tagging the same host without one of them losing —
/// is already bought by the client's per-key three-way merge, and a second item type is a second table, a
/// second migration and a second pass through every rule in this file for nothing.
/// </para>
/// </remarks>
public sealed class VaultTag : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a tag can be created offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted tag: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>
/// Soft-delete marker; a tombstone, so an offline client learns the tag went away.
/// </summary>
/// <remarks>
/// Deleting a tag leaves every host wearing it holding an id that resolves to nothing, and that is the
/// intended outcome: those hosts simply stop drawing that chip. Rewriting N host payloads inside one
/// delete would turn a single user action into N pushes, and each of those is a read-modify-write of a
/// set that can lose a merge — which would leave the vault holding a tag that is deleted and still worn.
/// </remarks>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary> /// <summary>
/// An S3-compatible bucket and the credentials that reach it, as ciphertext. /// An S3-compatible bucket and the credentials that reach it, as ciphertext.
/// </summary> /// </summary>
@@ -164,11 +164,12 @@ public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration<VaultKn
/// Maps <see cref="VaultHostGroup"/>. /// Maps <see cref="VaultHostGroup"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The same shape again, and by now the sameness is the design rather than a coincidence: four item types /// The same shape again, and by now the sameness is the design rather than a coincidence: most item types
/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is the /// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is a
/// type where a plaintext column would have been most tempting and least defensible — a <c>name</c> here /// type where plaintext columns would have been tempting and are not defensible — a <c>name</c> here would
/// would let the server order a list it never draws, in exchange for telling the operator how every user /// let the server order a list it never draws, and a <c>parent_id</c> would draw the shape of every user's
/// files their machines. /// estate, both in exchange for telling the operator how each of them files their machines. Groups nest
/// through a pointer inside the payload; nothing about that is visible from this table.
/// </remarks> /// </remarks>
public sealed class HostGroupConfiguration : IEntityTypeConfiguration<VaultHostGroup> public sealed class HostGroupConfiguration : IEntityTypeConfiguration<VaultHostGroup>
{ {
@@ -199,6 +200,43 @@ public sealed class HostGroupConfiguration : IEntityTypeConfiguration<VaultHostG
} }
} }
/// <summary>
/// Maps <see cref="VaultTag"/>.
/// </summary>
/// <remarks>
/// <see cref="HostGroupConfiguration"/> again, down to the index names. The one thing worth stating is what
/// is <em>not</em> here: no <c>host_tag</c> join table, because membership is a set inside each host's
/// payload — see <see cref="VaultTag"/> — so there is nothing to join and no second table to keep in step.
/// </remarks>
public sealed class TagConfiguration : IEntityTypeConfiguration<VaultTag>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<VaultTag> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("tag");
builder.HasKey(t => t.Id);
// Client-generated UUIDv7: a tag must be creatable offline, with its id, because the host that
// wears it is created offline too and needs something to point at.
builder.Property(t => t.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
builder.Property(t => t.Payload).IsRequired();
builder.HasIndex(t => new { t.VaultId, t.ChangeSequence });
builder.HasIndex(t => t.VaultId)
.HasFilter("deleted_at_utc IS NULL")
.HasDatabaseName("ix_tag_vault_live");
builder.ToTable(t => t.HasCheckConstraint(
"ck_tag_version",
"version >= 1"));
}
}
/// <summary> /// <summary>
/// Maps <see cref="VaultSnippet"/>. /// Maps <see cref="VaultSnippet"/>.
/// </summary> /// </summary>
@@ -66,6 +66,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>Host groups, held as ciphertext.</summary> /// <summary>Host groups, held as ciphertext.</summary>
public DbSet<VaultHostGroup> HostGroups => Set<VaultHostGroup>(); public DbSet<VaultHostGroup> HostGroups => Set<VaultHostGroup>();
/// <summary>Tags hosts can wear, held as ciphertext.</summary>
public DbSet<VaultTag> Tags => Set<VaultTag>();
/// <summary>Saved commands, held as ciphertext.</summary> /// <summary>Saved commands, held as ciphertext.</summary>
public DbSet<VaultSnippet> Snippets => Set<VaultSnippet>(); public DbSet<VaultSnippet> Snippets => Set<VaultSnippet>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTagItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "tag",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
payload = table.Column<byte[]>(type: "bytea", nullable: false),
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
key_generation = table.Column<int>(type: "integer", nullable: false),
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
version = table.Column<int>(type: "integer", nullable: false),
change_sequence = table.Column<long>(type: "bigint", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_tag", x => x.id);
table.CheckConstraint("ck_tag_version", "version >= 1");
table.ForeignKey(
name: "fk_tag_vaults_vault_id",
column: x => x.vault_id,
principalSchema: "dodo",
principalTable: "vault",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_tag_vault_id_change_sequence",
schema: "dodo",
table: "tag",
columns: new[] { "vault_id", "change_sequence" });
migrationBuilder.CreateIndex(
name: "ix_tag_vault_live",
schema: "dodo",
table: "tag",
column: "vault_id",
filter: "deleted_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "tag",
schema: "dodo");
}
}
}
@@ -1475,6 +1475,87 @@ namespace DodoSSH.Infrastructure.Migrations
}); });
}); });
modelBuilder.Entity("DodoSSH.Domain.VaultTag", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChangeSequence")
.HasColumnType("bigint")
.HasColumnName("change_sequence");
b.Property<Guid?>("ContentKeyId")
.HasColumnType("uuid")
.HasColumnName("content_key_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid")
.HasColumnName("created_by_user_id");
b.Property<byte[]>("DataKeyWrap")
.HasColumnType("bytea")
.HasColumnName("data_key_wrap");
b.Property<DateTimeOffset?>("DeletedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at_utc");
b.Property<int>("KeyGeneration")
.HasColumnType("integer")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("payload");
b.Property<short>("PayloadAadVersion")
.HasColumnType("smallint")
.HasColumnName("payload_aad_version");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UpdatedByUserId")
.HasColumnType("uuid")
.HasColumnName("updated_by_user_id");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_tag");
b.HasIndex("VaultId")
.HasDatabaseName("ix_tag_vault_live")
.HasFilter("deleted_at_utc IS NULL");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_tag_vault_id_change_sequence");
b.ToTable("tag", "dodo", t =>
{
t.HasCheckConstraint("ck_tag_version", "version >= 1");
});
});
modelBuilder.Entity("DodoSSH.Domain.Device", b => modelBuilder.Entity("DodoSSH.Domain.Device", b =>
{ {
b.HasOne("DodoSSH.Domain.UserAccount", "User") b.HasOne("DodoSSH.Domain.UserAccount", "User")
@@ -1687,6 +1768,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault"); b.Navigation("Vault");
}); });
modelBuilder.Entity("DodoSSH.Domain.VaultTag", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany()
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tag_vaults_vault_id");
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.Team", b => modelBuilder.Entity("DodoSSH.Domain.Team", b =>
{ {
b.Navigation("Memberships"); b.Navigation("Memberships");
+80 -7
View File
@@ -1026,11 +1026,16 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
} }
[Fact] [Fact]
public async Task AHostGroupCarryingAParent_IsRejected() public async Task AHostGroupCarryingAPlaintextParent_IsRejected()
{ {
// Groups are flat. A client that grew a tree would reach for ParentId, and the refusal is what stops // Groups nest, and this refusal survived the change that made them nest — with a different reason.
// it storing one — a cycle assembled from two offline re-parents has no repair path, because the // It used to mean "there is no such thing as a parent". It now means "the parent is not the server's
// pointers the server would have to check are inside payloads it cannot read. // to hold": the pointer lives inside the envelope, and a plaintext column here would hand the
// operator the shape of every user's estate, which is the disclosure ADR 0004 refuses everywhere
// except the one address the relay cannot dial without.
//
// The consequence the old reason worried about is real and is handled elsewhere: the server cannot
// see a cycle assembled from two offline re-parents, so every client walk carries a visited set.
var (subject, vaultId) = await SeedUserWithVaultAsync(); var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject); var client = fixture.CreateClientFor(subject);
@@ -1044,7 +1049,61 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
.ShouldHaveSingleItem(); .ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid); result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("flat"); result.Detail.ShouldNotBeNull().ShouldContain("inside its payload");
}
[Fact]
public async Task ATag_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var tagId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest([TagOperation(tagId, expectedVersion: null, envelope: [9, 9])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.Tag]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.Tag);
change.EntityId.ShouldBe(tagId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([9, 9]);
change.PlaintextFields.ShouldBeNull(
"a tag name says what a machine is, and one tag spans twenty of them — a column here would be a "
+ "labelled map of the estate, for a sort this server never performs");
}
[Fact]
public async Task ATagCarryingTheHostItIsOn_IsRejected()
{
// RelatedId is where a join row would put the host, and SyncEntityType.HostTag reserves the slot for
// exactly that join. This design keeps membership in the host's payload instead, so a client
// reaching for the field would be writing the host-to-tag graph into the clear one row at a time.
// SyncPlaintextFields is frozen, so refusing it here is the only place the decision can be enforced.
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = TagOperation(Guid.CreateVersion7(), null, [9])
with
{ PlaintextFields = new SyncPlaintextFields(RelatedId: Guid.CreateVersion7()) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("inside theirs");
} }
[Fact] [Fact]
@@ -1291,8 +1350,9 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
PlaintextFields: null); PlaintextFields: null);
/// <remarks> /// <remarks>
/// Narrow for the same reason as the two above, and the field it most conspicuously does not carry is the /// Narrow for the same reason as the two above, and the fields it most conspicuously does not carry are
/// one named after the type: a group's name is inside the envelope, and so is its membership. /// the two named after the type: a group's name is inside the envelope, and so are its membership and
/// its parent.
/// </remarks> /// </remarks>
private static SyncPushOperation HostGroupOperation( private static SyncPushOperation HostGroupOperation(
Guid entityId, Guid entityId,
@@ -1307,6 +1367,19 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
Payload(envelope), Payload(envelope),
PlaintextFields: null); PlaintextFields: null);
private static SyncPushOperation TagOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.Tag,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushOperation SnippetOperation( private static SyncPushOperation SnippetOperation(
Guid entityId, Guid entityId,
int? expectedVersion, int? expectedVersion,
@@ -1,6 +1,8 @@
namespace DodoSSH.Client.Domain.Tests; namespace DodoSSH.Client.Domain.Tests;
/// <summary>Builds hosts for the suites, so each test varies only what it is about.</summary> /// <summary>
/// Builds hosts, and the groups they are filed under, so each test varies only what it is about.
/// </summary>
internal static class HostFactory internal static class HostFactory
{ {
internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01"); internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01");
@@ -13,10 +15,21 @@ internal static class HostFactory
/// <summary>A group id, for the hosts that are filed under one.</summary> /// <summary>A group id, for the hosts that are filed under one.</summary>
internal static Guid Production { get; } = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04"); internal static Guid Production { get; } = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e04");
/// <summary>A tag id, for the hosts that wear one.</summary>
internal static Guid Pci { get; } = Guid.Parse("0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08");
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
/// <remarks>
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
/// suite here is still about. Passing <see langword="null"/> is how a test asks for the new one.
/// </remarks>
internal static HostSecret Host( internal static HostSecret Host(
string label = "prod-db", string label = "prod-db",
string hostname = "db.internal", string hostname = "db.internal",
int port = 22, int? port = 22,
string? username = "deploy", string? username = "deploy",
string? notes = null, string? notes = null,
Guid[]? jumps = null, Guid[]? jumps = null,
@@ -24,7 +37,9 @@ internal static class HostFactory
bool relayEnabled = false, bool relayEnabled = false,
Guid? sshKeyId = null, Guid? sshKeyId = null,
Guid? credentialId = null, Guid? credentialId = null,
Guid? groupId = null) => bool? asksForPassword = null,
Guid? groupId = null,
Guid[]? tags = null) =>
new() new()
{ {
Label = label, Label = label,
@@ -39,6 +54,33 @@ internal static class HostFactory
RelayEnabled = relayEnabled, RelayEnabled = relayEnabled,
SshKeyId = sshKeyId, SshKeyId = sshKeyId,
CredentialId = credentialId, CredentialId = credentialId,
AsksForPassword = asksForPassword,
GroupId = groupId, GroupId = groupId,
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
};
/// <summary>
/// A group, flat and defaulting nothing unless the test says otherwise.
/// </summary>
/// <remarks>
/// The defaults of this builder are the shape a group had before it could nest or default anything,
/// which is what most suites still want: a heading with a name. A test that passes nothing here is
/// asserting about the old shape on purpose.
/// </remarks>
internal static HostGroupSecret Group(
string label = "production",
Guid? parentId = null,
int? defaultPort = null,
string? defaultUsername = null,
Guid? defaultSshKeyId = null,
Guid? defaultCredentialId = null) =>
new()
{
Label = label,
ParentId = parentId,
DefaultPort = defaultPort,
DefaultUsername = defaultUsername,
DefaultSshKeyId = defaultSshKeyId,
DefaultCredentialId = defaultCredentialId,
}; };
} }
@@ -1,42 +1,141 @@
using System.Text; using System.Text;
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests; namespace DodoSSH.Client.Domain.Tests;
/// <summary> /// <summary>
/// A group: one name, and the reasons it is only that. /// A group: a name, a parent, and the four things hosts under it fall back to.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// There is very little behaviour here to test, which is itself the design — every field that was considered /// <para>
/// and left out (a parent, a member list) was left out because of what it would do to the merge. What these /// A member list is still absent and still for the reason it always was — membership is a pointer on each
/// tests pin is that the envelope round-trips, that a nameless group cannot be stored, and that renaming the /// host, so two people filing two machines into one group is two writes to two items. A parent is present,
/// same group on two machines is reported rather than silently resolved. /// and it was not: see <see cref="HostGroupSecret"/> for why nesting stopped being worth refusing once the
/// defaults made the chain something the connect path had to walk anyway.
/// </para>
/// <para>
/// What these pin is that the envelope round-trips, that a nameless or unstorable group cannot be stored,
/// that a group carrying none of the new fields still encodes at version 1 byte for byte, and that every
/// field is actually consulted by the merge rather than quietly deferring to the server forever.
/// </para>
/// </remarks> /// </remarks>
public sealed class HostGroupSecretTests public sealed class HostGroupSecretTests
{ {
private static Guid Parent { get; } = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05");
private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-6666-7c3d-8e4f-5a6b7c8d9e06");
[Fact] [Fact]
public void AGroup_RoundTrips() public void AFlatGroupWithNoDefaults_RoundTripsAtTheVersionItAlwaysHad()
{ {
var group = new HostGroupSecret { Label = "production" }; var group = Group();
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document) HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
.ShouldBeTrue(); .ShouldBeTrue();
document.ShouldNotBeNull(); document.ShouldNotBeNull();
document.Group.ShouldBe(group); document.Group.ShouldBe(group);
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.CurrentSchemaVersion); document.SchemaVersion.ShouldBe(HostGroupSecretCodec.BaseSchemaVersion);
document.IsReadOnly.ShouldBeFalse(); document.IsReadOnly.ShouldBeFalse();
} }
[Fact]
public void ANestedGroupCarryingEveryDefault_RoundTrips()
{
var group = Group(
parentId: Parent,
defaultPort: 2222,
defaultUsername: "deploy",
defaultCredentialId: TeamCredential);
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.Group.ShouldBe(group);
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.ParentAndDefaultsSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AddingTheParentAndDefaults_DidNotChangeTheBytesOfAGroupWithoutThem()
{
// Pinned against a literal rather than against the codec, because the claim is about history: every
// group already in every vault must re-encode to what it encoded before any of these fields existed,
// or the first sync after an upgrade would push every group as changed. Byte-for-byte, so a new
// field that serialised ahead of the name — or a null that serialised as null — would fail here.
//
// The version in this literal is the other half of the claim. This codec used to stamp
// CurrentSchemaVersion unconditionally, and had that survived, a flat group would now be written at
// 2 and read as uneditable on every machine that had not upgraded.
var bytes = HostGroupSecretCodec.Encode(Group());
Encoding.UTF8.GetString(bytes).ShouldBe("""{"schemaVersion":1,"label":"production"}""");
}
[Theory]
[MemberData(nameof(GroupsCarryingOneNewField))]
public void AGroupCarryingAnyNewField_IsWrittenAtTheVersionThatIntroducedThem(HostGroupSecret group)
{
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(
HostGroupSecretCodec.ParentAndDefaultsSchemaVersion,
"a version that cannot represent the field just written makes an older client decode the "
+ "group as editable and drop that field on the next save");
}
public static TheoryData<HostGroupSecret> GroupsCarryingOneNewField() =>
[
Group(parentId: Parent),
Group(defaultPort: 2222),
Group(defaultUsername: "deploy"),
Group(defaultSshKeyId: DeployKey),
Group(defaultCredentialId: TeamCredential),
];
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public void AGroupWithNoName_IsRefused(string label) public void AGroupWithNoName_IsRefused(string label)
{ {
new HostGroupSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse(); Group(label: label).TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull(); reason.ShouldNotBeNull();
} }
[Fact]
public void TryValidate_RejectsWhatCannotBeStored()
{
Group(parentId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Group(defaultPort: 0).TryValidate(out _).ShouldBeFalse();
Group(defaultPort: 65536).TryValidate(out _).ShouldBeFalse();
Group(defaultSshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Group(defaultCredentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
// Null is the absence of each of these, and the absence is always storable — it is what every group
// in every vault written before this build carries.
Group().TryValidate(out _).ShouldBeTrue();
}
[Fact]
public void AGroupDefaultsOneWay_NotTwo()
{
// The same exclusion a host is held to, for the same reason: a group naming both leaves "what do
// hosts under this authenticate with?" without a single answer. Necessary but not sufficient — a
// host naming a credential under a group naming a key is two valid records, so the resolver enforces
// it again across the chain.
var both = Group(defaultSshKeyId: DeployKey, defaultCredentialId: TeamCredential);
both.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
Group(defaultSshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
Group(defaultCredentialId: TeamCredential).TryValidate(out _).ShouldBeTrue();
}
[Fact] [Fact]
public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere() public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere()
{ {
@@ -64,10 +163,59 @@ public sealed class HostGroupSecretTests
document.ShouldBeNull(); document.ShouldBeNull();
} }
[Fact]
public void AnUnstorablePayload_FailsToDecodeRatherThanProducingAGroupThatCannotBeSaved()
{
// A default port outside the range cannot have been written by this build, so it is either a bug in
// some client or a corrupted write. Decoding it would produce a group the editor could open and
// never save, with nothing on screen to say which field was the problem.
var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":2,"label":"production","defaultPort":0}""");
HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();
document.ShouldBeNull();
}
[Fact]
public void EveryScalarField_IsRoutedThroughAMerge()
{
// A field added to HostGroupSecret but forgotten in the merge would silently revert to the remote
// value forever. Changing each one only locally proves each is actually consulted.
var ancestor = Group();
var local = ancestor with
{
Label = "prod",
ParentId = Parent,
DefaultPort = 2222,
DefaultUsername = "deploy",
DefaultCredentialId = TeamCredential,
};
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
result.Merged.ShouldBe(local);
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void TheKeyFieldTheExclusionKeepsOutOfTheOtherTest_IsAlsoRoutedThroughAMerge()
{
// DefaultSshKeyId cannot appear beside DefaultCredentialId in one valid group, so it gets its own
// pass rather than being the one field the guard above silently skips.
var ancestor = Group();
var local = ancestor with { DefaultSshKeyId = DeployKey };
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
result.Merged.ShouldBe(local);
result.HasConflicts.ShouldBeFalse();
}
[Fact] [Fact]
public void TwoDifferentRenames_AreReportedWithBothNames() public void TwoDifferentRenames_AreReportedWithBothNames()
{ {
var ancestor = new HostGroupSecret { Label = "production" }; var ancestor = Group();
var result = HostGroupSecretMerge.Merge( var result = HostGroupSecretMerge.Merge(
ancestor, ancestor,
@@ -83,6 +231,64 @@ public sealed class HostGroupSecretTests
conflict.Discarded.ShouldBe("prod"); conflict.Discarded.ShouldBe("prod");
} }
[Fact]
public void TwoDifferentReparentings_AreReportedWithBothParents()
{
// The clash that admits a cycle. This merge sees one group against one group, so it cannot know the
// pair it is half of — it resolves, reports, and leaves the containment to the resolver's visited
// set. What it must not do is resolve silently.
var other = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e07");
var ancestor = Group();
var result = HostGroupSecretMerge.Merge(
ancestor,
ancestor with { ParentId = Parent },
ancestor with { ParentId = other });
result.Merged.ParentId.ShouldBe(other);
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(HostGroupSecret.ParentId));
conflict.Kept.ShouldBe(other.ToString());
conflict.Discarded.ShouldBe(Parent.ToString());
}
[Fact]
public void ADefaultClearedLocally_IsNotResurrectedByTheOtherSide()
{
// Null is a value here, not an absence: a group deliberately put back to no default user must not
// silently regain one because the server's copy still names it.
var ancestor = Group(defaultUsername: "deploy");
var local = ancestor with { DefaultUsername = null };
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
result.Merged.DefaultUsername.ShouldBeNull();
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void ADefaultClearedOnOneSideAndChangedOnTheOther_NamesTheAbsenceInTheConflict()
{
// The formatter has to run for the null side too. Short-circuiting on null would print an empty
// string where the conflict log needs to say that what lost was the removal of the default.
var ancestor = Group(defaultPort: 22);
var result = HostGroupSecretMerge.Merge(
ancestor,
ancestor with { DefaultPort = null },
ancestor with { DefaultPort = 2222 });
result.Merged.DefaultPort.ShouldBe(2222);
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(HostGroupSecret.DefaultPort));
conflict.Kept.ShouldBe("2222");
conflict.Discarded.ShouldBe("no default port");
}
/// <remarks> /// <remarks>
/// The case that would collide if membership were held on the group instead of on each host: two people /// The case that would collide if membership were held on the group instead of on each host: two people
/// filing two different machines into one group at the same time. It cannot reach the merge at all, /// filing two different machines into one group at the same time. It cannot reach the merge at all,
@@ -91,7 +297,7 @@ public sealed class HostGroupSecretTests
[Fact] [Fact]
public void FilingHostsIntoAGroup_DoesNotTouchTheGroup() public void FilingHostsIntoAGroup_DoesNotTouchTheGroup()
{ {
var ancestor = new HostGroupSecret { Label = "production" }; var ancestor = Group();
var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor); var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor);
@@ -0,0 +1,257 @@
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Walking a host's group chain for the values it did not state itself.
/// </summary>
/// <remarks>
/// <para>
/// This is the code the connect path runs, so its failures are not cosmetic. A wrong port dials the wrong
/// machine or nothing at all; a wrong binding sends a password to a host set up for key-only access; and a
/// walk that does not terminate is a shell that never opens, which is the one failure a user cannot even
/// describe.
/// </para>
/// <para>
/// The cycle tests are the reason the walk carries a visited set at all. A cycle cannot be created through
/// the editor and cannot be seen by the merge, which resolves one group against one group, or by the
/// server, which cannot read the payload — so it arrives assembled from two offline re-parents or not at
/// all. What these pin is that it degrades to a group reading as a root rather than hanging.
/// </para>
/// </remarks>
public sealed class HostInheritanceTests
{
private static Guid Estate { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
private static Guid Region { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
[Fact]
public void AHostThatStatesEverything_InheritsNothing()
{
var host = Host(port: 2222, username: "deploy", sshKeyId: DeployKey, groupId: Production);
var resolved = HostInheritance.Resolve(
host,
Groups((Production, Group(defaultPort: 9999, defaultUsername: "root"))));
resolved.Port.Value.ShouldBe(2222);
resolved.Port.IsInherited.ShouldBeFalse();
resolved.Username.Value.ShouldBe("deploy");
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.SshKey);
resolved.Binding.EntityId.ShouldBe(DeployKey);
resolved.Binding.IsInherited.ShouldBeFalse();
}
[Fact]
public void AHostThatStatesNothing_TakesItsGroupsValuesAndSaysWhichGroup()
{
// The second half matters as much as the first: the editor draws an inherited value as a
// placeholder behind an empty box, so it has to know the value came from somewhere else.
var host = Host(port: null, username: null, groupId: Production);
var resolved = HostInheritance.Resolve(
host,
Groups((Production, Group(
defaultPort: 2222,
defaultUsername: "deploy",
defaultCredentialId: TeamCredential))));
resolved.Port.Value.ShouldBe(2222);
resolved.Port.FromGroupId.ShouldBe(Production);
resolved.Username.Value.ShouldBe("deploy");
resolved.Username.FromGroupId.ShouldBe(Production);
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
resolved.Binding.EntityId.ShouldBe(TeamCredential);
resolved.Binding.FromGroupId.ShouldBe(Production);
}
[Fact]
public void AHostWithNoGroupAndNothingStated_FallsBackToTwentyTwoAndATypedPassword()
{
// Every host stored before any of this existed. Nothing changed underneath them: naming neither a
// key nor a credential resolved to a typed password then, and resolves to one now.
var resolved = HostInheritance.Resolve(Host(port: null, username: null), Groups());
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
resolved.Port.IsInherited.ShouldBeFalse();
resolved.Username.Value.ShouldBeNull();
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
resolved.Binding.EntityId.ShouldBeNull();
}
[Fact]
public void TheNearestGroupThatStatesAValue_Wins()
{
var host = Host(port: null, username: null, groupId: Production);
var resolved = HostInheritance.Resolve(
host,
Groups(
(Production, Group(parentId: Region, defaultPort: 2222)),
(Region, Group(parentId: Estate, defaultPort: 9999, defaultUsername: "deploy")),
(Estate, Group(defaultUsername: "root"))));
resolved.Port.Value.ShouldBe(2222);
resolved.Port.FromGroupId.ShouldBe(Production);
// Each field is resolved on its own, so a group that answers one question does not stop the walk
// for the others.
resolved.Username.Value.ShouldBe("deploy");
resolved.Username.FromGroupId.ShouldBe(Region);
}
[Fact]
public void AHostWithAnEmptyUsername_HasNoUsernameRatherThanTheGroups()
{
// The three states in one nullable string. Null used to mean "no username", which the connect path
// refuses; it now means "ask the group", so the refusal has to stay reachable or a host under a
// group could never opt out of the group's user.
var resolved = HostInheritance.Resolve(
Host(username: string.Empty, groupId: Production),
Groups((Production, Group(defaultUsername: "deploy"))));
resolved.Username.Value.ShouldBe(string.Empty);
resolved.Username.IsInherited.ShouldBeFalse();
}
[Fact]
public void AHostPinnedToATypedPassword_DoesNotPickUpItsGroupsKey()
{
// The failure worth ruling out above all others in this file. A host deliberately put back on a
// typed password must not silently start authenticating with the fleet's key because somebody set a
// default on the group above it.
var resolved = HostInheritance.Resolve(
Host(asksForPassword: true, groupId: Production),
Groups((Production, Group(defaultSshKeyId: DeployKey))));
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
resolved.Binding.EntityId.ShouldBeNull();
resolved.Binding.IsInherited.ShouldBeFalse();
}
[Fact]
public void AHostNamingACredentialUnderAGroupNamingAKey_ResolvesToOneBindingAndItIsTheHosts()
{
// Two individually valid records that per-record validation cannot catch, because neither one is
// wrong on its own. The exclusion is enforced again here, and the nearer statement wins.
var resolved = HostInheritance.Resolve(
Host(credentialId: TeamCredential, groupId: Production),
Groups((Production, Group(defaultSshKeyId: DeployKey))));
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
resolved.Binding.EntityId.ShouldBe(TeamCredential);
}
[Fact]
public void ANearerGroupsBinding_BeatsAFurtherOnesEvenWhenTheyDiffer()
{
var resolved = HostInheritance.Resolve(
Host(port: null, groupId: Production),
Groups(
(Production, Group(parentId: Estate, defaultCredentialId: TeamCredential)),
(Estate, Group(defaultSshKeyId: DeployKey))));
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.Credential);
resolved.Binding.FromGroupId.ShouldBe(Production);
}
[Fact]
public void AGroupDeletedOnAnotherMachine_LeavesTheHostUngroupedRatherThanFailing()
{
// A dangling id is the ordinary outcome of a delete, not a corruption: deleting a group does not
// rewrite the hosts that named it, deliberately. See HostGroupRepository.
var resolved = HostInheritance.Resolve(Host(port: null, username: null, groupId: Production), Groups());
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
resolved.Username.Value.ShouldBeNull();
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
}
[Fact]
public void AGroupWhoseParentWasDeleted_StopsAtThatGroupRatherThanFailing()
{
var resolved = HostInheritance.Resolve(
Host(port: null, username: null, groupId: Production),
Groups((Production, Group(parentId: Estate, defaultPort: 2222))));
resolved.Port.Value.ShouldBe(2222);
resolved.Username.Value.ShouldBeNull();
}
[Fact]
public void ATwoGroupCycle_ResolvesRatherThanHanging()
{
// The state the old "groups are flat" decision existed to prevent, arriving anyway: two clients each
// re-parenting A under B and B under A while offline. The merge sees one group against one group and
// the server sees ciphertext, so nothing upstream can refuse the pair — which is why the answer is a
// walk that terminates rather than a state that cannot occur.
var resolved = HostInheritance.Resolve(
Host(port: null, username: null, groupId: Production),
Groups(
(Production, Group(parentId: Region, defaultPort: 2222)),
(Region, Group(parentId: Production, defaultUsername: "deploy"))));
// Both groups are still read once, because the repeat is what stops the walk rather than the loop
// being detected up front. What must not happen is a third visit.
resolved.Port.Value.ShouldBe(2222);
resolved.Username.Value.ShouldBe("deploy");
}
[Fact]
public void AGroupThatIsItsOwnParent_ReadsAsARoot()
{
// Not creatable through the editor and not knowable to HostGroupSecret.TryValidate, which sees the
// payload and not the item id. Contained here instead, in the same visited set that has to contain
// a longer cycle anyway.
var chain = HostInheritance
.Chain(Production, Groups((Production, Group(parentId: Production))))
.ToList();
chain.Count.ShouldBe(1);
chain[0].Id.ShouldBe(Production);
}
[Fact]
public void ACycleAboveAHost_StillResolvesEveryFieldItCanReach()
{
// The degradation the design accepts: defaults past the repeat are unresolved, everything before it
// is not, and clearing the parent in the editor is the repair. What it must never be is a connect
// that never returns.
var resolved = HostInheritance.Resolve(
Host(port: null, username: null, groupId: Production),
Groups(
(Production, Group(parentId: Region)),
(Region, Group(parentId: Production))));
resolved.Port.Value.ShouldBe(HostSecret.DefaultPort);
resolved.Username.Value.ShouldBeNull();
resolved.Binding.Kind.ShouldBe(ResolvedBindingKind.TypedPassword);
}
[Fact]
public void TheChain_YieldsNearestFirstAndStopsAtTheRoot()
{
var chain = HostInheritance
.Chain(
Production,
Groups(
(Production, Group(label: "production", parentId: Region)),
(Region, Group(label: "eu-west", parentId: Estate)),
(Estate, Group(label: "estate"))))
.ToList();
chain.Select(entry => entry.Group.Label).ShouldBe(["production", "eu-west", "estate"]);
}
[Fact]
public void TheChainOfAnUngroupedHost_IsEmpty()
{
HostInheritance.Chain(null, Groups()).ShouldBeEmpty();
}
private static Dictionary<Guid, HostGroupSecret> Groups(
params (Guid Id, HostGroupSecret Group)[] groups) =>
groups.ToDictionary(entry => entry.Id, entry => entry.Group);
}
@@ -16,9 +16,10 @@ public sealed class HostSecretCodecTests
{ {
/// <remarks> /// <remarks>
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both. This one carries the credential, because that is the newer /// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
/// of the two, plus a group — which is orthogonal to both and is what makes this host reach the highest /// one carries the credential, because that is the newer of the two, plus a group and a pair of tags —
/// schema version a valid host can. /// which are orthogonal to the binding and are what make this host reach the highest schema version a
/// valid host can.
/// </remarks> /// </remarks>
[Fact] [Fact]
public void AFullHost_RoundTrips() public void AFullHost_RoundTrips()
@@ -35,7 +36,8 @@ public sealed class HostSecretCodecTests
options: [("ServerAliveInterval", "30"), ("Compression", "yes")], options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
relayEnabled: true, relayEnabled: true,
credentialId: credentialId, credentialId: credentialId,
groupId: Production); groupId: Production,
tags: [Pci, EuWest]);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -146,6 +148,149 @@ public sealed class HostSecretCodecTests
document.Host.ShouldBe(host); document.Host.ShouldBe(host);
} }
[Fact]
public void AHostThatInheritsItsPort_IsWrittenAtTheVersionThatIntroducedInheritance()
{
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(
HostSecretCodec.PortInheritSchemaVersion,
"a host stamped at 4 with its port omitted is not read-only on an older build — it is "
+ "undecodable there, because int Port reads 0 and TryValidate refuses it");
document.Host.Port.ShouldBeNull();
}
[Fact]
public void AHostPinnedToATypedPassword_IsWrittenAtTheSameVersionAsAnInheritedPort()
{
// Both halves of inheritance share a version because they arrive together and answer the same
// question: what a host says when it declines to take its group's answer.
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(asksForPassword: true)), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.PortInheritSchemaVersion);
document.Host.AsksForPassword.ShouldBe(true);
}
[Fact]
public void AHostWearingTags_IsWrittenAtTheVersionThatIntroducedThem()
{
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.TagIdsSchemaVersion);
document.Host.TagIds.ShouldBe(TagSet.Create([Pci]));
}
[Fact]
public void ATaggedHostThatPinsItsPort_IsNotDraggedOntoTheInheritanceVersionOrBelowIt()
{
// The maximum, from both directions. Tags are independent of inheritance, so a host that only wears
// one must not be written at 5 — and a host that only inherits must not be written at 6, which would
// make it undecodable on a build that could have read it.
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var tagged)
.ShouldBeTrue();
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var inheriting)
.ShouldBeTrue();
tagged.ShouldNotBeNull().SchemaVersion
.ShouldBeGreaterThan(HostSecretCodec.PortInheritSchemaVersion);
inheriting.ShouldNotBeNull().SchemaVersion
.ShouldBeLessThan(HostSecretCodec.TagIdsSchemaVersion);
}
[Fact]
public void AHostWithAnEmptyTagSet_IsWrittenAtTheVersionItWouldHaveHadWithout()
{
// Wearing no tags is not using the feature. If an empty set bumped the version, adding tags would
// have made every host in every vault read-only on every machine that had not upgraded.
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
}
[Fact]
public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither()
{
// The companion to the pin below, and the one that matters most for these two fields: a set that
// serialised as [] and a flag that serialised as false would both land in every host in every vault,
// and the first sync after the upgrade would push all of them as changed.
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, tags: []));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
""");
}
[Fact]
public void AHostThatInheritsItsPort_OmitsTheKeyRatherThanWritingANull()
{
// The companion pin the old one could not be edited into. What an inheriting host must produce is
// the absence of "port", not "port":null and not "port":22 — the first would decode as 0 on any
// build, and the second is what inheritance exists to stop writing.
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, port: null));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":5,"label":"prod-db","hostname":"db.internal","jumpHostIds":[],"options":{},"relayEnabled":false}
""");
}
[Fact]
public void AHostWearingTags_WritesThemLastAndSorted()
{
// Last, so every field that existed before them keeps its bytes; sorted, so two users who tapped the
// same two chips in opposite orders produce one value and nothing to push.
var bytes = HostSecretCodec.Encode(
Host(username: null, notes: null, tags: [EuWest, Pci]));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08","0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"]}
""");
}
[Fact]
public void APayloadSayingItAsksForNoPassword_DecodesAsHavingSaidNothing()
{
// False and null mean the same thing — take the group's binding — so only one may reach the record.
// Two spellings of one state is a difference the merge would report as a change nobody made.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":5,"label":"prod-db","hostname":"db.internal","port":22,"asksForPassword":false}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.AsksForPassword.ShouldBeNull();
}
[Fact]
public void APayloadRepeatingATag_DecodesAsTheSetItMeant()
{
// Written by some other client, and it must not compare unequal to the same set written once — or
// the engine would push this host as changed on every pass for ever.
var payload = Encoding.UTF8.GetBytes(
$$"""
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["{{Pci}}","{{Pci}}"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.TagIds.ShouldBe(TagSet.Create([Pci]));
}
[Fact] [Fact]
public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne() public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne()
{ {
@@ -56,6 +56,8 @@ public sealed class HostSecretMergeTests
Options = HostOptions.Create([new HostOption("Compression", "yes")]), Options = HostOptions.Create([new HostOption("Compression", "yes")]),
RelayEnabled = true, RelayEnabled = true,
SshKeyId = DeployKey, SshKeyId = DeployKey,
GroupId = Production,
TagIds = TagSet.Create([Pci]),
}; };
var result = HostSecretMerge.Merge(ancestor, local, ancestor); var result = HostSecretMerge.Merge(ancestor, local, ancestor);
@@ -64,6 +66,122 @@ public sealed class HostSecretMergeTests
result.HasConflicts.ShouldBeFalse(); result.HasConflicts.ShouldBeFalse();
} }
[Fact]
public void TheFieldsTheExclusionsKeepOutOfTheGuardAbove_AreAlsoRoutedThroughAMerge()
{
// AsksForPassword cannot sit beside SshKeyId in one valid host, and a null Port cannot sit beside
// an explicit one — so both get their own pass rather than being the fields the guard silently
// skips. A forgotten one here means a host put back on a typed password, or set to take its group's
// port, quietly reverting to the server's copy for ever.
var ancestor = Host();
var local = ancestor with { Port = null, AsksForPassword = true };
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
result.Merged.ShouldBe(local);
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void TwoPeopleAddingDifferentTagsToOneHost_BothKeepTheirs()
{
// The single most visible difference between a field-level merge and last-writer-wins, and the
// reason TagIds merges per tag rather than as a whole value. A whole-value merge would take one
// side's set entire and drop the other's.
var ancestor = Host();
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { TagIds = TagSet.Create([Pci]) },
ancestor with { TagIds = TagSet.Create([EuWest]) });
result.Merged.TagIds.ShouldBe(TagSet.Create([Pci, EuWest]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void OneSideRemovingATagWhileTheOtherAddsAnother_KeepsBothDecisions()
{
// Each tag is resolved on its own, so a removal on one side and an addition on the other are two
// independent answers rather than two versions of one. A whole-value merge would have to pick.
var ancestor = Host(tags: [Pci]);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { TagIds = TagSet.Empty },
ancestor with { TagIds = TagSet.Create([Pci, EuWest]) });
result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest]));
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void ATagRemovedOnBothSides_IsNotResurrected()
{
var ancestor = Host(tags: [Pci, EuWest]);
var untagged = ancestor with { TagIds = TagSet.Create([EuWest]) };
var result = HostSecretMerge.Merge(ancestor, untagged, untagged);
result.Merged.TagIds.ShouldBe(TagSet.Create([EuWest]));
result.HasConflicts.ShouldBeFalse();
}
/// <remarks>
/// The property that makes a tag set the one field on a host which can never ask the user anything. A
/// tag is present or absent, so a key cannot hold two values, so the "both sides moved differently"
/// branch of the keyed merge is unreachable — see <c>HostSecretMerge.MergeTags</c>. Stated as a table
/// over every arrangement of one tag, because the claim is about the whole matrix rather than about any
/// one row of it.
/// </remarks>
[Theory]
[InlineData(true, true, true)]
[InlineData(true, true, false)]
[InlineData(true, false, true)]
[InlineData(true, false, false)]
[InlineData(false, true, true)]
[InlineData(false, true, false)]
[InlineData(false, false, true)]
[InlineData(false, false, false)]
public void NoArrangementOfOneTag_ProducesAConflict(bool inAncestor, bool inLocal, bool inRemote)
{
var result = HostSecretMerge.Merge(
Host(tags: Wearing(inAncestor)),
Host(tags: Wearing(inLocal)),
Host(tags: Wearing(inRemote)));
result.HasConflicts.ShouldBeFalse();
// And the outcome is the one a set should give: a side that moved gets its way, because the other
// one did not move.
result.Merged.TagIds.Contains(Pci).ShouldBe(inAncestor ? inLocal && inRemote : inLocal || inRemote);
}
private static Guid[] Wearing(bool tagged) => tagged ? [Pci] : [];
[Fact]
public void TwoSidesTakingDifferentPorts_NamesTheInheritedOneInTheConflict()
{
// The formatter has to run for the null side, and null here is not an absence — it is the decision
// to take the group's port. A conflict log printing an empty string in its place would leave the
// user unable to tell which of the two decisions was dropped.
var ancestor = Host(port: 22);
var result = HostSecretMerge.Merge(
ancestor,
ancestor with { Port = null },
ancestor with { Port = 2222 });
result.Merged.Port.ShouldBe(2222);
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(HostSecret.Port));
conflict.Kept.ShouldBe("2222");
conflict.Discarded.ShouldBe("the group's port");
}
[Fact] [Fact]
public void ARemovedKeyBinding_IsNotResurrectedByTheOtherSide() public void ARemovedKeyBinding_IsNotResurrectedByTheOtherSide()
{ {
@@ -0,0 +1,117 @@
using System.Text;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// A tag: one name, and the reasons it is only that.
/// </summary>
/// <remarks>
/// <para>
/// There is very little behaviour here, which is the design — a tag exists to have an identity, so that
/// renaming it is one write instead of twenty. What these pin is that the envelope round-trips, that a
/// nameless tag cannot be stored, and that renaming the same tag on two machines is reported rather than
/// silently resolved.
/// </para>
/// <para>
/// The absent test worth naming: nothing here refuses a second tag called the same thing. Two people
/// creating "staging" offline is how it happens, and refusing the loser would mean discarding the tags a
/// colleague had already put on their hosts. See <see cref="TagSecret.TryValidate"/>.
/// </para>
/// </remarks>
public sealed class TagSecretTests
{
[Fact]
public void ATag_RoundTrips()
{
var tag = new TagSecret { Label = "pci" };
TagSecretCodec.TryDecode(TagSecretCodec.Encode(tag), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Tag.ShouldBe(tag);
document.SchemaVersion.ShouldBe(TagSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ATag_EncodesAtTheBaseVersionWithNothingBesideItsName()
{
// Pinned as a literal, so the first field this type ever grows has to be appended and has to be
// omitted when null — the two properties that keep every tag already in every vault re-encoding to
// the bytes it was stored with, rather than looking like a change on the first sync after an upgrade.
Encoding.UTF8.GetString(TagSecretCodec.Encode(new TagSecret { Label = "pci" }))
.ShouldBe("""{"schemaVersion":1,"label":"pci"}""");
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public void ATagWithNoName_IsRefused(string label)
{
new TagSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
}
[Fact]
public void ATagWrittenByANewerClient_IsReadableButNotWritableHere()
{
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":99,"label":"pci","colour":"a field this build has never heard of"}
""");
TagSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Tag.Label.ShouldBe("pci");
document.IsReadOnly.ShouldBeTrue();
}
[Fact]
public void AnUnnamedPayload_FailsToDecodeRatherThanProducingABlankTag()
{
// Failing closed, as every codec in this folder does: a nameless tag is an empty chip beside a host,
// filtering a set nobody can name.
var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":1}""");
TagSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();
document.ShouldBeNull();
}
[Fact]
public void TwoDifferentRenames_AreReportedWithBothNames()
{
var ancestor = new TagSecret { Label = "pci" };
var result = TagSecretMerge.Merge(
ancestor,
ancestor with { Label = "pci-dss" },
ancestor with { Label = "in-scope" });
result.Merged.Label.ShouldBe("in-scope");
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(TagSecret.Label));
conflict.Kept.ShouldBe("in-scope");
conflict.Discarded.ShouldBe("pci-dss");
}
/// <remarks>
/// The case that would collide if membership were held on the tag instead of on each host: two people
/// putting one tag on two different machines at the same time. It cannot reach the merge at all, because
/// neither of those actions writes to this item.
/// </remarks>
[Fact]
public void TaggingHosts_DoesNotTouchTheTag()
{
var ancestor = new TagSecret { Label = "pci" };
var result = TagSecretMerge.Merge(ancestor, ancestor, ancestor);
result.HasConflicts.ShouldBeFalse();
result.Merged.ShouldBe(ancestor);
}
}
@@ -149,18 +149,79 @@ public sealed class ValueSemanticsTests
(copy with { Notes = host.Notes }).ShouldBe(host); (copy with { Notes = host.Notes }).ShouldBe(host);
} }
[Fact]
public void ATagSet_ComparesByContentsAndNotByOrder()
{
// The half that differs from a jump chain, and the reason it is a separate type. A route reordered
// is a different route; a tag list reordered is the same host, so two users who tapped the same two
// chips in opposite orders must produce one value and nothing to push.
TagSet.Create([Pci, EuWest]).Equals(TagSet.Create([EuWest, Pci])).ShouldBeTrue();
(TagSet.Create([Pci, EuWest]) == TagSet.Create([EuWest, Pci])).ShouldBeTrue();
TagSet.Create([Pci, EuWest]).GetHashCode()
.ShouldBe(TagSet.Create([EuWest, Pci]).GetHashCode());
TagSet.Create([Pci]).Equals(TagSet.Create([EuWest])).ShouldBeFalse();
TagSet.Create([Pci]).Equals(TagSet.Create([Pci, EuWest])).ShouldBeFalse();
TagSet.Create([]).Equals(TagSet.Empty).ShouldBeTrue();
TagSet.Create([Pci]).Equals(null).ShouldBeFalse();
}
[Fact]
public void ATagSet_CollapsesARepeatedTag()
{
// A repeat arrives from a payload some other client wrote. Left alone it would compare unequal to
// the same set written once, and the engine would push the host as changed on every pass for ever.
TagSet.Create([Pci, Pci]).Equals(TagSet.Create([Pci])).ShouldBeTrue();
TagSet.Create([Pci, Pci]).Count.ShouldBe(1);
}
[Fact]
public void ATagSet_MapsToItsOwnIdsSoAKeyedMergeIsASetMerge()
{
// The value repeats the key on purpose: no key can then hold two different values, so the only
// disagreement a keyed merge can report is one side adding what the other removed.
var map = TagSet.Create([Pci, EuWest]).ToIdMap();
map.Keys.Order().ShouldBe(new[] { Pci, EuWest }.Order());
map[Pci].ShouldBe(Pci);
map[EuWest].ShouldBe(EuWest);
}
[Fact] [Fact]
public void TryValidate_RejectsWhatCannotBeStored() public void TryValidate_RejectsWhatCannotBeStored()
{ {
Host(label: "").TryValidate(out _).ShouldBeFalse(); Host(label: "").TryValidate(out _).ShouldBeFalse();
Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
Host(port: 0).TryValidate(out _).ShouldBeFalse();
Host(port: 65536).TryValidate(out _).ShouldBeFalse(); Host(port: 65536).TryValidate(out _).ShouldBeFalse();
Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
// Null is "take the group's port", not an absent one, and it has to be storable — it is the whole
// of what inheritance stores.
Host(port: null).TryValidate(out _).ShouldBeTrue();
Host().TryValidate(out _).ShouldBeTrue(); Host().TryValidate(out _).ShouldBeTrue();
} }
[Fact]
public void AHostGivesOneAnswerAboutAuthentication_NotTwo()
{
// The same failure the key-or-credential rule catches, in the direction inheritance opened: a host
// that names a key and also says "ask me for a password" has answered one question twice, and the
// interface, the connect path and the user would each be free to pick a different answer.
var both = Host(sshKeyId: DeployKey, asksForPassword: true);
both.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
Host(asksForPassword: true).TryValidate(out _).ShouldBeTrue();
Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
}
[Fact] [Fact]
public void AHostAuthenticatesOneWay_NotTwo() public void AHostAuthenticatesOneWay_NotTwo()
{ {
@@ -71,6 +71,7 @@ public sealed class AadResourceTypeTests
(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential), (SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey), (SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup), (SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup),
(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag),
(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet), (SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet),
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry), (SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry), (SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
@@ -176,6 +177,12 @@ public sealed class AadResourceTypeTests
SyncEntityType.HostGroup => HostGroupCipher.Seal( SyncEntityType.HostGroup => HostGroupCipher.Seal(
NewGroup(), vaultKey, entityId, generation, version), NewGroup(), vaultKey, entityId, generation, version),
// Tag is the row this file was written for. It is 5 on the wire and 8 in the crypto enum, and 5
// in the crypto enum is Credential — so a TagCipher written by casting its wire type would seal
// every tag in the vault under the resource type for a password, and only this test would say so.
SyncEntityType.Tag => TagCipher.Seal(
NewTag(), vaultKey, entityId, generation, version),
SyncEntityType.Snippet => SnippetCipher.Seal( SyncEntityType.Snippet => SnippetCipher.Seal(
NewSnippet(), vaultKey, entityId, generation, version), NewSnippet(), vaultKey, entityId, generation, version),
@@ -350,6 +357,8 @@ public sealed class AadResourceTypeTests
private static HostGroupSecret NewGroup() => new() { Label = "production" }; private static HostGroupSecret NewGroup() => new() { Label = "production" };
private static TagSecret NewTag() => new() { Label = "pci" };
private static SnippetSecret NewSnippet() => new() private static SnippetSecret NewSnippet() => new()
{ {
Label = "restart the api", Label = "restart the api",
@@ -24,6 +24,7 @@ public sealed class ItemKindsTests
SyncEntityType.Credential, SyncEntityType.Credential,
SyncEntityType.KnownHostKey, SyncEntityType.KnownHostKey,
SyncEntityType.HostGroup, SyncEntityType.HostGroup,
SyncEntityType.Tag,
SyncEntityType.Snippet, SyncEntityType.Snippet,
SyncEntityType.ConnectionLogEntry, SyncEntityType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry, SyncEntityType.ActivityLogEntry,
@@ -379,8 +379,13 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var factory = new SshNetConnectionFactory(knownHosts); var factory = new SshNetConnectionFactory(knownHosts);
// The host this slice built pins its own port, so resolving it against no groups is the identity —
// stated through the resolver anyway, because reading Port directly is the habit that makes an
// inheriting host dial 22 while the rest of the product says otherwise.
var dialled = HostInheritance.Resolve(host, new Dictionary<Guid, HostGroupSecret>()).Port.Value;
var request = new SshConnectionRequest( var request = new SshConnectionRequest(
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword)); host.Hostname, dialled, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
HostKeyPresentation? pin = null; HostKeyPresentation? pin = null;