From 06f9f4b47f131d08ad903eb18ce316fb0e012280 Mon Sep 17 00:00:00 2001 From: Alex Soto Date: Thu, 12 Mar 2026 01:29:25 -0400 Subject: [PATCH] Fix ArgumentOutOfRangeException in Type and Decl constructors during ObjC AST traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When using ClangSharp to traverse Objective-C translation units that include Apple system frameworks (e.g. ``), the Type constructor throws `ArgumentOutOfRangeException("handle")` during deep AST traversal. This makes it impossible to bind large Objective-C frameworks like Facebook's FBSDKCoreKit (219 headers) using tools built on ClangSharp. The crash was discovered while using the `sharpie bind` tool (from dotnet/macios) to generate C# bindings for iOS frameworks. The specific crash path is: TranslationUnitDecl.Decls → RecordDecl.Decls (C struct fields from Foundation) → FieldDecl.Type → PointerType.PointeeType (lazy evaluation) → Type.Create() dispatches to AttributedType based on TypeClass → AttributedType constructor passes CXType_Attributed as expectedKind → Type constructor: handle.kind (CXType_ObjCId) != expectedKind → THROW ## Root Cause The `Type` constructor validates `CXTypeKind` (from libclang) BEFORE `CX_TypeClass` (from libClangSharp), and throws when they don't match. However, libclang's `CXTypeKind` is a coarser classification than libClangSharp's `CX_TypeClass`. For certain Objective-C types, libclang returns a broad kind like `CXType_ObjCId` (27) or `CXType_Unexposed` (1) while libClangSharp correctly classifies the same type as `CX_TypeClass_Attributed` by inspecting the Clang AST directly. The `Type.Create()` factory method dispatches on `TypeClass` (which is correct), but the resulting subclass constructor then rejects the handle because `CXTypeKind` doesn't match the expected value. This is a false rejection — `TypeClass` is the authoritative classifier and should take precedence. A secondary issue exists in the `Decl` constructor: the explicit check `handle.DeclKind == CX_DeclKind_Invalid` causes an unconditional throw even when `Decl.Create()`'s default case intentionally constructs a generic Decl wrapper for unknown declaration kinds. The default case passes `expectedDeclKind = handle.DeclKind = CX_DeclKind_Invalid`, which then triggers the Invalid-specific guard. This makes the default case dead code that always crashes instead of gracefully degrading. ## Fix ### Type.cs - Reorder validation: check `CX_TypeClass` first (authoritative), then `CXTypeKind` (informational) - When `TypeClass` matches but `CXTypeKind` doesn't, accept the type instead of throwing. This handles the common case where libclang uses a broader kind (e.g. `CXType_ObjCId`, `CXType_Unexposed`, `CXType_ObjCObjectPointer`) for a type that libClangSharp classifies more precisely ### Decl.cs - Remove the `handle.DeclKind == CX_DeclKind_Invalid` guard so that the default case in `Decl.Create()` can construct a generic Decl wrapper for unknown declaration kinds instead of crashing ## Tests Added 4 new tests in `ObjectiveCTest.cs`: - `Type_AttributedType_WithMismatchedCXTypeKind`: Parses an ObjC file with `nullable id` parameters using Foundation headers and deeply traverses all type information including PointeeType and AttributedType chains - `Type_FullFoundationTraversal_DoesNotCrash`: Full recursive traversal of all declarations from a Foundation-importing translation unit, including ObjCContainerDecl children and RecordDecl fields (the actual crash path) - `Type_DeepTraversal_DoesNotCrash`: Deep traversal of inline ObjC code with interfaces, protocols, categories, methods, and properties - `Decl_InvalidDeclKind_DoesNotCrash`: Traversal of diverse ObjC declaration kinds including CursorChildren access The Foundation-based tests: - Try iPhoneOS SDK first (where the bug manifests), fall back to macOS SDK - Use `xcrun clang --print-resource-dir` for clang resource headers - Gracefully skip via `Assert.Ignore` if no SDK is available - Are already guarded by `[Platform("macosx")]` on the test class Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sources/ClangSharp/Cursors/Decls/Decl.cs | 7 +- sources/ClangSharp/Types/Type.cs | 24 +- tests/ClangSharp.UnitTests/ObjectiveCTest.cs | 470 +++++++++++++++++++ 3 files changed, 493 insertions(+), 8 deletions(-) diff --git a/sources/ClangSharp/Cursors/Decls/Decl.cs b/sources/ClangSharp/Cursors/Decls/Decl.cs index 154b4508..71d4c4ed 100644 --- a/sources/ClangSharp/Cursors/Decls/Decl.cs +++ b/sources/ClangSharp/Cursors/Decls/Decl.cs @@ -26,7 +26,12 @@ public class Decl : Cursor private protected Decl(CXCursor handle, CXCursorKind expectedCursorKind, CX_DeclKind expectedDeclKind) : base(handle, expectedCursorKind) { - if ((handle.DeclKind == CX_DeclKind_Invalid) || (handle.DeclKind != expectedDeclKind)) + // When the native libClangSharp doesn't have a mapping for a declaration kind, + // it returns CX_DeclKind_Invalid. When the default case in Decl.Create() constructs + // a generic Decl with expectedDeclKind == CX_DeclKind_Invalid, we should allow it + // rather than throwing, so that unknown declaration kinds degrade gracefully to a + // base Decl wrapper instead of crashing the entire traversal. + if (handle.DeclKind != expectedDeclKind) { throw new ArgumentOutOfRangeException(nameof(handle)); } diff --git a/sources/ClangSharp/Types/Type.cs b/sources/ClangSharp/Types/Type.cs index df2f5973..50ad5fa6 100644 --- a/sources/ClangSharp/Types/Type.cs +++ b/sources/ClangSharp/Types/Type.cs @@ -20,19 +20,29 @@ public unsafe class Type : IEquatable protected Type(CXType handle, CXTypeKind expectedKind, CX_TypeClass expectedTypeClass, params ReadOnlySpan additionalExpectedKinds) { -#if NET10_0_OR_GREATER - if (handle.kind != expectedKind && !additionalExpectedKinds.Contains(handle.kind)) -#else - if (handle.kind != expectedKind && !Contains(additionalExpectedKinds, handle.kind)) -#endif + if ((handle.TypeClass == CX_TypeClass_Invalid) || (handle.TypeClass != expectedTypeClass)) { throw new ArgumentOutOfRangeException(nameof(handle)); } - if ((handle.TypeClass == CX_TypeClass_Invalid) || (handle.TypeClass != expectedTypeClass)) + // CXTypeKind is validated after TypeClass because libclang's CXTypeKind uses a + // coarser classification than libClangSharp's CX_TypeClass. For example, libclang + // may return CXType_ObjCId or CXType_Unexposed for a type that libClangSharp + // correctly classifies as CX_TypeClass_Attributed via the Clang AST. Since + // TypeClass is the authoritative classifier and already validated above, a + // CXTypeKind mismatch is not fatal. + var kindMatches = handle.kind == expectedKind +#if NET10_0_OR_GREATER + || additionalExpectedKinds.Contains(handle.kind); +#else + || Contains(additionalExpectedKinds, handle.kind); +#endif + + if (!kindMatches) { - throw new ArgumentOutOfRangeException(nameof(handle)); + Debug.WriteLine($"Unexpected CXTypeKind for {handle.TypeClass}: {handle.kind}."); } + Handle = handle; _asString = new ValueLazy(Handle.Spelling.ToString); diff --git a/tests/ClangSharp.UnitTests/ObjectiveCTest.cs b/tests/ClangSharp.UnitTests/ObjectiveCTest.cs index bfc9c0a7..798cecac 100644 --- a/tests/ClangSharp.UnitTests/ObjectiveCTest.cs +++ b/tests/ClangSharp.UnitTests/ObjectiveCTest.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using ClangSharp.Interop; using NUnit.Framework; @@ -764,4 +765,473 @@ @interface MyClass Assert.That(properties, Is.Empty, "All properties processed"); } + + [Test] + public void Type_AttributedType_WithMismatchedCXTypeKind() + { + // Regression test: libclang may return a CXTypeKind (e.g. CXType_ObjCId) that + // differs from what ClangSharp expects for a given CX_TypeClass (e.g. CX_TypeClass_Attributed + // expects CXType_Attributed). This should not crash — TypeClass is the authoritative + // classifier from libClangSharp and should be trusted when CXTypeKind is coarser. + // + // This pattern occurs with nullable/attributed ObjC pointer types in system framework + // headers. The bug manifests with the iOS SDK where id types are attributed. + + var tmpDir = Path.Combine(Path.GetTempPath(), "clangsharp-test-" + Guid.NewGuid().ToString("N")[..8]); + _ = Directory.CreateDirectory(tmpDir); + var tmpFile = Path.Combine(tmpDir, "test.m"); + try + { + File.WriteAllText(tmpFile, """ +#import + +@interface TestClass : NSObject + // nullable id creates an AttributedType where libclang returns CXType_ObjCId + // but libClangSharp classifies it as CX_TypeClass_Attributed + -(void) doSomethingWith:(nullable id)value; + @property (nullable, nonatomic, strong) NSObject *obj; +@end +"""); + + using var translationUnit = CreateFoundationTranslationUnit(tmpFile, ["iphoneos", "macosx"]); + if (translationUnit is null) + { + Assert.Ignore("No SDK with Foundation available — skipping test"); + return; + } + + // Deep traversal of all declarations and their types — this is where the crash occurred. + // PointerType.PointeeType would create an AttributedType with CXType_ObjCId, + // and the Type constructor would throw ArgumentOutOfRangeException("handle"). + foreach (var decl in translationUnit.TranslationUnitDecl.Decls) + { + if (decl is ObjCContainerDecl containerDecl) + { + foreach (var childDecl in containerDecl.Decls) + { + if (childDecl is ObjCMethodDecl methodDecl) + { + // Accessing ReturnType and parameter Types triggers type creation + _ = methodDecl.ReturnType; + foreach (var param in methodDecl.Parameters) + { + var paramType = param.Type; + // Walk the type chain to trigger PointeeType access + if (paramType is PointerType pointerType) + { + _ = pointerType.PointeeType; + } + else if (paramType is AttributedType attributedType) + { + _ = attributedType.ModifiedType; + _ = attributedType.EquivalentType; + } + } + } + + if (childDecl is ObjCPropertyDecl propertyDecl) + { + var propType = propertyDecl.Type; + if (propType is AttributedType attributedType) + { + _ = attributedType.ModifiedType; + } + } + } + } + } + + // Verify we found our test class + var classes = translationUnit.TranslationUnitDecl.Decls.OfType().ToList(); + Assert.That(classes.Any(v => v.Name == "TestClass"), Is.True, "TestClass should be found"); + } + finally + { + Directory.Delete(tmpDir, true); + } + } + + [Test] + public void Type_FullFoundationTraversal_DoesNotCrash() + { + // Regression test: traversing ALL declarations from a translation unit that + // includes should not throw ArgumentOutOfRangeException. + // This is the exact scenario that crashed before the fix. + // The bug manifests with the iOS SDK where attributed types have CXType_ObjCId + // instead of CXType_Attributed. + + var tmpDir = Path.Combine(Path.GetTempPath(), "clangsharp-test-" + Guid.NewGuid().ToString("N")[..8]); + _ = Directory.CreateDirectory(tmpDir); + var tmpFile = Path.Combine(tmpDir, "test.m"); + try + { + File.WriteAllText(tmpFile, """ +#import +@interface MyClass : NSObject +@property int val; +@end +"""); + + using var translationUnit = CreateFoundationTranslationUnit(tmpFile, ["iphoneos", "macosx"]); + if (translationUnit is null) + { + Assert.Ignore("No SDK with Foundation available — skipping test"); + return; + } + + // Full recursive traversal — this crashed with ArgumentOutOfRangeException("handle") + // because AttributedType constructor rejected CXType_ObjCId as a valid kind. + // The crash path was: RecordDecl → FieldDecl → Type.PointeeType → AttributedType + var topLevelCount = 0; + var childCount = 0; + foreach (var decl in translationUnit.TranslationUnitDecl.Decls) + { + topLevelCount++; + + // Visit ALL container types (ObjC interfaces, protocols, categories) + if (decl is ObjCContainerDecl containerDecl) + { + foreach (var childDecl in containerDecl.Decls) + { + childCount++; + VisitDeclTypes(childDecl); + } + } + + // Visit RecordDecls (C structs) — this is the actual crash path. + // Foundation headers contain structs with fields that have attributed + // pointer types (e.g. nullable id), and accessing FieldDecl.Type.PointeeType + // triggers the AttributedType constructor with mismatched CXTypeKind. + if (decl is RecordDecl recordDecl) + { + foreach (var field in recordDecl.Decls) + { + childCount++; + VisitDeclTypes(field); + } + } + } + + // Foundation should have thousands of declarations + Assert.That(topLevelCount, Is.GreaterThan(100), "Should have many top-level declarations from Foundation"); + Assert.That(childCount, Is.GreaterThan(100), "Should have many child declarations"); + } + finally + { + Directory.Delete(tmpDir, true); + } + } + + [Test] + public void Type_DeepTraversal_DoesNotCrash() + { + // Regression test: traversing all declarations in an ObjC translation unit + // and accessing their child declarations, method return types, parameter types, + // and property types should not throw ArgumentOutOfRangeException("handle"). + // + // This covers the scenario where a large number of ObjC declarations are parsed + // and the binding generator needs to recursively visit all type information. + var inputContents = """ +@class NSObject; +@class NSString; +@class NSArray; +@class NSError; + +@protocol Proto1 + @required + -(void) requiredMethod; + @optional + -(void) optionalMethod; + @property (nullable) NSString *optionalProp; +@end + +@protocol Proto2 + -(NSString*) stringMethod:(NSArray*)array error:(NSError**)error; +@end + +@interface Base + @property int baseValue; +@end + +@interface Child : Base + @property (nonatomic, copy) NSString *name; + @property (nullable, nonatomic, strong) NSArray *items; + -(instancetype) initWithName:(NSString *)name; + -(void) doSomething:(nullable id)param; + +(Child*) sharedInstance; +@end + +@interface Child (Category) + -(void) categoryMethod; + @property (readonly) int categoryProp; +@end +"""; + using var translationUnit = CreateTranslationUnit(inputContents, "objective-c++"); + + // Deep traversal: access all declarations, their children, and all types. + // This should complete without any ArgumentOutOfRangeException. + foreach (var decl in translationUnit.TranslationUnitDecl.Decls) + { + // Access cursor properties + _ = decl.CursorKindSpelling; + + if (decl is NamedDecl namedDecl) + { + _ = namedDecl.Name; + } + + // Recurse into containers + if (decl is ObjCContainerDecl containerDecl) + { + foreach (var childDecl in containerDecl.Decls) + { + _ = childDecl.CursorKindSpelling; + + if (childDecl is ObjCMethodDecl methodDecl) + { + _ = methodDecl.ReturnType; + foreach (var param in methodDecl.Parameters) + { + _ = param.Type; + _ = param.Name; + } + } + + if (childDecl is ObjCPropertyDecl propertyDecl) + { + _ = propertyDecl.Type; + _ = propertyDecl.Name; + } + } + } + } + + // If we get here without throwing, the test passes + var classes = translationUnit.TranslationUnitDecl.Decls.OfType().ToList(); + Assert.That(classes.Count, Is.GreaterThanOrEqualTo(2), "Should have at least Base and Child classes"); + } + + [Test] + public void Decl_InvalidDeclKind_DoesNotCrash() + { + // Regression test: when libClangSharp returns CX_DeclKind_Invalid for a cursor + // that libclang considers a valid declaration, Decl.Create() should create a + // generic Decl wrapper instead of throwing ArgumentOutOfRangeException. + // + // This is tested indirectly by ensuring that complex ObjC code with diverse + // declaration kinds can be fully traversed without crashes. + var inputContents = """ +@class NSObject; + +@protocol DelegateProtocol + @required + -(void) didFinish; + @optional + -(void) didFail; +@end + +@interface Manager + @property (weak) id delegate; + -(void) start; +@end + +// Categories add diverse declaration kinds to the AST +@interface Manager (Extensions) + -(void) reset; +@end + +@interface Manager (MoreExtensions) + @property (readonly) int state; +@end +"""; + using var translationUnit = CreateTranslationUnit(inputContents, "objective-c++"); + + // Walk the entire AST including child decls — this exercises Decl.Create() + // for diverse declaration kinds + var declCount = 0; + foreach (var decl in translationUnit.TranslationUnitDecl.Decls) + { + declCount++; + foreach (var child in decl.CursorChildren) + { + _ = child.CursorKindSpelling; + } + } + + Assert.That(declCount, Is.GreaterThan(0), "Should have declarations"); + + var classes = translationUnit.TranslationUnitDecl.Decls.OfType().ToList(); + Assert.That(classes.Any(v => v.Name == "Manager"), Is.True, "Manager class found"); + + var protocols = translationUnit.TranslationUnitDecl.Decls.OfType().ToList(); + Assert.That(protocols.Any(v => v.Name == "DelegateProtocol"), Is.True, "DelegateProtocol found"); + + var categories = translationUnit.TranslationUnitDecl.Decls.OfType().ToList(); + Assert.That(categories.Count, Is.GreaterThanOrEqualTo(2), "At least 2 categories"); + } + + private static string? GetMacOSSdkPath() => GetSdkPath("macosx"); + + private static string? GetIPhoneOSSdkPath() => GetSdkPath("iphoneos"); + + /// + /// Create a TranslationUnit that imports Foundation, trying available SDKs. + /// Returns null if no SDK can successfully compile the input. + /// + private static TranslationUnit? CreateFoundationTranslationUnit(string sourceFile, string[] sdksToTry) + { + var clangResourceDir = GetClangResourceDir(); + + foreach (var sdk in sdksToTry) + { + var sdkPath = GetSdkPath(sdk); + if (string.IsNullOrEmpty(sdkPath)) + { + continue; + } + + var index = CXIndex.Create(); + var args = new List { "-isysroot", sdkPath, "-xobjective-c", "-fobjc-arc" }; + if (!string.IsNullOrEmpty(clangResourceDir)) + { + args.AddRange(["-resource-dir", clangResourceDir]); + } + + var errorCode = CXTranslationUnit.TryParse(index, sourceFile, args.ToArray(), [], DefaultTranslationUnitFlags, out var handle); + + if (errorCode != CXErrorCode.CXError_Success) + { + continue; + } + + var tu = TranslationUnit.GetOrCreate(handle); + + // Check for fatal compilation errors (e.g. missing headers) + var hasFatalErrors = false; + for (uint i = 0; i < tu.Handle.NumDiagnostics; i++) + { + using var diag = tu.Handle.GetDiagnostic(i); + if (diag.Severity >= CXDiagnosticSeverity.CXDiagnostic_Fatal) + { + hasFatalErrors = true; + break; + } + } + + if (!hasFatalErrors) + { + return tu; + } + + tu.Dispose(); + } + + return null; + } + + private static string? GetClangResourceDir() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "clang", "--print-resource-dir" }, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + var output = process?.StandardOutput.ReadToEnd().Trim(); + process?.WaitForExit(); + if (process?.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output)) + { + return output; + } + } + catch (InvalidOperationException) + { + // xcrun not available + } + + return null; + } + + private static string? GetSdkPath(string sdk) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "xcrun", + ArgumentList = { "--show-sdk-path", "--sdk", sdk }, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + var output = process?.StandardOutput.ReadToEnd().Trim(); + process?.WaitForExit(); + if (process?.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output)) + { + return output; + } + } + catch (InvalidOperationException) + { + // xcrun not available + } + + return null; + } + + /// + /// Recursively visit a declaration's type information, including parameter types, + /// return types, and property types. This exercises the ClangSharp type creation + /// path that can crash with ArgumentOutOfRangeException("handle") when a type's + /// CXTypeKind doesn't match what ClangSharp expects for its CX_TypeClass. + /// + private static void VisitDeclTypes(Decl decl) + { + if (decl is ObjCMethodDecl methodDecl) + { + _ = methodDecl.ReturnType; + foreach (var param in methodDecl.Parameters) + { + WalkType(param.Type); + } + } + else if (decl is ObjCPropertyDecl propertyDecl) + { + WalkType(propertyDecl.Type); + } + else if (decl is FieldDecl fieldDecl) + { + WalkType(fieldDecl.Type); + } + else if (decl is VarDecl varDecl) + { + WalkType(varDecl.Type); + } + } + + /// + /// Walk a type's structure to force ClangSharp to materialize related types. + /// This triggers PointeeType, ModifiedType, etc. which are lazily evaluated + /// and can crash if CXTypeKind doesn't match the expected kind. + /// + private static void WalkType(Type type) + { + if (type is PointerType pointerType) + { + _ = pointerType.PointeeType; + } + else if (type is AttributedType attributedType) + { + _ = attributedType.ModifiedType; + _ = attributedType.EquivalentType; + } + else if (type is ObjCObjectPointerType objcPointerType) + { + _ = objcPointerType.PointeeType; + } + } }