From 9ebf225ad08bfe738b56bb10cdff98bec45ca337 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Wed, 4 Nov 2026 23:28:33 +0100 Subject: [PATCH 1/2] fix: align identifier validation with spec constraints Use URL-safe ASCII identifier keys (letters, digits, dots, underscores, and hyphens) as the library baseline, with stricter rules wherever the specification requires them. - Reject dots in server keys. - Restrict component keys to ASCII. - Preserve the existing channel-key restriction. - Correct channel-address expression detection to include 0 and require nonempty names. - Add regression coverage for validation and parameter serialization. Compatibility: dotted server keys and Unicode component keys now produce validation errors, while their values remain preserved in the model. --- .../Extensions/AsyncApiExtensions.cs | 2 +- .../Rules/AsyncApiComponentsRules.cs | 4 +- .../Validation/Rules/AsyncApiDocumentRules.cs | 6 +- .../Models/ChannelAddressExpressionTests.cs | 49 +++++++ .../Validation/KeyRegexTests.cs | 120 ++++++++++++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs create mode 100644 test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs diff --git a/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs b/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs index 622a7b7..bd2d4da 100644 --- a/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs +++ b/src/ByteBard.AsyncAPI/Extensions/AsyncApiExtensions.cs @@ -4,7 +4,7 @@ public static class AsyncApiExtensions { - private static Regex channelAddressExpressionRegex = new Regex("{[a-zA-Z1-9_-]*}"); + private static Regex channelAddressExpressionRegex = new Regex(@"\{[A-Za-z0-9_-]+\}"); public static bool IsChannelAddressExpression(this string address) { diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs index 380ef42..e22765f 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiComponentsRules.cs @@ -11,11 +11,11 @@ public static class AsyncApiComponentsRules /// /// The key regex. /// - public static Regex KeyRegex = new Regex(@"^[\w\d\.\-_]+$"); + public static Regex KeyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$"); /// /// All the fixed fields declared above are objects - /// that MUST use keys that match the regular expression: ^[\w\d\.\-_]+$. + /// that MUST use keys that match the regular expression: ^[a-zA-Z0-9\.\-_]+$. /// public static ValidationRule KeyMustBeRegularExpression => new ValidationRule( diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs index b96f94d..868b0bb 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs @@ -16,6 +16,8 @@ public static class AsyncApiDocumentRules /// private static Regex keyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, RegexTimeout); + private static Regex serverKeyRegex = new Regex(@"^[a-zA-Z0-9_\-]+$", RegexOptions.None, RegexTimeout); + public static ValidationRule DocumentRequiredFields => new ValidationRule( (context, document) => @@ -67,11 +69,11 @@ public static class AsyncApiDocumentRules context.Enter("servers"); foreach (var key in document.Servers?.Keys) { - if (!keyRegex.IsMatch(key)) + if (!serverKeyRegex.IsMatch(key)) { context.CreateError( "ServerKeys", - string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", keyRegex.ToString())); + string.Format(Resource.Validation_KeyMustMatchRegularExpr, key, "servers", serverKeyRegex.ToString())); } } diff --git a/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs b/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs new file mode 100644 index 0000000..af5be0b --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Models/ChannelAddressExpressionTests.cs @@ -0,0 +1,49 @@ +namespace ByteBard.AsyncAPI.Tests.Models; + +using ByteBard.AsyncAPI.Extensions; +using ByteBard.AsyncAPI.Models; +using ByteBard.AsyncAPI.Readers; +using FluentAssertions; +using NUnit.Framework; + +public class ChannelAddressExpressionTests +{ + [TestCase("id")] + [TestCase("id0")] + [TestCase("user_id")] + [TestCase("user-id")] + public void NamedExpression_PreservesParametersWhenSerialized(string name) + { + var channel = new AsyncApiChannel { Address = "orders/{" + name + "}" }; + channel.Parameters.Add(name, new AsyncApiParameter { Description = "Order identifier" }); + + channel.Address.IsChannelAddressExpression().Should().BeTrue(); + var yaml = channel.SerializeAsYaml(AsyncApiVersion.AsyncApi3_0); + + yaml.Should().Contain("parameters:"); + yaml.Should().Contain("description: Order identifier"); + + var roundTripped = new AsyncApiStringReader().ReadFragment( + yaml, AsyncApiVersion.AsyncApi3_0, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + roundTripped.Parameters.Should().ContainKey(name); + roundTripped.Address.Should().Be(channel.Address); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("orders")] + [TestCase("orders/{}")] + [TestCase("orders/{user.id}")] + [TestCase("orders/{用户}")] + [TestCase("orders/{user id}")] + [TestCase("orders/{user/id}")] + [TestCase("orders/{user%20id}")] + [TestCase("orders/{id")] + [TestCase("orders/id}")] + public void AddressWithoutNamedExpression_IsNotRecognized(string? address) + { + address.IsChannelAddressExpression().Should().BeFalse(); + } +} diff --git a/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs new file mode 100644 index 0000000..eedefcf --- /dev/null +++ b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs @@ -0,0 +1,120 @@ +namespace ByteBard.AsyncAPI.Tests.Validation; + +using System.Collections.Generic; +using ByteBard.AsyncAPI.Readers; +using FluentAssertions; +using NUnit.Framework; + +public class KeyRegexTests +{ + [TestCase("chat.v1")] + [TestCase("chat_01")] + [TestCase("chat-1")] + public void ChannelKey_RetainsExistingPattern(string key) + { + var input = $$""" + asyncapi: 3.0.0 + info: + title: Key validation + version: '1.0' + channels: + '{{key}}': {} + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + diagnostic.Errors.Should().BeEmpty(); + document.Channels.Should().ContainKey(key); + } + + public static IEnumerable ServerKeys() + { + foreach (var version in new[] { "2.6.0", "3.0.0" }) + { + foreach (var key in new[] { "production", "Server_01", "server-1", "0" }) + { + yield return new TestCaseData(version, key, true); + } + + foreach (var key in new[] { "server.one", "sérver", "server/name", "server name", "" }) + { + yield return new TestCaseData(version, key, false); + } + } + } + + public static IEnumerable ComponentKeys() + { + foreach (var version in new[] { "2.6.0", "3.0.0" }) + { + foreach (var key in new[] { "Message", "message.v1", "message_01", "message-1", "0" }) + { + yield return new TestCaseData(version, key, true); + } + + foreach (var key in new[] { "méssage", "消息", "message\u0301", "message/name", "message name", "" }) + { + yield return new TestCaseData(version, key, false); + } + } + } + + [TestCaseSource(nameof(ServerKeys))] + public void ServerKey_UsesSpecificationPattern(string version, string key, bool valid) + { + var addressField = version == "2.6.0" ? "url" : "host"; + var input = $$""" + asyncapi: {{version}} + info: + title: Key validation + version: '1.0' + channels: + chat: {} + servers: + '{{key}}': + {{addressField}}: localhost + protocol: kafka + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + document.Servers.Should().ContainKey(key); + if (valid) + { + diagnostic.Errors.Should().BeEmpty(); + } + else + { + diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'servers' MUST match")); + } + } + + [TestCaseSource(nameof(ComponentKeys))] + public void ComponentKey_UsesAsciiSpecificationPattern(string version, string key, bool valid) + { + var input = $$""" + asyncapi: {{version}} + info: + title: Key validation + version: '1.0' + channels: + chat: {} + components: + messages: + '{{key}}': + name: example + """; + + var document = new AsyncApiStringReader().Read(input, out var diagnostic); + + document.Components.Messages.Should().ContainKey(key); + if (valid) + { + diagnostic.Errors.Should().BeEmpty(); + } + else + { + diagnostic.Errors.Should().ContainSingle(e => e.Message.Contains("in 'messages' MUST match")); + } + } +} From 43b49effd77069ca2c6bce4b3f3e5b687f16cb1e Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Wed, 4 Nov 2026 23:51:41 +0100 Subject: [PATCH 2/2] fix(validation): reject server keys containing newlines Use the strict \z end-of-string anchor for server key validation so keys with a trailing newline cannot pass through the $ anchor. Add regression cases for trailing LF, CRLF, and embedded newlines in AsyncAPI 2.6.0 and 3.0.0. Escape carriage returns and newlines in double-quoted YAML keys so the tests preserve these characters for validation. --- .../Validation/Rules/AsyncApiDocumentRules.cs | 2 +- test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs index 868b0bb..b5c911d 100644 --- a/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs +++ b/src/ByteBard.AsyncAPI/Validation/Rules/AsyncApiDocumentRules.cs @@ -16,7 +16,7 @@ public static class AsyncApiDocumentRules /// private static Regex keyRegex = new Regex(@"^[a-zA-Z0-9\.\-_]+$", RegexOptions.None, RegexTimeout); - private static Regex serverKeyRegex = new Regex(@"^[a-zA-Z0-9_\-]+$", RegexOptions.None, RegexTimeout); + private static Regex serverKeyRegex = new Regex(@"^[a-zA-Z0-9_\-]+\z", RegexOptions.None, RegexTimeout); public static ValidationRule DocumentRequiredFields => new ValidationRule( diff --git a/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs index eedefcf..a8bc733 100644 --- a/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs +++ b/test/ByteBard.AsyncAPI.Tests/Validation/KeyRegexTests.cs @@ -36,7 +36,7 @@ public static IEnumerable ServerKeys() yield return new TestCaseData(version, key, true); } - foreach (var key in new[] { "server.one", "sérver", "server/name", "server name", "" }) + foreach (var key in new[] { "server.one", "sérver", "server/name", "server name", "", "server\n", "server\r\n", "server\nother" }) { yield return new TestCaseData(version, key, false); } @@ -63,6 +63,7 @@ public static IEnumerable ComponentKeys() public void ServerKey_UsesSpecificationPattern(string version, string key, bool valid) { var addressField = version == "2.6.0" ? "url" : "host"; + var yamlKey = key.Replace("\r", "\\r").Replace("\n", "\\n"); var input = $$""" asyncapi: {{version}} info: @@ -71,7 +72,7 @@ public void ServerKey_UsesSpecificationPattern(string version, string key, bool channels: chat: {} servers: - '{{key}}': + "{{yamlKey}}": {{addressField}}: localhost protocol: kafka """;