namespace MxGateway.Server.Security.Authentication; public static class ApiKeyAdminCommandLineParser { public static ApiKeyAdminParseResult Parse(IReadOnlyList args) { if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase)) { return ApiKeyAdminParseResult.NotApiKeyCommand(); } if (args.Count < 2) { return ApiKeyAdminParseResult.Fail("Missing apikey subcommand."); } if (!TryParseKind(args[1], out ApiKeyAdminCommandKind kind)) { return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'."); } Dictionary options = new(StringComparer.OrdinalIgnoreCase); bool json = false; for (int index = 2; index < args.Count; index++) { string arg = args[index]; if (string.Equals(arg, "--json", StringComparison.OrdinalIgnoreCase)) { json = true; continue; } if (!arg.StartsWith("--", StringComparison.Ordinal)) { return ApiKeyAdminParseResult.Fail($"Unexpected argument '{arg}'."); } string name = arg[2..]; string? value; int equalsIndex = name.IndexOf('=', StringComparison.Ordinal); if (equalsIndex >= 0) { value = name[(equalsIndex + 1)..]; name = name[..equalsIndex]; } else { if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal)) { return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value."); } value = args[++index]; } options[name] = value; } string? keyId = GetOption(options, "key-id"); string? displayName = GetOption(options, "display-name"); IReadOnlySet scopes = ParseScopes(GetOption(options, "scopes")); string? validationError = Validate(kind, keyId, displayName); if (validationError is not null) { return ApiKeyAdminParseResult.Fail(validationError); } return ApiKeyAdminParseResult.Success(new ApiKeyAdminCommand( Kind: kind, Json: json, SqlitePath: GetOption(options, "sqlite-path"), Pepper: GetOption(options, "pepper"), KeyId: keyId, DisplayName: displayName, Scopes: scopes)); } private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind) { switch (value.ToLowerInvariant()) { case "init-db": kind = ApiKeyAdminCommandKind.InitDb; return true; case "create-key": kind = ApiKeyAdminCommandKind.CreateKey; return true; case "list-keys": kind = ApiKeyAdminCommandKind.ListKeys; return true; case "revoke-key": kind = ApiKeyAdminCommandKind.RevokeKey; return true; case "rotate-key": kind = ApiKeyAdminCommandKind.RotateKey; return true; default: kind = default; return false; } } private static string? Validate(ApiKeyAdminCommandKind kind, string? keyId, string? displayName) { if (kind is ApiKeyAdminCommandKind.CreateKey or ApiKeyAdminCommandKind.RevokeKey or ApiKeyAdminCommandKind.RotateKey && string.IsNullOrWhiteSpace(keyId)) { return $"Subcommand '{KindName(kind)}' requires --key-id."; } if (!string.IsNullOrWhiteSpace(keyId) && !IsValidKeyId(keyId)) { return "API key id may contain only letters, numbers, periods, and hyphens."; } if (kind == ApiKeyAdminCommandKind.CreateKey && string.IsNullOrWhiteSpace(displayName)) { return "Subcommand 'create-key' requires --display-name."; } return null; } private static string KindName(ApiKeyAdminCommandKind kind) { return kind switch { ApiKeyAdminCommandKind.InitDb => "init-db", ApiKeyAdminCommandKind.CreateKey => "create-key", ApiKeyAdminCommandKind.ListKeys => "list-keys", ApiKeyAdminCommandKind.RevokeKey => "revoke-key", ApiKeyAdminCommandKind.RotateKey => "rotate-key", _ => kind.ToString() }; } private static bool IsValidKeyId(string keyId) { return keyId.All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '-'); } private static string? GetOption(Dictionary options, string name) { return options.TryGetValue(name, out string? value) ? value : null; } private static IReadOnlySet ParseScopes(string? scopes) { return new HashSet( (scopes ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.Ordinal); } }