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;