In my previous post, I explained a problem that we have encountered with .Net, WPF and custom Assembly redirection.
In this post, I will explain how to build a custom resolver class that can attach to an AppDomain and redirect the assembly requests to whatever assembly you would like it to. Recall that if the .Net runtime is unable to locate a particular assembly, it will raise the AppDomain’s AssemblyResolve event. If the handler for this event returns an Assembly, .Net will bind to it. We will use this event to implement the resolver.
Requirements
The resolver should be able to:
- Load an Assembly from anywhere on the local desktop
- Redirect a request from an Assembly to any other Assembly
Representing the problem
The main class we are going to work with is Resolver. This class will attach to an AppDomain’s AssemblyResolve event. We will need some configuration objects to configure the Resolver. For simplicity, the resolver object will be immutable.
We require the concept of a path from which .Net can load the Assemblies. In .Net, this is known as a probing path. However, for security reasons (more on this later), .Net is unable to bind to Assemblies anywhere on the desktop. Our resolver will allow this feature and the paths will be represented by the ProbingPath class.
We also need the concept of a RedirectionItem. In our problem, we will have two kinds of redirection items:
- VersionRedirectionItem, which redirects from one version to another version of a single Assembly Name.
- AssemblyRedirectionItem, which redirection from one Assembly version to any other Assembly.
The sample below utilizes inheritance to represent the redirection items, though other approaches may be more appropriate.
These classes will fulfill our requirements.
Executing the redirection and probing
A Resolver is just a collection of ProbingPaths and RedirectionItems. The AssemblyResolve handler will check if the AssemblyName being looked for needs to be redirected using the redirection item collection. There is a possibility for multiple redirections and circular dependencies so our code should be able to handle this in a recursive manner (… I love recursive code).
public AssemblyName GetAssemblyNameToLoad(AssemblyName currentName)
{
List<RedirectionAction> visitedActions = new List<RedirectionAction>();
AssemblyName name = GetAssemblyNameToLoad(currentName, visitedActions);
return name;
}
private AssemblyName GetAssemblyNameToLoad(AssemblyName currentName,
List<RedirectionAction> visitedActions)
{
var actions = this[currentName.Name];
AssemblyName nextAssemblyName = null;
foreach (var action in actions)
{
if (action.ShouldRedirectAssembly(currentName))
{
if (visitedActions.Contains(action))
{
throw new Exception(string.Format("Circular dependency path detected. {0}", action.ToString()));
}
visitedActions.Add(action);
nextAssemblyName = action.GetRedirectedAssembly(currentName);
break;
}
}
if(nextAssemblyName == null) return currentName;
nextAssemblyName = GetAssemblyNameToLoad(nextAssemblyName, visitedActions);
return nextAssemblyName;
}
The logic for the Resolve method is simply:
public Assembly Resolve(string assemblyName)
{
Assembly asm = null;
var name = new AssemblyName(assemblyName);
AssemblyName assemblyToGet = null;
if (name.Version != null)
{
assemblyToGet = GetAssemblyNameToLoad(name);
}
foreach (var input in probingPaths)
{
if (!input.ContainsAssembly(assemblyToGet.Name))
continue;
if (assemblyToGet.Version == null) { asm = input.Load(assemblyToGet.Name); }
else
{
asm = input.Load(assemblyToGet.Name, assemblyToGet.Version);
if (asm != null)
break;
}
}
return asm;
}
Implications
With regards to performance, each assembly load takes about 10ms. The difference between this and the .Net binding engine is insignificant. The probing path objects cache the contents of the path so there is 0 I/O operations when it comes to finding the proper assembly to load.
One thing to be careful of. All of this logic executes after the four steps specified by the How the Runtime Loads Assemblies document. This means that if .Net finds the originally requested assembly in the GAC or through some sort of private probing path or assembly binding redirection, this logic will never kick in. This code does not have logic to deal with the GAC. Additionally, if the assembly is found in the working directory, the AssemblyResolve event will never kick in. This means that if your application is built against LibA.dll v 1.0.0.0 and you’d like to redirect the app to LibA.dll v 2.0.0.0, the 1.0.0.0 Assembly should not exist int he working directory.
This code is not meant to be secure but rather to illustrate the mechanisms through which .Net binds assemblies. In fact, as long as the .Net can’t bind to an assembly using its standard binding mechanisms, the resolver could be used to execute any arbitrary .Net code.
The Sample
You can find a sample implementation ResolverSample here. This code is a quick and dirty demo of the redirection concepts. The RedirectionItem and subclasses and the Version Range classes should most likely implement IComparable. In addition, the ResolverConfiguration class should make sure no duplicate probing paths or redirection items are inserted.
The idea in the sample is the following. I have two libraries that define the type: Lib.Class1. The two assemblies are: Lib1 and Lib2. There are two versions of each library: 1.0.0.0 and 2.0.0.0. All 4 builds print out “AssemblyName Version”, so: Lib1 1.0.0.0 will print out “Lib1 1.0.0.0″, Lib2 2.0.0.0 will print out “Lib2 2.0.0.0″, etc.
By default, the sample application is built against Lib1 version 1.0.0.0.
static void Main(string[] args)
{
List<string> probingPaths = new List<string>();
probingPaths.Add(@"Library\lib1\1.0.0.0");
probingPaths.Add(@"Library\lib1\2.0.0.0");
probingPaths.Add(@"Library\lib2\1.0.0.0");
probingPaths.Add(@"Library\lib2\2.0.0.0");
VersionRedirectionItem lib1from1to2 =
new VersionRedirectionItem("Lib1", VersionRange.CreateVersionRange("1.0.0.0"), new Version("2.0.0.0"));
AssemblyRedirectionItem lib1tolib2_1 =
new AssemblyRedirectionItem("Lib1",VersionRange.CreateVersionRange("1.0.0.0"),
"Lib2", new Version("1.0.0.0"));
AssemblyRedirectionItem lib1tolib2_2 =
new AssemblyRedirectionItem("Lib1", VersionRange.CreateVersionRange("1.0.0.0"),
"Lib2", new Version("2.0.0.0"));
List<RedirectionItem> redirection = new List<RedirectionItem>();
// By default, the output of the program will be: Lib1 1.0.0.0
// If you uncomment this, the output will be: Lib1 2.0.0.0
// redirection.Add(lib1from1to2);
// If you uncomment this, the output will be: Lib2 1.0.0.0
//redirection.Add(lib1tolib2_1);
// If you uncomment this, the output will be: Lib2 2.0.0.0
//redirection.Add(lib1tolib2_2);
ResolverConfiguration config = new ResolverConfiguration(probingPaths, redirection);
using (Resolver.Resolver resolver = Resolver.ResolverFactory.CreateResolver(config))
{
Runner runner = new Runner();
runner.Run();
}
}
The directory in which the sample.exe lives has a Library folder with both Lib1 and Lib2 versions in the sub folder structures passed into the probing paths configuration entry. Depending on which redirection item we pass into the ResolverConfiguration, we will see different outputs from the Run() method.
In this sample, the redirection is inserted programmatically but there is no reason we can’t deserialize the configuration from any external config file.
I hope this post helps clarify how the .Net framework binds Assemblies at runtime.