JarClassLoader.java

/*
** Module   : JarClassLoader.java
** Abstract : Provides a custom class loader which handles classes loaded from
**            a jar file.
**
** Copyright (c) 2011-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------Description----------------------------------
** 001 CA  20111130 Initial version.
** 002 EVL 20151027 Adding method to perform resource search depending on case sensitivity mode.
** 003 EVL 20160223 Javadoc fixes to make compatible with Oracle Java 8 for Solaris 10.
** 004 ECF 20180102 Use String.replace instead of more expensive String.replaceAll in methods
**                  called frequently
** 005 SBI 20210414 Moved jclCache from FieSystemOps, added createJarClassLoader and getOrCreateCachedJarClassLoader
**                  for two scenarios of usages. 
** 006 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 007 GBB 20240425 containsResource() and getResourceName() to work for Windows file separator too.
** 008 ICP 20240705 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.p2j.util.logging.*;
import org.apache.bcel.classfile.*;

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

/**
 * Provides a class loader for files in a specified jar.
 */
public class JarClassLoader
extends ClassLoader
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(JarClassLoader.class.getName(), false, false);

   /** Jar classloader cache. */
   private static Map<String, JarClassLoader> jclCache = new ConcurrentHashMap<>();

   /** The resources contained in this jar. */
   private final Set<String> resources = new HashSet<String>();
   
   /** The resources contained in this jar.  Case insensitive version. */
   private final Map<String, String> resourcesNoCase = new HashMap<String, String>();
   
   /** The classes loaded from this jar. */
   private final Map<String, Class<?>> classes = new HashMap<String, Class<?>>();

   /** The classes loaded from this jar using BCEL. */
   private Map<String, JavaClass> bcelClasses = null;

   /** The jar name, including path. */
   private final String jarName;

   /** The jar file. */
   private JarFile jar = null;

   /**
    * Gets jar file class loader to obtain resources. If not in current cache - the new
    * loader is created and stored in cache.
    * 
    * @param    jarFullName
    *           The fully qualified jar file name to load.  It must be correct refined name.
    * @param    parentCl
    *           The class loader to be used as parent class loaded when new class loader should
    *           be created.
    *
    * @return   The instance of the JarClassLoader to use in resource loading.
    */
   public static JarClassLoader getOrCreateCachedJarClassLoader(String jarFullName, ClassLoader parentCl)
   {
      // TODO: This code is NOT thread safe and needs to be rewritten.
      // first try to reuse the cache 
      JarClassLoader jclRes = jclCache.get(jarFullName);
      
      // not in cache, create and store for further usage
      if (jclRes == null)
      {
         jclRes = createJarClassLoader(jarFullName, parentCl);
         
         if (jclRes != null)
         {
            JarClassLoader currLoader = jclCache.putIfAbsent(jarFullName, jclRes); // cache this loader
            if (currLoader != null && currLoader != jclRes)
            {
               jclRes.closeLoader();
               jclRes = null;
               jclRes = currLoader;
            }
         }
      }
      
      return jclRes;
   }

   /**
    * Creates jar file class loader to obtain resources.
    * 
    * @param    jarFullName
    *           The fully qualified jar file name to load.  It must be correct refined name.
    * @param    parentCl
    *           The class loader to be used as parent class loaded when new class loader should
    *           be created.
    *
    * @return   The instance of the JarClassLoader to use in resource loading.
    */
   public static JarClassLoader createJarClassLoader(String jarFullName, ClassLoader parentCl)
   {
      JarClassLoader jclRes = new JarClassLoader(jarFullName, parentCl);
      
      // loader created, initialize
      if (!jclRes.init()) 
      {
         jclRes = null;
      }
      
      return jclRes;
   }
   
   /**
    * Initiate a new jar classloader instance, setting its parent classloader
    * to the given instance.
    *  
    * @param   jarName
    *          The jar name, including path.
    * @param   parent
    *          The parent classloader.
    */
   private JarClassLoader(String jarName, ClassLoader parent)
   {
      super(parent);

      this.jarName = jarName;
   }

   /**
    * Initiatilize this classloader, by opening the jar file and saving all
    * its resources into the {@link #resources} set.
    *  
    * @return  <code>true</code> if the jar was initialized, else 
    *          <code>false</code>.
    */
   private synchronized boolean init()
   {
      if (jar != null && !closeLoader())
      {
         return false;
      }

      try
      {
         jar = new JarFile(jarName);

         Enumeration<JarEntry> entries = jar.entries();
         while (entries.hasMoreElements())
         {
            JarEntry entry = entries.nextElement();
            // actual resource name inside jar
            String name = entry.getName();
            resources.add(name);
            // also make mapping from case insensitive version to actual name
            // directories and classes are not something we are interesting in this time
            if (!name.endsWith("/") && !name.endsWith(".class"))
            {
               resourcesNoCase.put(name.toLowerCase(), name);
            }
         }
      }
      catch (IOException e)
      {
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE, 
                    "Could not initiatilize this classloader " + jarName + "!", e);
         }
         return false;
      }
      
      return true;
   }

   /** 
    * Get the {@link #jarName jar name}.
    * 
    * @return   See above.
    */
   public String getJarName()
   {
      return jarName;
   }

   /**
    * Check if the given class is handled by this jar class loader (i.e. the
    * class is defined in the jar).
    * 
    * @param   className
    *          The fully qualified class name.
    *          
    * @return  <code>true</code> if the class belongs to this class loader.
    */
   public boolean containsClass(String className)
   {
      String resName = classNameToResource(className);
      return containsResource(resName);
   }

   /**
    * Check if the given resource belongs to this jar.
    * 
    * @param   name
    *          The fully qualified resource name.
    *          
    * @return  <code>true</code> if the resource belongs to this jar.  
    */
   public synchronized boolean containsResource(String name)
   {
      if (name != null && name.startsWith("/"))
         name = name.substring(1);

      return resources.contains(name);
   }

   /**
    * Check if the given resource belongs to this jar.
    * 
    * @param   name
    *          The fully qualified resource name.
    * @param   caseSens
    *          The flag to control case sensitivity while searching.
    *          
    * @return  The name of the resource in given jar that match to the given name and case
    *          sensitivy flag or <code>null</code> if not found.  
    */
   public synchronized String getResourceName(String name, boolean caseSens)
   {
      // prepare string to check
      if (name != null && name.startsWith("/"))
      {
         name = name.substring(1);
      }

      return caseSens ? resources.contains(name) ? name : null
                      : resourcesNoCase.get(name.toLowerCase());
   }

   /**
    * Finds the resource with the given name.
    *
    * @param  resName
    *         The resource name
    *
    * @return  A <tt>URL</tt> object for reading the resource, or
    *          <tt>null</tt> if the resource could not be found
    */
   public URL findResource(String resName)
   {
      try
      {
         File f = new File(jarName);
         String jarPath = f.getAbsolutePath();
         return new URL("jar:file:" + jarPath + "!/" + resName);
      }
      catch (MalformedURLException e)
      {
         LOG.warning("Couldn't fine the resource " + resName, e);
         return null;
      }
   }

   /**
    * Loads the class with the specified <a href="#name">binary name</a>.
    * This method searches for classes in the same manner as the {@link
    * #loadClass(String, boolean)} method.  It is invoked by the Java virtual
    * machine to resolve class references.  Invoking this method is equivalent
    * to invoking {@link #loadClass(String, boolean) <tt>loadClass(name,
    * false)</tt>}.
    *
    * @param   name
    *          The <a href="#name">binary name</a> of the class
    *
    * @return  The resulting <tt>Class</tt> object
    *
    * @throws  ClassNotFoundException
    *          If the class was not found
    */
   public synchronized Class<?> loadClass(String name)
   throws ClassNotFoundException
   {
      if (name == null)
         throw new NullPointerException("The class name can not be null !");

      if (!containsClass(name))
      {
         // class is not handled by this jar loader, delegate the loading to
         // the system class loader.
         return MultiClassLoader.getClassLoader().loadClass(name);
      }

      if (classes.containsKey(name))
      {
         // class already loaded, get it from cache
         return classes.get(name);
      }

      // load the bytecode of the class
      byte[] data = null;
      try
      {
         String resName = classNameToResource(name);
         JarEntry entry = (JarEntry) jar.getEntry(resName);
         data = new byte[(int) entry.getSize()];
         DataInputStream dis = new DataInputStream(jar.getInputStream(entry));
         int off = 0;
         while (dis.available() > 0)
         {
            off = off + dis.read(data, off, Math.min(data.length - off, 2048));
         }
         dis.close();
      } 
      catch (IOException e)
      {
         throw new ClassNotFoundException("Error reading bytecode for class "
               + name, e);
      }

      try
      {
         Class<?> clazz = defineClass(name, data, 0, data.length);
         classes.put(name, clazz);
         return clazz;
      }
      catch (LinkageError e)
      {
         throw new ClassNotFoundException("Linkage error for class "
               + name, e);
      }
   }

   /**
    * Loads the class information for the class with the specified
    * <a href="#name">binary name</a>. This method uses information provided
    * by system class loader if the class is already loaded. Otherwise it uses
    * BCEL to get class information. in this case, if any of the superclasses
    * of the target class were not found, that is not treated as error, the
    * class information that was already loaded is returned. I.e. at least the
    * target class definitions are returned, and, if it is possible,
    * definitions of the superclasses are included too.
    *
    * @param   name
    *          The <a href="#name">binary name</a> of the class
    *
    * @return  The resulting <tt>ClassInfo</tt> object
    *
    * @throws  ClassNotFoundException
    *          If the class itself (not a superclass of it) was not found or
    *          an error occurred while reading bytecode.
    */
   public ClassInfo loadClassInfo(String name)
   throws ClassNotFoundException
   {
      return loadClassInfo(name, null);
   }

   /**
    * Closes this class loader, by closing the jar handle.
    * 
    * @return  <code>true</code> if the class loader was closed.
    */
   public synchronized boolean closeLoader()
   {
      try
      {
         JarClassLoader loader = jclCache.get(jarName);
         
         if (loader == this)
         {
            jclCache.remove(jarName);
         }
         
         jar.close();
         jar = null;

         resources.clear();
         classes.clear();
         bcelClasses = null;
      }
      catch (IOException e)
      {
         if (LOG.isLoggable(Level.SEVERE))
         {
            LOG.log(Level.SEVERE, 
                    "Could not find close jar file " + jarName + "!", e);
         }

         return false;
      }
      
      return true;
   }

   /**
    * Loads class information for all classes handled by this jar, without
    * initiating them (i.e. their static c'tor is not called).
    * 
    * @return  The classes handled by this classloader.
    */
   public synchronized ClassInfo[] listClasses()
   {
      List<ClassInfo> classes = new ArrayList<ClassInfo>();

      for (String resource : resources)
      {
         if (!resource.endsWith(".class") ||
             resource.endsWith("package-info.class"))
            continue;

         try
         {
            String className = resourceToClassName(resource);
            ClassInfo cls = loadClassInfo(className);
            classes.add(cls);
         } 
         catch (ClassNotFoundException e)
         {
            if (LOG.isLoggable(Level.SEVERE))
            {
               LOG.log(Level.SEVERE,
                     "Could not load class " + resource +
                     " in jar " + jarName + " while listing jar classes.", e);
            }
         }
         catch (LinkageError e)
         {
            if (LOG.isLoggable(Level.SEVERE))
            {
               LOG.log(Level.SEVERE,
                     "Could not load class " + resource +
                     " in jar " + jarName + " while listing jar classes.", e);
            }
         }
         catch (Error e)
         {
            if (LOG.isLoggable(Level.SEVERE))
            {
               LOG.log(Level.SEVERE,
                     "Unexpected class loading error " + resource +
                     " in jar " + jarName + "!", e);
            }
            throw e;
         }
      }

      return classes.toArray(new ClassInfo[classes.size()]);
   }

   /**
    * Convert the fully-qualified class name to resource name. This is done
    * by replacing all dots with the <code>/</code> char and adding the
    * ".class" suffix.
    *  
    * @param   name
    *          The fully-qualified class name.
    *          
    * @return  The name of the resource which contains the bytecode of this 
    *          class.
    */
   private String classNameToResource(String name)
   {
      return name.replace('.', '/') + ".class";
   }

   /**
    * Convert the resource name to fully-qualified class name. This is done
    * by replacing all <code>/</code> chars with dots with and removing
    * trailing ".class" suffix.
    *
    * @param   resource
    *          Resource name.
    *
    * @return  Fully-qualified class name which corresponds the given
    *          resource.
    */
   private String resourceToClassName(String resource)
   {
      String name = resource.replace('/', '.');
      if (name.endsWith(".class"))
         name = name.substring(0, name.lastIndexOf(".class"));
      return name;
   }

   /**
    * Loads the class information for the class with the specified
    * <a href="#name">binary name</a>. This method uses information provided
    * by system class loader if the class is already loaded. Otherwise it uses
    * BCEL to get class information. in this case, if any of the superclasses
    * of the target class were not found, that is not treated as error, the
    * class information that was already loaded is returned. I.e. at least the
    * target class definitions are returned, and, if it is possible,
    * definitions of the superclasses are included too.<br>
    * Also, the information about the interfaces implemented by target class
    * is loaded. If the class to be loaded represents an interface it added to
    * the specified <tt>ClassInfo</tt> object using add*Interface functions.
    *
    * @param   name
    *          The <a href="#name">binary name</a> of the class to be loaded.
    * @param   classInfo
    *          <tt>ClassInfo</tt> object that stores definitions of already
    *          loaded child classes. <code>null</code> if it is the first
    *          class in hierarchy we are trying to load.
    *
    * @return  The resulting <tt>ClassInfo</tt> object
    *
    * @throws  ClassNotFoundException
    *          If the first class in hierarchy we are trying to load was not
    *          found or an error occurred while reading bytecode.
    */
   private synchronized ClassInfo loadClassInfo(String name,
                                                ClassInfo classInfo)
   throws ClassNotFoundException
   {
      if (name == null)
         throw new NullPointerException("The class name can not be null !");

      boolean firstClass = classInfo == null;

      if (!containsClass(name))
      {
         // class is not handled by this jar loader, delegate the loading to
         // the system class loader.
         try
         {
            Class cls = MultiClassLoader.getClassLoader().loadClass(name);
            if (cls.isInterface() && !firstClass)
            {
               classInfo.addInterface(cls);
            }
            else
            {
               if (firstClass)
                  classInfo = new ClassInfo(cls);
               else
                  classInfo.setRootSuperclass(cls);
            }
         }
         catch (ClassNotFoundException e)
         {
            // if it is not the first class in hierarchy then we return as
            // much class info as we have loaded
            if (firstClass)
               throw e;
         }

         return classInfo;
      }

      if (classes.containsKey(name))
      {
         // class already loaded, get it from cache
         Class cls = classes.get(name);
         if (cls.isInterface() && !firstClass)
         {
            classInfo.addInterface(cls);
         }
         else
         {
            if (firstClass)
               classInfo = new ClassInfo(cls);
            else
               classInfo.setRootSuperclass(cls);
         }

         return classInfo;
      }

      JavaClass cls = null;
      if (bcelClasses != null && bcelClasses.containsKey(name))
      {
         // class already loaded using BCEL, get it from cache
         cls = bcelClasses.get(name);
      }

      // load the bytecode of the class
      if (cls == null)
      {
         try
         {
            String resName = classNameToResource(name);
            JarEntry entry = (JarEntry) jar.getEntry(resName);
            ClassParser parser = new ClassParser(jar.getInputStream(entry),
                                                 resName);
            cls = parser.parse();

            if (bcelClasses == null)
               bcelClasses = new HashMap<String, JavaClass>();
            bcelClasses.put(name, cls);
         }
         catch (IOException e)
         {
            throw new ClassNotFoundException("Error reading bytecode for class "
                                             + name, e);
         }
         catch (ClassFormatException e)
         {
            throw new ClassNotFoundException("Class format error for class "
                                             + name, e);
         }
      }

      if (cls.isInterface() && !firstClass)
      {
         classInfo.addBcelInterface(cls.getClassName());

         // load super interfaces
         String[] superIfaces = cls.getInterfaceNames();
         if (superIfaces != null)
         {
            for (String iface : superIfaces)
            {
               classInfo = loadClassInfo(iface, classInfo);
            }
         }
      }
      else
      {
         if (firstClass)
            classInfo = new ClassInfo(cls);
         else
            classInfo.addSuperclass(cls);

         // load super class
         String superclassName = cls.getSuperclassName();
         if (!superclassName.equals(Object.class.getName()))
         {
            classInfo = loadClassInfo(superclassName, classInfo);
         }
      }

      if (firstClass)
      {
         // at this point we have loaded the first class in hierarchy using
         // BCEL and all its superclasses, so we should manually load
         // information about interfaces (and their parent interfaces) for
         // the classes which were loaded using BCEL
         String[] ifaces = classInfo.getFirstLevelBcelInterfaces();
         for (String iface : ifaces)
         {
            loadClassInfo(iface, classInfo);
         }
      }

      return classInfo;
   }
}