From db57bcc101fc6b04ee11ab31f966b7b5c0ae0dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Sun, 19 Jul 2026 17:36:43 +0200 Subject: [PATCH 1/3] AVRO-4314: [csharp] Validate names against the Avro name grammar Record/fixed/enum names, record field names and protocol message names were accepted verbatim when parsing, unlike enum symbols which were already validated. Because the code generator splices some of these values directly into generated C# source (for example protocol message names into a case label and field names into Get/Put switch bodies), an out-of-spec name produced malformed or unexpected generated code. Add a shared, Unicode-aware name validator (first character a letter or '_', remaining characters letters, digits or '_') and apply it to schema names, field names and message names during parsing. The rule is Unicode-aware to preserve the existing support for non-ASCII names, and namespaces are intentionally not validated because the code generator's namespace-mapping feature rewrites them to C#-specific values (such as "@return") and re-parses the schema. Add negative tests for invalid field, record and message names. --- .../src/apache/main/Protocol/Message.cs | 1 + lang/csharp/src/apache/main/Schema/Field.cs | 2 + .../src/apache/main/Schema/SchemaName.cs | 39 +++++++++++++++++++ .../src/apache/test/Protocol/ProtocolTest.cs | 17 ++++++++ .../src/apache/test/Schema/SchemaTests.cs | 8 ++++ 5 files changed, 67 insertions(+) diff --git a/lang/csharp/src/apache/main/Protocol/Message.cs b/lang/csharp/src/apache/main/Protocol/Message.cs index 19cc61c84fe..090b0f75aa5 100644 --- a/lang/csharp/src/apache/main/Protocol/Message.cs +++ b/lang/csharp/src/apache/main/Protocol/Message.cs @@ -76,6 +76,7 @@ public class Message public Message(string name, string doc, RecordSchema request, Schema response, UnionSchema error, bool? oneway) { if (string.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name), "name cannot be null."); + SchemaName.ValidateName(name, "message"); this.Request = request; this.Response = response; this.Error = error; diff --git a/lang/csharp/src/apache/main/Schema/Field.cs b/lang/csharp/src/apache/main/Schema/Field.cs index 799f265b320..857a09ab41d 100644 --- a/lang/csharp/src/apache/main/Schema/Field.cs +++ b/lang/csharp/src/apache/main/Schema/Field.cs @@ -153,6 +153,8 @@ internal Field(Schema schema, string name, IList aliases, int pos, strin throw new ArgumentNullException(nameof(name), "name cannot be null."); } + SchemaName.ValidateName(name, "field"); + Schema = schema ?? throw new ArgumentNullException("type", "type cannot be null."); Name = name; Aliases = aliases; diff --git a/lang/csharp/src/apache/main/Schema/SchemaName.cs b/lang/csharp/src/apache/main/Schema/SchemaName.cs index 7716d7a55ff..554341a3770 100644 --- a/lang/csharp/src/apache/main/Schema/SchemaName.cs +++ b/lang/csharp/src/apache/main/Schema/SchemaName.cs @@ -25,6 +25,35 @@ namespace Avro /// public class SchemaName { + /// + /// Validates that a simple (unqualified) name conforms to the Avro name + /// grammar: a non-empty string whose first character is a letter or '_' + /// and whose remaining characters are letters, digits or '_'. Letters and + /// digits are recognized in a Unicode-aware way, matching the default + /// behavior of the Java SDK (and the C# SDK's existing support for + /// non-ASCII field names). Throws when + /// the name is invalid. + /// + /// the simple name to validate + /// description of the kind of name, used in error messages + internal static void ValidateName(string name, string what) + { + if (string.IsNullOrEmpty(name) + || !(char.IsLetter(name[0]) || name[0] == '_')) + { + throw new SchemaParseException($"Invalid {what} name: {name}"); + } + + for (int i = 1; i < name.Length; i++) + { + char c = name[i]; + if (!(char.IsLetterOrDigit(c) || c == '_')) + { + throw new SchemaParseException($"Invalid {what} name: {name}"); + } + } + } + // cache the full name, so it won't allocate new strings on each call private String fullName; @@ -88,6 +117,16 @@ public SchemaName(string name, string space, string encspace, string documentati Documentation = documentation; fullName = string.IsNullOrEmpty(Namespace) ? Name : Namespace + "." + Name; + + // Validate the simple name only. Namespaces are intentionally not + // validated here: the code generator's namespace-mapping feature + // rewrites schema namespaces to C#-specific values (for example + // "@return" for reserved words) and re-parses the schema, so a + // namespace component may legitimately not match the Avro name grammar. + if (Name != null) + { + ValidateName(Name, "schema"); + } } /// diff --git a/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs b/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs index 8cc507116c1..47f8d98ef00 100644 --- a/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs +++ b/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs @@ -181,6 +181,23 @@ public static void TestProtocol(string str, bool valid) Assert.AreEqual(json,json2); } + [TestCase(@"{ + ""protocol"": ""P"", + ""namespace"": ""com.acme"", + ""types"": [], + ""messages"": { ""bad name"": { ""request"": [], ""response"": ""null"" } } +}", TestName = "MessageNameWithSpace")] + [TestCase(@"{ + ""protocol"": ""P"", + ""namespace"": ""com.acme"", + ""types"": [], + ""messages"": { ""x; int y"": { ""request"": [], ""response"": ""null"" } } +}", TestName = "MessageNameInjection")] + public static void TestInvalidMessageName(string str) + { + Assert.Throws(() => Protocol.Parse(str)); + } + // Protocols match [TestCase( @"{ diff --git a/lang/csharp/src/apache/test/Schema/SchemaTests.cs b/lang/csharp/src/apache/test/Schema/SchemaTests.cs index 309ecf4d601..83cfc722938 100644 --- a/lang/csharp/src/apache/test/Schema/SchemaTests.cs +++ b/lang/csharp/src/apache/test/Schema/SchemaTests.cs @@ -109,6 +109,14 @@ public class SchemaTests [TestCase("{\"type\": \"fixed\", \"name\": \"Missing size\"}", typeof(SchemaParseException))] [TestCase("{\"type\": \"fixed\", \"size\": 314}", typeof(SchemaParseException), Description = "No name")] + + // Names outside the Avro name grammar + [TestCase("{\"type\":\"record\",\"name\":\"Bad Name\",\"fields\":[]}", + typeof(SchemaParseException), Description = "Record name with a space")] + [TestCase("{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"in valid\",\"type\":\"long\"}]}", + typeof(SchemaParseException), Description = "Field name with a space")] + [TestCase("{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"x; int y\",\"type\":\"long\"}]}", + typeof(SchemaParseException), Description = "Field name attempting identifier injection")] public void TestBasic(string s, Type expectedExceptionType = null) { if (expectedExceptionType != null) From 9cddeb29b2c25904b72fd96a0483e11e7646ffec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 20 Jul 2026 09:38:58 +0200 Subject: [PATCH 2/3] AVRO-4314: [csharp] Validate field aliases and sanitize name errors Address review feedback: - Validate record field aliases with the same rule as field names, since aliases participate in schema resolution and are names per the Avro grammar. Previously they were accepted verbatim. - Escape control characters (and quote the value) when embedding an invalid name in the SchemaParseException message, so a crafted name containing newlines or other control characters cannot produce multi-line or ambiguous error output. Add a negative test for an invalid field alias. --- lang/csharp/src/apache/main/Schema/Field.cs | 8 ++++ .../src/apache/main/Schema/SchemaName.cs | 47 ++++++++++++++++++- .../src/apache/test/Schema/SchemaTests.cs | 2 + 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/lang/csharp/src/apache/main/Schema/Field.cs b/lang/csharp/src/apache/main/Schema/Field.cs index 857a09ab41d..3425919fdb5 100644 --- a/lang/csharp/src/apache/main/Schema/Field.cs +++ b/lang/csharp/src/apache/main/Schema/Field.cs @@ -155,6 +155,14 @@ internal Field(Schema schema, string name, IList aliases, int pos, strin SchemaName.ValidateName(name, "field"); + if (aliases != null) + { + foreach (string alias in aliases) + { + SchemaName.ValidateName(alias, "field alias"); + } + } + Schema = schema ?? throw new ArgumentNullException("type", "type cannot be null."); Name = name; Aliases = aliases; diff --git a/lang/csharp/src/apache/main/Schema/SchemaName.cs b/lang/csharp/src/apache/main/Schema/SchemaName.cs index 554341a3770..f23df6fce50 100644 --- a/lang/csharp/src/apache/main/Schema/SchemaName.cs +++ b/lang/csharp/src/apache/main/Schema/SchemaName.cs @@ -17,6 +17,8 @@ */ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text; namespace Avro { @@ -41,7 +43,7 @@ internal static void ValidateName(string name, string what) if (string.IsNullOrEmpty(name) || !(char.IsLetter(name[0]) || name[0] == '_')) { - throw new SchemaParseException($"Invalid {what} name: {name}"); + throw new SchemaParseException($"Invalid {what} name: {Quote(name)}"); } for (int i = 1; i < name.Length; i++) @@ -49,11 +51,52 @@ internal static void ValidateName(string name, string what) char c = name[i]; if (!(char.IsLetterOrDigit(c) || c == '_')) { - throw new SchemaParseException($"Invalid {what} name: {name}"); + throw new SchemaParseException($"Invalid {what} name: {Quote(name)}"); } } } + /// + /// Returns a quoted representation of the given value with control + /// characters escaped, so that an invalid name embedded in an error + /// message cannot inject newlines or other control characters. + /// + private static string Quote(string value) + { + if (value == null) + { + return "null"; + } + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (char c in value) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\r': sb.Append("\\r"); break; + case '\n': sb.Append("\\n"); break; + case '\t': sb.Append("\\t"); break; + default: + if (char.IsControl(c)) + { + sb.AppendFormat(CultureInfo.InvariantCulture, "\\u{0:x4}", (int)c); + } + else + { + sb.Append(c); + } + + break; + } + } + + sb.Append('"'); + return sb.ToString(); + } + // cache the full name, so it won't allocate new strings on each call private String fullName; diff --git a/lang/csharp/src/apache/test/Schema/SchemaTests.cs b/lang/csharp/src/apache/test/Schema/SchemaTests.cs index 83cfc722938..b2809a4d204 100644 --- a/lang/csharp/src/apache/test/Schema/SchemaTests.cs +++ b/lang/csharp/src/apache/test/Schema/SchemaTests.cs @@ -117,6 +117,8 @@ public class SchemaTests typeof(SchemaParseException), Description = "Field name with a space")] [TestCase("{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"x; int y\",\"type\":\"long\"}]}", typeof(SchemaParseException), Description = "Field name attempting identifier injection")] + [TestCase("{\"type\":\"record\",\"name\":\"R\",\"fields\":[{\"name\":\"valid\",\"aliases\":[\"bad alias\"],\"type\":\"long\"}]}", + typeof(SchemaParseException), Description = "Field alias with a space")] public void TestBasic(string s, Type expectedExceptionType = null) { if (expectedExceptionType != null) From fb84499ee000ce19d63af72bca6efe339c48028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isma=C3=ABl=20Mej=C3=ADa?= Date: Mon, 20 Jul 2026 09:44:24 +0200 Subject: [PATCH 3/3] AVRO-4314: [csharp] Handle supplementary-plane characters in names Address review feedback: ValidateName inspected individual UTF-16 code units, which rejected valid supplementary-plane letters and digits that are encoded as surrogate pairs. Iterate by Unicode scalar value using the char.IsLetter(string, int) / char.IsLetterOrDigit(string, int) overloads and advance past surrogate pairs. Add a test that a name containing a supplementary-plane letter (U+20000) is accepted. --- .../csharp/src/apache/main/Schema/SchemaName.cs | 17 ++++++++++++----- .../src/apache/test/Schema/SchemaTests.cs | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lang/csharp/src/apache/main/Schema/SchemaName.cs b/lang/csharp/src/apache/main/Schema/SchemaName.cs index f23df6fce50..1052a66d92b 100644 --- a/lang/csharp/src/apache/main/Schema/SchemaName.cs +++ b/lang/csharp/src/apache/main/Schema/SchemaName.cs @@ -31,7 +31,8 @@ public class SchemaName /// Validates that a simple (unqualified) name conforms to the Avro name /// grammar: a non-empty string whose first character is a letter or '_' /// and whose remaining characters are letters, digits or '_'. Letters and - /// digits are recognized in a Unicode-aware way, matching the default + /// digits are recognized in a Unicode-aware way (including supplementary + /// characters represented by surrogate pairs), matching the default /// behavior of the Java SDK (and the C# SDK's existing support for /// non-ASCII field names). Throws when /// the name is invalid. @@ -41,15 +42,21 @@ public class SchemaName internal static void ValidateName(string name, string what) { if (string.IsNullOrEmpty(name) - || !(char.IsLetter(name[0]) || name[0] == '_')) + || !(name[0] == '_' || char.IsLetter(name, 0))) { throw new SchemaParseException($"Invalid {what} name: {Quote(name)}"); } - for (int i = 1; i < name.Length; i++) + // Iterate by Unicode scalar value so supplementary-plane letters and + // digits (encoded as surrogate pairs) are handled correctly. + int i = char.IsSurrogatePair(name, 0) ? 2 : 1; + while (i < name.Length) { - char c = name[i]; - if (!(char.IsLetterOrDigit(c) || c == '_')) + if (name[i] == '_' || char.IsLetterOrDigit(name, i)) + { + i += char.IsSurrogatePair(name, i) ? 2 : 1; + } + else { throw new SchemaParseException($"Invalid {what} name: {Quote(name)}"); } diff --git a/lang/csharp/src/apache/test/Schema/SchemaTests.cs b/lang/csharp/src/apache/test/Schema/SchemaTests.cs index b2809a4d204..d2958a82042 100644 --- a/lang/csharp/src/apache/test/Schema/SchemaTests.cs +++ b/lang/csharp/src/apache/test/Schema/SchemaTests.cs @@ -388,6 +388,17 @@ public void TestRecordFieldNames() { Field f = recordSchema.Fields[0]; Assert.AreEqual("歳以上", f.Name); + + // A supplementary-plane letter (U+20000, encoded as a surrogate pair) + // is a valid name character and must be accepted. + const string astralName = "\U00020000field"; + var astralFields = new List + { + new Field(PrimitiveSchema.Create(Schema.Type.Long), astralName, null, 0, null, null, + Field.SortOrder.ignore, null) + }; + var astralRecord = RecordSchema.Create("AstralRecord", astralFields); + Assert.AreEqual(astralName, astralRecord.Fields[0].Name); } [TestCase]