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 654a9c8eb..5f420d4ca 100644 --- a/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs +++ b/ArchUnitNET/Loader/MonoCecilMemberExtensions.cs @@ -321,8 +321,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..e05f7b41a --- /dev/null +++ b/ArchUnitNET/Loader/MonoCecilResolveExtensions.cs @@ -0,0 +1,67 @@ +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 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 193812176..a55c63d4f 100644 --- a/ArchUnitNET/Loader/TypeProcessor.cs +++ b/ArchUnitNET/Loader/TypeProcessor.cs @@ -797,15 +797,10 @@ var calledMethodReference in calledMethodReferences.Except(visitedMethodReferenc if (calledMethodReference.IsCompilerGenerated()) { - MethodDefinition calledMethodDefinition; - try - { - calledMethodDefinition = calledMethodReference.Resolve(); - } - catch (AssemblyResolutionException) - { - calledMethodDefinition = null; - } + // Here an AssemblyResolutionException would only occur if the method is from a referenced assembly that is not available. + // However, since we are only resolving compiler-generated methods here, we can safely assume that the method is from an + // available assembly and don't need to handle the exception. + var calledMethodDefinition = calledMethodReference.Resolve(); if (calledMethodDefinition?.Body == null) { @@ -1071,7 +1066,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/ArchLoaderTests.cs b/ArchUnitNETTests/Loader/ArchLoaderTests.cs index a5129e411..b8d44ffcb 100644 --- a/ArchUnitNETTests/Loader/ArchLoaderTests.cs +++ b/ArchUnitNETTests/Loader/ArchLoaderTests.cs @@ -327,7 +327,7 @@ public void UnavailableTypeTest() System.IO.SearchOption.AllDirectories ) .Build(); - Assert.Equal(3, architecture.Types.Count()); + Assert.Equal(4, architecture.Types.Count()); var loggerType = architecture.ReferencedTypes.WhereFullNameIs("Serilog.ILogger"); Assert.NotNull(loggerType); Assert.True(loggerType is UnavailableType); diff --git a/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs b/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs new file mode 100644 index 000000000..82d761f03 --- /dev/null +++ b/ArchUnitNETTests/Loader/UnresolvableAssemblyTests.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Linq; +using ArchUnitNET.Loader; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; +using static ArchUnitNET.Loader.MonoCecilMemberExtensions; +using static ArchUnitNET.Loader.MonoCecilTypeExtensions; + +namespace ArchUnitNETTests.Loader +{ + /// + /// Tests that loader code 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 FilteredDirectoryLoaderTestAssemblyDir => + Path.Combine(RepoRoot, "TestAssemblies", "FilteredDirectoryLoaderTestAssembly"); + + private static string FilteredDirectoryLoaderTestAssemblyPath => + Directory + .EnumerateFiles( + Path.Combine(RepoRoot, "TestAssemblies", "FilteredDirectoryLoaderTestAssembly"), + "FilteredDirectoryLoaderTestAssembly.dll", + SearchOption.AllDirectories + ) + .First(); + + private static (DefaultAssemblyResolver resolver, ModuleDefinition module) LoadModule() + { + 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 } + ); + return (resolver, module); + } + + private static TypeDefinition GetDerivedAttribute(ModuleDefinition module) + { + var derivedAttribute = module.Types.FirstOrDefault(t => + t.FullName == "FilteredDirectoryLoaderTestAssembly.DerivedAttribute" + ); + Assert.NotNull(derivedAttribute); + return derivedAttribute; + } + + /// + /// 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. + /// + /// Exercises → + /// . + /// + [Fact] + public void IsAttribute_HandlesUnresolvableBaseTypeAssembly() + { + var (resolver, module) = LoadModule(); + try + { + var derivedAttribute = GetDerivedAttribute(module); + Assert.Equal( + "FilteredDirectoryUnavailableTypesAssembly.BaseAttribute", + derivedAttribute.BaseType.FullName + ); + + Assert.False(derivedAttribute.IsAttribute()); + } + finally + { + module.Dispose(); + resolver.Dispose(); + } + } + + /// + /// DerivedAttribute's constructor calls BaseAttribute..ctor(). The method reference + /// points into the unavailable assembly. IsCompilerGenerated must not throw when + /// resolving the member reference fails. + /// + /// Exercises → + /// . + /// + [Fact] + public void IsCompilerGenerated_HandlesUnresolvableMemberAssembly() + { + var (resolver, module) = LoadModule(); + try + { + var derivedCtor = GetDerivedAttribute(module).Methods.First(m => m.IsConstructor); + + // The constructor body contains a call to BaseAttribute..ctor() + var baseCtorRef = derivedCtor + .Body.Instructions.Where(i => i.OpCode == OpCodes.Call) + .Select(i => i.Operand as MethodReference) + .First(m => + m != null + && m.DeclaringType.FullName + == "FilteredDirectoryUnavailableTypesAssembly.BaseAttribute" + ); + + Assert.False(baseCtorRef.IsCompilerGenerated()); + } + finally + { + module.Dispose(); + resolver.Dispose(); + } + } + + /// + /// UnavailableFieldAssigner.AssignUnavailableField assigns the result of a method call + /// to UnavailableFieldHolder.Value, a static field declared in the unavailable assembly. + /// GetAssigneeFieldDefinition must not throw when resolving the field reference fails. + /// + /// Exercises → + /// . + /// + [Fact] + public void GetAssigneeFieldDefinition_HandlesUnresolvableFieldAssembly() + { + var (resolver, module) = LoadModule(); + try + { + var assigner = module.Types.FirstOrDefault(t => + t.FullName == "FilteredDirectoryLoaderTestAssembly.UnavailableFieldAssigner" + ); + Assert.NotNull(assigner); + var method = assigner.Methods.First(m => m.Name == "AssignUnavailableField"); + + var callInstruction = method.Body.Instructions.First(i => + i.IsMethodCallOp() + && i.Next?.Operand is FieldReference f + && f.DeclaringType.FullName + == "FilteredDirectoryUnavailableTypesAssembly.UnavailableFieldHolder" + ); + + Assert.Null(callInstruction.GetAssigneeFieldDefinition()); + } + finally + { + module.Dispose(); + resolver.Dispose(); + } + } + + /// + /// End-to-end check that can load and build an architecture + /// from FilteredDirectoryLoaderTestAssembly, including its direct dependencies, + /// when one of those dependencies (FilteredDirectoryUnavailableTypesAssembly) cannot be + /// resolved. That assembly is not copied to the output directory (Private=False), so + /// base types such as BaseAttribute and member and field references into it cannot + /// be resolved during loading. + /// + /// The test passes if loading and building finish without throwing an exception. + /// + /// Exercises → + /// ... → + /// + [Fact] + public void ArchLoader_HandlesFallbackToDefaultAssemblyResolverForUnavailableAssemblyWithoutException() + { + var assemblyDir = FilteredDirectoryLoaderTestAssemblyDir; + Assert.True( + Directory.Exists(assemblyDir), + $"Test assembly directory not found at {assemblyDir}" + ); + + var architecture = new ArchLoader() + .LoadFilteredDirectoryIncludingDependencies( + assemblyDir, + "FilteredDirectoryLoaderTestAssembly.dll", + false, + SearchOption.AllDirectories + ) + .Build(); + } + } +} diff --git a/TestAssemblies/FilteredDirectoryLoaderTestAssembly/Class1.cs b/TestAssemblies/FilteredDirectoryLoaderTestAssembly/Class1.cs index 8f69b7fd3..1d21c67c2 100644 --- a/TestAssemblies/FilteredDirectoryLoaderTestAssembly/Class1.cs +++ b/TestAssemblies/FilteredDirectoryLoaderTestAssembly/Class1.cs @@ -19,3 +19,13 @@ public class DerivedAttribute : BaseAttribute { } [Derived] public class ClassWithDerivedAttribute { } + +public class UnavailableFieldAssigner +{ + public void AssignUnavailableField() + { + UnavailableFieldHolder.Value = CreateValue(); + } + + private static int CreateValue() => 42; +} diff --git a/TestAssemblies/FilteredDirectoryUnavailableTypesAssembly/UnavailableTypes.cs b/TestAssemblies/FilteredDirectoryUnavailableTypesAssembly/UnavailableTypes.cs index fe44501b3..bb98322f9 100644 --- a/TestAssemblies/FilteredDirectoryUnavailableTypesAssembly/UnavailableTypes.cs +++ b/TestAssemblies/FilteredDirectoryUnavailableTypesAssembly/UnavailableTypes.cs @@ -5,3 +5,8 @@ public class AssemblyAttribute : Attribute { } [AttributeUsage(AttributeTargets.All)] public class BaseAttribute : Attribute { } + +public static class UnavailableFieldHolder +{ + public static int Value; +}