Code/SboxSuperuser/Core/Prefixes/PrefixCodec.cs

JSON codec for the prefix system. Serializes and parses the stored prefix catalog (prefixes, groups, per-user assignments) and the snapshot sent to clients, both schema 1, with size and count limits and structural validation on read.

using System.Text;
using System.Text.Json;
using SboxSuperuser.Core.Identity;
using SboxSuperuser.Core.Storage;

namespace SboxSuperuser.Core.Prefixes;

/// <summary>Reads and writes the stored prefix catalog and the snapshot sent to clients. Both are schema 1.</summary>
internal static class PrefixCodec
{
	internal const int Schema = 1;
	internal const int MaximumStateBytes = 48 * 1024;
	internal const int MaximumSnapshotBytes = 64 * 1024;
	internal const int MaximumSnapshotPlayers = 256;

	internal static string WriteState( PrefixCatalog catalog )
	{
		var state = JsonSerializer.Serialize( new
		{
			schema = Schema,
			prefixes = catalog.Prefixes.Select( Prefix ).ToArray(),
			groups = catalog.Groups,
			users = catalog.Users
		} );
		if ( Encoding.UTF8.GetByteCount( state ) > MaximumStateBytes )
			throw new InvalidOperationException( $"Prefixes are limited to {MaximumStateBytes / 1024} KB in total; shorten or delete some effects." );
		return state;
	}

	internal static PrefixCatalog ReadState( JsonElement state )
	{
		StoreFormat.RequireFields( state, "schema", "prefixes", "groups", "users" );
		RequireSchema( state );
		var users = Map( state.GetProperty( "users" ) );
		foreach ( var steamId in users.Keys ) SteamIdentity.Validate( steamId, "users" );
		return new PrefixCatalog( Prefixes( state.GetProperty( "prefixes" ) ), Map( state.GetProperty( "groups" ) ), users );
	}

	internal static string WriteSnapshot( PrefixSnapshot snapshot )
	{
		var text = JsonSerializer.Serialize( new
		{
			schema = Schema,
			enabled = snapshot.Enabled,
			chat = snapshot.Chat,
			prefixes = snapshot.Prefixes.Select( Prefix ).ToArray(),
			players = snapshot.Players
		} );
		if ( Encoding.UTF8.GetByteCount( text ) > MaximumSnapshotBytes ) throw new InvalidOperationException( "The prefix snapshot is too large to send." );
		return text;
	}

	internal static PrefixSnapshot ReadSnapshot( string text )
	{
		if ( text is null || text.Length > MaximumSnapshotBytes ) throw new FormatException( "The prefix snapshot is too large." );
		using var json = JsonDocument.Parse( text );
		var root = json.RootElement;
		StoreFormat.RequireFields( root, "schema", "enabled", "chat", "prefixes", "players" );
		RequireSchema( root );
		var players = Map( root.GetProperty( "players" ) );
		if ( players.Count > MaximumSnapshotPlayers ) throw new FormatException( "Too many players in the prefix snapshot." );
		var prefixes = Prefixes( root.GetProperty( "prefixes" ) );
		if ( prefixes.Length > PrefixCatalog.MaximumPrefixes ) throw new FormatException( "Too many prefixes." );
		return new PrefixSnapshot( root.GetProperty( "enabled" ).GetBoolean(), root.GetProperty( "chat" ).GetBoolean(), prefixes, players );
	}

	/// <summary>Whether two definitions would store identically.</summary>
	internal static bool Same( PrefixDefinition first, PrefixDefinition second ) =>
		JsonSerializer.Serialize( Prefix( first ) ) == JsonSerializer.Serialize( Prefix( second ) );

	private static object Prefix( PrefixDefinition prefix ) => new
	{
		id = prefix.Id,
		name = prefix.Name,
		tag = prefix.Tag,
		template = prefix.Template,
		tagStyle = Style( prefix.TagStyle ),
		nameStyle = Style( prefix.NameStyle ),
		palette = prefix.Palette
	};

	private static object Style( PrefixStyle style ) => new { kind = style.Kind.ToString(), value = style.Value };

	private static PrefixDefinition[] Prefixes( JsonElement array )
	{
		if ( array.ValueKind != JsonValueKind.Array || array.GetArrayLength() > PrefixCatalog.MaximumPrefixes )
			throw new FormatException( "Expected at most 64 prefixes." );
		return array.EnumerateArray().Select( ReadPrefix ).ToArray();
	}

	private static PrefixDefinition ReadPrefix( JsonElement prefix )
	{
		StoreFormat.RequireFields( prefix, "id", "name", "tag", "template", "tagStyle", "nameStyle", "palette" );
		var palette = prefix.GetProperty( "palette" );
		if ( palette.ValueKind != JsonValueKind.Array || palette.GetArrayLength() > PrefixDefinition.MaximumPaletteColors )
			throw new FormatException( "Invalid palette." );
		return PrefixDefinition.Create( prefix.GetProperty( "id" ).GetString(), prefix.GetProperty( "name" ).GetString(),
			prefix.GetProperty( "tag" ).GetString(), prefix.GetProperty( "template" ).GetString(),
			ReadStyle( prefix.GetProperty( "tagStyle" ) ), ReadStyle( prefix.GetProperty( "nameStyle" ) ),
			palette.EnumerateArray().Select( color => color.GetString() ).ToArray() );
	}

	private static PrefixStyle ReadStyle( JsonElement style )
	{
		StoreFormat.RequireFields( style, "kind", "value" );
		var name = style.GetProperty( "kind" ).GetString();
		if ( !Enum.TryParse<PrefixStyleKind>( name, out var kind ) || kind.ToString() != name ) throw new FormatException( "Unknown style kind." );
		var value = style.GetProperty( "value" ).GetString();
		// A preset removed by a later version draws plain instead of making the whole catalog unreadable.
		if ( kind == PrefixStyleKind.Preset && Effects.EffectPresets.Find( value ) is null ) return PrefixStyle.Plain;
		return PrefixStyle.Create( kind, value );
	}

	private static Dictionary<string, string> Map( JsonElement value )
	{
		if ( value.ValueKind != JsonValueKind.Object ) throw new FormatException( "Expected an object." );
		var map = new Dictionary<string, string>( StringComparer.Ordinal );
		foreach ( var property in value.EnumerateObject() )
		{
			if ( !map.TryAdd( property.Name, property.Value.GetString() ?? throw new FormatException( "Expected text." ) ) )
				throw new FormatException( "Duplicate assignment." );
		}
		return map;
	}

	private static void RequireSchema( JsonElement value )
	{
		if ( value.GetProperty( "schema" ).GetInt32() != Schema ) throw new UnsupportedStoreSchemaException();
	}
}