001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase;
019
020import static org.hamcrest.MatcherAssert.assertThat;
021import static org.hamcrest.Matchers.arrayContainingInAnyOrder;
022import static org.junit.jupiter.api.Assertions.assertEquals;
023import static org.junit.jupiter.api.Assertions.assertFalse;
024import static org.junit.jupiter.api.Assertions.assertTrue;
025
026import java.io.File;
027import java.io.FileInputStream;
028import java.io.FileOutputStream;
029import java.io.IOException;
030import java.io.PrintStream;
031import java.net.URL;
032import java.net.URLClassLoader;
033import java.util.HashSet;
034import java.util.Set;
035import java.util.concurrent.atomic.AtomicLong;
036import java.util.jar.Attributes;
037import java.util.jar.JarEntry;
038import java.util.jar.JarOutputStream;
039import java.util.jar.Manifest;
040import javax.tools.JavaCompiler;
041import javax.tools.ToolProvider;
042import org.apache.hadoop.hbase.testclassification.MiscTests;
043import org.apache.hadoop.hbase.testclassification.SmallTests;
044import org.junit.jupiter.api.AfterAll;
045import org.junit.jupiter.api.BeforeAll;
046import org.junit.jupiter.api.Tag;
047import org.junit.jupiter.api.Test;
048import org.junit.jupiter.api.TestInfo;
049import org.slf4j.Logger;
050import org.slf4j.LoggerFactory;
051
052@Tag(MiscTests.TAG)
053@Tag(SmallTests.TAG)
054public class TestClassFinder {
055
056  private static final Logger LOG = LoggerFactory.getLogger(TestClassFinder.class);
057
058  private static final HBaseCommonTestingUtil testUtil = new HBaseCommonTestingUtil();
059  private static final String BASEPKG = "tfcpkg";
060  private static final String PREFIX = "Prefix";
061
062  // Use unique jar/class/package names in each test case with the help
063  // of these global counters; we are mucking with ClassLoader in this test
064  // and we don't want individual test cases to conflict via it.
065  private static AtomicLong testCounter = new AtomicLong(0);
066  private static AtomicLong jarCounter = new AtomicLong(0);
067
068  private static String basePath = null;
069
070  private static CustomClassloader classLoader;
071
072  @BeforeAll
073  public static void createTestDir() throws IOException {
074    basePath = testUtil.getDataTestDir(TestClassFinder.class.getSimpleName()).toString();
075    if (!basePath.endsWith("/")) {
076      basePath += "/";
077    }
078    // Make sure we get a brand new directory.
079    File testDir = new File(basePath);
080    if (testDir.exists()) {
081      deleteTestDir();
082    }
083    assertTrue(testDir.mkdirs());
084    LOG.info("Using new, clean directory=" + testDir);
085
086    classLoader = new CustomClassloader(new URL[0], ClassLoader.getSystemClassLoader());
087  }
088
089  @AfterAll
090  public static void deleteTestDir() {
091    testUtil.cleanupTestDir(TestClassFinder.class.getSimpleName());
092  }
093
094  @Test
095  public void testClassFinderCanFindClassesInJars() throws Exception {
096    long counter = testCounter.incrementAndGet();
097    FileAndPath c1 = compileTestClass(counter, "", "c1");
098    FileAndPath c2 = compileTestClass(counter, ".nested", "c2");
099    FileAndPath c3 = compileTestClass(counter, "", "c3");
100    packageAndLoadJar(c1, c3);
101    packageAndLoadJar(c2);
102
103    ClassFinder allClassesFinder = new ClassFinder(classLoader);
104    Set<Class<?>> allClasses = allClassesFinder.findClasses(makePackageName("", counter), false);
105    assertEquals(3, allClasses.size());
106  }
107
108  @Test
109  public void testClassFinderHandlesConflicts() throws Exception {
110    long counter = testCounter.incrementAndGet();
111    FileAndPath c1 = compileTestClass(counter, "", "c1");
112    FileAndPath c2 = compileTestClass(counter, "", "c2");
113    packageAndLoadJar(c1, c2);
114    packageAndLoadJar(c1);
115
116    ClassFinder allClassesFinder = new ClassFinder(classLoader);
117    Set<Class<?>> allClasses = allClassesFinder.findClasses(makePackageName("", counter), false);
118    assertEquals(2, allClasses.size());
119  }
120
121  @Test
122  public void testClassFinderHandlesNestedPackages(TestInfo testInfo) throws Exception {
123    final String NESTED = ".nested";
124    final String CLASSNAME1 = testInfo.getTestMethod().get().getName() + "1";
125    final String CLASSNAME2 = testInfo.getTestMethod().get().getName() + "2";
126    long counter = testCounter.incrementAndGet();
127    FileAndPath c1 = compileTestClass(counter, "", "c1");
128    FileAndPath c2 = compileTestClass(counter, NESTED, CLASSNAME1);
129    FileAndPath c3 = compileTestClass(counter, NESTED, CLASSNAME2);
130    packageAndLoadJar(c1, c2);
131    packageAndLoadJar(c3);
132
133    ClassFinder allClassesFinder = new ClassFinder(classLoader);
134    Set<Class<?>> nestedClasses =
135      allClassesFinder.findClasses(makePackageName(NESTED, counter), false);
136    assertEquals(2, nestedClasses.size());
137    Class<?> nestedClass1 = makeClass(NESTED, CLASSNAME1, counter);
138    assertTrue(nestedClasses.contains(nestedClass1));
139    Class<?> nestedClass2 = makeClass(NESTED, CLASSNAME2, counter);
140    assertTrue(nestedClasses.contains(nestedClass2));
141  }
142
143  @Test
144  public void testClassFinderFiltersByNameInJar(TestInfo testInfo) throws Exception {
145    final long counter = testCounter.incrementAndGet();
146    final String classNamePrefix = testInfo.getTestMethod().get().getName();
147    LOG.info("Created jar " + createAndLoadJar("", classNamePrefix, counter));
148
149    ClassFinder.FileNameFilter notExcNameFilter =
150      (fileName, absFilePath) -> !fileName.startsWith(PREFIX);
151    ClassFinder incClassesFinder = new ClassFinder(null, notExcNameFilter, null, classLoader);
152    Set<Class<?>> incClasses = incClassesFinder.findClasses(makePackageName("", counter), false);
153    assertEquals(1, incClasses.size());
154    Class<?> incClass = makeClass("", classNamePrefix, counter);
155    assertTrue(incClasses.contains(incClass));
156  }
157
158  @Test
159  public void testClassFinderFiltersByClassInJar(TestInfo testInfo) throws Exception {
160    final long counter = testCounter.incrementAndGet();
161    final String classNamePrefix = testInfo.getTestMethod().get().getName();
162    LOG.info("Created jar " + createAndLoadJar("", classNamePrefix, counter));
163
164    final ClassFinder.ClassFilter notExcClassFilter = c -> !c.getSimpleName().startsWith(PREFIX);
165    ClassFinder incClassesFinder = new ClassFinder(null, null, notExcClassFilter, classLoader);
166    Set<Class<?>> incClasses = incClassesFinder.findClasses(makePackageName("", counter), false);
167    assertEquals(1, incClasses.size());
168    Class<?> incClass = makeClass("", classNamePrefix, counter);
169    assertTrue(incClasses.contains(incClass));
170  }
171
172  private static String createAndLoadJar(final String packageNameSuffix,
173    final String classNamePrefix, final long counter) throws Exception {
174    FileAndPath c1 = compileTestClass(counter, packageNameSuffix, classNamePrefix);
175    FileAndPath c2 = compileTestClass(counter, packageNameSuffix, PREFIX + "1");
176    FileAndPath c3 = compileTestClass(counter, packageNameSuffix, PREFIX + classNamePrefix + "2");
177    return packageAndLoadJar(c1, c2, c3);
178  }
179
180  @Test
181  public void testClassFinderFiltersByPathInJar(TestInfo testInfo) throws Exception {
182    final String CLASSNAME = testInfo.getTestMethod().get().getName();
183    long counter = testCounter.incrementAndGet();
184    FileAndPath c1 = compileTestClass(counter, "", CLASSNAME);
185    FileAndPath c2 = compileTestClass(counter, "", "c2");
186    packageAndLoadJar(c1);
187    final String excludedJar = packageAndLoadJar(c2);
188    /*
189     * ResourcePathFilter will pass us the resourcePath as a path of a URL from the classloader. For
190     * Windows, the ablosute path and the one from the URL have different file separators.
191     */
192    final String excludedJarResource = new File(excludedJar).toURI().getRawSchemeSpecificPart();
193
194    final ClassFinder.ResourcePathFilter notExcJarFilter =
195      (resourcePath, isJar) -> !isJar || !resourcePath.equals(excludedJarResource);
196    ClassFinder incClassesFinder = new ClassFinder(notExcJarFilter, null, null, classLoader);
197    Set<Class<?>> incClasses = incClassesFinder.findClasses(makePackageName("", counter), false);
198    assertEquals(1, incClasses.size());
199    Class<?> incClass = makeClass("", CLASSNAME, counter);
200    assertTrue(incClasses.contains(incClass));
201  }
202
203  @Test
204  public void testClassFinderCanFindClassesInDirs(TestInfo testInfo) throws Exception {
205    // Make some classes for us to find. Class naming and packaging is kinda cryptic.
206    // TODO: Fix.
207    final long counter = testCounter.incrementAndGet();
208    final String classNamePrefix = testInfo.getTestMethod().get().getName();
209    String pkgNameSuffix = testInfo.getTestMethod().get().getName();
210    LOG.info("Created jar " + createAndLoadJar(pkgNameSuffix, classNamePrefix, counter));
211    ClassFinder allClassesFinder = new ClassFinder(classLoader);
212    String pkgName = makePackageName(pkgNameSuffix, counter);
213    Set<Class<?>> allClasses = allClassesFinder.findClasses(pkgName, false);
214    assertTrue(allClasses.size() > 0, "Classes in " + pkgName);
215    String classNameToFind = classNamePrefix + counter;
216    assertTrue(contains(allClasses, classNameToFind));
217  }
218
219  private static boolean contains(final Set<Class<?>> classes, final String simpleName) {
220    for (Class<?> c : classes) {
221      if (c.getSimpleName().equals(simpleName)) {
222        return true;
223      }
224    }
225    return false;
226  }
227
228  @Test
229  public void testClassFinderFiltersByNameInDirs(TestInfo testInfo) throws Exception {
230    // Make some classes for us to find. Class naming and packaging is kinda cryptic.
231    // TODO: Fix.
232    final long counter = testCounter.incrementAndGet();
233    final String classNamePrefix = testInfo.getTestMethod().get().getName();
234    String pkgNameSuffix = testInfo.getTestMethod().get().getName();
235    LOG.info("Created jar " + createAndLoadJar(pkgNameSuffix, classNamePrefix, counter));
236    final String classNameToFilterOut = classNamePrefix + counter;
237    final ClassFinder.FileNameFilter notThisFilter =
238      (fileName, absFilePath) -> !fileName.equals(classNameToFilterOut + ".class");
239    String pkgName = makePackageName(pkgNameSuffix, counter);
240    ClassFinder allClassesFinder = new ClassFinder(classLoader);
241    Set<Class<?>> allClasses = allClassesFinder.findClasses(pkgName, false);
242    assertTrue(allClasses.size() > 0, "Classes in " + pkgName);
243    ClassFinder notThisClassFinder = new ClassFinder(null, notThisFilter, null, classLoader);
244    Set<Class<?>> notAllClasses = notThisClassFinder.findClasses(pkgName, false);
245    assertFalse(contains(notAllClasses, classNameToFilterOut));
246    assertEquals(allClasses.size() - 1, notAllClasses.size());
247  }
248
249  @Test
250  public void testClassFinderFiltersByClassInDirs(TestInfo testInfo) throws Exception {
251    // Make some classes for us to find. Class naming and packaging is kinda cryptic.
252    // TODO: Fix.
253    final long counter = testCounter.incrementAndGet();
254    final String classNamePrefix = testInfo.getTestMethod().get().getName();
255    String pkgNameSuffix = testInfo.getTestMethod().get().getName();
256    LOG.info("Created jar " + createAndLoadJar(pkgNameSuffix, classNamePrefix, counter));
257    final Class<?> clazz = makeClass(pkgNameSuffix, classNamePrefix, counter);
258    final ClassFinder.ClassFilter notThisFilter = c -> c != clazz;
259    String pkgName = makePackageName(pkgNameSuffix, counter);
260    ClassFinder allClassesFinder = new ClassFinder(classLoader);
261    Set<Class<?>> allClasses = allClassesFinder.findClasses(pkgName, false);
262    assertTrue(allClasses.size() > 0, "Classes in " + pkgName);
263    ClassFinder notThisClassFinder = new ClassFinder(null, null, notThisFilter, classLoader);
264    Set<Class<?>> notAllClasses = notThisClassFinder.findClasses(pkgName, false);
265    assertFalse(contains(notAllClasses, clazz.getSimpleName()));
266    assertEquals(allClasses.size() - 1, notAllClasses.size());
267  }
268
269  @Test
270  public void testClassFinderFiltersByPathInDirs() throws Exception {
271    final String hardcodedThisSubdir = "hbase-common";
272    final ClassFinder.ResourcePathFilter notExcJarFilter =
273      (resourcePath, isJar) -> isJar || !resourcePath.contains(hardcodedThisSubdir);
274    String thisPackage = this.getClass().getPackage().getName();
275    ClassFinder notThisClassFinder = new ClassFinder(notExcJarFilter, null, null, classLoader);
276    Set<Class<?>> notAllClasses = notThisClassFinder.findClasses(thisPackage, false);
277    assertFalse(notAllClasses.contains(this.getClass()));
278  }
279
280  @Test
281  public void testClassFinderDefaultsToOwnPackage() throws Exception {
282    // Correct handling of nested packages is tested elsewhere, so here we just assume
283    // pkgClasses is the correct answer that we don't have to check.
284    ClassFinder allClassesFinder = new ClassFinder(classLoader);
285    Set<Class<?>> pkgClasses =
286      allClassesFinder.findClasses(ClassFinder.class.getPackage().getName(), false);
287    Set<Class<?>> defaultClasses = allClassesFinder.findClasses(false);
288    Object[] pkgClassesArray = pkgClasses.toArray();
289    Object[] defaultClassesArray = defaultClasses.toArray();
290    assertEquals(pkgClassesArray.length, defaultClassesArray.length);
291    assertThat(pkgClassesArray, arrayContainingInAnyOrder(defaultClassesArray));
292  }
293
294  private static class FileAndPath {
295    String path;
296    File file;
297
298    public FileAndPath(String path, File file) {
299      this.file = file;
300      this.path = path;
301    }
302  }
303
304  private static Class<?> makeClass(String nestedPkgSuffix, String className, long counter)
305    throws ClassNotFoundException {
306    String name = makePackageName(nestedPkgSuffix, counter) + "." + className + counter;
307    return Class.forName(name, true, classLoader);
308  }
309
310  private static String makePackageName(String nestedSuffix, long counter) {
311    return BASEPKG + counter + nestedSuffix;
312  }
313
314  /**
315   * Compiles the test class with bogus code into a .class file. Unfortunately it's very tedious.
316   * @param counter           Unique test counter.
317   * @param packageNameSuffix Package name suffix (e.g. ".suffix") for nesting, or "".
318   * @return The resulting .class file and the location in jar it is supposed to go to.
319   */
320  private static FileAndPath compileTestClass(long counter, String packageNameSuffix,
321    String classNamePrefix) throws Exception {
322    classNamePrefix = classNamePrefix + counter;
323    String packageName = makePackageName(packageNameSuffix, counter);
324    String javaPath = basePath + classNamePrefix + ".java";
325    String classPath = basePath + classNamePrefix + ".class";
326    PrintStream source = new PrintStream(javaPath);
327    source.println("package " + packageName + ";");
328    source.println(
329      "public class " + classNamePrefix + " { public static void main(String[] args) { } };");
330    source.close();
331    JavaCompiler jc = ToolProvider.getSystemJavaCompiler();
332    int result = jc.run(null, null, null, javaPath);
333    assertEquals(0, result);
334    File classFile = new File(classPath);
335    assertTrue(classFile.exists());
336    return new FileAndPath(packageName.replace('.', '/') + '/', classFile);
337  }
338
339  /**
340   * Makes a jar out of some class files. Unfortunately it's very tedious.
341   * @param filesInJar Files created via compileTestClass.
342   * @return path to the resulting jar file.
343   */
344  private static String packageAndLoadJar(FileAndPath... filesInJar) throws Exception {
345    // First, write the bogus jar file.
346    String path = basePath + "jar" + jarCounter.incrementAndGet() + ".jar";
347    Manifest manifest = new Manifest();
348    manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
349    FileOutputStream fos = new FileOutputStream(path);
350    JarOutputStream jarOutputStream = new JarOutputStream(fos, manifest);
351    // Directory entries for all packages have to be added explicitly for
352    // resources to be findable via ClassLoader. Directory entries must end
353    // with "/"; the initial one is expected to, also.
354    Set<String> pathsInJar = new HashSet<>();
355    for (FileAndPath fileAndPath : filesInJar) {
356      String pathToAdd = fileAndPath.path;
357      while (pathsInJar.add(pathToAdd)) {
358        int ix = pathToAdd.lastIndexOf('/', pathToAdd.length() - 2);
359        if (ix < 0) {
360          break;
361        }
362        pathToAdd = pathToAdd.substring(0, ix);
363      }
364    }
365    for (String pathInJar : pathsInJar) {
366      jarOutputStream.putNextEntry(new JarEntry(pathInJar));
367      jarOutputStream.closeEntry();
368    }
369    for (FileAndPath fileAndPath : filesInJar) {
370      File file = fileAndPath.file;
371      jarOutputStream.putNextEntry(new JarEntry(fileAndPath.path + file.getName()));
372      byte[] allBytes = new byte[(int) file.length()];
373      FileInputStream fis = new FileInputStream(file);
374      fis.read(allBytes);
375      fis.close();
376      jarOutputStream.write(allBytes);
377      jarOutputStream.closeEntry();
378    }
379    jarOutputStream.close();
380    fos.close();
381
382    // Add the file to classpath.
383    File jarFile = new File(path);
384    assertTrue(jarFile.exists());
385    classLoader.addURL(jarFile.toURI().toURL());
386    return jarFile.getAbsolutePath();
387  }
388
389  // Java 11 workaround - Custom class loader to expose addUrl method of URLClassLoader
390  private static class CustomClassloader extends URLClassLoader {
391    public CustomClassloader(URL[] urls, ClassLoader parentLoader) {
392      super(urls, parentLoader);
393    }
394
395    @Override
396    public void addURL(URL url) {
397      super.addURL(url);
398    }
399  }
400}