using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// /// A folder hosts can be filed under, decrypted, and the defaults they inherit from it. /// /// /// /// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a /// so headings nest, and four Default fields a host under this group falls /// back to when it leaves the matching field unset. /// /// /// No member list. Membership is a 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 while 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. /// /// /// Groups nest, and the cycle is contained rather than prevented. 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. /// /// /// 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. /// /// public sealed record HostGroupSecret : IVaultSecret { /// What the group is called. The only name it has anywhere. public required string Label { get; init; } /// /// The group this one sits under, or null for a root. /// /// /// /// Points upwards for the same reason does: re-parenting two groups /// under one parent on two machines is then two writes to two items rather than two writes to one. /// /// /// The reference may dangle, 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. /// /// /// Inside the payload. SyncPlaintextFields has a ParentId 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 GroupId would have been. See ADR 0004. /// /// public Guid? ParentId { get; init; } /// /// The TCP port hosts in this group use when they do not pin one, or null for no default. /// /// /// 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. /// public int? DefaultPort { get; init; } /// /// The login user hosts in this group use when they do not pin one, or null for no default. /// /// public string? DefaultUsername { get; init; } /// /// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no /// default. /// /// /// /// Same dangling-reference story as , 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. /// /// /// Mutually exclusive with 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. /// /// public Guid? DefaultSshKeyId { get; init; } /// /// The vault credential hosts in this group authenticate with when they bind nothing, or null for no /// default. /// /// public Guid? DefaultCredentialId { get; init; } /// Whether this is storable, and why not if it is not. /// /// /// 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. /// /// /// A group's own id is not knowable here, 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. /// /// public bool TryValidate([NotNullWhen(false)] out string? reason) { if (string.IsNullOrWhiteSpace(Label)) { reason = "A group needs a name."; 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; } }