Public Access
Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,24 +3,36 @@ using System.Diagnostics.CodeAnalysis;
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A folder hosts can be filed under, decrypted.
|
||||
/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
|
||||
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
|
||||
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
|
||||
/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
|
||||
/// <see cref="ParentId"/> so headings nest, and four <c>Default</c> fields a host under this group falls
|
||||
/// back to when it leaves the matching field unset.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
|
||||
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
|
||||
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
|
||||
/// side winning outright and the other host silently leaving the group it was just put in.
|
||||
/// writes to one item, and while <see cref="ThreeWayMerge.Map"/> could now resolve that key by key, the
|
||||
/// pointer on the host is still the better place: it is one write to the item the user just edited, and it
|
||||
/// cannot disagree with itself about which group a host is in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
|
||||
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
|
||||
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
|
||||
/// <b>Groups nest, and the cycle is contained rather than prevented.</b> This is a reversal, and the
|
||||
/// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a
|
||||
/// scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot even see,
|
||||
/// because it is inside the payload. What changed is that a merge is not the only place a cycle has to
|
||||
/// survive. Inheritance means the chain is walked at connect time, so the answer had to be a walk that
|
||||
/// terminates regardless — the resolver carries a visited set and stops at a repeat. A cycle therefore
|
||||
/// degrades to a group that reads as a root: flat headings, defaults unresolved past that point, and
|
||||
/// clearing the parent in the editor is the repair. Given a walk that already had to be cycle-safe,
|
||||
/// refusing to nest bought nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The editor additionally refuses a parent that is already a descendant, which stops a cycle being made
|
||||
/// on this machine. That is a convenience, not the guarantee — the guarantee is the visited set, because a
|
||||
/// cycle assembled from two offline edits was never offered to an editor at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HostGroupSecret : IVaultSecret
|
||||
@@ -28,11 +40,85 @@ public sealed record HostGroupSecret : IVaultSecret
|
||||
/// <summary>What the group is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The group this one sits under, or null for a root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Points upwards for the same reason <see cref="HostSecret.GroupId"/> does: re-parenting two groups
|
||||
/// under one parent on two machines is then two writes to two items rather than two writes to one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The reference may dangle</b>, and a dangling one is a root — the group whose parent was deleted on
|
||||
/// another machine appears at the top level rather than disappearing. Same handling as a host whose group
|
||||
/// is gone, and for the same reason: preventing it would mean one delete rewriting every item that named
|
||||
/// the deleted thing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Inside the payload. <c>SyncPlaintextFields</c> has a <c>ParentId</c> and the server refuses to accept
|
||||
/// one, deliberately: what a plaintext parent hands over is the shape of the user's estate, which is the
|
||||
/// same disclosure a plaintext <c>GroupId</c> would have been. See ADR 0004.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? ParentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The TCP port hosts in this group use when they do not pin one, or null for no default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inherited, not copied. A host created here is not stamped with this value; it is left unset and reads
|
||||
/// through at connect time, so changing this changes every host that never overrode it. Copying at
|
||||
/// creation was the alternative and it makes a group a one-shot template — every host ever created under
|
||||
/// it stays pinned to whatever the default happened to be that day.
|
||||
/// </remarks>
|
||||
public int? DefaultPort { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The login user hosts in this group use when they do not pin one, or null for no default.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="DefaultPort" path="/remarks" />
|
||||
public string? DefaultUsername { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no
|
||||
/// default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Same dangling-reference story as <see cref="HostSecret.SshKeyId"/>, and the same refusal to fall back
|
||||
/// to a password when it cannot be resolved. One extra consequence: a key named only here is named by no
|
||||
/// host at all, so anything counting the hosts bound to a key has to walk groups or it will report a key
|
||||
/// as unused and then refuse every host under this group at connect time.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Mutually exclusive with <see cref="DefaultCredentialId"/></b> on this record, exactly as the two
|
||||
/// are on a host. That is necessary and not sufficient: a host naming a credential under a group naming a
|
||||
/// key is two individually valid records, so the resolver enforces the same exclusion again across the
|
||||
/// chain, host over group, and never returns both.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? DefaultSshKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault credential hosts in this group authenticate with when they bind nothing, or null for no
|
||||
/// default.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="DefaultSshKeyId" path="/remarks" />
|
||||
public Guid? DefaultCredentialId { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
|
||||
/// <para>
|
||||
/// A blank name is refused rather than defaulted. A group is a heading, so a nameless one is
|
||||
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
|
||||
/// cannot tell apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A group's own id is not knowable here</b>, so "a group is not its own parent" cannot be checked at
|
||||
/// this layer: the id belongs to the item, and this record is only the payload inside it. A self-parent
|
||||
/// is therefore caught twice further out — refused by the editor, and survived by the resolver's visited
|
||||
/// set, which is the same machinery that has to contain a longer cycle anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -42,6 +128,40 @@ public sealed record HostGroupSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ParentId == Guid.Empty)
|
||||
{
|
||||
// An empty id is not "no parent" — that is null. It is a reference that can never resolve, and a
|
||||
// group holding one would read as a root while claiming to be nested.
|
||||
reason = "A parent reference cannot be an empty id; use no parent instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Null is "no default port" and passes this comparison, which is the wanted behaviour and is
|
||||
// silent about it: `is < 1 or > 65535` is false for a null int?.
|
||||
if (DefaultPort is < 1 or > 65535)
|
||||
{
|
||||
reason = $"A default port must be between 1 and 65535, not {DefaultPort}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultSshKeyId == Guid.Empty)
|
||||
{
|
||||
reason = "An SSH key reference cannot be an empty id; use no default key instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultCredentialId == Guid.Empty)
|
||||
{
|
||||
reason = "A credential reference cannot be an empty id; use no default credential instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DefaultSshKeyId is not null && DefaultCredentialId is not null)
|
||||
{
|
||||
reason = "A group defaults to a key or to a credential, not both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user