using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded connection log payload, together with the schema version it was written at. /// The entry. /// The version the writing client used. public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion) { /// /// /// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that /// could drop a newer client's field. It stays because the reconciler asks every kind. /// public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside a connection log entry's encrypted payload. /// /// /// /// Mirrors . The two enums are written as numbers rather than names, /// unlike : they are closed sets this codec owns, where the item kind /// is an open one that a newer build may extend. /// /// /// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a /// newer client that has learned a fourth outcome is still worth showing with its host, its times and its /// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway. /// /// public static class ConnectionLogSecretCodec { /// The schema version this build writes. public const int CurrentSchemaVersion = 1; /// Serialises an entry to the bytes that get sealed. /// The entry is not valid for storage. public static byte[] Encode(ConnectionLogSecret entry) { ArgumentNullException.ThrowIfNull(entry); if (!entry.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(entry)); } var document = new ConnectionLogPayloadDocument { SchemaVersion = CurrentSchemaVersion, HostLabel = entry.HostLabel, Address = entry.Address, HostId = entry.HostId, Kind = (int)entry.Kind, StartedAt = entry.StartedAt, DurationMs = (long)entry.Duration.TotalMilliseconds, Outcome = (int)entry.Outcome, DeviceName = entry.DeviceName, ActorUserId = entry.ActorUserId, }; return JsonSerializer.SerializeToUtf8Bytes( document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument); } /// Parses a decrypted payload. /// public static bool TryDecode( ReadOnlySpan payload, [NotNullWhen(true)] out ConnectionLogSecretDocument? document) { document = null; ConnectionLogPayloadDocument? parsed; try { parsed = JsonSerializer.Deserialize( payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument); } catch (JsonException) { return false; } if (parsed is null || parsed.SchemaVersion < 1) { return false; } var candidate = new ConnectionLogSecret { HostLabel = parsed.HostLabel ?? string.Empty, Address = parsed.Address ?? string.Empty, HostId = parsed.HostId, Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default, StartedAt = parsed.StartedAt, Duration = TimeSpan.FromMilliseconds(parsed.DurationMs), Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome) ? (ConnectionOutcome)parsed.Outcome : default, DeviceName = parsed.DeviceName ?? string.Empty, ActorUserId = parsed.ActorUserId, }; if (!candidate.TryValidate(out _)) { return false; } document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion); return true; } } /// The serialised shape. Mutable and nullable because it models untrusted input. /// internal sealed class ConnectionLogPayloadDocument { public int SchemaVersion { get; set; } public string? HostLabel { get; set; } public string? Address { get; set; } public Guid? HostId { get; set; } public int Kind { get; set; } public DateTimeOffset StartedAt { get; set; } /// /// Milliseconds as an integer rather than a , which System.Text.Json writes /// as "00:11:03.4560000" — a format whose parsing varies between platforms and whose precision /// invites a round-trip that is nearly but not exactly the value written. /// public long DurationMs { get; set; } public int Outcome { get; set; } public string? DeviceName { get; set; } public Guid ActorUserId { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(ConnectionLogPayloadDocument))] internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;