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;