< Summary

Information
Class: DotNetApiDiff.AssemblyLoading.AssemblyLoader
Assembly: DotNetApiDiff
File(s): /home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/AssemblyLoading/AssemblyLoader.cs
Line coverage
45%
Covered lines: 117
Uncovered lines: 140
Coverable lines: 257
Total lines: 440
Line coverage: 45.5%
Branch coverage
32%
Covered branches: 20
Total branches: 62
Branch coverage: 32.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%22100%
LoadAssembly(...)50%1242444.13%
IsProbablyNativeDll(...)3.33%6293012.69%
IsValidAssembly(...)100%44100%
UnloadAll()100%2273.33%
Dispose()100%11100%

File(s)

/home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/AssemblyLoading/AssemblyLoader.cs

#LineLine coverage
 1// Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT
 2using System.Reflection;
 3using System.Security;
 4using DotNetApiDiff.Interfaces;
 5using Microsoft.Extensions.Logging;
 6
 7namespace DotNetApiDiff.AssemblyLoading;
 8
 9/// <summary>
 10/// Implementation of IAssemblyLoader that loads assemblies in isolated contexts
 11/// </summary>
 12public class AssemblyLoader : IAssemblyLoader, IDisposable
 13{
 14    private readonly ILogger<AssemblyLoader> logger;
 3215    private readonly Dictionary<string, Assembly> loadedAssemblies = new Dictionary<string, Assembly>();
 3216    private readonly Dictionary<string, IsolatedAssemblyLoadContext> loadContexts = new Dictionary<string, IsolatedAssem
 17
 18    /// <summary>
 19    /// Creates a new assembly loader with the specified logger
 20    /// </summary>
 21    /// <param name="logger">Logger for diagnostic information</param>
 3222    public AssemblyLoader(ILogger<AssemblyLoader> logger)
 3223    {
 3224        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
 3225    }
 26
 27    /// <summary>
 28    /// Loads an assembly from the specified file path
 29    /// </summary>
 30    /// <param name="assemblyPath">Path to the assembly file</param>
 31    /// <returns>Loaded assembly</returns>
 32    /// <exception cref="ArgumentException">Thrown when assembly path is null or empty</exception>
 33    /// <exception cref="FileNotFoundException">Thrown when assembly file is not found</exception>
 34    /// <exception cref="BadImageFormatException">Thrown when assembly file is invalid</exception>
 35    /// <exception cref="SecurityException">Thrown when there are insufficient permissions to load the assembly</excepti
 36    /// <exception cref="PathTooLongException">Thrown when the assembly path is too long</exception>
 37    /// <exception cref="ReflectionTypeLoadException">Thrown when types in the assembly cannot be loaded</exception>
 38    public Assembly LoadAssembly(string assemblyPath)
 1739    {
 1740        if (string.IsNullOrWhiteSpace(assemblyPath))
 541        {
 542            this.logger.LogError("Assembly path cannot be null or empty");
 543            throw new ArgumentException("Assembly path cannot be null or empty", nameof(assemblyPath));
 44        }
 45
 46        // Normalize the path to ensure consistent dictionary keys
 47        try
 1248        {
 1249            assemblyPath = Path.GetFullPath(assemblyPath);
 1250        }
 051        catch (PathTooLongException ex)
 052        {
 053            this.logger.LogError(ex, "Path too long for assembly: {Path}", assemblyPath);
 054            throw;
 55        }
 056        catch (SecurityException ex)
 057        {
 058            this.logger.LogError(ex, "Security exception accessing path: {Path}", assemblyPath);
 059            throw;
 60        }
 061        catch (Exception ex)
 062        {
 063            this.logger.LogError(ex, "Error normalizing assembly path: {Path}", assemblyPath);
 064            throw new ArgumentException($"Invalid assembly path: {assemblyPath}", nameof(assemblyPath), ex);
 65        }
 66
 67        // Check if we've already loaded this assembly
 1268        if (this.loadedAssemblies.TryGetValue(assemblyPath, out var loadedAssembly))
 169        {
 170            this.logger.LogDebug("Returning previously loaded assembly from {Path}", assemblyPath);
 171            return loadedAssembly;
 72        }
 73
 1174        using (this.logger.BeginScope("Loading assembly {Path}", assemblyPath))
 1175        {
 1176            this.logger.LogInformation("Loading assembly from {Path}", assemblyPath);
 77
 78            try
 1179            {
 80                // Verify the file exists
 1181                if (!File.Exists(assemblyPath))
 282                {
 283                    this.logger.LogError("Assembly file not found: {Path}", assemblyPath);
 284                    throw new FileNotFoundException($"Assembly file not found: {assemblyPath}", assemblyPath);
 85                }
 86
 87                // Verify the file is accessible
 88                try
 989                {
 990                    using (var fileStream = File.OpenRead(assemblyPath))
 991                    {
 92                        // Just testing if we can open the file
 993                    }
 994                }
 095                catch (IOException ex)
 096                {
 097                    this.logger.LogError(ex, "Cannot access assembly file: {Path}", assemblyPath);
 098                    throw new IOException($"Cannot access assembly file: {assemblyPath}", ex);
 99                }
 100
 101                // Create a new isolated load context for this assembly
 9102                var loadContext = new IsolatedAssemblyLoadContext(assemblyPath, this.logger);
 103
 104                // Add the directory of the assembly as a search path
 9105                var assemblyDirectory = Path.GetDirectoryName(assemblyPath);
 9106                if (!string.IsNullOrEmpty(assemblyDirectory))
 9107                {
 9108                    loadContext.AddSearchPath(assemblyDirectory);
 109
 110                    // Also add any subdirectories that might contain dependencies
 111                    try
 9112                    {
 311113                        foreach (var subDir in Directory.GetDirectories(assemblyDirectory, "*", SearchOption.TopDirector
 142114                        {
 142115                            loadContext.AddSearchPath(subDir);
 142116                        }
 9117                    }
 0118                    catch (Exception ex)
 0119                    {
 120                        // Log but don't fail if we can't access subdirectories
 0121                        this.logger.LogWarning(ex, "Could not access subdirectories of {Directory}", assemblyDirectory);
 0122                    }
 9123                }
 124
 125                // Load the assembly in the isolated context
 126                Assembly assembly;
 127                try
 9128                {
 9129                    assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
 7130                }
 2131                catch (BadImageFormatException ex)
 2132                {
 2133                    this.logger.LogError(ex, "Invalid assembly format: {Path}", assemblyPath);
 134
 135                    // Try to determine if this is a native DLL or other non-.NET assembly
 2136                    if (IsProbablyNativeDll(assemblyPath))
 0137                    {
 0138                        this.logger.LogError("The file appears to be a native DLL, not a .NET assembly: {Path}", assembl
 0139                        throw new BadImageFormatException($"The file appears to be a native DLL, not a .NET assembly: {a
 140                    }
 141
 2142                    throw;
 143                }
 0144                catch (FileLoadException ex)
 0145                {
 0146                    this.logger.LogError(ex, "Failed to load assembly file: {Path}, FileName: {FileName}", assemblyPath,
 0147                    throw;
 148                }
 149
 150                // Store the assembly and load context for later use
 7151                this.loadedAssemblies[assemblyPath] = assembly;
 7152                this.loadContexts[assemblyPath] = loadContext;
 153
 154                // Log assembly details
 7155                var assemblyName = assembly.GetName();
 7156                this.logger.LogInformation(
 7157                    "Successfully loaded assembly: {AssemblyName} v{Version} from {Path}",
 7158                    assemblyName.Name,
 7159                    assemblyName.Version,
 7160                    assemblyPath);
 161
 162                // Log referenced assemblies at debug level
 7163                if (this.logger.IsEnabled(LogLevel.Debug))
 0164                {
 165                    try
 0166                    {
 0167                        var referencedAssemblies = assembly.GetReferencedAssemblies();
 0168                        this.logger.LogDebug(
 0169                            "Assembly {AssemblyName} references {Count} assemblies",
 0170                            assemblyName.Name,
 0171                            referencedAssemblies.Length);
 172
 0173                        foreach (var reference in referencedAssemblies)
 0174                        {
 0175                            this.logger.LogDebug(
 0176                                "Referenced assembly: {Name} v{Version}",
 0177                                reference.Name,
 0178                                reference.Version);
 0179                        }
 0180                    }
 0181                    catch (Exception ex)
 0182                    {
 0183                        this.logger.LogDebug(ex, "Error getting referenced assemblies for {AssemblyName}", assemblyName.
 0184                    }
 0185                }
 186
 7187                return assembly;
 188            }
 2189            catch (FileNotFoundException ex)
 2190            {
 2191                this.logger.LogError(ex, "Assembly file not found: {Path}", assemblyPath);
 2192                throw;
 193            }
 2194            catch (BadImageFormatException ex)
 2195            {
 2196                this.logger.LogError(ex, "Invalid assembly format: {Path}", assemblyPath);
 2197                throw;
 198            }
 0199            catch (SecurityException ex)
 0200            {
 0201                this.logger.LogError(ex, "Security exception loading assembly: {Path}", assemblyPath);
 0202                throw;
 203            }
 0204            catch (PathTooLongException ex)
 0205            {
 0206                this.logger.LogError(ex, "Path too long for assembly: {Path}", assemblyPath);
 0207                throw;
 208            }
 0209            catch (ReflectionTypeLoadException ex)
 0210            {
 0211                this.logger.LogError(ex, "Failed to load types from assembly: {Path}", assemblyPath);
 212
 213                // Log the loader exceptions for more detailed diagnostics
 0214                if (ex.LoaderExceptions != null)
 0215                {
 0216                    int loaderExceptionCount = ex.LoaderExceptions.Length;
 0217                    this.logger.LogError("Loader exceptions count: {Count}", loaderExceptionCount);
 218
 219                    // Log up to 5 loader exceptions to avoid excessive logging
 0220                    int logCount = Math.Min(loaderExceptionCount, 5);
 0221                    for (int i = 0; i < logCount; i++)
 0222                    {
 0223                        var loaderEx = ex.LoaderExceptions[i];
 0224                        if (loaderEx != null)
 0225                        {
 0226                            this.logger.LogError(loaderEx, "Loader exception {Index}: {Message}", i + 1, loaderEx.Messag
 0227                        }
 0228                    }
 229
 0230                    if (loaderExceptionCount > logCount)
 0231                    {
 0232                        this.logger.LogError("... and {Count} more loader exceptions", loaderExceptionCount - logCount);
 0233                    }
 0234                }
 235
 0236                throw;
 237            }
 0238            catch (Exception ex)
 0239            {
 0240                this.logger.LogError(ex, "Unexpected error loading assembly: {Path}", assemblyPath);
 0241                throw;
 242            }
 243        }
 8244    }
 245
 246    /// <summary>
 247    /// Attempts to determine if a file is likely a native DLL rather than a .NET assembly
 248    /// </summary>
 249    /// <param name="filePath">Path to the file to check</param>
 250    /// <returns>True if the file appears to be a native DLL, false otherwise</returns>
 251    private bool IsProbablyNativeDll(string filePath)
 2252    {
 253        try
 2254        {
 255            // Read the first few bytes to check for the PE header
 2256            using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
 2257            {
 2258                if (fileStream.Length < 64)
 2259                {
 2260                    return false; // Too small to be a valid DLL
 261                }
 262
 0263                byte[] buffer = new byte[2];
 0264                int bytesRead = fileStream.Read(buffer, 0, 2);
 0265                if (bytesRead < 2)
 0266                {
 0267                    return false; // Not enough bytes to determine if it's a DLL
 268                }
 269
 270                // Check for the MZ header (0x4D, 0x5A)
 0271                if (buffer[0] != 0x4D || buffer[1] != 0x5A)
 0272                {
 0273                    return false; // Not a valid PE file
 274                }
 275
 276                // Skip to the PE header offset location
 0277                fileStream.Seek(0x3C, SeekOrigin.Begin);
 278
 279                // Read the PE header offset
 0280                byte[] offsetBuffer = new byte[4];
 0281                bytesRead = fileStream.Read(offsetBuffer, 0, 4);
 0282                if (bytesRead < 4)
 0283                {
 0284                    return false; // Not enough bytes to determine if it's a DLL
 285                }
 286
 0287                int peOffset = BitConverter.ToInt32(offsetBuffer, 0);
 288
 289                // Seek to the PE header
 0290                fileStream.Seek(peOffset, SeekOrigin.Begin);
 291
 292                // Read the PE signature
 0293                byte[] peBuffer = new byte[4];
 0294                bytesRead = fileStream.Read(peBuffer, 0, 4);
 0295                if (bytesRead < 4)
 0296                {
 0297                    return false; // Not enough bytes to determine if it's a DLL
 298                }
 299
 300                // Check for PE signature "PE\0\0"
 0301                if (peBuffer[0] != 0x50 || peBuffer[1] != 0x45 || peBuffer[2] != 0 || peBuffer[3] != 0)
 0302                {
 0303                    return false; // Not a valid PE file
 304                }
 305
 306                // It's a valid PE file, but we need more checks to determine if it's a .NET assembly
 307                // Skip the COFF header (20 bytes)
 0308                fileStream.Seek(peOffset + 4 + 20, SeekOrigin.Begin);
 309
 310                // Read the Optional Header magic value
 0311                byte[] magicBuffer = new byte[2];
 0312                bytesRead = fileStream.Read(magicBuffer, 0, 2);
 0313                if (bytesRead < 2)
 0314                {
 0315                    return false; // Not enough bytes to determine if it's a DLL
 316                }
 317
 318                // PE32 (0x10B) or PE32+ (0x20B)
 0319                ushort magic = BitConverter.ToUInt16(magicBuffer, 0);
 0320                if (magic != 0x10B && magic != 0x20B)
 0321                {
 0322                    return false; // Not a valid PE optional header
 323                }
 324
 325                // Skip to the data directories
 326                int dataDirectoryOffset;
 0327                if (magic == 0x10B)
 0328                {
 0329                    dataDirectoryOffset = 96; // PE32
 0330                }
 331                else
 0332                {
 0333                    dataDirectoryOffset = 112; // PE32+
 0334                }
 335
 0336                fileStream.Seek(peOffset + 4 + 20 + dataDirectoryOffset, SeekOrigin.Begin);
 337
 338                // The 15th data directory is the CLR header (14 zero-based index)
 0339                fileStream.Seek(14 * 8, SeekOrigin.Current);
 340
 341                // Read the CLR header RVA and size
 0342                byte[] clrBuffer = new byte[8];
 0343                bytesRead = fileStream.Read(clrBuffer, 0, 8);
 0344                if (bytesRead < 8)
 0345                {
 0346                    return false; // Not enough bytes to determine if it's a DLL
 347                }
 348
 0349                uint clrRva = BitConverter.ToUInt32(clrBuffer, 0);
 0350                uint clrSize = BitConverter.ToUInt32(clrBuffer, 4);
 351
 352                // If the CLR header RVA is 0, it's not a .NET assembly
 0353                return clrRva == 0;
 354            }
 355        }
 0356        catch (Exception ex)
 0357        {
 0358            this.logger.LogDebug(ex, "Error checking if file is a native DLL: {Path}", filePath);
 0359            return false; // Assume it's not a native DLL if we can't check
 360        }
 2361    }
 362
 363    /// <summary>
 364    /// Validates that the specified path contains a valid .NET assembly
 365    /// </summary>
 366    /// <param name="assemblyPath">Path to validate</param>
 367    /// <returns>True if path contains a valid assembly, false otherwise</returns>
 368    public bool IsValidAssembly(string assemblyPath)
 11369    {
 11370        if (string.IsNullOrWhiteSpace(assemblyPath))
 5371        {
 5372            this.logger.LogDebug("Assembly path is null or empty");
 5373            return false;
 374        }
 375
 376        try
 6377        {
 378            // Check if the file exists
 6379            if (!File.Exists(assemblyPath))
 2380            {
 2381                this.logger.LogDebug("Assembly file does not exist: {Path}", assemblyPath);
 2382                return false;
 383            }
 384
 385            // Try to load the assembly in a temporary context to validate it
 4386            var tempContext = new IsolatedAssemblyLoadContext(assemblyPath, this.logger);
 387            try
 4388            {
 4389                var assembly = tempContext.LoadFromAssemblyPath(assemblyPath);
 390
 391                // If we got here, the assembly is valid
 2392                this.logger.LogDebug("Successfully validated assembly: {Path}", assemblyPath);
 2393                return true;
 394            }
 395            finally
 4396            {
 397                // Unload the temporary context
 4398                tempContext.Unload();
 4399            }
 400        }
 2401        catch (Exception ex)
 2402        {
 2403            this.logger.LogDebug(ex, "Assembly validation failed for {Path}: {Message}", assemblyPath, ex.Message);
 2404            return false;
 405        }
 11406    }
 407
 408    /// <summary>
 409    /// Unloads all loaded assemblies and their contexts
 410    /// </summary>
 411    public void UnloadAll()
 6412    {
 6413        this.logger.LogInformation("Unloading all assemblies");
 414
 24415        foreach (var context in this.loadContexts.Values)
 3416        {
 417            try
 3418            {
 3419                context.Unload();
 3420            }
 0421            catch (Exception ex)
 0422            {
 0423                this.logger.LogWarning(ex, "Error unloading assembly context");
 0424            }
 3425        }
 426
 6427        this.loadContexts.Clear();
 6428        this.loadedAssemblies.Clear();
 6429    }
 430
 431    /// <summary>
 432    /// Disposes the assembly loader and unloads all assemblies
 433    /// </summary>
 434    public void Dispose()
 4435    {
 4436        this.logger.LogDebug("Disposing AssemblyLoader");
 4437        UnloadAll();
 4438        GC.SuppressFinalize(this);
 4439    }
 440}