using System.Net; using System.Net.Sockets; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.TestClient; internal enum TestClientMode { Host, Browse, Watch, Join, } internal sealed class TestClientOptions { internal TestClientMode Mode { get; init; } internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/"); internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050); internal GameId GameId { get; init; } = new("space-game"); internal EnvironmentId EnvironmentId { get; init; } = new("development"); internal RegionId RegionId { get; init; } = new("local"); internal uint ProtocolVersion { get; init; } = 1; internal string BuildVersion { get; init; } = "test-client"; internal string DisplayName { get; init; } = "Rendezvous diagnostic host"; internal string PublisherCredentialEnvironmentVariable { get; init; } = "RENDEZVOUS_PUBLISHER_CREDENTIAL"; internal Dictionary Metadata { get; init; } = new(StringComparer.Ordinal); internal NetworkEndpoint? DedicatedFallback { get; init; } internal SessionListingId? ListingId { get; init; } internal int LocalPort { get; init; } internal int PageSize { get; init; } = 20; internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20); internal TimeSpan? RunDuration { get; init; } internal bool Script { get; init; } internal bool Json { get; init; } internal bool ExitAfterEcho { get; init; } internal bool ExerciseReconnect { get; init; } internal bool ExerciseReset { get; init; } } internal sealed class TestClientParseResult { private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp) { Options = options; Error = error; ShowHelp = showHelp; } internal TestClientOptions? Options { get; } internal string? Error { get; } internal bool ShowHelp { get; } internal bool Succeeded => Options is not null; internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false); internal static TestClientParseResult Failure(string error) => new(null, error, false); internal static TestClientParseResult Help() => new(null, null, true); } internal static class TestClientOptionParser { internal const string Usage = """ Rendezvous diagnostic client Usage: rendezvous-test-client host [options] rendezvous-test-client browse [options] rendezvous-test-client watch [options] rendezvous-test-client join [options] Common options: --service URL HTTP(S) Rendezvous base URL --mediator IP:PORT UDP mediator endpoint --game ID Game scope (default: space-game) --environment ID Environment scope (default: development) --protocol NUMBER Exact gameplay protocol (default: 1) --region ID Region filter/publication (default: local) --timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20) --port NUMBER Caller-owned gameplay UDP port; 0 chooses one --page-size NUMBER Bounded browser page size, 1-100 (default: 20) --script Never prompt; select the first compatible listing --json Emit one versioned JSON event per line --help Show this help Host options: --publisher-credential-env NAME Environment variable containing the credential --display-name TEXT Public listing name --build-version TEXT Public build version --metadata KEY=VALUE Bounded public metadata; may be repeated --fallback IP:PORT Optional policy-authorized dedicated fallback --run-seconds NUMBER Stop after 1-86400 seconds --exit-after-echo Stop after an authenticated ping/echo/ack exchange Watch options: --run-seconds NUMBER Stop after 1-86400 seconds --exercise-reconnect Disconnect after an update and verify ordered replay --exercise-reset Corrupt the snapshot cursor and verify reset/refresh Join options: --listing UUID Join an exact listing; otherwise browse/select Credentials are accepted only through the named environment variable. They are never accepted on the command line and are never written to human or JSON output. """; internal static TestClientParseResult Parse(string[] args) { if (args.Length == 0 || args.Length == 1 && IsHelp(args[0])) { return TestClientParseResult.Help(); } if (args.Length > 64) { return TestClientParseResult.Failure("Too many command-line arguments."); } if (!TryMode(args[0], out TestClientMode mode)) { return TestClientParseResult.Failure("The first argument must be host, browse, or join."); } Uri serviceUri = new("http://127.0.0.1:5000/"); IPEndPoint mediator = new(IPAddress.Loopback, 9050); string game = "space-game"; string environment = "development"; string region = "local"; uint protocol = 1; string buildVersion = "test-client"; string displayName = "Rendezvous diagnostic host"; string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL"; Dictionary metadata = new(StringComparer.Ordinal); NetworkEndpoint? dedicatedFallback = null; SessionListingId? listingId = null; int localPort = 0; int pageSize = 20; int timeoutSeconds = 20; int? runSeconds = null; bool script = false; bool json = false; bool exitAfterEcho = false; bool exerciseReconnect = false; bool exerciseReset = false; HashSet seen = new(StringComparer.Ordinal); for (int index = 1; index < args.Length; index++) { string option = args[index]; if (IsHelp(option)) { return TestClientParseResult.Help(); } if (option is "--script" or "--json" or "--exit-after-echo" or "--exercise-reconnect" or "--exercise-reset") { if (!seen.Add(option)) { return TestClientParseResult.Failure($"Option {option} was specified more than once."); } script |= option == "--script"; json |= option == "--json"; exitAfterEcho |= option == "--exit-after-echo"; exerciseReconnect |= option == "--exercise-reconnect"; exerciseReset |= option == "--exercise-reset"; continue; } if (!option.StartsWith("--", StringComparison.Ordinal) || index + 1 >= args.Length) { return TestClientParseResult.Failure("Every option must use the form --name value."); } string value = args[++index]; if (value.Length is 0 or > 512) { return TestClientParseResult.Failure($"Option {option} has an invalid value length."); } if (option != "--metadata" && !seen.Add(option)) { return TestClientParseResult.Failure($"Option {option} was specified more than once."); } switch (option) { case "--service": if (!TryServiceUri(value, out serviceUri)) { return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free."); } break; case "--mediator": if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator) || parsedMediator.Port == 0) { return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port."); } mediator = parsedMediator; break; case "--game": game = value; break; case "--environment": environment = value; break; case "--region": region = value; break; case "--protocol": if (!uint.TryParse(value, out protocol) || protocol == 0) { return TestClientParseResult.Failure("The protocol must be a positive integer."); } break; case "--build-version": buildVersion = value; break; case "--display-name": displayName = value; break; case "--publisher-credential-env": if (!IsEnvironmentVariableName(value)) { return TestClientParseResult.Failure("The credential environment-variable name is invalid."); } credentialEnvironmentVariable = value; break; case "--metadata": if (!TryMetadata(value, metadata)) { return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key."); } break; case "--fallback": if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint) || fallbackEndpoint.Port == 0) { return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port."); } dedicatedFallback = new NetworkEndpoint { AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork ? AddressFamilyKind.Ipv4 : AddressFamilyKind.Ipv6, Address = fallbackEndpoint.Address.ToString(), Port = fallbackEndpoint.Port, }; break; case "--listing": if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty) { return TestClientParseResult.Failure("The listing must be a non-empty UUID."); } listingId = new SessionListingId(parsedListing); break; case "--port": if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535) { return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535."); } break; case "--page-size": if (!int.TryParse(value, out pageSize) || pageSize is < 1 or > ContractLimits.BrowserPageMaxItems) { return TestClientParseResult.Failure("The page size is outside the contract limit."); } break; case "--timeout-seconds": if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300) { return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds."); } break; case "--run-seconds": if (!int.TryParse(value, out int parsedRunSeconds) || parsedRunSeconds is < 1 or > 86_400) { return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds."); } runSeconds = parsedRunSeconds; break; default: return TestClientParseResult.Failure($"Unknown option {option}."); } } if (!IsSlug(game, ContractLimits.GameIdMaxCharacters) || !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters) || !IsSlug(region, ContractLimits.RegionIdMaxCharacters) || !ContractValidation.IsBuildVersionValid(buildVersion) || !ContractValidation.IsDisplayNameValid(displayName) || !ContractValidation.IsMetadataValid(metadata)) { return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits."); } if (listingId.HasValue && mode != TestClientMode.Join || runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch) || exitAfterEcho && mode != TestClientMode.Host || exerciseReconnect && mode != TestClientMode.Watch || exerciseReset && mode != TestClientMode.Watch || exerciseReconnect && exerciseReset || metadata.Count > 0 && mode != TestClientMode.Host || dedicatedFallback is not null && mode != TestClientMode.Host || seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host || seen.Contains("--display-name") && mode != TestClientMode.Host || seen.Contains("--build-version") && mode != TestClientMode.Host) { return TestClientParseResult.Failure("One or more options do not apply to the selected mode."); } return TestClientParseResult.Success(new TestClientOptions { Mode = mode, ServiceUri = serviceUri, Mediator = mediator, GameId = new(game), EnvironmentId = new(environment), RegionId = new(region), ProtocolVersion = protocol, BuildVersion = buildVersion, DisplayName = displayName, PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable, Metadata = metadata, DedicatedFallback = dedicatedFallback, ListingId = listingId, LocalPort = localPort, PageSize = pageSize, OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds), RunDuration = runSeconds.HasValue ? TimeSpan.FromSeconds(runSeconds.Value) : mode == TestClientMode.Host && script ? TimeSpan.FromSeconds(timeoutSeconds) : null, Script = script, Json = json, ExitAfterEcho = exitAfterEcho, ExerciseReconnect = exerciseReconnect, ExerciseReset = exerciseReset, }); } private static bool TryMode(string value, out TestClientMode mode) => Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode); private static bool IsHelp(string value) => value is "--help" or "-h" or "help"; private static bool TryServiceUri(string value, out Uri uri) { uri = null!; if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed) || parsed.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(parsed.UserInfo) || !string.IsNullOrEmpty(parsed.Query) || !string.IsNullOrEmpty(parsed.Fragment)) { return false; } UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" }; uri = builder.Uri; return true; } private static bool IsEnvironmentVariableName(string value) { if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_')) { return false; } return value.All(static character => char.IsAsciiLetterOrDigit(character) || character == '_'); } private static bool TryMetadata(string value, Dictionary metadata) { int separator = value.IndexOf('='); if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes || metadata.Count >= ContractLimits.MetadataMaxKeys) { return false; } string key = value[..separator]; string metadataValue = value[(separator + 1)..]; return !string.IsNullOrWhiteSpace(key) && ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes) && ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes) && metadata.TryAdd(key, metadataValue); } private static bool IsSlug(string value, int maximumCharacters) => value.Length is > 0 && value.Length <= maximumCharacters && value[0] is >= 'a' and <= 'z' && value.All(static character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-'); }