This is an automated email from the ASF dual-hosted git repository.

RyanSkraba pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/avro.git


The following commit(s) were added to refs/heads/main by this push:
     new 787adb119c AVRO-4314: [csharp] Validate names against the Avro name 
grammar (#3895)
787adb119c is described below

commit 787adb119c707815e8c6e75289a40a8ec989bc65
Author: Ismaël Mejía <[email protected]>
AuthorDate: Wed Jul 22 18:13:45 2026 +0200

    AVRO-4314: [csharp] Validate names against the Avro name grammar (#3895)
    
    * 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.
    
    * 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.
    
    * 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.
---
 lang/csharp/src/apache/main/Protocol/Message.cs    |  1 +
 lang/csharp/src/apache/main/Schema/Field.cs        | 10 +++
 lang/csharp/src/apache/main/Schema/SchemaName.cs   | 89 ++++++++++++++++++++++
 .../src/apache/test/Protocol/ProtocolTest.cs       | 17 +++++
 lang/csharp/src/apache/test/Schema/SchemaTests.cs  | 21 +++++
 5 files changed, 138 insertions(+)

diff --git a/lang/csharp/src/apache/main/Protocol/Message.cs 
b/lang/csharp/src/apache/main/Protocol/Message.cs
index 19cc61c84f..090b0f75aa 100644
--- a/lang/csharp/src/apache/main/Protocol/Message.cs
+++ b/lang/csharp/src/apache/main/Protocol/Message.cs
@@ -76,6 +76,7 @@ namespace Avro
         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 799f265b32..3425919fdb 100644
--- a/lang/csharp/src/apache/main/Schema/Field.cs
+++ b/lang/csharp/src/apache/main/Schema/Field.cs
@@ -153,6 +153,16 @@ namespace Avro
                 throw new ArgumentNullException(nameof(name), "name cannot be 
null.");
             }
 
+            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 7716d7a55f..1052a66d92 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
 {
@@ -25,6 +27,83 @@ namespace Avro
     /// </summary>
     public class SchemaName
     {
+        /// <summary>
+        /// 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 (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 <see cref="SchemaParseException"/> 
when
+        /// the name is invalid.
+        /// </summary>
+        /// <param name="name">the simple name to validate</param>
+        /// <param name="what">description of the kind of name, used in error 
messages</param>
+        internal static void ValidateName(string name, string what)
+        {
+            if (string.IsNullOrEmpty(name)
+                || !(name[0] == '_' || char.IsLetter(name, 0)))
+            {
+                throw new SchemaParseException($"Invalid {what} name: 
{Quote(name)}");
+            }
+
+            // 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)
+            {
+                if (name[i] == '_' || char.IsLetterOrDigit(name, i))
+                {
+                    i += char.IsSurrogatePair(name, i) ? 2 : 1;
+                }
+                else
+                {
+                    throw new SchemaParseException($"Invalid {what} name: 
{Quote(name)}");
+                }
+            }
+        }
+
+        /// <summary>
+        /// 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.
+        /// </summary>
+        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;
         
@@ -88,6 +167,16 @@ namespace Avro
 
             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");
+            }
         }
 
         /// <summary>
diff --git a/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs 
b/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs
index 8cc507116c..47f8d98ef0 100644
--- a/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs
+++ b/lang/csharp/src/apache/test/Protocol/ProtocolTest.cs
@@ -181,6 +181,23 @@ namespace Avro.Test
             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<ProtocolParseException>(() => 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 309ecf4d60..d2958a8204 100644
--- a/lang/csharp/src/apache/test/Schema/SchemaTests.cs
+++ b/lang/csharp/src/apache/test/Schema/SchemaTests.cs
@@ -109,6 +109,16 @@ namespace Avro.Test
         [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")]
+        
[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)
@@ -378,6 +388,17 @@ namespace Avro.Test
 
             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<Field>
+                {
+                    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]

Reply via email to