Handling Entry Assemblies that Won't Load: Method 1.1

Yesterday we developed a simple Shim application in order to fail gracefully when our application's entry assembly doesn't have enough permission to meet its minimum grant set, and therefore won't be loaded.  However, there were quite a few problems with that version of the Shim.  Today, lets improve on it a bit, to make a nicer shim application.

One of the issues with yesterday's solution was that you needed to ship two executables with your application in order for the solution to work, and you had to rely on your customers to not run the wrong application.  Another oversight was that there was no way to pass in command line parameters, which could be a show-stopper for a lot of apps.  Finally, the version of the Shim yesterday was bound to one specific application, main.exe.

I've updated the shim to solve all of these problems.  Instead of binding to main.exe, the application configuration file can be used to specify which assembly is the real entry point.  And that assembly isn't stored in the file system, instead we embed it into the shim itself as a resource.  This prevents users from being confused by having multiple executables for your application.  Finally, we pass any parameters that the shim received to the application itself.

The updated Shim code looks like this:

using System;
using System.Diagnostics;
using System.Configuration;
using System.IO;
using System.Reflection;

public class Shim
{
    public const int FailCode = 0;
    public const int SuccessCode = 1;

    public static int Main(string[] commandLine)
    {
        string entryPointResource = ConfigurationSettings.AppSettings["entryPointAssembly"];
        if(entryPointResource == null || entryPointResource.Length == 0)
        {
            Console.WriteLine("ERROR: No entry point assembly was specified.");
            return FailCode;
        }

        // load the entry assembly out of the resources
        byte[] entryBytes = null;
        using(Stream entryStream = typeof(Shim).Assembly.GetManifestResourceStream(entryPointResource + ".exe"))
        {
            if(entryStream == null)
            {
                Console.WriteLine("ERROR: Entry point assembly did not exist.");
                return FailCode;
            }

            using(BinaryReader entryReader = new BinaryReader(entryStream))
            {
                Debug.Assert(entryStream.Length < Int32.MaxValue, "Main assembly is too large");
                entryBytes = entryReader.ReadBytes((int)entryStream.Length);
            }
        }

        // create an assembly from it
        Assembly entryAssembly = null;
        try
        {        
            Debug.Assert(entryBytes != null, "Did not read main out of resources");
            entryAssembly = Assembly.Load(entryBytes);
        }
        catch(FileLoadException)
        {
            Console.WriteLine("Could not load {0}.exe, possibly due to security issues. Please copy to a local location.", entryPointResource);
            return FailCode;
        }

        // invoke its entry point
        object returnValue = null;
        Debug.Assert(entryAssembly != null, "Did not load the entry assembly");
        Debug.Assert(entryAssembly.EntryPoint != null, "No entry point");
        if(entryAssembly.EntryPoint.GetParameters().Length == 0)
        {
            // if there are no parameters, don't pass any
            returnValue = entryAssembly.EntryPoint.Invoke(null, new object[] { });
        }
        else
        {
            Debug.Assert(entryAssembly.EntryPoint.GetParameters().Length == 1, "Wrong number of parameters");
            Debug.Assert(entryAssembly.EntryPoint.GetParameters()[0].ParameterType == typeof(string[]), "Wrong parameter type");
                
            // if it takes string[], pass along our parameters
            returnValue = entryAssembly.EntryPoint.Invoke(null, new object[] { commandLine });
        }
            
        // if the Main function returned int, then pass it along, otherwise return a generic code
        if(entryAssembly.EntryPoint.ReturnType == typeof(int))
        {
            Debug.Assert(returnValue != null, "Didn't get a return value");
            return (int)returnValue;
        }
        else
            return SuccessCode;
    }
}

It would then be compiled with a command like:

csc Shim.cs /res:Main.exe

And provided a config file similar to:

<configuration>
  <appSettings>
    <add key="entryPointAssembly" value="main"/>
  </appSettings>
</configuration>

You'll notice that the code to detect that the entry assembly could not be loaded is much neater now.  That's because we separate loading the assembly from actually invoking its main method.  This means that any load exception that we do see must have been caused by failure to load the entry assembly or one of its dependencies, and wasn't caused by the application itself.

Once the assembly is loaded, we check to see if it needs any parameters ... if it does, we pass along the parameters the Shim received, otherwise we pass it empty parameters.  Finally, we check to see if the entry point returns a value, and pass that along if it does.

One final thing to note is that we only need to verify that the config file specified an entry assembly, and that the assembly was in the resources.  Once we load it from the resource stream, we can simply Assert all the other error conditions because the end user does not have control over them anymore.  They can only specify which resource that we embeded into our assembly should be run, not the resource code itself.

Overall this design seems much nicer than yesterday's design, but there are still some improvements that could be made.  More on that tomorrow.