using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// A decoded host payload, together with the schema version it was written at.
/// The host.
///
/// The version the writing client used. May exceed
/// , which is the case this type exists to make
/// visible.
///
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
{
///
/// Whether this payload was written by a newer client than the one reading it.
///
///
///
/// Such an item is safe to read — every field this build knows about decodes normally —
/// but must not be re-encoded, because fields added by the newer schema are not represented here
/// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
/// class of bug that makes people stop trusting a synced vault.
///
///
/// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
/// fields through a round trip was the alternative and it is worse — it means carrying opaque
/// JSON inside the domain model, which then has no usable structural equality and so breaks the
/// merge.
///
///
public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
}
///
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
///
///
///
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
/// grows a field without a migration — and the one thing that must not happen is an old client
/// quietly dropping a field a new one wrote, which is what
/// prevents.
///
///
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
/// a codec that produced different bytes for the same host would make every pass look like a change.
///
///
public static class HostSecretCodec
{
/// The first version, and the one a host with no newer field is still written at.
public const int BaseSchemaVersion = 1;
/// The version that introduced .
public const int SshKeyIdSchemaVersion = 2;
/// The version that introduced .
public const int CredentialIdSchemaVersion = 3;
/// The version that introduced .
public const int GroupIdSchemaVersion = 4;
///
/// The version that introduced inheritance: a null and
/// .
///
///
///
/// The one version where "read-only on an older client" understates the cost. 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 int Port reads 0, and
/// refuses the host outright. The item does not appear locked on
/// that machine; it does not appear at all.
///
///
/// 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
/// : only a host that actually inherits its port is written here.
///
///
/// 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".
///
///
public const int PortInheritSchemaVersion = 5;
/// The version that introduced .
///
/// 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.
///
public const int TagIdsSchemaVersion = 6;
/// The version that introduced .
///
/// One past tags rather than sharing with them, for the reason gives
/// for sharing with inheritance instead of standing alone: the two fields are independent, a host can
/// pin paths without wearing a single tag, and stating the version such a host would actually need
/// keeps the rule in a maximum over what is genuinely present rather
/// than a coincidence of what shipped together.
///
public const int PinnedPathsSchemaVersion = 7;
/// The highest schema version this build can write.
///
/// Names the highest constant above, which assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
///
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
/// Serialises a host to the bytes that get sealed.
/// The host is not valid for storage.
public static byte[] Encode(HostSecret host)
{
ArgumentNullException.ThrowIfNull(host);
if (!host.TryValidate(out var error))
{
throw new ArgumentException(error, nameof(host));
}
var options = new SortedDictionary(HostOption.NameComparer);
foreach (var option in host.Options)
{
options[option.Name] = option.Value;
}
var document = new HostPayloadDocument
{
SchemaVersion = SchemaVersionFor(host),
Label = host.Label,
Hostname = host.Hostname,
Port = host.Port,
Username = host.Username,
Notes = host.Notes,
JumpHostIds = [.. host.JumpHostIds],
Options = options,
RelayEnabled = host.RelayEnabled,
SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId,
GroupId = host.GroupId,
// Null rather than false, so a host that never said anything about this encodes exactly as it
// did before the field existed. See HostSecret.AsksForPassword.
AsksForPassword = host.AsksForPassword is true ? true : null,
// Null rather than an empty array, for the same reason and with a wider blast radius: an empty
// [] here would land in every host in every vault and make the first sync after the upgrade
// read as though every one of them had changed.
TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostPayloadJsonContext.Default.HostPayloadDocument);
}
///
/// The lowest schema version that can represent this host without losing anything.
///
///
///
/// Not simply , and in a shared vault the difference is the whole
/// point. The version is what makes an older client treat an item as read-only, so stamping the newest
/// one unconditionally would mean that upgrading a single machine and then touching any host —
/// renaming it, changing a port — made that host uneditable on every machine that had not been upgraded
/// yet. Emitting the lowest version that loses nothing confines that cost to the hosts which actually
/// use the newer field.
///
///
/// The rule generalises, and every field added since has followed it: a host is written at the version
/// that introduced the newest field it actually carries. It also means the bytes for a host that binds
/// nothing are identical to what this codec produced before either binding existed, so adding the fields
/// did not make every host in every vault look like a change to the sync engine.
///
///
/// A maximum, not a ladder, and the difference arrived with . The
/// two authentication bindings are mutually exclusive — see — so
/// while they were the only versioned fields, a switch that returned the first match was
/// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
/// a credential and a group, and the ladder would have answered 3 for it, writing a version that
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
/// drop the field on the next save.
///
///
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
/// stays correct however the next field relates to these.
///
///
private static int SchemaVersionFor(HostSecret host)
{
var version = BaseSchemaVersion;
if (host.SshKeyId is not null)
{
version = Math.Max(version, SshKeyIdSchemaVersion);
}
if (host.CredentialId is not null)
{
version = Math.Max(version, CredentialIdSchemaVersion);
}
if (host.GroupId is not null)
{
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);
}
if (host.PinnedPaths.Count > 0)
{
version = Math.Max(version, PinnedPathsSchemaVersion);
}
return version;
}
///
/// Parses a decrypted payload.
///
///
/// Returns rather than throwing on anything malformed. These bytes
/// authenticated under a key only vault members hold, so a failure here is not an attack — it is
/// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
/// rather than an exception that aborts the whole sync pass and strands every other change.
///
public static bool TryDecode(
ReadOnlySpan payload,
[NotNullWhen(true)] out HostSecretDocument? document)
{
document = null;
HostPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, HostPayloadJsonContext.Default.HostPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
if (!TryBuild(parsed, out var host))
{
return false;
}
document = new HostSecretDocument(host, parsed.SchemaVersion);
return true;
}
private static bool TryBuild(
HostPayloadDocument parsed,
[NotNullWhen(true)] out HostSecret? host)
{
host = null;
var directives = (parsed.Options ?? [])
.Select(entry => new HostOption(entry.Key, entry.Value));
if (!HostOptions.TryCreate(directives, out var options, out _))
{
return false;
}
var candidate = new HostSecret
{
Label = parsed.Label ?? string.Empty,
Hostname = parsed.Hostname ?? string.Empty,
Port = parsed.Port,
Username = parsed.Username,
Notes = parsed.Notes,
JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
Options = options,
RelayEnabled = parsed.RelayEnabled,
SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId,
// False folds back to null: they say the same thing, and letting both onto the record would
// give the merge two spellings of one state to report as a change nobody made.
AsksForPassword = parsed.AsksForPassword is true ? true : null,
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
};
if (!candidate.TryValidate(out _))
{
return false;
}
host = candidate;
return true;
}
}
///
/// The serialised shape. Mutable and nullable because it models untrusted input.
///
///
/// Deliberately separate from . A single type would force the domain model to
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
/// everything — and would let a decode failure produce a half-built host that looks valid to
/// everything downstream.
///
internal sealed class HostPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Hostname { get; set; }
///
/// 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
/// 0 there, which refuses. See
/// .
///
public int? Port { get; set; }
public string? Username { get; set; }
public string? Notes { get; set; }
public Guid[]? JumpHostIds { get; set; }
///
/// Sorted, so serialisation order is defined by the type rather than by insertion order — a
/// plain does not guarantee enumeration order, and this
/// encoding has to be reproducible.
///
public SortedDictionary? Options { get; set; }
public bool RelayEnabled { get; set; }
///
/// Last, deliberately. Property order is the serialisation order, so appending keeps the bytes for every
/// field that existed before this one byte-identical — and a null is omitted entirely, which is what
/// makes a host with no key encode exactly as it did before the field existed.
///
public Guid? SshKeyId { get; set; }
///
public Guid? CredentialId { get; set; }
///
public Guid? GroupId { get; set; }
///
public bool? AsksForPassword { get; set; }
///
/// Last, and it must stay last for the reason gives. Null when the host wears no
/// tags, never [] — 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.
///
public Guid[]? TagIds { get; set; }
///
/// Last, and null rather than [], for exactly the reasons gives — it is the
/// newer of the two and follows the same rule.
///
public string[]? PinnedPaths { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// An unknown member means a newer client wrote a field this build has no concept of. Skipping it
// is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostPayloadDocument))]
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;