using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; namespace DodoSSH.Client.Domain; /// A decoded activity log payload, together with the schema version it was written at. /// The entry. /// The version the writing client used. public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion) { /// public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion; } /// /// Encodes and decodes the plaintext inside an activity log entry's encrypted payload. /// /// /// travels as its name and not its number, which is the one thing /// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one /// can still show "PortForward" where a number would leave it showing "9". /// public static class ActivityLogSecretCodec { /// 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(ActivityLogSecret entry) { ArgumentNullException.ThrowIfNull(entry); if (!entry.TryValidate(out var reason)) { throw new ArgumentException(reason, nameof(entry)); } var document = new ActivityLogPayloadDocument { SchemaVersion = CurrentSchemaVersion, ItemKind = entry.ItemKind, ItemId = entry.ItemId, ItemLabel = entry.ItemLabel, Operation = (int)entry.Operation, ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields, At = entry.At, DeviceName = entry.DeviceName, ActorUserId = entry.ActorUserId, }; return JsonSerializer.SerializeToUtf8Bytes( document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument); } /// Parses a decrypted payload. /// public static bool TryDecode( ReadOnlySpan payload, [NotNullWhen(true)] out ActivityLogSecretDocument? document) { document = null; ActivityLogPayloadDocument? parsed; try { parsed = JsonSerializer.Deserialize( payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument); } catch (JsonException) { return false; } if (parsed is null || parsed.SchemaVersion < 1) { return false; } var candidate = new ActivityLogSecret { ItemKind = parsed.ItemKind ?? string.Empty, ItemId = parsed.ItemId, ItemLabel = parsed.ItemLabel ?? string.Empty, Operation = Enum.IsDefined((ActivityOperation)parsed.Operation) ? (ActivityOperation)parsed.Operation : default, ChangedFields = parsed.ChangedFields ?? string.Empty, At = parsed.At, DeviceName = parsed.DeviceName ?? string.Empty, ActorUserId = parsed.ActorUserId, }; if (!candidate.TryValidate(out _)) { return false; } document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion); return true; } } /// The serialised shape. Mutable and nullable because it models untrusted input. /// internal sealed class ActivityLogPayloadDocument { public int SchemaVersion { get; set; } public string? ItemKind { get; set; } public Guid ItemId { get; set; } public string? ItemLabel { get; set; } public int Operation { get; set; } /// /// Written as null when empty rather than as "", so that a create and a delete — which have no /// changed fields by definition — omit the property entirely instead of carrying an empty one. /// public string? ChangedFields { get; set; } public DateTimeOffset At { get; set; } public string? DeviceName { get; set; } public Guid ActorUserId { get; set; } } [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] [JsonSerializable(typeof(ActivityLogPayloadDocument))] internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;