MultiClassLoader.java

/*
** Module   : MultiClassLoader.java
** Abstract : Provides a custom class loader which can manage multiple jars.
**
** Copyright (c) 2011-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description----------------------------------
** 001 CA  20111130 Initial version.
** 002 SVL 20130822 On construction this class loader creates InMemoryClassLoader as the parent.
** 003 CA  20140213 Added appendToClassPathForInstrumentation, to allow profilers to be added via
**                  the Hot Spot VM.
** 004 ECF 20150413 Replaced InMemoryClassLoader with AsmClassLoader in the chain of delegates.
** 005 ECF 20150828 Javadoc cleanup.
** 006 IAS 20150403 Fixed managerLibsDir declaration and initialization.
** 007 VVT 20200924 Fixed: logging in class initialization section causes recursion,
**                  See. #4527. Also some code polishing: missing annotations added etc.
** 008 SBI 20210414 Changed usages of JarClassLoader to simplify the code.
** 009 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 010 GBB 20230517 Remove use of CentralLogger in static initializer.
** 011 GBB 20230825 checkCallerAbort moved to SecurityUtil & calls updated.
** 012 GBB 20240404 Methods resolveJarName and prettyName moved to JarUtil.
**                  Static initializer code to JarUtil.resolveClasspathJars to be reused.
**                  classpathJars type changed from Set to List.
** 013 ICP 20240704 Fixed findResource to ensure that the loaded jars are correctly searched.
**     ICP 20240705 Added static method getClassLoader() to retrieve the MultiClassLoader
**                  from the current thread's context class loader.
**                  Fixed method of obtaining the class loader.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.classloader;

import com.goldencode.asm.*;
import com.goldencode.p2j.directory.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

import java.io.*;
import java.net.*;
import java.util.*;
import java.util.logging.*;

/**
 * This class provides a container for managing class loaders for multiple 
 * jars.  When determining the class loader for a file, first it searches for
 * a managed jar which handles that file.  If it was found, then it delegates
 * the request to it; else, it delegates the request to this class loader's
 * parent.
 * <p>
 * To use this class loader, set this property when starting the server:
 * <code>-Djava.system.class.loader=com.goldencode.p2j.classloader.MultiClassLoader</code>
 */
public class MultiClassLoader
extends ClassLoader
{
   /** logger */
   private static final CentralLogger LOG = CentralLogger.get(MultiClassLoader.class.getName(), false, false);


   /** Map containing as key the jar name and as value its associated class loader. */
   private final Map<String, JarClassLoader> loaders = new HashMap<>();
   
   /** Map each class to the classloader which handles it. */
   private final Map<String, JarClassLoader> jclByClasses = new HashMap<>();

   /**
    * Managed customer libraries directory path. If <code>null</code> then
    * it hasn't been read yet, otherwise it is contained in the zero element
    * of the array.
    */
   private static volatile String managedLibsDir = null;

   /** A set containing all jars added to the classpath when the server was started. */
   private static final List<String> classpathJars = JarUtil.resolveClasspathJars(null);
   
   /**
    * C'tor. Instantiate the class loader by linking it with its parent.
    *
    * @param parent
    *        Parent class loader.
    */
   public MultiClassLoader(ClassLoader parent)
   {
      // class loaders will be chained: MultiClassLoader -> AsmClassLoader -> standard class
      // loader
      super(new AsmClassLoader(parent));
   }

   /**
    * Check if the given class is handled by a custom jar loader for the given
    * jar.
    * 
    * @param   clazz
    *          The class object.
    * @param   jarName
    *          The jar name.
    *          
    * @return  See above.
    */
   public static boolean handledByJar(Class<?> clazz, String jarName)
   {
      return handledByJar(clazz.getName(), jarName);
   }

   /**
    * Check if the given class is handled by a custom jar loader for the given
    * jar.
    *
    * @param   className
    *          The name of the class to be checked.
    * @param   jarName
    *          The jar name.
    *
    * @return  See above.
    */
   public static boolean handledByJar(String className, String jarName)
   {
      jarName = JarUtil.resolveJarName(LOG, jarName, false);
      if (jarName == null)
      {
         return false;
      }

      ClassLoader scl = getClassLoader();
      if (!(scl instanceof MultiClassLoader))
      {
         return false;
      }

      MultiClassLoader mcl = (MultiClassLoader) scl;

      JarClassLoader jcl;
      synchronized (mcl)
      {
         jcl = mcl.loaders.get(jarName);
      }

      if (jcl != null)
      {
         return jcl.containsClass(className);
      }

      return false;
   }

   /**
    * Register the given jar to be handled by its own class loader.
    * 
    * @param   jarName
    *          The jar name.
    *          
    * @return  {@link ErrorCode#SUCCESS} if the loading was done. Else,
    *          it returns an {@link ErrorCode error code}.
    *
    * @throws  RestrictedUseException
    *          if called improperly.
    */
   public static ErrorCode addJarLoader(String jarName)
   throws RestrictedUseException
   {
      SecurityUtil.checkCallerAbort(new String[]{
            "com.goldencode.p2j.admin.AdminServerImpl.registerJar",
            "com.goldencode.p2j.main.StandardServer.initClassLoaders"},
            "com.goldencode.p2j.classloader.MultiClassLoader");

      ClassLoader scl = getClassLoader();
      if (!(scl instanceof MultiClassLoader))
      {
         return ErrorCode.BAD_LOADER;
      }
      
      return ((MultiClassLoader) scl).addJarLoaderImpl(jarName, false);
   }

   /**
    * Remove the classloader associated with the given jar.
    * 
    * @param   jarName
    *          The jar name.
    *          
    * @return  {@link ErrorCode#SUCCESS} if the deregistration was done. Else,
    *          it returns an {@link ErrorCode error code}.
    *
    * @throws  RestrictedUseException
    *          if called improperly.
    */
   public static ErrorCode removeJarLoader(String jarName)
   throws RestrictedUseException
   {
      SecurityUtil.checkCallerAbort(new String[]{
            "com.goldencode.p2j.admin.AdminServerImpl.registerJar",
            "com.goldencode.p2j.admin.AdminServerImpl.deregisterJar"},
            "com.goldencode.p2j.classloader.MultiClassLoader");

      ClassLoader scl = getClassLoader();
      if (!(scl instanceof MultiClassLoader))
      {
         return ErrorCode.BAD_LOADER;
      }
      
      return ((MultiClassLoader) scl).removeJarLoaderImpl(jarName);
   }
   
   /**
    * Get the managed customer libs folder. First, it checks managed.libs.dir
    * system property, then, as a fallback, it checks the configuration
    * directory (path is <code>/server/default/managed-libs-dir</code> or
    * <code>/server/&lt;serverID&gt;/managed-libs-dir</code>).
    * 
    * @return   managed customer libs folder or <code>null</code> if it is not
    *           specified.
    */
   public static String managedCustomerLibs()
   {
      // quick check
      if (managedLibsDir == null)
      {
         synchronized (MultiClassLoader.class)
         {
            if (managedLibsDir == null)
            {
               String cl = System.getProperty("managed.libs.dir", null);

               if (cl == null)
               {
                  DirectoryService ds = DirectoryService.getInstance();
                  cl = Utils.getDirectoryNodeString(ds,
                                                    "managed-libs-dir",
                                                    null,
                                                    false);
               }

               if (cl != null && !cl.endsWith(File.separator))
               {
                  cl = cl + File.separator;
               }

               managedLibsDir = cl;
            }
         }
      }
      return managedLibsDir;
   }

   /**
    * List all classes in the given jar. The classes will not be initialized.
    * 
    * @param   jarName
    *          The jar name.
    *          
    * @return  The list of classes in this jar or <code>null</code> in case
    *          of error.
    */
   public static ClassInfo[] listClasses(String jarName)
   {
      ClassLoader cl = getClassLoader();
      jarName = JarUtil.resolveJarName(LOG, jarName, false);

      if (jarName == null || !(cl instanceof MultiClassLoader))
      {
         return null;
      }
      
      MultiClassLoader  mcl = (MultiClassLoader) cl;

      JarClassLoader jcl;
      synchronized (mcl)
      {
         jcl = mcl.loaders.get(jarName);
      }

      if (jcl != null)
      {
         // jar is already loaded
         return jcl.listClasses();
      }
      
      // load jar, get the list of classes, close jar
      JarClassLoader loader = JarClassLoader.createJarClassLoader(jarName, cl.getParent());
      if (loader == null)
      {
         return null;
      }

      ClassInfo[] res = null;
      try
      {
         res = loader.listClasses();
      }
      finally
      {
         loader.closeLoader();
      }
      return res;
   }

   /**
    * List the jars handled by custom class loaders.
    * 
    * @return   See above.
    */
   public static String[] listRegisteredJars()
   {
      ClassLoader cl = getClassLoader();
      if (!(cl instanceof MultiClassLoader))
      {
         return new String[0];
      }
      
      MultiClassLoader mcl = (MultiClassLoader) cl;
      Set<String> names = new TreeSet<>();

      synchronized (mcl)
      {
         for (String name : mcl.loaders.keySet())
         {
            names.add(JarUtil.prettyName(name));
         }
      }
      
      return names.toArray(new String[0]);
   }
   
   /**
    * List the jars which may be handled by custom class loaders (they reside
    * in the given path, are not yet handled by custom class loaders and are
    * not in the classpath).
    * <p>
    * The unregistered jars are searched in the customer_libs property 
    * specified at server startup. If it was not specified, then it will 
    * return an empty list.
    * 
    * @return   See above.
    */
   public static String[] listUnregisteredJars()
   {
      String customerLibs = managedCustomerLibs();
      if (customerLibs == null)
         return new String[0];

      File f = new File(customerLibs);
      ClassLoader cl = getClassLoader();

      if (!(cl instanceof MultiClassLoader) || 
          !f.exists()                       || 
          !f.isDirectory())
      {
         return new String[0];
      }
      
      MultiClassLoader mcl = (MultiClassLoader) cl;
      Set<String> registered;
      synchronized (mcl)
      {
         registered = new TreeSet<>(mcl.loaders.keySet());
      }

      Set<String> unregs = new TreeSet<>();
      
      File[] files = f.listFiles();
      for (File file : files)
      {
         String name = file.getPath();
         String jarName = JarUtil.resolveJarName(LOG, name, true);

         if (file.isDirectory()               ||
             !file.getName().endsWith(".jar") ||
             classpathJars.contains(jarName)  ||
             registered.contains(jarName))
         {
            continue;
         }

         unregs.add(JarUtil.prettyName(name));
      }
      
      return unregs.toArray(new String[unregs.size()]);
   }

   /**
    * Finds the resource with the given name. First it searches the loaded
    * jars; if not found, delegates the search to the parent class loader.
    *
    * @param   name
    *          The resource name
    *
    * @return  A <tt>URL</tt> object for reading the resource, or
    *          <tt>null</tt> if the resource could not be found
    */
   @Override
   protected synchronized URL findResource(String name)
   {
      if (LOG.isLoggable(Level.FINE))
      {
         LOG.log(Level.FINE, "Searching for resource " + name);
      }
      
      // search in loaders first
      URL url = null;

      for (String pathToJar : classpathJars)
      {
         JarClassLoader jcl = loaders.get(pathToJar);
         if (jcl != null && jcl.containsResource(name))
         {
            url = jcl.getResource(name);
            break;
         }
      }
      
      // delegate to parent if not found
      return url == null ? getParent().getResource(name) : url;
   }
   
   /**
    * Find and load the class with the given name. If this is in one of the
    * registered Jar class loaders, it will delegate the loading to it. Else,
    * the search is delegated to the parent class loader.
    * 
    * @return  The {@link Class} object.
    * 
    * @throws  ClassNotFoundException
    *          If the class can not be found or class linkage has error
    *          occurred.
    */
   @Override
   protected Class<?> findClass(String name) 
   throws ClassNotFoundException
   {
      if (LOG.isLoggable(Level.FINE))
      {
         LOG.log(Level.FINE, "Searching for class " + name);
      }

      Class<?> clazz = getClass(name);

      try
      {
         return clazz == null ? super.findClass(name) : clazz;
      }
      catch (NoClassDefFoundError e)
      {
         throw new ClassNotFoundException("Linkage error for class " + name,
                                          e);
      }
   }

   /**
    * Find and load the class with the given name. If this is in one of the
    * registered Jar class loaders, it will delegate the loading to it. Else,
    * the search is delegated to the parent class loader.
    * 
    * @return  The {@link Class} object.
    * 
    * @throws  ClassNotFoundException
    *          If the class can not be found or class linkage has error
    *          occurred.
    */
   @Override
   public Class<?> loadClass(String name) 
   throws ClassNotFoundException
   {
      if (LOG.isLoggable(Level.FINE))
      {
         LOG.log(Level.FINE, "Loading class " + name);
      }

      try
      {
         Class<?> clazz;
         try
         {
            clazz = getParent().loadClass(name);
         }
         catch (ClassNotFoundException e)
         {
            clazz = findClass(name);
         }

         return clazz == null ? super.loadClass(name) : clazz;
      }
      catch (NoClassDefFoundError e)
      {
         throw new ClassNotFoundException("Linkage error for class " + name,
                                          e);
      }
   }

   /**
    * Find and load the class with the given name. If this is in one of the
    * registered Jar class loaders, it will delegate the loading to it. Else,
    * the search is delegated to the parent class loader.
    * 
    * @param    name
    *           The class name
    * 
    * @return   The {@link Class} object, or <code>null</code> if this class 
    *           does not belong to any of the registered jars.
    * 
    * @throws   ClassNotFoundException
    *           If the class belongs must be handled by a {@link JarClassLoader} 
    *           but can not be loaded.
    */
   public synchronized Class<?> getClass(String name) 
   throws ClassNotFoundException
   {
      JarClassLoader jcl = jclByClasses.get(name);
      
      if (jcl == null)
      {
         // check the jar loaders

         for (String pathToJar : loaders.keySet())
         {
            JarClassLoader jar = loaders.get(pathToJar);
            if (jar != null && jar.containsClass(name))
            {
               if (jcl != null)
               {
                  // class found in more than one jar, log inf
                  String jar1 = jar.getJarName();
                  String jar2 = jcl.getJarName();

                  // JVM says that only the first returned reference is
                  // handled - if more, I don't care
                  if (LOG.isLoggable(Level.WARNING))
                  {
                     LOG.warning("Class " + name + 
                                 " is defined in both " +
                                 jar1 + " and " + jar2 + " jars !");
                  }
                  
                  break;
               }

               jcl = jar;
            }
         }
         
         if (jcl != null)
         {
            jclByClasses.put(name, jcl);
         }
      }
      
      return jcl == null ? null : jcl.loadClass(name);
   }

   /**
    * To support dynamic additions to the class path at runtime, the class loader needs to define
    * this private method. This will be invoked from the native VM code, to load a jar.
    * <p>
    * This method does not have to be public.
    *
    * @param    path
    *           The full path of the jar to be appended.
    */
   @SuppressWarnings("unused")
   private void appendToClassPathForInstrumentation(String path)
   {
      addJarLoaderImpl(path, true);
   }

   /**
    * Register the given jar to be handled by its own class loader.
    * 
    * @param   jarName
    *          The jar name.
    * @param   fullPath
    *          <code>true</code> if the <code>jarName</code> contains also
    *          the jar's path.  When <code>false</code>, it is assumed that
    *          the path is the one specified in the "customer_libs" system
    *          property.
    *          
    * @return  {@link ErrorCode#SUCCESS} if the loading was done. Else,
    *          it returns an {@link ErrorCode error code}.
    */
   private synchronized ErrorCode addJarLoaderImpl(String jarName, boolean fullPath)
   {
      jarName = JarUtil.resolveJarName(LOG, jarName, fullPath);
      if (jarName == null)
      {
         return ErrorCode.NOT_FOUND;
      }
      
      if (classpathJars.contains(jarName))
      {
         if (LOG.isLoggable(Level.INFO))
         {
            LOG.log(Level.INFO, 
                    "Could not add jar loader for jar " + jarName +
                    " - it is already added to classpath.");
         }

         return ErrorCode.IN_CLASSPATH;
      }
      
      if (LOG.isLoggable(Level.INFO))
      {
         LOG.log(Level.INFO, 
                 "Adding jar loader for jar " + jarName);
      }

      if (loaders.containsKey(jarName))
      {
         // jar already loaded, must remove first
         if (LOG.isLoggable(Level.WARNING))
         {
            LOG.log(Level.WARNING, 
                    "Jar loader for jar " + jarName + 
                    "is already loaded - it will be removed before adding.");
         }
         ErrorCode er = removeJarLoaderImpl(jarName);
         if (er != ErrorCode.SUCCESS)
         {
            return er;
         }
      }
      
      JarClassLoader loader = JarClassLoader.getOrCreateCachedJarClassLoader(jarName, getParent());
      if (loader != null)
      {
         loaders.put(jarName, loader);
      }
      else
      {
         // jar can not be initialized
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE, 
                    "Could not initialize loader for jar " + jarName);
         }
         
         return ErrorCode.INIT_ERROR;
      }
      
      return ErrorCode.SUCCESS;
   }

   /**
    * Remove the classloader associated with the given jar.
    * 
    * @param   jarName
    *          The jar name.
    *          
    * @return  {@link ErrorCode#SUCCESS} if the deregistration was done. Else,
    *          it returns an {@link ErrorCode error code}.
    */
   private synchronized ErrorCode removeJarLoaderImpl(String jarName)
   {
      jarName = JarUtil.resolveJarName(LOG, jarName, false);
      if (jarName == null)
      {
         return ErrorCode.NOT_FOUND;
      }

      if (LOG.isLoggable(Level.INFO))
      {
         LOG.log(Level.INFO, "Removing jar loader for jar " + jarName);
      }

      JarClassLoader jar = loaders.get(jarName);
      if (jar == null)
      {
         // jar can not be found
         if (LOG.isLoggable(Level.WARNING))
         {
            LOG.log(Level.WARNING, 
                    "Can not remove jar loader for jar " + jarName + 
                    ": the jar is not registered with a class loader !");
         }
         
         return ErrorCode.NOT_REGISTERED;
      }
      
      if (!jar.closeLoader())
      {
         // jar can not be closed
         return ErrorCode.CLOSE_ERROR;
      }
      loaders.remove(jarName);
      
      // remove all cached classes
      Iterator<JarClassLoader> itr = jclByClasses.values().iterator();
      while (itr.hasNext())
      {
         JarClassLoader jcl = itr.next();
         if (jcl == jar)
            itr.remove();
      }

      return ErrorCode.SUCCESS;
   }

   /**
    * Utility method to obtain the MultiClassLoader instance from the current thread's context
    * class loader. If no MultiClassLoader is found in the thread hierarchy, it returns the
    * system class loader.
    *
    * @return  The MultiClassLoader instance if found, otherwise the system class loader.
    */
   public static ClassLoader getClassLoader()
   {
      ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
      while (!(classLoader instanceof MultiClassLoader) &&
             classLoader.getParent() != null)
      {
         classLoader = classLoader.getParent();
      }
      if (!(classLoader instanceof MultiClassLoader))
      {
         classLoader = ClassLoader.getSystemClassLoader();
      }
      return classLoader;
   }
}