From 8475c9439512ea4f9ff31e7582b69d99c552bf77 Mon Sep 17 00:00:00 2001 From: Malte Kunath Date: Mon, 28 Sep 2026 16:39:29 +0200 Subject: [PATCH] fix: handle unresolvable assemblies during type loading DotNetCoreAssemblyResolver.AddLib previously fell back to DefaultAssemblyResolver when an assembly couldn't be found. Since DefaultAssemblyResolver loads assemblies with itself as the resolver, later TypeReference.Resolve() calls threw AssemblyResolutionException instead of returning null. The fallback now passes the original ReaderParameters so loaded assemblies keep using DotNetCoreAssemblyResolver. All .Resolve() call sites that didn't catch AssemblyResolutionException are switched to TryResolve helpers that return null on failure. Signed-off-by: Malte Kunath --- .../Loader/DotNetCoreAssemblyResolver.cs | 39 ++++++--- ArchUnitNET/Loader/InstructionExtensions.cs | 2 +- .../Loader/MonoCecilMemberExtensions.cs | 5 +- .../Loader/MonoCecilResolveExtensions.cs | 85 +++++++++++++++++++ ArchUnitNET/Loader/MonoCecilTypeExtensions.cs | 9 +- ArchUnitNET/Loader/TypeProcessor.cs | 16 +--- .../Loader/UnresolvableAssemblyTests.cs | 72 ++++++++++++++++ 7 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 ArchUnitNET/Loader/MonoCecilResolveExtensions.cs create mode 100644 ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs diff --git a/ArchUnitNET/Loader/DotNetCoreAssemblyResolver.cs b/ArchUnitNET/Loader/DotNetCoreAssemblyResolver.cs index a4e798d8b..ee9e0eb2a 100644 --- a/ArchUnitNET/Loader/DotNetCoreAssemblyResolver.cs +++ b/ArchUnitNET/Loader/DotNetCoreAssemblyResolver.cs @@ -34,25 +34,42 @@ public AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters p throw new ArgumentNullException(nameof(name)); } - if ( - _libraries.TryGetValue(name.FullName, out var assemblyDefinition) - || string.IsNullOrEmpty(AssemblyPath) - ) + if (_libraries.TryGetValue(name.FullName, out var assemblyDefinition)) { return assemblyDefinition; } - var file = Directory - .EnumerateFiles(AssemblyPath, $"{name.Name}.dll", SearchOption.AllDirectories) - .FirstOrDefault(); + if (!string.IsNullOrEmpty(AssemblyPath)) + { + var file = Directory + .EnumerateFiles(AssemblyPath, $"{name.Name}.dll", SearchOption.AllDirectories) + .FirstOrDefault(); + + if (file != null) + { + assemblyDefinition = AssemblyDefinition.ReadAssembly(file, parameters); + _libraries.Add(name.FullName, assemblyDefinition); + return assemblyDefinition; + } + } - if (file == null) + // Fall back to DefaultAssemblyResolver for framework assemblies not found in + // AssemblyPath. Pass the original parameters so the loaded assembly keeps using + // this resolver (which returns null for unresolvable references) instead of the + // DefaultAssemblyResolver (which throws). + try + { + assemblyDefinition = _defaultAssemblyResolver.Resolve(name, parameters); + } + catch (AssemblyResolutionException) { return null; } - assemblyDefinition = AssemblyDefinition.ReadAssembly(file, parameters); - _libraries.Add(name.FullName, assemblyDefinition); + if (assemblyDefinition != null) + { + _libraries.Add(name.FullName, assemblyDefinition); + } return assemblyDefinition; } @@ -84,7 +101,7 @@ [NotNull] AssemblyDefinition moduleAssembly public void AddLib(AssemblyNameReference name) { - var assembly = Resolve(name) ?? _defaultAssemblyResolver.Resolve(name); + var assembly = Resolve(name); AddLib(name, assembly ?? throw new AssemblyResolutionException(name)); } diff --git a/ArchUnitNET/Loader/InstructionExtensions.cs b/ArchUnitNET/Loader/InstructionExtensions.cs index 3df361f31..bc413a3d6 100644 --- a/ArchUnitNET/Loader/InstructionExtensions.cs +++ b/ArchUnitNET/Loader/InstructionExtensions.cs @@ -46,7 +46,7 @@ [NotNull] this Instruction methodCallInstruction && methodCallAssignment.Operand is FieldReference fieldReference ) { - return fieldReference.Resolve(); + return fieldReference.TryResolve(); } return null; diff --git a/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs b/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs index 061dc5a28..834445005 100644 --- a/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs +++ b/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs @@ -295,8 +295,9 @@ internal static bool IsCompilerGenerated(this MemberReference memberReference) { return true; } - var declaringType = - memberReference.Resolve()?.DeclaringType ?? memberReference.DeclaringType; + + var resolvedType = memberReference.TryResolve(); + var declaringType = resolvedType?.DeclaringType ?? memberReference.DeclaringType; return declaringType != null && declaringType.Name.HasCompilerGeneratedName(); } diff --git a/ArchUnitNET/Loader/MonoCecilResolveExtensions.cs b/ArchUnitNET/Loader/MonoCecilResolveExtensions.cs new file mode 100644 index 000000000..d8b25ea59 --- /dev/null +++ b/ArchUnitNET/Loader/MonoCecilResolveExtensions.cs @@ -0,0 +1,85 @@ +using System; +using JetBrains.Annotations; +using Mono.Cecil; + +namespace ArchUnitNET.Loader +{ + /// + /// Resolve extensions that return null instead of throwing + /// when an assembly cannot be located. + /// + internal static class MonoCecilResolveExtensions + { + [CanBeNull] + public static TypeDefinition TryResolve([CanBeNull] this TypeReference typeReference) + { + if (typeReference == null) + { + return null; + } + + try + { + return typeReference.Resolve(); + } + catch (AssemblyResolutionException) + { + return null; + } + } + + [CanBeNull] + public static MethodDefinition TryResolve([CanBeNull] this MethodReference methodReference) + { + if (methodReference == null) + { + return null; + } + + try + { + return methodReference.Resolve(); + } + catch (AssemblyResolutionException) + { + return null; + } + } + + [CanBeNull] + public static FieldDefinition TryResolve([CanBeNull] this FieldReference fieldReference) + { + if (fieldReference == null) + { + return null; + } + + try + { + return fieldReference.Resolve(); + } + catch (AssemblyResolutionException) + { + return null; + } + } + + [CanBeNull] + public static IMemberDefinition TryResolve([CanBeNull] this MemberReference memberReference) + { + if (memberReference == null) + { + return null; + } + + try + { + return memberReference.Resolve(); + } + catch (AssemblyResolutionException) + { + return null; + } + } + } +} diff --git a/ArchUnitNET/Loader/MonoCecilTypeExtensions.cs b/ArchUnitNET/Loader/MonoCecilTypeExtensions.cs index 83af639b3..b27d536f2 100644 --- a/ArchUnitNET/Loader/MonoCecilTypeExtensions.cs +++ b/ArchUnitNET/Loader/MonoCecilTypeExtensions.cs @@ -51,8 +51,13 @@ internal static bool IsAttribute([CanBeNull] this TypeDefinition typeDefinition) { if (typeDefinition?.BaseType != null) { - return typeDefinition.BaseType.FullName == "System.Attribute" - || IsAttribute(typeDefinition.BaseType.Resolve()); + if (typeDefinition.BaseType.FullName == "System.Attribute") + { + return true; + } + + var resolvedBaseType = typeDefinition.BaseType.TryResolve(); + return resolvedBaseType != null && IsAttribute(resolvedBaseType); } return false; diff --git a/ArchUnitNET/Loader/TypeProcessor.cs b/ArchUnitNET/Loader/TypeProcessor.cs index c765cdd8a..45c470511 100644 --- a/ArchUnitNET/Loader/TypeProcessor.cs +++ b/ArchUnitNET/Loader/TypeProcessor.cs @@ -797,15 +797,7 @@ var calledMethodReference in calledMethodReferences.Except(visitedMethodReferenc if (calledMethodReference.IsCompilerGenerated()) { - MethodDefinition calledMethodDefinition; - try - { - calledMethodDefinition = calledMethodReference.Resolve(); - } - catch (AssemblyResolutionException) - { - calledMethodDefinition = null; - } + var calledMethodDefinition = calledMethodReference.TryResolve(); if (calledMethodDefinition?.Body == null) { @@ -866,7 +858,7 @@ DomainResolver domainResolver { var compilerGeneratedGeneratorObject = methodBody .Instructions.Where(inst => inst.IsNewObjectOp()) - .Select(inst => ((MethodReference)inst.Operand).DeclaringType.Resolve()) + .Select(inst => ((MethodReference)inst.Operand).DeclaringType.TryResolve()) .FirstOrDefault(type => type != null && type.Methods.Any(method => method.Name == nameof(IEnumerator.MoveNext)) @@ -911,7 +903,7 @@ DomainResolver domainResolver { var compilerGeneratedGeneratorObject = methodBody .Instructions.Where(inst => inst.IsNewObjectOp()) - .Select(inst => ((MethodReference)inst.Operand).DeclaringType.Resolve()) + .Select(inst => ((MethodReference)inst.Operand).DeclaringType.TryResolve()) .FirstOrDefault(type => type != null && type.Methods.Any(method => @@ -1083,7 +1075,7 @@ private static IEnumerable GetInterfacesImplementedByClass( TypeDefinition typeDefinition ) { - var baseType = typeDefinition.BaseType?.Resolve(); + var baseType = typeDefinition.BaseType.TryResolve(); var baseInterfaces = baseType != null ? GetInterfacesImplementedByClass(baseType) diff --git a/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs b/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs new file mode 100644 index 000000000..2ae0fb8d3 --- /dev/null +++ b/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.Linq; +using Mono.Cecil; +using Xunit; +using static ArchUnitNET.Loader.MonoCecilTypeExtensions; + +namespace ArchUnitNETTests.Loader +{ + /// + /// Tests that the loader handles assemblies whose dependencies cannot be resolved + /// (e.g. when a referenced assembly is not present in the output directory). + /// + public class UnresolvableAssemblyTests + { + private static string RepoRoot => + AppDomain.CurrentDomain.BaseDirectory[ + ..AppDomain.CurrentDomain.BaseDirectory.IndexOf( + @"ArchUnitNETTests", + StringComparison.InvariantCulture + ) + ]; + + private static string FilteredDirectoryLoaderTestAssemblyPath => + Directory + .EnumerateFiles( + Path.Combine(RepoRoot, "TestAssemblies", "FilteredDirectoryLoaderTestAssembly"), + "FilteredDirectoryLoaderTestAssembly.dll", + SearchOption.AllDirectories + ) + .First(); + + /// + /// DerivedAttribute extends BaseAttribute from FilteredDirectoryUnavailableTypesAssembly, + /// which is not copied to the output directory (Private=False). IsAttribute must not + /// throw when the base type's assembly is unavailable. + /// + [Fact] + public void IsAttributeHandlesUnresolvableBaseTypeAssembly() + { + var assemblyPath = FilteredDirectoryLoaderTestAssemblyPath; + Assert.True(File.Exists(assemblyPath), $"Test assembly not found at {assemblyPath}"); + + // DefaultAssemblyResolver has no search directories, so resolving + // FilteredDirectoryUnavailableTypesAssembly will fail. + var resolver = new DefaultAssemblyResolver(); + var module = ModuleDefinition.ReadModule( + assemblyPath, + new ReaderParameters { AssemblyResolver = resolver } + ); + + try + { + var derivedAttribute = module.Types.FirstOrDefault(t => + t.FullName == "FilteredDirectoryLoaderTestAssembly.DerivedAttribute" + ); + Assert.NotNull(derivedAttribute); + Assert.Equal( + "FilteredDirectoryUnavailableTypesAssembly.BaseAttribute", + derivedAttribute.BaseType.FullName + ); + + Assert.False(derivedAttribute.IsAttribute()); + } + finally + { + module.Dispose(); + resolver.Dispose(); + } + } + } +}