using System.Net; using System.Net.Sockets; using System.Text; using System.Text.Json; namespace FinalFactory.Rendezvous.Contracts; public static class ContractValidation { private const string CapabilityAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; public static RendezvousErrorCode ValidateContractVersion(int contractVersion) => contractVersion == ContractLimits.ContractVersion ? RendezvousErrorCode.None : RendezvousErrorCode.UnsupportedContractVersion; public static bool AreProtocolsCompatible(uint requested, uint offered) => requested == offered; public static bool IsHttpRequestSizeValid(int byteCount) => byteCount is >= 0 and <= ContractLimits.HttpRequestMaxBytes; public static bool IsBrowserResponseSizeValid(int byteCount) => byteCount is >= 0 and <= ContractLimits.BrowserResponseMaxBytes; public static bool IsUtf8LengthWithin(string? value, int maximumBytes) { if (maximumBytes < 0) { throw new ArgumentOutOfRangeException(nameof(maximumBytes)); } return value is not null && Encoding.UTF8.GetByteCount(value) <= maximumBytes; } public static bool IsPageSizeValid(int pageSize) => pageSize is >= 1 and <= ContractLimits.BrowserPageMaxItems; public static bool IsIdempotencyKeyValid(string? value) => IsVisibleAsciiWithin(value, ContractLimits.IdempotencyKeyMaxCharacters); public static bool IsCursorValid(string? value) => value is null || IsVisibleAsciiWithin(value, ContractLimits.CursorMaxCharacters); public static bool IsDiagnosticCodeValid(string? value) => value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters); public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is ConnectionOutcomeKind.Connected or ConnectionOutcomeKind.Cancelled or ConnectionOutcomeKind.TimedOut or ConnectionOutcomeKind.StaleHost or ConnectionOutcomeKind.TransportFailed or ConnectionOutcomeKind.FallbackOffered or ConnectionOutcomeKind.AttemptExpired or ConnectionOutcomeKind.NoHostPresence or ConnectionOutcomeKind.MediatorUnavailable or ConnectionOutcomeKind.PunchTimedOut or ConnectionOutcomeKind.DirectConnectTimedOut or ConnectionOutcomeKind.HostRejected or ConnectionOutcomeKind.TransportError or ConnectionOutcomeKind.ManagerStopped or ConnectionOutcomeKind.Disposed; public static bool IsBuildVersionValid(string? value) => !string.IsNullOrWhiteSpace(value) && IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); public static bool IsDisplayNameValid(string? value) => !string.IsNullOrWhiteSpace(value) && IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes); public static bool IsOpaqueHttpCredentialValid(string? value) => value is not null && value.Length is > 0 and <= ContractLimits.OpaqueHttpCredentialMaxCharacters; public static bool IsCapacityValid(SessionCapacity? capacity) => capacity is not null && capacity.MaximumPlayers is >= 1 and <= ContractLimits.SessionCapacityMaxPlayers && capacity.CurrentPlayers >= 0 && capacity.CurrentPlayers <= capacity.MaximumPlayers; public static bool IsCapabilityValid(string? capability) => IsBase64UrlValueValid(capability, ContractLimits.UdpCapabilityMaxCharacters); public static bool IsConnectionTicketValid(string? ticket) => IsBase64UrlValueValid(ticket, ContractLimits.ConnectionTicketMaxCharacters); public static bool IsNetworkEndpointValid(NetworkEndpoint? endpoint) { if (endpoint is null || endpoint.Port is < 1 or > ushort.MaxValue || !IPAddress.TryParse(endpoint.Address, out IPAddress? address)) { return false; } return endpoint.AddressFamily switch { AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork, AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6, _ => false, }; } public static bool IsMetadataValid(IReadOnlyDictionary? metadata) { if (metadata is null || metadata.Count > ContractLimits.MetadataMaxKeys) { return false; } foreach (KeyValuePair item in metadata) { if (string.IsNullOrWhiteSpace(item.Key) || !IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes) || !IsUtf8LengthWithin(item.Value, ContractLimits.MetadataValueMaxBytes)) { return false; } } byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options); return encoded.Length <= ContractLimits.MetadataMaxBytes; } internal static bool IsSlug(string? value, int maximumCharacters) { if (string.IsNullOrEmpty(value) || value.Length > maximumCharacters || value[0] is < 'a' or > 'z') { return false; } return value.All(static character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-'); } private static bool IsBase64UrlValueValid(string? value, int maximumCharacters) => value is not null && value.Length is > 0 && value.Length <= maximumCharacters && value.All(static character => CapabilityAlphabet.Contains(character)); private static bool IsVisibleAsciiWithin(string? value, int maximumCharacters) => value is not null && value.Length is > 0 && value.Length <= maximumCharacters && value.All(static character => character is >= '!' and <= '~'); }